backend-skeleton 1.6.0 → 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/bin/bskel.mjs +220 -12
- package/contracts/completeness.mjs +12 -1
- package/lib/cli.mjs +48 -2
- package/lib/decision-log.mjs +58 -0
- package/lib/gate-export.mjs +55 -4
- package/lib/impact.mjs +51 -3
- package/package.json +1 -1
- package/scanners/adapters/javascript-express.mjs +133 -83
- package/schemas/decision-event.schema.json +46 -0
- package/schemas/gate-export.schema.json +114 -38
package/README.md
CHANGED
|
@@ -602,9 +602,10 @@ string for anything missing.
|
|
|
602
602
|
(entities come from `@Entity`/`@PrimaryGeneratedColumn`); no operation extraction — plain Express
|
|
603
603
|
has no operationId concept, so pass `--openapi-file` for a contract. See
|
|
604
604
|
`D-typescript-express-provider` in `DECISIONS.md`.
|
|
605
|
-
- `javascript-express` — plain-JavaScript ESM
|
|
606
|
-
including `serverless-http`/Lambda deployments. **Scanner only** — routes
|
|
607
|
-
paths are resolved through a full mount-graph walk
|
|
605
|
+
- `javascript-express` — plain-JavaScript Express, both ESM and CommonJS, with **no ORM** (raw
|
|
606
|
+
`mysql2`/`mariadb`), including `serverless-http`/Lambda deployments. **Scanner only** — routes
|
|
607
|
+
and their real absolute paths are resolved through a full mount-graph walk (including direct
|
|
608
|
+
CommonJS `require()` mounts and `module.exports`), but every capability is honestly `false`:
|
|
608
609
|
there is no codegen provider, because raw SQL string literals carry no trustworthy
|
|
609
610
|
table/primary-key/column-allow-list metadata. See `D-javascript-express-adapter` in `DECISIONS.md`
|
|
610
611
|
for the measured reasoning.
|
package/bin/bskel.mjs
CHANGED
|
@@ -30,8 +30,9 @@ import { ADAPTERS, LOAD_ERRORS, adapterById } from '../scanners/registry.mjs';
|
|
|
30
30
|
import { COMMAND_CAPABILITIES, CAPABILITY_SATISFIERS, explainMissingCapability } from '../scanners/capabilities.mjs';
|
|
31
31
|
import { buildContract, selectModule, CONTRACT_SCHEMA_VERSION } from '../contracts/emit.mjs';
|
|
32
32
|
import { validateEnvelope, operationPayloadSchema } from '../contracts/validate.mjs';
|
|
33
|
-
import { evaluateResolution, loadResolution, saveResolution, requireWarningCode, warningKey, countByCode } from '../contracts/completeness.mjs';
|
|
33
|
+
import { evaluateResolution, loadResolution, saveResolution, requireWarningCode, warningKey, countByCode, isWaiverExpired } from '../contracts/completeness.mjs';
|
|
34
34
|
import { loadPatchApprovals, savePatchApprovals, approvalKey } from '../lib/patch-approvals.mjs';
|
|
35
|
+
import { appendDecisionEvent, readDecisionLog } from '../lib/decision-log.mjs';
|
|
35
36
|
import { proposeTransaction, approveTransaction, applyTransaction, rollbackTransaction, loadTransaction, listTransactions } from '../lib/patch-transactions.mjs';
|
|
36
37
|
import { getPatchKind, replanTransaction, PATCH_KIND_NAMES } from '../lib/patch-kinds.mjs';
|
|
37
38
|
import { generateKeypair, signPayload, verifyPayload, publicKeyIdFromPrivate, publicKeyIdFromPublic } from '../lib/attest.mjs';
|
|
@@ -42,7 +43,7 @@ import {
|
|
|
42
43
|
declareDependency, removeDependency, buildDependencyListReport,
|
|
43
44
|
} from '../lib/field-dependencies.mjs';
|
|
44
45
|
import {
|
|
45
|
-
ImpactOperationError, checkImpact, acceptImpact, recordDisposition, acknowledgeInbound,
|
|
46
|
+
ImpactOperationError, checkImpact, acceptImpact, recordDisposition, acknowledgeInbound, withdrawDisposition,
|
|
46
47
|
} from '../lib/impact.mjs';
|
|
47
48
|
import { buildImpactGraph } from '../lib/impact-graph.mjs';
|
|
48
49
|
import { toGraphifyExtraction, toMermaid } from '../lib/impact-export-graphify.mjs';
|
|
@@ -103,6 +104,7 @@ function usage() {
|
|
|
103
104
|
bskel scan repair --feature <id> [--json]
|
|
104
105
|
bskel scan cross-feature-check --feature <id> [--db [--database-url-env <NAME>] [--schema public]] [--json]
|
|
105
106
|
bskel scan cross-feature-waive --feature <id> --signal resource_type|table|operation_id|db_foreign_key --identifier <name> --other-feature <id> --reason "..."
|
|
107
|
+
bskel scan cross-feature-unwaive --feature <id> --signal resource_type|table|operation_id|db_foreign_key --identifier <name> --other-feature <id> --reason "..." [--json]
|
|
106
108
|
bskel feature init --slug <name>
|
|
107
109
|
bskel feature list [--all] [--json]
|
|
108
110
|
bskel feature show <id> [--json]
|
|
@@ -117,12 +119,13 @@ function usage() {
|
|
|
117
119
|
bskel contract validate --feature <id> --file <envelope.json>
|
|
118
120
|
bskel contract tool-schema --feature <id> --operation <operationId>
|
|
119
121
|
bskel contract waive --feature <id> --code <CODE> (--subject "VERB /path"|--all) --reason "..." [--expires <Nd>]
|
|
122
|
+
bskel contract unwaive --feature <id> --code <CODE> --subject "VERB /path" --reason "..." [--json]
|
|
120
123
|
bskel dependency declare --feature <id> --resource <Type> --field <name> --source-feature <id> --source-resource <Type> --source-field <name> --reason "..." [--memo "..."]
|
|
121
124
|
bskel dependency remove --feature <id> --resource <Type> --field <name> --source-feature <id> --source-resource <Type> --source-field <name> --reason "..."
|
|
122
125
|
bskel dependency list --feature <id> [--json]
|
|
123
126
|
bskel impact check --feature <id> [--all] [--json]
|
|
124
127
|
bskel impact accept --feature <id> [--json]
|
|
125
|
-
bskel impact disposition --feature <id> --change <change_key> --downstream <id> --mode <compatible|migrate|waive>
|
|
128
|
+
bskel impact disposition --feature <id> --change <change_key> --downstream <id> (--mode <compatible|migrate|waive> [--tracked-by "..."] [--expires-days <N>] | --withdraw) --reason "..." [--json]
|
|
126
129
|
bskel impact ack --feature <id> --from <id> --change <change_key> --reason "..." [--json]
|
|
127
130
|
bskel impact export --format <graphify|json|mermaid> [--out <path>] [--focus <id>] [--rings <N>]
|
|
128
131
|
bskel rules check --feature <id> [--init] [--json]
|
|
@@ -134,6 +137,7 @@ function usage() {
|
|
|
134
137
|
bskel handles plan --feature <id> [--module <name>] [--resource type1,type2] [--diff] [--ast]
|
|
135
138
|
bskel handles emit --feature <id> [--module <name>] [--resource type1,type2] [--force --reason "..."] [--check] [--diff] [--enforce-registry on|off --reason "..."]
|
|
136
139
|
bskel handles patch approve --feature <id> [--module <name>] --resource <Type> --field <name> --strategy patch-wrapper|null-means-unchanged --reason "..." [--json]
|
|
140
|
+
bskel handles patch unapprove --feature <id> --resource <Type> --field <name> --reason "..." [--json]
|
|
137
141
|
bskel handles audit --feature <id> --database-url-env <NAME> [--resource type1,type2] [--module <name>] [--check-registry-coverage] [--json]
|
|
138
142
|
bskel patch propose --feature <id> [--kind config-apply|ddl-apply|java-source-splice] --choice <stackChoiceId> --target <config_check target path> --database-url-env <NAME> --schema <name> --sql-file <path> --splice-file <path> [--json]
|
|
139
143
|
bskel patch approve --feature <id> --transaction <id> --reason "..." [--json]
|
|
@@ -1025,6 +1029,10 @@ function cmdScanCrossFeatureWaive(args) {
|
|
|
1025
1029
|
waivers: [...resolution.waivers.filter((w) => waiverKey(w) !== key), entry],
|
|
1026
1030
|
};
|
|
1027
1031
|
saveCrossFeatureResolution(root, flags.feature, next);
|
|
1032
|
+
appendDecisionEvent(root, flags.feature, {
|
|
1033
|
+
kind: 'cross_feature_waiver', action: 'record', at: entry.at, reason: entry.reason, feature_id: flags.feature,
|
|
1034
|
+
subject: { signal: entry.signal, identifier: entry.identifier, other_feature: entry.other_feature },
|
|
1035
|
+
});
|
|
1028
1036
|
return next;
|
|
1029
1037
|
});
|
|
1030
1038
|
|
|
@@ -1048,6 +1056,64 @@ function cmdScanCrossFeatureWaive(args) {
|
|
|
1048
1056
|
process.exit(evaluation.blocking ? EXIT.AWAITING_DISPOSITION : EXIT.PASS);
|
|
1049
1057
|
}
|
|
1050
1058
|
|
|
1059
|
+
// D-decision-event-log (D6): forward-only retraction, mirroring `gate revoke` -- removes the
|
|
1060
|
+
// waiver entry and appends a `withdraw` decision event. Never a snapshot restore.
|
|
1061
|
+
function cmdScanCrossFeatureUnwaive(args) {
|
|
1062
|
+
const flags = parseCommand('scan cross-feature-unwaive', args);
|
|
1063
|
+
if (flags.help) { console.log(renderCommandHelp('scan cross-feature-unwaive')); process.exit(0); }
|
|
1064
|
+
setContext('scan cross-feature-unwaive', flags);
|
|
1065
|
+
const root = requireRepoRoot();
|
|
1066
|
+
requireValidFeatureId(flags.feature);
|
|
1067
|
+
requireValidFeatureId(flags['other-feature']);
|
|
1068
|
+
if (!flags.reason || !flags.reason.trim()) {
|
|
1069
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel scan cross-feature-unwaive requires --reason "..." -- every withdrawal must be auditable');
|
|
1070
|
+
}
|
|
1071
|
+
const report = loadCrossFeatureReport(root, flags.feature);
|
|
1072
|
+
if (!report) {
|
|
1073
|
+
fail(EXIT_CODES.NOT_PASSED, 'MISSING_ARTIFACT', `no cross-feature-report.json for feature "${flags.feature}"`);
|
|
1074
|
+
}
|
|
1075
|
+
const key = waiverKey({ signal: flags.signal, identifier: flags.identifier, other_feature: flags['other-feature'] });
|
|
1076
|
+
|
|
1077
|
+
const updated = withLockSync(root, 'state', () => {
|
|
1078
|
+
const resolution = loadCrossFeatureResolution(root, flags.feature);
|
|
1079
|
+
const match = resolution.waivers.find((w) => waiverKey(w) === key);
|
|
1080
|
+
if (!match) {
|
|
1081
|
+
fail(EXIT_CODES.NOT_PASSED, 'MISSING_ARTIFACT', `no waiver recorded for --signal ${flags.signal} --identifier "${flags.identifier}" --other-feature ${flags['other-feature']} -- nothing to unwaive`);
|
|
1082
|
+
}
|
|
1083
|
+
const at = new Date().toISOString();
|
|
1084
|
+
const next = {
|
|
1085
|
+
schema: 'sbf.cross-feature-resolution/1',
|
|
1086
|
+
feature_id: flags.feature,
|
|
1087
|
+
waivers: resolution.waivers.filter((w) => waiverKey(w) !== key),
|
|
1088
|
+
};
|
|
1089
|
+
saveCrossFeatureResolution(root, flags.feature, next);
|
|
1090
|
+
appendDecisionEvent(root, flags.feature, {
|
|
1091
|
+
kind: 'cross_feature_waiver', action: 'withdraw', at, reason: flags.reason, feature_id: flags.feature,
|
|
1092
|
+
subject: { signal: match.signal, identifier: match.identifier, other_feature: match.other_feature },
|
|
1093
|
+
});
|
|
1094
|
+
return next;
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
const evaluation = evaluateCrossFeatureFindings(report.findings, updated);
|
|
1098
|
+
const evidence = {
|
|
1099
|
+
finding_count: report.findings.length,
|
|
1100
|
+
high_confidence_count: report.findings.filter((f) => f.confidence === 'high').length,
|
|
1101
|
+
waived_count: evaluation.waived.length,
|
|
1102
|
+
stale_waivers: evaluation.staleWaivers.length,
|
|
1103
|
+
};
|
|
1104
|
+
const gateState = evaluation.blocking
|
|
1105
|
+
? awaitNamedGateDisposition(root, 'cross_feature', flags.feature, { ...evidence, unwaived: evaluation.unwaived })
|
|
1106
|
+
: passNamedGate(root, 'cross_feature', flags.feature, evidence);
|
|
1107
|
+
|
|
1108
|
+
if (flags.json) {
|
|
1109
|
+
console.log(JSON.stringify({ withdrawn: true, gate: gateState.gates.cross_feature }, null, 2));
|
|
1110
|
+
} else if (!flags.quiet) {
|
|
1111
|
+
console.log(`unwaived: ${flags.signal} "${flags.identifier}" (${flags['other-feature']})`);
|
|
1112
|
+
console.log(`gate: cross_feature -> ${gateState.gates.cross_feature.status}`);
|
|
1113
|
+
}
|
|
1114
|
+
process.exit(evaluation.blocking ? EXIT.AWAITING_DISPOSITION : EXIT.PASS);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1051
1117
|
// D6 (D-feature-lifecycle): the whole read-specs/->compute-NNN->write-feature.json->
|
|
1052
1118
|
// load-modify-save-feature-index.json sequence runs under one exclusive lock -- confirmed live
|
|
1053
1119
|
// during this item's own grounding, the same lost-update shape S5 already fixed for setGate():
|
|
@@ -1913,20 +1979,38 @@ function cmdContractWaive(args) {
|
|
|
1913
1979
|
// entries). Locking only the final write (inside saveResolution()) would NOT close this race --
|
|
1914
1980
|
// the window is between this function's own loadResolution() read and its save, not inside the
|
|
1915
1981
|
// write call itself.
|
|
1916
|
-
|
|
1982
|
+
// D-waiver-renewal: `existingKeys` -- the "already waived, do nothing" set -- must only contain
|
|
1983
|
+
// keys of waivers that are still LIVE. Before this fix it was built from every stored waiver
|
|
1984
|
+
// regardless of expires_at, so an expired waiver's key permanently "existed" and every renewal
|
|
1985
|
+
// attempt matched it and produced zero new entries (see contracts/completeness.mjs's
|
|
1986
|
+
// isWaiverExpired() for the shared predicate this now shares with evaluateResolution() -- the
|
|
1987
|
+
// two were previously two unsynchronized inline checks). A renewal replaces the expired entry
|
|
1988
|
+
// (same key never appears twice) rather than appending alongside it, which would otherwise leave
|
|
1989
|
+
// evaluateResolution()'s expiredKeys/waivedKeys sets computing over two same-key entries.
|
|
1990
|
+
const { resolution: updatedResolution, newEntries, renewedEntries } = withLockSync(root, 'state', () => {
|
|
1917
1991
|
const resolution = loadResolution(root, flags.feature);
|
|
1918
|
-
const
|
|
1992
|
+
const now = Date.now();
|
|
1993
|
+
const liveWaivers = (resolution.waivers ?? []).filter((w) => !isWaiverExpired(w, now));
|
|
1994
|
+
const expiredKeys = new Set((resolution.waivers ?? []).filter((w) => isWaiverExpired(w, now)).map(warningKey));
|
|
1995
|
+
const liveKeys = new Set(liveWaivers.map(warningKey));
|
|
1919
1996
|
const at = new Date().toISOString();
|
|
1920
|
-
const
|
|
1921
|
-
|
|
1922
|
-
|
|
1997
|
+
const toRecord = toWaive.filter((w) => !liveKeys.has(warningKey(w)));
|
|
1998
|
+
const entries = toRecord.map((w) => ({ code: w.code, subject: w.subject, reason: flags.reason, at, ...(expiresAt ? { expires_at: expiresAt } : {}) }));
|
|
1999
|
+
const newEntriesOut = entries.filter((e) => !expiredKeys.has(warningKey(e)));
|
|
2000
|
+
const renewedEntriesOut = entries.filter((e) => expiredKeys.has(warningKey(e)));
|
|
1923
2001
|
const next = {
|
|
1924
2002
|
schema: 'sbf.contract-resolution/1',
|
|
1925
2003
|
feature_id: flags.feature,
|
|
1926
|
-
waivers: [...
|
|
2004
|
+
waivers: [...liveWaivers, ...entries],
|
|
1927
2005
|
};
|
|
1928
2006
|
saveResolution(root, flags.feature, next);
|
|
1929
|
-
|
|
2007
|
+
for (const e of newEntriesOut) {
|
|
2008
|
+
appendDecisionEvent(root, flags.feature, { kind: 'contract_waiver', action: 'record', at: e.at, reason: e.reason, feature_id: flags.feature, subject: { code: e.code, subject: e.subject ?? null }, expires_at: e.expires_at ?? null });
|
|
2009
|
+
}
|
|
2010
|
+
for (const e of renewedEntriesOut) {
|
|
2011
|
+
appendDecisionEvent(root, flags.feature, { kind: 'contract_waiver', action: 'renew', at: e.at, reason: e.reason, feature_id: flags.feature, subject: { code: e.code, subject: e.subject ?? null }, expires_at: e.expires_at ?? null });
|
|
2012
|
+
}
|
|
2013
|
+
return { resolution: next, newEntries: newEntriesOut, renewedEntries: renewedEntriesOut };
|
|
1930
2014
|
});
|
|
1931
2015
|
|
|
1932
2016
|
const evaluation = evaluateResolution(contract, updatedResolution);
|
|
@@ -1944,10 +2028,14 @@ function cmdContractWaive(args) {
|
|
|
1944
2028
|
: passNamedGate(root, 'contract', flags.feature, evidence);
|
|
1945
2029
|
|
|
1946
2030
|
if (flags.json) {
|
|
1947
|
-
console.log(JSON.stringify({ waived: newEntries, gate: gateState.gates.contract }, null, 2));
|
|
2031
|
+
console.log(JSON.stringify({ waived: newEntries, renewed: renewedEntries, gate: gateState.gates.contract }, null, 2));
|
|
1948
2032
|
} else {
|
|
1949
2033
|
if (!flags.quiet) {
|
|
1950
|
-
|
|
2034
|
+
const alreadyWaived = toWaive.length - newEntries.length - renewedEntries.length;
|
|
2035
|
+
console.log(`waived ${newEntries.length} new warning(s)${alreadyWaived > 0 ? ` (${alreadyWaived} already waived)` : ''}${expiresAt ? `, expiring ${expiresAt}` : ''}`);
|
|
2036
|
+
if (renewedEntries.length > 0) {
|
|
2037
|
+
console.log(`renewed ${renewedEntries.length} expired waiver(s)${expiresAt ? `, expiring ${expiresAt}` : ''}`);
|
|
2038
|
+
}
|
|
1951
2039
|
console.log(`gate: contract -> ${gateState.gates.contract.status}`);
|
|
1952
2040
|
}
|
|
1953
2041
|
if (evaluation.expiredWaivers.length > 0) {
|
|
@@ -1962,6 +2050,65 @@ function cmdContractWaive(args) {
|
|
|
1962
2050
|
process.exit(evaluation.blocking ? EXIT.AWAITING_DISPOSITION : EXIT.PASS);
|
|
1963
2051
|
}
|
|
1964
2052
|
|
|
2053
|
+
// D-decision-event-log (D6): forward-only retraction of exactly one {code, subject} waiver --
|
|
2054
|
+
// mirrors `gate revoke`'s own precedent (the entry's absence IS the state, not a snapshot restore
|
|
2055
|
+
// of its prior reason/expiry, which stays recoverable only from the decision log). Re-evaluates
|
|
2056
|
+
// and re-sets the gate the same way `cmdContractWaive` does, since removing a waiver can turn a
|
|
2057
|
+
// passing gate back into an awaiting_disposition one.
|
|
2058
|
+
function cmdContractUnwaive(args) {
|
|
2059
|
+
const flags = parseCommand('contract unwaive', args);
|
|
2060
|
+
if (flags.help) { console.log(renderCommandHelp('contract unwaive')); process.exit(0); }
|
|
2061
|
+
setContext('contract unwaive', flags);
|
|
2062
|
+
const root = requireRepoRoot();
|
|
2063
|
+
if (!flags.reason || !flags.reason.trim()) {
|
|
2064
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel contract unwaive requires --reason "..." -- every withdrawal must be auditable');
|
|
2065
|
+
}
|
|
2066
|
+
const contract = loadContract(root, flags.feature);
|
|
2067
|
+
const key = warningKey({ code: flags.code, subject: flags.subject });
|
|
2068
|
+
|
|
2069
|
+
const updatedResolution = withLockSync(root, 'state', () => {
|
|
2070
|
+
const resolution = loadResolution(root, flags.feature);
|
|
2071
|
+
const match = (resolution.waivers ?? []).find((w) => warningKey(w) === key);
|
|
2072
|
+
if (!match) {
|
|
2073
|
+
fail(EXIT_CODES.NOT_PASSED, 'MISSING_ARTIFACT', `no waiver recorded for "${flags.code}" (${flags.subject}) on "${flags.feature}" -- nothing to unwaive`);
|
|
2074
|
+
}
|
|
2075
|
+
const at = new Date().toISOString();
|
|
2076
|
+
const next = {
|
|
2077
|
+
schema: 'sbf.contract-resolution/1',
|
|
2078
|
+
feature_id: flags.feature,
|
|
2079
|
+
waivers: (resolution.waivers ?? []).filter((w) => warningKey(w) !== key),
|
|
2080
|
+
};
|
|
2081
|
+
saveResolution(root, flags.feature, next);
|
|
2082
|
+
appendDecisionEvent(root, flags.feature, {
|
|
2083
|
+
kind: 'contract_waiver', action: 'withdraw', at, reason: flags.reason, feature_id: flags.feature,
|
|
2084
|
+
subject: { code: match.code, subject: match.subject ?? null }, expires_at: match.expires_at ?? null,
|
|
2085
|
+
});
|
|
2086
|
+
return next;
|
|
2087
|
+
});
|
|
2088
|
+
|
|
2089
|
+
const evaluation = evaluateResolution(contract, updatedResolution);
|
|
2090
|
+
const evidence = {
|
|
2091
|
+
operation_count: contract.completeness.operation_count,
|
|
2092
|
+
endpoint_count: contract.completeness.endpoint_count,
|
|
2093
|
+
completeness: evaluation.status,
|
|
2094
|
+
warning_codes: countByCode(contract.warnings),
|
|
2095
|
+
waived_count: evaluation.waived.length,
|
|
2096
|
+
stale_waivers: evaluation.staleWaivers.length,
|
|
2097
|
+
expired_waivers: evaluation.expiredWaivers.length,
|
|
2098
|
+
};
|
|
2099
|
+
const gateState = evaluation.blocking
|
|
2100
|
+
? awaitNamedGateDisposition(root, 'contract', flags.feature, { ...evidence, unwaived: evaluation.unwaived.map(({ code, subject }) => ({ code, subject })) })
|
|
2101
|
+
: passNamedGate(root, 'contract', flags.feature, evidence);
|
|
2102
|
+
|
|
2103
|
+
if (flags.json) {
|
|
2104
|
+
console.log(JSON.stringify({ withdrawn: { code: flags.code, subject: flags.subject }, gate: gateState.gates.contract }, null, 2));
|
|
2105
|
+
} else {
|
|
2106
|
+
console.log(`unwaived: ${flags.code} (${flags.subject})`);
|
|
2107
|
+
console.log(`gate: contract -> ${gateState.gates.contract.status}`);
|
|
2108
|
+
}
|
|
2109
|
+
process.exit(evaluation.blocking ? EXIT.AWAITING_DISPOSITION : EXIT.PASS);
|
|
2110
|
+
}
|
|
2111
|
+
|
|
1965
2112
|
// D-dependency-propagation-notice: called from cmdContractEmit/cmdHandlesEmit to warn a SOURCE
|
|
1966
2113
|
// feature, at the moment its own generated artifacts are refreshed, that other features declared a
|
|
1967
2114
|
// dependency on one of its fields. Only surfaces a note when the dependent's OWN `dependencies` gate
|
|
@@ -2142,11 +2289,30 @@ function cmdImpactAccept(args) {
|
|
|
2142
2289
|
process.exit(EXIT.PASS);
|
|
2143
2290
|
}
|
|
2144
2291
|
|
|
2292
|
+
// D-decision-event-log (D6): --withdraw is a flag variant of this same command, not a separate
|
|
2293
|
+
// subcommand -- withdrawal needs no --mode (there is nothing left to mode-classify once the
|
|
2294
|
+
// disposition is gone), so branching inside cmdImpactDisposition keeps the option set honest
|
|
2295
|
+
// rather than requiring --mode on a withdrawal that doesn't use it.
|
|
2145
2296
|
function cmdImpactDisposition(args) {
|
|
2146
2297
|
const flags = parseCommand('impact disposition', args);
|
|
2147
2298
|
if (flags.help) { console.log(renderCommandHelp('impact disposition')); process.exit(0); }
|
|
2148
2299
|
setContext('impact disposition', flags);
|
|
2149
2300
|
const root = requireRepoRoot();
|
|
2301
|
+
if (flags.withdraw) {
|
|
2302
|
+
let result;
|
|
2303
|
+
try {
|
|
2304
|
+
result = withdrawDisposition(root, { feature: flags.feature, changeKey: flags.change, downstreamFeature: flags.downstream, reason: flags.reason });
|
|
2305
|
+
} catch (err) {
|
|
2306
|
+
if (err instanceof ImpactOperationError) fail(err.exitCode, err.reasonCode, err.message);
|
|
2307
|
+
throw err;
|
|
2308
|
+
}
|
|
2309
|
+
if (flags.json) {
|
|
2310
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2311
|
+
} else {
|
|
2312
|
+
console.log(`disposition withdrawn: ${flags.change} -> ${flags.downstream}`);
|
|
2313
|
+
}
|
|
2314
|
+
process.exit(EXIT.PASS);
|
|
2315
|
+
}
|
|
2150
2316
|
let result;
|
|
2151
2317
|
try {
|
|
2152
2318
|
result = recordDisposition(root, {
|
|
@@ -3346,6 +3512,10 @@ function cmdHandlesPatchApprove(args) {
|
|
|
3346
3512
|
const withoutExisting = (current.approvals ?? []).filter((a) => approvalKey(a.resource, a.field) !== key);
|
|
3347
3513
|
const next = { schema: 'sbf.patch-approvals/1', feature_id: flags.feature, approvals: [...withoutExisting, entry] };
|
|
3348
3514
|
savePatchApprovals(root, flags.feature, next);
|
|
3515
|
+
appendDecisionEvent(root, flags.feature, {
|
|
3516
|
+
kind: 'patch_approval', action: 'record', at, reason: flags.reason, feature_id: flags.feature,
|
|
3517
|
+
subject: { resource: flags.resource, field: flags.field }, strategy: flags.strategy,
|
|
3518
|
+
});
|
|
3349
3519
|
return next;
|
|
3350
3520
|
});
|
|
3351
3521
|
|
|
@@ -3353,6 +3523,41 @@ function cmdHandlesPatchApprove(args) {
|
|
|
3353
3523
|
process.exit(0);
|
|
3354
3524
|
}
|
|
3355
3525
|
|
|
3526
|
+
// D-decision-event-log (D6): forward-only retraction, mirroring `gate revoke` -- removes the
|
|
3527
|
+
// approval entry and appends a `withdraw` decision event. patch-approvals.json is not itself a
|
|
3528
|
+
// gate input (F3's fix is binding it into signed attestations, not making it a gate -- see
|
|
3529
|
+
// D-decision-event-log's own EXIT), so unlike the other three withdraw commands there is no gate
|
|
3530
|
+
// to re-evaluate here.
|
|
3531
|
+
function cmdHandlesPatchUnapprove(args) {
|
|
3532
|
+
const flags = parseCommand('handles patch unapprove', args);
|
|
3533
|
+
if (flags.help) { console.log(renderCommandHelp('handles patch unapprove')); process.exit(0); }
|
|
3534
|
+
setContext('handles patch unapprove', flags);
|
|
3535
|
+
const root = requireRepoRoot();
|
|
3536
|
+
if (!flags.reason || !flags.reason.trim()) {
|
|
3537
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel handles patch unapprove requires --reason "..." -- every withdrawal must be auditable');
|
|
3538
|
+
}
|
|
3539
|
+
const key = approvalKey(flags.resource, flags.field);
|
|
3540
|
+
|
|
3541
|
+
const updated = withLockSync(root, 'state', () => {
|
|
3542
|
+
const current = loadPatchApprovals(root, flags.feature);
|
|
3543
|
+
const match = (current.approvals ?? []).find((a) => approvalKey(a.resource, a.field) === key);
|
|
3544
|
+
if (!match) {
|
|
3545
|
+
fail(EXIT_CODES.NOT_PASSED, 'MISSING_ARTIFACT', `no approval recorded for "${flags.resource}.${flags.field}" on "${flags.feature}" -- nothing to unapprove`);
|
|
3546
|
+
}
|
|
3547
|
+
const at = new Date().toISOString();
|
|
3548
|
+
const next = { schema: 'sbf.patch-approvals/1', feature_id: flags.feature, approvals: (current.approvals ?? []).filter((a) => approvalKey(a.resource, a.field) !== key) };
|
|
3549
|
+
savePatchApprovals(root, flags.feature, next);
|
|
3550
|
+
appendDecisionEvent(root, flags.feature, {
|
|
3551
|
+
kind: 'patch_approval', action: 'withdraw', at, reason: flags.reason, feature_id: flags.feature,
|
|
3552
|
+
subject: { resource: flags.resource, field: flags.field }, strategy: match.strategy,
|
|
3553
|
+
});
|
|
3554
|
+
return next;
|
|
3555
|
+
});
|
|
3556
|
+
|
|
3557
|
+
console.log(flags.json ? JSON.stringify(updated, null, 2) : `unapproved: ${flags.resource}.${flags.field}`);
|
|
3558
|
+
process.exit(0);
|
|
3559
|
+
}
|
|
3560
|
+
|
|
3356
3561
|
// D-patch-transactions: Slice 1 (config_check -> config_apply). All four commands only touch
|
|
3357
3562
|
// specs/<featureId>/patch-transactions/ except `apply`/`rollback`, which write to the real target
|
|
3358
3563
|
// file too -- matching D4's own "propose/approve are specs/-only, apply/rollback touch the repo"
|
|
@@ -4681,6 +4886,7 @@ async function dispatchCommand(cmd, rest) {
|
|
|
4681
4886
|
if (rest[0] === 'repair') return cmdScanRepair(rest.slice(1));
|
|
4682
4887
|
if (rest[0] === 'cross-feature-check') return cmdScanCrossFeatureCheck(rest.slice(1));
|
|
4683
4888
|
if (rest[0] === 'cross-feature-waive') return cmdScanCrossFeatureWaive(rest.slice(1));
|
|
4889
|
+
if (rest[0] === 'cross-feature-unwaive') return cmdScanCrossFeatureUnwaive(rest.slice(1));
|
|
4684
4890
|
await cmdScan(rest);
|
|
4685
4891
|
break;
|
|
4686
4892
|
}
|
|
@@ -4705,6 +4911,7 @@ async function dispatchCommand(cmd, rest) {
|
|
|
4705
4911
|
if (sub === 'validate') return cmdContractValidate(subArgs);
|
|
4706
4912
|
if (sub === 'tool-schema') return cmdContractToolSchema(subArgs);
|
|
4707
4913
|
if (sub === 'waive') return cmdContractWaive(subArgs);
|
|
4914
|
+
if (sub === 'unwaive') return cmdContractUnwaive(subArgs);
|
|
4708
4915
|
usage();
|
|
4709
4916
|
process.exit(14);
|
|
4710
4917
|
break;
|
|
@@ -4768,6 +4975,7 @@ async function dispatchCommand(cmd, rest) {
|
|
|
4768
4975
|
if (rest[0] === 'plan') return cmdHandlesPlan(rest.slice(1));
|
|
4769
4976
|
if (rest[0] === 'emit') return cmdHandlesEmit(rest.slice(1));
|
|
4770
4977
|
if (rest[0] === 'patch' && rest[1] === 'approve') return cmdHandlesPatchApprove(rest.slice(2));
|
|
4978
|
+
if (rest[0] === 'patch' && rest[1] === 'unapprove') return cmdHandlesPatchUnapprove(rest.slice(2));
|
|
4771
4979
|
if (rest[0] === 'audit') return await cmdHandlesAudit(rest.slice(1));
|
|
4772
4980
|
usage();
|
|
4773
4981
|
process.exit(14);
|
|
@@ -225,11 +225,22 @@ export function saveResolution(root, featureId, resolution) {
|
|
|
225
225
|
// its warning, so `unwaived`/`blocking` treat it exactly as if it had never been recorded. A
|
|
226
226
|
// waiver can be BOTH stale and expired at once (nothing prevents that combination); the two
|
|
227
227
|
// lists are independent, not mutually exclusive.
|
|
228
|
+
// D-waiver-renewal: the single shared expiry predicate. Previously inlined once here and NOT
|
|
229
|
+
// consulted by `cmdContractWaive`'s own "is this already waived" check (bin/bskel.mjs) -- that
|
|
230
|
+
// second, unsynchronized check meant an expired waiver was `waived` for evaluateResolution's
|
|
231
|
+
// purposes (correctly stops blocking) but simultaneously "already exists" for the CLI's own
|
|
232
|
+
// existingKeys set, so re-waiving it silently matched zero new entries and never updated the
|
|
233
|
+
// stored expires_at. Exporting one function closes that gap at the source instead of requiring
|
|
234
|
+
// two call sites to independently reimplement `Date.parse(w.expires_at) <= now` in sync forever.
|
|
235
|
+
export function isWaiverExpired(waiver, now = Date.now()) {
|
|
236
|
+
return typeof waiver.expires_at === 'string' && Date.parse(waiver.expires_at) <= now;
|
|
237
|
+
}
|
|
238
|
+
|
|
228
239
|
export function evaluateResolution(contract, resolution) {
|
|
229
240
|
const status = classifyContract(contract);
|
|
230
241
|
const waivers = resolution.waivers ?? [];
|
|
231
242
|
const now = Date.now();
|
|
232
|
-
const expiredWaivers = waivers.filter((w) =>
|
|
243
|
+
const expiredWaivers = waivers.filter((w) => isWaiverExpired(w, now));
|
|
233
244
|
const expiredKeys = new Set(expiredWaivers.map(warningKey));
|
|
234
245
|
const waivedKeys = new Set(waivers.filter((w) => !expiredKeys.has(warningKey(w))).map(warningKey));
|
|
235
246
|
|
package/lib/cli.mjs
CHANGED
|
@@ -187,6 +187,20 @@ export const COMMANDS = {
|
|
|
187
187
|
json: { type: 'boolean', default: false },
|
|
188
188
|
},
|
|
189
189
|
},
|
|
190
|
+
// D-decision-event-log (D6): forward-only retraction, mirroring `gate revoke` -- removes the
|
|
191
|
+
// waiver entry (its absence is the state) and appends a `withdraw` decision event. Never a
|
|
192
|
+
// snapshot restore.
|
|
193
|
+
'scan cross-feature-unwaive': {
|
|
194
|
+
usage: 'bskel scan cross-feature-unwaive --feature <id> --signal resource_type|table|operation_id|db_foreign_key --identifier <name> --other-feature <id> --reason "..." [--json]',
|
|
195
|
+
options: {
|
|
196
|
+
feature: { type: 'string', default: null, required: true },
|
|
197
|
+
signal: { type: 'string', default: null, required: true },
|
|
198
|
+
identifier: { type: 'string', default: null, required: true },
|
|
199
|
+
'other-feature': { type: 'string', default: null, required: true },
|
|
200
|
+
reason: { type: 'string', default: '' },
|
|
201
|
+
json: { type: 'boolean', default: false },
|
|
202
|
+
},
|
|
203
|
+
},
|
|
190
204
|
'feature init': {
|
|
191
205
|
usage: 'bskel feature init --slug <name>',
|
|
192
206
|
options: { slug: { type: 'string', default: null, required: true } },
|
|
@@ -299,6 +313,20 @@ export const COMMANDS = {
|
|
|
299
313
|
json: { type: 'boolean', default: false },
|
|
300
314
|
},
|
|
301
315
|
},
|
|
316
|
+
// D-decision-event-log (D6): forward-only retraction, mirroring `gate revoke` -- removes the
|
|
317
|
+
// waiver entry (its absence is the state) and appends a `withdraw` decision event. Never a
|
|
318
|
+
// snapshot restore (the entry's prior reason/expiry is preserved only in the decision log, not
|
|
319
|
+
// restorable through this command).
|
|
320
|
+
'contract unwaive': {
|
|
321
|
+
usage: 'bskel contract unwaive --feature <id> --code <CODE> --subject "VERB /path" --reason "..." [--json]',
|
|
322
|
+
options: {
|
|
323
|
+
feature: { type: 'string', default: null, required: true },
|
|
324
|
+
code: { type: 'string', default: null, required: true },
|
|
325
|
+
subject: { type: 'string', default: null, required: true },
|
|
326
|
+
reason: { type: 'string', default: '' },
|
|
327
|
+
json: { type: 'boolean', default: false },
|
|
328
|
+
},
|
|
329
|
+
},
|
|
302
330
|
'contract validate': {
|
|
303
331
|
usage: 'bskel contract validate --feature <id> --file <envelope.json>',
|
|
304
332
|
options: {
|
|
@@ -370,16 +398,21 @@ export const COMMANDS = {
|
|
|
370
398
|
},
|
|
371
399
|
// D-cross-feature-impact-graph (D6): three modes, three mechanically different consequences --
|
|
372
400
|
// `migrate` requires --tracked-by, `waive` requires --expires-days. `compatible` needs neither.
|
|
401
|
+
// D-decision-event-log (D6): --withdraw is a flag variant of this same command, not a separate
|
|
402
|
+
// subcommand -- a withdrawal needs no --mode, so `mode` is NOT `required: true` at the parse
|
|
403
|
+
// layer (recordDisposition() itself still refuses a missing/invalid mode on the record path --
|
|
404
|
+
// see cmdImpactDisposition's own comment for why this split is deliberate).
|
|
373
405
|
'impact disposition': {
|
|
374
|
-
usage: 'bskel impact disposition --feature <id> --change <change_key> --downstream <id> --mode compatible|migrate|waive
|
|
406
|
+
usage: 'bskel impact disposition --feature <id> --change <change_key> --downstream <id> (--mode compatible|migrate|waive [--tracked-by "<ref>"] [--expires-days <N>] | --withdraw) --reason "..." [--json]',
|
|
375
407
|
options: {
|
|
376
408
|
feature: { type: 'string', default: null, required: true },
|
|
377
409
|
change: { type: 'string', default: null, required: true },
|
|
378
410
|
downstream: { type: 'string', default: null, required: true },
|
|
379
|
-
mode: { type: 'string', default: null
|
|
411
|
+
mode: { type: 'string', default: null },
|
|
380
412
|
reason: { type: 'string', default: '' },
|
|
381
413
|
'tracked-by': { type: 'string', default: null },
|
|
382
414
|
'expires-days': { type: 'string', default: null, numeric: { min: 1 } },
|
|
415
|
+
withdraw: { type: 'boolean', default: false },
|
|
383
416
|
json: { type: 'boolean', default: false },
|
|
384
417
|
},
|
|
385
418
|
},
|
|
@@ -645,6 +678,19 @@ export const COMMANDS = {
|
|
|
645
678
|
json: { type: 'boolean', default: false },
|
|
646
679
|
},
|
|
647
680
|
},
|
|
681
|
+
// D-decision-event-log (D6): forward-only retraction, mirroring `gate revoke` -- removes the
|
|
682
|
+
// approval entry (its absence is the state) and appends a `withdraw` decision event. Never a
|
|
683
|
+
// snapshot restore.
|
|
684
|
+
'handles patch unapprove': {
|
|
685
|
+
usage: 'bskel handles patch unapprove --feature <id> --resource <Type> --field <name> --reason "..." [--json]',
|
|
686
|
+
options: {
|
|
687
|
+
feature: { type: 'string', default: null, required: true },
|
|
688
|
+
resource: { type: 'string', default: null, required: true },
|
|
689
|
+
field: { type: 'string', default: null, required: true },
|
|
690
|
+
reason: { type: 'string', default: '' },
|
|
691
|
+
json: { type: 'boolean', default: false },
|
|
692
|
+
},
|
|
693
|
+
},
|
|
648
694
|
// D-patch-transactions: content-addressed patch transactions, Slice 1 (config_check ->
|
|
649
695
|
// config_apply). `propose`/`approve` only touch specs/, so no --force escape exists on either --
|
|
650
696
|
// re-propose is the only remediation for a stale target. `rollback` alone gets --force (reverting
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// D-decision-event-log: an append-only audit trail for the four spec-side decision files
|
|
2
|
+
// (contract waivers, cross-feature waivers, impact dispositions, patch approvals) -- mirrors
|
|
3
|
+
// lib/state.mjs's appendGateEvent()/readGateHistory() shape exactly (same file family, same
|
|
4
|
+
// schema-validated-JSONL contract, same corrupt-line-is-skipped-not-fatal resilience). A sibling
|
|
5
|
+
// to .sbf/<featureId>.history.jsonl, not a replacement for it -- gates keep their own log.
|
|
6
|
+
// WHY: F2 (found during this item's own grounding) -- all four decision files silently
|
|
7
|
+
// OVERWRITE the prior decision (reason/actor/timestamp) on re-decision, and none of the four had
|
|
8
|
+
// any retraction command. `gate revoke` is the only retraction primitive in the whole tool.
|
|
9
|
+
// COST: one more .sbf/ file per feature. Machine-local, gitignored-by-convention -- this log is
|
|
10
|
+
// NOT tamper-evident on its own (see schemas/decision-event.schema.json's own description);
|
|
11
|
+
// its evidentiary value comes from being rolled into a SIGNED gate-export attestation.
|
|
12
|
+
// EXIT: no cross-feature aggregate view; per-feature only, matching gate history's own scoping.
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import { sbfDir } from './state.mjs';
|
|
15
|
+
import { validateAgainstSchema, formatSchemaErrors } from './schema-validate.mjs';
|
|
16
|
+
|
|
17
|
+
export function decisionLogPath(repoRoot, featureId) {
|
|
18
|
+
return `${sbfDir(repoRoot)}/${featureId}.decisions.jsonl`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Called from inside the SAME withLockSync(root, 'state', ...) each of the four write paths
|
|
22
|
+
// already holds -- never opens its own lock, so the decision write and the log append can never
|
|
23
|
+
// observe each other out of order (same discipline setGate() already applies to gate writes).
|
|
24
|
+
export function appendDecisionEvent(repoRoot, featureId, event) {
|
|
25
|
+
const line = { schema: 'sbf.decision-event/1', ...event };
|
|
26
|
+
const { ok, errors } = validateAgainstSchema('decision-event.schema.json', line);
|
|
27
|
+
if (!ok) {
|
|
28
|
+
throw new Error(`refusing to append an invalid decision event for "${featureId}":\n${formatSchemaErrors(errors).join('\n')}`);
|
|
29
|
+
}
|
|
30
|
+
fs.mkdirSync(sbfDir(repoRoot), { recursive: true });
|
|
31
|
+
fs.appendFileSync(decisionLogPath(repoRoot, featureId), `${JSON.stringify(line)}\n`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Mirrors lib/gate-export.mjs's readGateHistory() resilience contract exactly: a corrupt/invalid
|
|
35
|
+
// line is skipped with a warning, never a hard failure.
|
|
36
|
+
export function readDecisionLog(repoRoot, featureId, { kind = null, onWarning } = {}) {
|
|
37
|
+
const file = decisionLogPath(repoRoot, featureId);
|
|
38
|
+
if (!fs.existsSync(file)) return [];
|
|
39
|
+
const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
|
|
40
|
+
const events = [];
|
|
41
|
+
for (const [i, line] of lines.entries()) {
|
|
42
|
+
let parsed;
|
|
43
|
+
try {
|
|
44
|
+
parsed = JSON.parse(line);
|
|
45
|
+
} catch {
|
|
46
|
+
onWarning?.(`${file}:${i + 1}: not valid JSON, skipped`);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const { ok, errors } = validateAgainstSchema('decision-event.schema.json', parsed);
|
|
50
|
+
if (!ok) {
|
|
51
|
+
onWarning?.(`${file}:${i + 1}: does not match schemas/decision-event.schema.json, skipped`, errors);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (kind && parsed.kind !== kind) continue;
|
|
55
|
+
events.push(parsed);
|
|
56
|
+
}
|
|
57
|
+
return events;
|
|
58
|
+
}
|