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 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 Express with **no ORM** (raw `mysql2`/`mariadb`),
606
- including `serverless-http`/Lambda deployments. **Scanner only** — routes and their real absolute
607
- paths are resolved through a full mount-graph walk, but every capability is honestly `false`:
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> --reason "..." [--tracked-by "..."] [--expires-days <N>] [--json]
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
- const { resolution: updatedResolution, newEntries } = withLockSync(root, 'state', () => {
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 existingKeys = new Set((resolution.waivers ?? []).map(warningKey));
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 entries = toWaive
1921
- .filter((w) => !existingKeys.has(warningKey(w)))
1922
- .map((w) => ({ code: w.code, subject: w.subject, reason: flags.reason, at, ...(expiresAt ? { expires_at: expiresAt } : {}) }));
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: [...(resolution.waivers ?? []), ...entries],
2004
+ waivers: [...liveWaivers, ...entries],
1927
2005
  };
1928
2006
  saveResolution(root, flags.feature, next);
1929
- return { resolution: next, newEntries: entries };
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
- console.log(`waived ${newEntries.length} new warning(s)${newEntries.length < toWaive.length ? ` (${toWaive.length - newEntries.length} already waived)` : ''}${expiresAt ? `, expiring ${expiresAt}` : ''}`);
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) => typeof w.expires_at === 'string' && Date.parse(w.expires_at) <= now);
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 --reason "..." [--tracked-by "<ref>"] [--expires-days <N>] [--json]',
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, required: true },
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
+ }