backend-skeleton 1.5.0 → 1.7.0

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.
Files changed (40) hide show
  1. package/README.md +113 -8
  2. package/bin/bskel.mjs +549 -63
  3. package/contracts/completeness.mjs +12 -1
  4. package/contracts/openapi.mjs +125 -18
  5. package/handles/providers/java-spring/ast-bridge.mjs +85 -1
  6. package/handles/providers/java-spring/ast-helper/src/main/java/com/backendskeleton/asthelper/Main.java +407 -0
  7. package/handles/providers/java-spring/emit.mjs +126 -6
  8. package/handles/providers/java-spring/plan.mjs +220 -74
  9. package/handles/providers/java-spring/source-splice.mjs +477 -0
  10. package/handles/providers/java-spring/templates/AuthorizationPolicyStub.java.tmpl +30 -0
  11. package/handles/providers/java-spring/templates/HandleController.java.tmpl +21 -3
  12. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +26 -0
  13. package/handles/providers/java-spring/templates/ResourceResolverPolicyStub.java.tmpl +9 -0
  14. package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +3 -3
  15. package/lib/attest.mjs +59 -1
  16. package/lib/cli.mjs +125 -7
  17. package/lib/decision-log.mjs +58 -0
  18. package/lib/doctor.mjs +23 -0
  19. package/lib/exit-codes.mjs +17 -0
  20. package/lib/gate-definitions.mjs +65 -2
  21. package/lib/gate-export.mjs +250 -0
  22. package/lib/impact-export-graphify.mjs +145 -0
  23. package/lib/impact-graph.mjs +194 -0
  24. package/lib/impact-surface.mjs +158 -0
  25. package/lib/impact.mjs +334 -0
  26. package/lib/patch-kinds.mjs +24 -0
  27. package/lib/repo.mjs +46 -0
  28. package/lib/workflow.mjs +16 -0
  29. package/package.json +1 -1
  30. package/scanners/adapters/_java-spring-analyzer.mjs +6 -0
  31. package/schemas/decision-event.schema.json +46 -0
  32. package/schemas/gate-attestation.schema.json +6 -1
  33. package/schemas/gate-export.schema.json +606 -22
  34. package/schemas/handles-plan.schema.json +32 -0
  35. package/schemas/impact-baseline.schema.json +59 -0
  36. package/schemas/impact-graph.schema.json +53 -0
  37. package/schemas/impact-report.schema.json +86 -0
  38. package/schemas/impact-resolution.schema.json +33 -0
  39. package/schemas/java-source-splice.schema.json +84 -0
  40. package/schemas/patch-transaction.schema.json +87 -2
package/bin/bskel.mjs CHANGED
@@ -9,6 +9,7 @@ import { repoRoot, localDefaultBranch, fileHistory, showFileAtRevision, headSha,
9
9
  import { forceNamedGate, revokeNamedGate, requireNamedGate, passNamedGate, awaitNamedGateDisposition, EXIT } from '../lib/gates.mjs';
10
10
  import { REPO_GATE_ID, GATE_NAMES, gateScopeId, requireGateDefinition } from '../lib/gate-definitions.mjs';
11
11
  import { getGate, loadState, historyPath } from '../lib/state.mjs';
12
+ import { buildGateExportReport, readGateHistory } from '../lib/gate-export.mjs';
12
13
  import { writeFileAtomic, sha256File, readJsonIfExists } from '../lib/fsutil.mjs';
13
14
  import { hydrateScanReportFilePaths, dehydrateScanReportFilePaths } from '../lib/scan-report-paths.mjs';
14
15
  import { validateAgainstSchema, formatSchemaErrors } from '../lib/schema-validate.mjs';
@@ -29,17 +30,23 @@ import { ADAPTERS, LOAD_ERRORS, adapterById } from '../scanners/registry.mjs';
29
30
  import { COMMAND_CAPABILITIES, CAPABILITY_SATISFIERS, explainMissingCapability } from '../scanners/capabilities.mjs';
30
31
  import { buildContract, selectModule, CONTRACT_SCHEMA_VERSION } from '../contracts/emit.mjs';
31
32
  import { validateEnvelope, operationPayloadSchema } from '../contracts/validate.mjs';
32
- import { evaluateResolution, loadResolution, saveResolution, requireWarningCode, warningKey, countByCode } from '../contracts/completeness.mjs';
33
+ import { evaluateResolution, loadResolution, saveResolution, requireWarningCode, warningKey, countByCode, isWaiverExpired } from '../contracts/completeness.mjs';
33
34
  import { loadPatchApprovals, savePatchApprovals, approvalKey } from '../lib/patch-approvals.mjs';
35
+ import { appendDecisionEvent, readDecisionLog } from '../lib/decision-log.mjs';
34
36
  import { proposeTransaction, approveTransaction, applyTransaction, rollbackTransaction, loadTransaction, listTransactions } from '../lib/patch-transactions.mjs';
35
37
  import { getPatchKind, replanTransaction, PATCH_KIND_NAMES } from '../lib/patch-kinds.mjs';
36
- import { generateKeypair, signPayload, verifyPayload } from '../lib/attest.mjs';
38
+ import { generateKeypair, signPayload, verifyPayload, publicKeyIdFromPrivate, publicKeyIdFromPublic } from '../lib/attest.mjs';
37
39
  import { loadManifest, saveManifest } from '../lib/handles-manifest.mjs';
38
40
  import { createHttpServer } from '../lib/http-server.mjs';
39
41
  import {
40
42
  resolveClassFile, listDownstreamDependents, DependencyOperationError,
41
43
  declareDependency, removeDependency, buildDependencyListReport,
42
44
  } from '../lib/field-dependencies.mjs';
45
+ import {
46
+ ImpactOperationError, checkImpact, acceptImpact, recordDisposition, acknowledgeInbound, withdrawDisposition,
47
+ } from '../lib/impact.mjs';
48
+ import { buildImpactGraph } from '../lib/impact-graph.mjs';
49
+ import { toGraphifyExtraction, toMermaid } from '../lib/impact-export-graphify.mjs';
43
50
  import {
44
51
  findCollisions, evaluateCrossFeatureFindings, waiverKey,
45
52
  crossFeatureReportPath, loadCrossFeatureReport, loadCrossFeatureResolution, saveCrossFeatureResolution,
@@ -97,6 +104,7 @@ function usage() {
97
104
  bskel scan repair --feature <id> [--json]
98
105
  bskel scan cross-feature-check --feature <id> [--db [--database-url-env <NAME>] [--schema public]] [--json]
99
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]
100
108
  bskel feature init --slug <name>
101
109
  bskel feature list [--all] [--json]
102
110
  bskel feature show <id> [--json]
@@ -111,9 +119,15 @@ function usage() {
111
119
  bskel contract validate --feature <id> --file <envelope.json>
112
120
  bskel contract tool-schema --feature <id> --operation <operationId>
113
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]
114
123
  bskel dependency declare --feature <id> --resource <Type> --field <name> --source-feature <id> --source-resource <Type> --source-field <name> --reason "..." [--memo "..."]
115
124
  bskel dependency remove --feature <id> --resource <Type> --field <name> --source-feature <id> --source-resource <Type> --source-field <name> --reason "..."
116
125
  bskel dependency list --feature <id> [--json]
126
+ bskel impact check --feature <id> [--all] [--json]
127
+ bskel impact accept --feature <id> [--json]
128
+ bskel impact disposition --feature <id> --change <change_key> --downstream <id> (--mode <compatible|migrate|waive> [--tracked-by "..."] [--expires-days <N>] | --withdraw) --reason "..." [--json]
129
+ bskel impact ack --feature <id> --from <id> --change <change_key> --reason "..." [--json]
130
+ bskel impact export --format <graphify|json|mermaid> [--out <path>] [--focus <id>] [--rings <N>]
117
131
  bskel rules check --feature <id> [--init] [--json]
118
132
  bskel rules list --feature <id> [--json]
119
133
  bskel rules explain --feature <id> --rule <id> [--json]
@@ -123,8 +137,9 @@ function usage() {
123
137
  bskel handles plan --feature <id> [--module <name>] [--resource type1,type2] [--diff] [--ast]
124
138
  bskel handles emit --feature <id> [--module <name>] [--resource type1,type2] [--force --reason "..."] [--check] [--diff] [--enforce-registry on|off --reason "..."]
125
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]
126
141
  bskel handles audit --feature <id> --database-url-env <NAME> [--resource type1,type2] [--module <name>] [--check-registry-coverage] [--json]
127
- bskel patch propose --feature <id> [--kind config-apply|ddl-apply] --choice <stackChoiceId> --target <config_check target path> --database-url-env <NAME> --schema <name> --sql-file <path> [--json]
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]
128
143
  bskel patch approve --feature <id> --transaction <id> --reason "..." [--json]
129
144
  bskel patch apply --feature <id> --transaction <id> [--confirm <id-or-dropped-table-name>] [--json]
130
145
  bskel patch rollback --feature <id> --transaction <id> --reason "..." [--force] [--json]
@@ -139,9 +154,9 @@ function usage() {
139
154
  bskel gate revoke <name> --reason "..." [--feature <id>]
140
155
  bskel gate history <name> [--feature <id>] [--json]
141
156
  bskel gate show [<name>] [--feature <id>]
142
- bskel gate export --feature <id> [--out <path>] [--sign --key <privateKeyPath>] [--json]
157
+ bskel gate export --feature <id> [--out <path>] [--sign --key <privateKeyPath> [--allow-dirty]] [--json]
143
158
  bskel attest keygen --out <dir> [--force] [--json]
144
- bskel attest verify --file <path> --pubkey <path> [--json]
159
+ bskel attest verify --file <path> --pubkey <path> [--expect-head <sha>] [--max-age-minutes N] [--reject-dirty] [--json]
145
160
  bskel doctor [--workflow ${DOCTOR_WORKFLOWS.join('|')}] [--json]
146
161
  bskel serve [--port N] [--host <addr>] [--database-url-env <NAME>] [--schema <name>] [--sign-key <path>] [--require-sign-key] [--json]
147
162
  `);
@@ -352,32 +367,6 @@ function cmdGateRevoke(args) {
352
367
  process.exit(EXIT_CODES.NOT_PASSED);
353
368
  }
354
369
 
355
- // S4 (D-gate-history): reads the append-only .sbf/<feature>.history.jsonl -- a corrupt/invalid
356
- // line is skipped with a warning, not a hard failure, matching JSONL's own resilience rationale
357
- // (see lib/state.mjs's appendGateEvent).
358
- function readGateHistory(root, featureId, gateName) {
359
- const file = historyPath(root, featureId);
360
- if (!fs.existsSync(file)) return [];
361
- const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
362
- const events = [];
363
- for (const [i, line] of lines.entries()) {
364
- let parsed;
365
- try {
366
- parsed = JSON.parse(line);
367
- } catch {
368
- console.error(`warning: ${file}:${i + 1}: not valid JSON, skipped`);
369
- continue;
370
- }
371
- const { ok, errors } = validateAgainstSchema('gate-event.schema.json', parsed);
372
- if (!ok) {
373
- console.error(`warning: ${file}:${i + 1}: does not match schemas/gate-event.schema.json, skipped:\n${formatSchemaErrors(errors).join('\n')}`);
374
- continue;
375
- }
376
- if (parsed.gate === gateName) events.push(parsed);
377
- }
378
- return events;
379
- }
380
-
381
370
  function cmdGateHistory(args) {
382
371
  const flags = parseCommand('gate history', args);
383
372
  if (flags.help) { console.log(renderCommandHelp('gate history')); process.exit(0); }
@@ -386,7 +375,11 @@ function cmdGateHistory(args) {
386
375
  const gateName = flags._[0];
387
376
  if (!gateName) fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'usage: bskel gate history <name> [--feature <id>] [--json]');
388
377
  resolveGateArg(gateName, flags.feature);
389
- const events = readGateHistory(root, flags.feature, gateName);
378
+ // S4 (D-gate-history): a corrupt/invalid history line is skipped with a warning, not a hard
379
+ // failure, matching JSONL's own resilience rationale (see lib/state.mjs's appendGateEvent).
380
+ const events = readGateHistory(root, flags.feature, gateName, {
381
+ onWarning: (msg, errors) => console.error(`warning: ${msg}${errors ? `:\n${formatSchemaErrors(errors).join('\n')}` : ''}`),
382
+ });
390
383
  if (flags.json) {
391
384
  console.log(JSON.stringify(events, null, 2));
392
385
  } else if (events.length === 0) {
@@ -439,20 +432,15 @@ function cmdGateExport(args) {
439
432
  if (flags.key && !flags.sign) {
440
433
  fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', '--key only has an effect together with --sign');
441
434
  }
442
-
443
- const gates = {};
444
- for (const name of GATE_NAMES) {
445
- const scopeId = gateScopeId(name, flags.feature);
446
- gates[name] = { scope: scopeId, current: getGate(root, scopeId, name), history: readGateHistory(root, scopeId, name) };
435
+ // D-attestation-payload-completeness (K4): --allow-dirty only means something alongside --sign
436
+ // -- an unsigned export never refuses a dirty tree (unchanged behavior), so a bare --allow-dirty
437
+ // would otherwise be a silently-ignored flag, the exact failure mode D-cli-contract already
438
+ // refuses everywhere else.
439
+ if (flags['allow-dirty'] && !flags.sign) {
440
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', '--allow-dirty only has an effect together with --sign -- an unsigned gate export never refuses a dirty tree');
447
441
  }
448
442
 
449
- const report = {
450
- schema: 'sbf.gate-export/1',
451
- feature_id: flags.feature,
452
- generated_at: new Date().toISOString(),
453
- git: { branch: currentBranch(root), head_sha: headSha(root), dirty: isDirty(root) },
454
- gates,
455
- };
443
+ const report = buildGateExportReport(root, flags.feature, { dirtyAcknowledged: Boolean(flags['allow-dirty']) });
456
444
  // D-gate-attestation-signing: validated unconditionally, signed or not -- a document that can
457
445
  // be exported unsigned should be exactly as trustworthy in shape as one that gets signed later.
458
446
  {
@@ -464,6 +452,20 @@ function cmdGateExport(args) {
464
452
 
465
453
  let payload = report;
466
454
  if (flags.sign) {
455
+ // D-attestation-payload-completeness (K4): refuses to sign over a dirty working tree unless
456
+ // explicitly acknowledged -- direct reuse of scripts/preflight-base-ref.sh's own already-
457
+ // shipped --allow-dirty convention (same flag name, same DIRTY exit code, same reasoning: "so
458
+ // evidence can honestly distinguish 'clean tree, passed' from 'dirty tree, --allow-dirty
459
+ // overrode it'"). Signing is a strictly stronger claim than preflighting, so it cannot be
460
+ // laxer. `report.git.dirty === true` is checked specifically, not truthiness -- isDirty()
461
+ // (and therefore this field) can be `null` when git itself failed, and "we could not
462
+ // determine dirtiness" must not silently pass this refusal.
463
+ if (report.git.dirty === null) {
464
+ fail(EXIT_CODES.DIRTY, 'DIRTY', 'refusing to sign an attestation: could not determine whether the working tree is dirty (git status failed) -- fix the git error, or pass --allow-dirty to sign anyway.');
465
+ }
466
+ if (report.git.dirty === true && !flags['allow-dirty']) {
467
+ fail(EXIT_CODES.DIRTY, 'DIRTY', `refusing to sign an attestation over a dirty working tree (${report.git.dirty_file_count} uncommitted change(s)) -- commit/stash them, or pass --allow-dirty to sign anyway (the acknowledgement is recorded inside the signed payload). The same refusal \`bskel preflight\` already makes.`);
468
+ }
467
469
  let privateKeyPem;
468
470
  try {
469
471
  privateKeyPem = fs.readFileSync(path.resolve(process.cwd(), flags.key), 'utf8');
@@ -471,12 +473,14 @@ function cmdGateExport(args) {
471
473
  fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `could not read --key "${flags.key}": ${err.message}`);
472
474
  }
473
475
  let signatureValue;
476
+ let keyId;
474
477
  try {
475
478
  signatureValue = signPayload(report, privateKeyPem);
479
+ keyId = publicKeyIdFromPrivate(privateKeyPem);
476
480
  } catch (err) {
477
481
  fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--key "${flags.key}" is not a usable Ed25519 private key: ${err.message}`);
478
482
  }
479
- const attestation = { schema: 'sbf.gate-attestation/1', report, signature: { algorithm: 'ed25519', value: signatureValue } };
483
+ const attestation = { schema: 'sbf.gate-attestation/1', report, signature: { algorithm: 'ed25519', value: signatureValue, key_id: keyId } };
480
484
  const { ok, errors } = validateAgainstSchema('gate-attestation.schema.json', attestation);
481
485
  if (!ok) {
482
486
  fail(EXIT_CODES.NOT_PASSED, 'INVALID_ARTIFACT', `internal error: the computed gate attestation failed its own schema -- ${formatSchemaErrors(errors).join('; ')}`);
@@ -489,9 +493,11 @@ function cmdGateExport(args) {
489
493
  const outPath = path.resolve(process.cwd(), flags.out);
490
494
  writeFileAtomic(outPath, rendered);
491
495
  if (!flags.quiet) {
492
- const passCount = GATE_NAMES.filter((n) => gates[n].current?.status === 'pass').length;
496
+ const passCount = report.verdict.passing;
493
497
  const signedNote = flags.sign ? ' (signed)' : '';
494
- console.log(`wrote ${flags.out}${signedNote} -- ${passCount}/${GATE_NAMES.length} gate(s) currently passing, ${report.git.branch}@${report.git.head_sha?.slice(0, 12) ?? '(unknown)'}${report.git.dirty ? ' (dirty)' : ''}`);
498
+ const dirtyNote = report.git.dirty ? (report.git.dirty_acknowledged ? ', dirty (acknowledged)' : ', dirty') : '';
499
+ const blockingNote = report.verdict.blocking_gates.length > 0 ? ` -- blocking: ${report.verdict.blocking_gates.join(', ')}` : '';
500
+ console.log(`wrote ${flags.out}${signedNote} -- ${passCount}/${GATE_NAMES.length} gate(s) currently passing, ${report.git.branch}@${report.git.head_sha?.slice(0, 12) ?? '(unknown)'}${dirtyNote}${blockingNote}`);
495
501
  }
496
502
  } else {
497
503
  console.log(rendered);
@@ -562,13 +568,64 @@ function cmdAttestVerify(args) {
562
568
  const valid = verifyPayload(attestation.report, attestation.signature.value, publicKeyPem);
563
569
  const passCount = GATE_NAMES.filter((n) => attestation.report.gates[n]?.current?.status === 'pass').length;
564
570
 
571
+ // D-attestation-payload-completeness (K5): three OPT-IN, default-off assertions, evaluated
572
+ // purely against fields already inside the report (so verification stays fully offline and
573
+ // repo-independent). They exist for both sbf.gate-export/1 and /2 reports -- head_sha/
574
+ // generated_at/dirty are unchanged fields from /1, nothing here needed a /2-only field.
575
+ const assertions = [];
576
+ if (flags['expect-head']) {
577
+ const actual = attestation.report.git?.head_sha ?? null;
578
+ const ok = actual === flags['expect-head'];
579
+ assertions.push({ name: 'expect-head', ok, detail: ok ? `head_sha matches ${flags['expect-head']}` : `report's head_sha is "${actual}", expected "${flags['expect-head']}"` });
580
+ }
581
+ const maxAgeMinutes = flags['max-age-minutes'] != null ? Number(flags['max-age-minutes']) : 0;
582
+ if (maxAgeMinutes > 0) {
583
+ const generatedAtMs = Date.parse(attestation.report.generated_at);
584
+ const ageSeconds = Number.isFinite(generatedAtMs) ? Math.max(0, Math.round((Date.now() - generatedAtMs) / 1000)) : null;
585
+ const ok = ageSeconds !== null && ageSeconds <= maxAgeMinutes * 60;
586
+ assertions.push({ name: 'max-age-minutes', ok, detail: ageSeconds === null ? `report's generated_at ("${attestation.report.generated_at}") is not a parseable timestamp` : `age ${ageSeconds}s, limit ${maxAgeMinutes * 60}s` });
587
+ }
588
+ if (flags['reject-dirty']) {
589
+ const dirty = attestation.report.git?.dirty ?? null;
590
+ const ok = dirty !== true;
591
+ assertions.push({ name: 'reject-dirty', ok, detail: ok ? 'report is not dirty' : 'report.git.dirty === true' });
592
+ }
593
+ const assertionsOk = assertions.every((a) => a.ok);
594
+
595
+ // D-attestation-payload-completeness (K6): key_id is a SELECTION HINT only -- it sits OUTSIDE
596
+ // the signed bytes (only `report` is signed), so it cannot change whether `valid` is true. It
597
+ // exists purely to make a wrong---pubkey mistake legible instead of an unexplained INVALID.
598
+ let keyIdNote = null;
599
+ if (attestation.signature.key_id) {
600
+ try {
601
+ const actualKeyId = publicKeyIdFromPublic(publicKeyPem);
602
+ if (attestation.signature.key_id !== actualKeyId) {
603
+ keyIdNote = `this attestation declares key_id ${attestation.signature.key_id}, but --pubkey is ${actualKeyId} -- you may have the wrong public key`;
604
+ }
605
+ } catch {
606
+ // A --pubkey that isn't even a parseable key already drives verifyPayload() to false
607
+ // above; nothing more useful to say here.
608
+ }
609
+ }
610
+ const legacyReport = attestation.report.schema === 'sbf.gate-export/1';
611
+
565
612
  if (flags.json) {
566
- console.log(JSON.stringify({ valid, report_summary: { feature_id: attestation.report.feature_id, generated_at: attestation.report.generated_at, gates_passing: `${passCount}/${GATE_NAMES.length}` } }, null, 2));
613
+ console.log(JSON.stringify({
614
+ valid,
615
+ report_format: attestation.report.schema ?? 'sbf.gate-export/1',
616
+ report_summary: { feature_id: attestation.report.feature_id, generated_at: attestation.report.generated_at, gates_passing: `${passCount}/${GATE_NAMES.length}` },
617
+ assertions,
618
+ key_id_note: keyIdNote,
619
+ }, null, 2));
567
620
  } else if (!flags.quiet) {
568
621
  console.log(valid ? 'VALID: this attestation was genuinely signed by the holder of the matching private key' : 'INVALID: signature does not match this report + public key');
622
+ if (legacyReport) console.log('report format: sbf.gate-export/1 (pre-D-attestation-payload-completeness -- no tool version, no live gate verdict)');
623
+ if (keyIdNote) console.log(`note: ${keyIdNote}`);
569
624
  console.log(`report says: feature ${attestation.report.feature_id}, ${passCount}/${GATE_NAMES.length} gate(s) passing, generated ${attestation.report.generated_at}`);
625
+ for (const a of assertions) console.log(`assertion ${a.name}: ${a.ok ? 'ok' : 'FAILED'} -- ${a.detail}`);
570
626
  }
571
- process.exit(valid ? EXIT_CODES.OK : EXIT_CODES.CHECK_FAILED);
627
+ if (!valid) process.exit(EXIT_CODES.CHECK_FAILED);
628
+ process.exit(assertionsOk ? EXIT_CODES.OK : EXIT_CODES.ATTESTATION_ASSERTION_FAILED);
572
629
  }
573
630
 
574
631
  // Structural enforcement of "preflight blocks everything below it" (see the workflow table in
@@ -972,6 +1029,10 @@ function cmdScanCrossFeatureWaive(args) {
972
1029
  waivers: [...resolution.waivers.filter((w) => waiverKey(w) !== key), entry],
973
1030
  };
974
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
+ });
975
1036
  return next;
976
1037
  });
977
1038
 
@@ -995,6 +1056,64 @@ function cmdScanCrossFeatureWaive(args) {
995
1056
  process.exit(evaluation.blocking ? EXIT.AWAITING_DISPOSITION : EXIT.PASS);
996
1057
  }
997
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
+
998
1117
  // D6 (D-feature-lifecycle): the whole read-specs/->compute-NNN->write-feature.json->
999
1118
  // load-modify-save-feature-index.json sequence runs under one exclusive lock -- confirmed live
1000
1119
  // during this item's own grounding, the same lost-update shape S5 already fixed for setGate():
@@ -1860,20 +1979,38 @@ function cmdContractWaive(args) {
1860
1979
  // entries). Locking only the final write (inside saveResolution()) would NOT close this race --
1861
1980
  // the window is between this function's own loadResolution() read and its save, not inside the
1862
1981
  // write call itself.
1863
- 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', () => {
1864
1991
  const resolution = loadResolution(root, flags.feature);
1865
- 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));
1866
1996
  const at = new Date().toISOString();
1867
- const entries = toWaive
1868
- .filter((w) => !existingKeys.has(warningKey(w)))
1869
- .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)));
1870
2001
  const next = {
1871
2002
  schema: 'sbf.contract-resolution/1',
1872
2003
  feature_id: flags.feature,
1873
- waivers: [...(resolution.waivers ?? []), ...entries],
2004
+ waivers: [...liveWaivers, ...entries],
1874
2005
  };
1875
2006
  saveResolution(root, flags.feature, next);
1876
- 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 };
1877
2014
  });
1878
2015
 
1879
2016
  const evaluation = evaluateResolution(contract, updatedResolution);
@@ -1891,10 +2028,14 @@ function cmdContractWaive(args) {
1891
2028
  : passNamedGate(root, 'contract', flags.feature, evidence);
1892
2029
 
1893
2030
  if (flags.json) {
1894
- 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));
1895
2032
  } else {
1896
2033
  if (!flags.quiet) {
1897
- 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
+ }
1898
2039
  console.log(`gate: contract -> ${gateState.gates.contract.status}`);
1899
2040
  }
1900
2041
  if (evaluation.expiredWaivers.length > 0) {
@@ -1909,6 +2050,65 @@ function cmdContractWaive(args) {
1909
2050
  process.exit(evaluation.blocking ? EXIT.AWAITING_DISPOSITION : EXIT.PASS);
1910
2051
  }
1911
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
+
1912
2112
  // D-dependency-propagation-notice: called from cmdContractEmit/cmdHandlesEmit to warn a SOURCE
1913
2113
  // feature, at the moment its own generated artifacts are refreshed, that other features declared a
1914
2114
  // dependency on one of its fields. Only surfaces a note when the dependent's OWN `dependencies` gate
@@ -2026,6 +2226,162 @@ function cmdDependencyList(args) {
2026
2226
  process.exit(0);
2027
2227
  }
2028
2228
 
2229
+ // D-cross-feature-impact-graph: `bskel impact check` -- read-mostly, never advances the baseline.
2230
+ // `--all` sweeps every active feature; this is the only thing that closes the `impact` gate's own
2231
+ // counterparty-narrowing limitation (a brand-new downstream feature is otherwise only caught at the
2232
+ // next explicit check, see D4/D7 in DECISIONS.md) -- the recommended CI invocation.
2233
+ function cmdImpactCheck(args) {
2234
+ const flags = parseCommand('impact check', args);
2235
+ if (flags.help) { console.log(renderCommandHelp('impact check')); process.exit(0); }
2236
+ setContext('impact check', flags);
2237
+ const root = requireRepoRoot();
2238
+ if (!flags.all && !flags.feature) fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel impact check requires --feature <id> or --all');
2239
+ const featureIds = flags.all ? listFeatures(root).map((r) => r.feature_id) : [flags.feature];
2240
+
2241
+ const results = [];
2242
+ for (const featureId of featureIds) {
2243
+ let outcome;
2244
+ try {
2245
+ outcome = checkImpact(root, featureId);
2246
+ } catch (err) {
2247
+ if (err instanceof ImpactOperationError) fail(err.exitCode, err.reasonCode, err.message);
2248
+ throw err;
2249
+ }
2250
+ results.push({ feature_id: featureId, ...outcome });
2251
+ }
2252
+
2253
+ if (flags.json) {
2254
+ console.log(JSON.stringify(flags.all ? results : results[0], null, 2));
2255
+ } else {
2256
+ for (const r of results) {
2257
+ console.log(`impact check -- ${r.feature_id}`);
2258
+ console.log(` changes: ${r.report.changes.length}, outbound: ${r.report.outbound.length}, inbound: ${r.report.inbound.length}`);
2259
+ console.log(` gate: ${r.evaluation.blocking ? 'awaiting_disposition' : 'pass'}`);
2260
+ for (const o of r.evaluation.blockingOutbound) console.log(` BLOCKING: ${o.change_key} -> ${o.downstream_feature} (${o.via}, ${o.confidence})`);
2261
+ for (const i of r.evaluation.blockingInbound) console.log(` BLOCKING (inbound): ${i.change_key} from ${i.upstream_feature}, unacknowledged`);
2262
+ }
2263
+ }
2264
+ const anyBlocking = results.some((r) => r.evaluation.blocking);
2265
+ process.exit(anyBlocking ? EXIT_CODES.AWAITING_DISPOSITION : EXIT.PASS);
2266
+ }
2267
+
2268
+ // D-cross-feature-impact-graph: the DECIDE half -- refuses (exit 3) if anything proven is
2269
+ // undisposed, otherwise atomically rewrites impact-baseline.json from the CURRENT surface.
2270
+ function cmdImpactAccept(args) {
2271
+ const flags = parseCommand('impact accept', args);
2272
+ if (flags.help) { console.log(renderCommandHelp('impact accept')); process.exit(0); }
2273
+ setContext('impact accept', flags);
2274
+ const root = requireRepoRoot();
2275
+ let result;
2276
+ try {
2277
+ result = acceptImpact(root, flags.feature);
2278
+ } catch (err) {
2279
+ if (err instanceof ImpactOperationError) fail(err.exitCode, err.reasonCode, err.message);
2280
+ throw err;
2281
+ }
2282
+ const gateState = passNamedGate(root, 'impact', flags.feature);
2283
+ if (flags.json) {
2284
+ console.log(JSON.stringify({ ...result, gate: gateState.gates.impact }, null, 2));
2285
+ } else {
2286
+ console.log(`accepted: ${flags.feature} -- baseline captured at ${result.baseline.captured_at}`);
2287
+ console.log(`gate: impact -> ${gateState.gates.impact.status}`);
2288
+ }
2289
+ process.exit(EXIT.PASS);
2290
+ }
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.
2296
+ function cmdImpactDisposition(args) {
2297
+ const flags = parseCommand('impact disposition', args);
2298
+ if (flags.help) { console.log(renderCommandHelp('impact disposition')); process.exit(0); }
2299
+ setContext('impact disposition', flags);
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
+ }
2316
+ let result;
2317
+ try {
2318
+ result = recordDisposition(root, {
2319
+ feature: flags.feature, changeKey: flags.change, downstreamFeature: flags.downstream,
2320
+ mode: flags.mode, reason: flags.reason, trackedBy: flags['tracked-by'],
2321
+ expiresDays: flags['expires-days'] != null ? Number(flags['expires-days']) : null,
2322
+ });
2323
+ } catch (err) {
2324
+ if (err instanceof ImpactOperationError) fail(err.exitCode, err.reasonCode, err.message);
2325
+ throw err;
2326
+ }
2327
+ if (flags.json) {
2328
+ console.log(JSON.stringify(result, null, 2));
2329
+ } else {
2330
+ console.log(`disposition recorded: ${flags.change} -> ${flags.downstream} (${flags.mode})`);
2331
+ }
2332
+ process.exit(EXIT.PASS);
2333
+ }
2334
+
2335
+ function cmdImpactAck(args) {
2336
+ const flags = parseCommand('impact ack', args);
2337
+ if (flags.help) { console.log(renderCommandHelp('impact ack')); process.exit(0); }
2338
+ setContext('impact ack', flags);
2339
+ const root = requireRepoRoot();
2340
+ let result;
2341
+ try {
2342
+ result = acknowledgeInbound(root, { feature: flags.feature, from: flags.from, changeKey: flags.change, reason: flags.reason });
2343
+ } catch (err) {
2344
+ if (err instanceof ImpactOperationError) fail(err.exitCode, err.reasonCode, err.message);
2345
+ throw err;
2346
+ }
2347
+ if (flags.json) {
2348
+ console.log(JSON.stringify(result, null, 2));
2349
+ } else {
2350
+ console.log(`acknowledged: ${flags.change} from ${flags.from}`);
2351
+ }
2352
+ process.exit(EXIT.PASS);
2353
+ }
2354
+
2355
+ // D-cross-feature-impact-graph (IG8/IG9): the one seam to the LLM-driven exploration layer -- writes
2356
+ // data only, spawns nothing, never touches a gate. `--format graphify` writes graphify's own native
2357
+ // extraction shape directly (see lib/impact-export-graphify.mjs's header for why this bypasses its
2358
+ // own Steps 1-3 entirely).
2359
+ function cmdImpactExport(args) {
2360
+ const flags = parseCommand('impact export', args);
2361
+ if (flags.help) { console.log(renderCommandHelp('impact export')); process.exit(0); }
2362
+ setContext('impact export', flags);
2363
+ const root = requireRepoRoot();
2364
+ if (!['graphify', 'json', 'mermaid'].includes(flags.format)) {
2365
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--format must be one of graphify|json|mermaid (got "${flags.format}")`);
2366
+ }
2367
+ const graph = buildImpactGraph(root);
2368
+ const rings = flags.rings != null ? Number(flags.rings) : null;
2369
+ if (rings != null && !flags.focus) fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', '--rings only has an effect together with --focus');
2370
+
2371
+ let output;
2372
+ if (flags.format === 'json') output = `${JSON.stringify(graph, null, 2)}\n`;
2373
+ else if (flags.format === 'mermaid') output = `${toMermaid(graph)}\n`;
2374
+ else output = `${JSON.stringify(toGraphifyExtraction(graph, { focus: flags.focus, rings }), null, 2)}\n`;
2375
+
2376
+ if (flags.out) {
2377
+ writeFileAtomic(flags.out, output);
2378
+ console.log(`wrote ${flags.out}`);
2379
+ } else {
2380
+ process.stdout.write(output);
2381
+ }
2382
+ process.exit(EXIT.PASS);
2383
+ }
2384
+
2029
2385
  // D-business-rules (R6): compiles specs/<id>/rules.yaml (optional) plus the feature's own contract
2030
2386
  // into specs/<id>/rules/<id>.rules.json, and establishes the `rules` gate.
2031
2387
  //
@@ -2719,6 +3075,15 @@ function renderHandlesPlan(plan, actions) {
2719
3075
  lines.push(`- read via: ${r.readPath ?? '(not found)'}`);
2720
3076
  lines.push(`- requiredAuthority (fetch/recover): ${r.requiredAuthority}`);
2721
3077
  if (r.requiredAuthorityForPatch !== undefined) lines.push(`- requiredAuthorityForPatch: ${r.requiredAuthorityForPatch}`);
3078
+ // D-resolver-policy-contract (PC2): one line per policies[] record -- java-spring only
3079
+ // (the field is absent on python-fastapi/typescript-express plans).
3080
+ for (const p of r.policies ?? []) {
3081
+ if (p.status === 'materialized') {
3082
+ lines.push(`- policy[${p.action}]: materialized (${p.mode}) ${p.authority} -- ${p.evidence.kind}${p.evidence.file ? ` @ ${p.evidence.file}${p.evidence.line ? `:${p.evidence.line}` : ''}` : ''}`);
3083
+ } else {
3084
+ lines.push(`- policy[${p.action}]: UNRESOLVED (${p.mode}) -- ${p.evidence.kind}${p.evidence.file ? ` @ ${p.evidence.file}${p.evidence.line ? `:${p.evidence.line}` : ''}` : ''} -- ${p.reason}`);
3085
+ }
3086
+ }
2722
3087
  lines.push('');
2723
3088
  }
2724
3089
  if (plan.notes.length > 0) {
@@ -2959,7 +3324,7 @@ function cmdHandlesEmit(args) {
2959
3324
  // a diff" that also means "and actually write it", so --diff forces dryRun the same as --check
2960
3325
  // does, without requiring both flags together.
2961
3326
  const dryRun = flags.check || flags.diff;
2962
- const { written, resolverStubs, conflicts, orphans, notes, forced, blocked, actions, postEmitNotes = [], registrationGaps = [] } = provider.emit({
3327
+ const { written, resolverStubs, conflicts, orphans, notes, forced, blocked, actions, postEmitNotes = [], registrationGaps = [], unresolvedPolicies = [] } = provider.emit({
2963
3328
  repoRoot: root, featureId: flags.feature, plan, resourceFilter, force: flags.force, reason: flags.reason, dryRun, computeDiff: flags.diff, enforceRegistry,
2964
3329
  });
2965
3330
 
@@ -3039,6 +3404,24 @@ function cmdHandlesEmit(args) {
3039
3404
  process.exit(EXIT_CODES.HANDLES_REGISTRATION_GAP);
3040
3405
  }
3041
3406
 
3407
+ // D-resolver-policy-contract (PC9): unconditional -- NOT gated on enforceRegistry (orthogonal
3408
+ // axis: that one is about revocation/lifecycle and produces 404s; this one is about
3409
+ // authorization and produces 403s/boot failures). Same acknowledgement mechanism as
3410
+ // registrationGaps above (--force --reason, no new flag) -- the AuthorizationPolicy.java
3411
+ // file(s) ARE still written either way; only the overall command's reported success, and the
3412
+ // `handles` gate passing, are gated on acknowledging the gap.
3413
+ if (unresolvedPolicies.length > 0 && !flags.force) {
3414
+ if (flags.json) {
3415
+ console.log(JSON.stringify({ written, resolverStubs, conflicts, orphans, forced, notes: allNotes, actions, unresolvedPolicies, blocked: true, gate: null, check: dryRun }, null, 2));
3416
+ } else {
3417
+ const verb = dryRun ? 'would refuse to report success' : 'refusing to report success';
3418
+ console.error(`${verb}: ${unresolvedPolicies.length} resource action(s) could not have their authorization safely auto-derived:`);
3419
+ for (const u of unresolvedPolicies) console.error(` ${u.resourceType} [${u.action}] (${u.kind}${u.file ? ` @ ${u.file}${u.line ? `:${u.line}` : ''}` : ''})\n ${u.note}`);
3420
+ if (!dryRun) console.error(`\nthe resolver/AuthorizationPolicy file(s) above were still written -- nothing about their content is wrong. Implement the generated AuthorizationPolicy interface(s) by hand and re-run, or acknowledge and proceed with: bskel handles emit --feature ${flags.feature}${flags.module ? ` --module ${flags.module}` : ''}${flags.resource ? ` --resource ${flags.resource}` : ''} --force --reason "..."`);
3421
+ }
3422
+ process.exit(EXIT_CODES.HANDLES_UNRESOLVED_POLICY);
3423
+ }
3424
+
3042
3425
  // D4: dryRun never marks the gate passed -- nothing real happened this run.
3043
3426
  const gateState = dryRun ? null : passNamedGate(root, 'handles', flags.feature, { resolverStubs });
3044
3427
 
@@ -3048,7 +3431,7 @@ function cmdHandlesEmit(args) {
3048
3431
  postEmitNotes.push(...describeDownstreamImpact(root, flags.feature));
3049
3432
 
3050
3433
  if (flags.json) {
3051
- console.log(JSON.stringify({ written, resolverStubs, conflicts, orphans, forced, notes: allNotes, actions, blocked: false, gate: gateState?.gates.handles ?? null, check: dryRun, postEmitNotes }, null, 2));
3434
+ console.log(JSON.stringify({ written, resolverStubs, conflicts, orphans, forced, notes: allNotes, actions, unresolvedPolicies, blocked: false, gate: gateState?.gates.handles ?? null, check: dryRun, postEmitNotes }, null, 2));
3052
3435
  } else if (!flags.quiet) {
3053
3436
  console.log(`${dryRun ? 'would write' : 'wrote'} ${written.length} file(s):`);
3054
3437
  for (const w of written) console.log(` ${w}`);
@@ -3129,6 +3512,10 @@ function cmdHandlesPatchApprove(args) {
3129
3512
  const withoutExisting = (current.approvals ?? []).filter((a) => approvalKey(a.resource, a.field) !== key);
3130
3513
  const next = { schema: 'sbf.patch-approvals/1', feature_id: flags.feature, approvals: [...withoutExisting, entry] };
3131
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
+ });
3132
3519
  return next;
3133
3520
  });
3134
3521
 
@@ -3136,6 +3523,41 @@ function cmdHandlesPatchApprove(args) {
3136
3523
  process.exit(0);
3137
3524
  }
3138
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
+
3139
3561
  // D-patch-transactions: Slice 1 (config_check -> config_apply). All four commands only touch
3140
3562
  // specs/<featureId>/patch-transactions/ except `apply`/`rollback`, which write to the real target
3141
3563
  // file too -- matching D4's own "propose/approve are specs/-only, apply/rollback touch the repo"
@@ -3164,7 +3586,7 @@ async function cmdPatchPropose(args) {
3164
3586
  }
3165
3587
  params = { choice: flags.choice, target: flags.target };
3166
3588
  source = { choice: flags.choice };
3167
- } else {
3589
+ } else if (kind === 'ddl-apply') {
3168
3590
  if (!flags['database-url-env'] || !flags['sql-file']) {
3169
3591
  fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel patch propose --kind ddl-apply requires --database-url-env <NAME> --sql-file <path>');
3170
3592
  }
@@ -3176,6 +3598,23 @@ async function cmdPatchPropose(args) {
3176
3598
  }
3177
3599
  params = { databaseUrlEnv: flags['database-url-env'], schema: flags.schema, sqlText };
3178
3600
  source = { database_url_env: flags['database-url-env'], schema: flags.schema };
3601
+ } else {
3602
+ // java-source-splice
3603
+ if (!flags['splice-file']) {
3604
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel patch propose --kind java-source-splice requires --splice-file <path>');
3605
+ }
3606
+ let spliceDoc;
3607
+ try {
3608
+ spliceDoc = JSON.parse(fs.readFileSync(flags['splice-file'], 'utf8'));
3609
+ } catch (err) {
3610
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `could not read/parse --splice-file "${flags['splice-file']}": ${err.message}`);
3611
+ }
3612
+ const { ok: spliceOk, errors: spliceErrors } = validateAgainstSchema('java-source-splice.schema.json', spliceDoc);
3613
+ if (!spliceOk) {
3614
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `"${flags['splice-file']}" does not match schemas/java-source-splice.schema.json:\n${formatSchemaErrors(spliceErrors).join('\n')}`);
3615
+ }
3616
+ params = { file: spliceDoc.file, edits: spliceDoc.edits };
3617
+ source = { request_file: flags['splice-file'] };
3179
3618
  }
3180
3619
 
3181
3620
  let plan;
@@ -3202,6 +3641,12 @@ function describePatchTransaction(txn) {
3202
3641
  if (txn.kind === 'config-apply') {
3203
3642
  return `${txn.target.file} @ ${txn.target.key_path.join('.')}: "${txn.current_value}" -> "${txn.proposed_value}"`;
3204
3643
  }
3644
+ if (txn.kind === 'java-source-splice') {
3645
+ const members = txn.target.edits
3646
+ .map((e) => (e.locator ? `${e.op}:${e.locator.member_name}` : `add-import:${e.imports?.[0]}`))
3647
+ .join(', ');
3648
+ return `[java-source-splice] ${txn.target.file}: ${members}`;
3649
+ }
3205
3650
  const sql = txn.target.sql_text.replace(/\s+/g, ' ').trim();
3206
3651
  return `[${txn.kind}] ${txn.target.database_url_env}/${txn.target.schema}: ${sql.slice(0, 100)}${sql.length > 100 ? '...' : ''}`;
3207
3652
  }
@@ -3231,12 +3676,33 @@ async function cmdPatchApprove(args) {
3231
3676
  } catch (err) {
3232
3677
  fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', err.message);
3233
3678
  }
3234
- const updated = approveTransaction(root, flags.feature, flags.transaction, flags.reason, freshPlan);
3679
+ let updated;
3680
+ try {
3681
+ updated = approveTransaction(root, flags.feature, flags.transaction, flags.reason, freshPlan);
3682
+ } catch (err) {
3683
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', describeStaleTransactionError(err, root, txn, freshPlan));
3684
+ }
3235
3685
 
3236
3686
  console.log(flags.json ? JSON.stringify(updated, null, 2) : `approved: ${flags.transaction}`);
3237
3687
  process.exit(0);
3238
3688
  }
3239
3689
 
3690
+ // D-java-source-splice: enriches a StaleTransactionError with the kind's own optional
3691
+ // describeStaleness() hook (only java-source-splice defines one today) -- never changes WHETHER
3692
+ // the transaction is rejected, only the diagnostic text. Checked by `.name`, not `instanceof`
3693
+ // (StaleTransactionError is intentionally not exported from lib/patch-transactions.mjs -- this is
3694
+ // its only consumer, and importing the class just to narrow a catch is unnecessary coupling).
3695
+ function describeStaleTransactionError(err, root, txn, freshPlan) {
3696
+ if (err.name !== 'StaleTransactionError') return err.message;
3697
+ const describe = getPatchKind(txn.kind).describeStaleness;
3698
+ if (!describe) return err.message;
3699
+ try {
3700
+ return describe(root, txn, freshPlan);
3701
+ } catch {
3702
+ return err.message;
3703
+ }
3704
+ }
3705
+
3240
3706
  // D-ddl-apply: --confirm is required (and must exactly equal --transaction) for any kind other
3241
3707
  // than 'config-apply' -- deliberately checked here, at the CLI boundary, before applyTransaction()
3242
3708
  // is ever called, as human-factors friction layered ON TOP of the engine's own load-bearing
@@ -3262,7 +3728,12 @@ async function cmdPatchApply(args) {
3262
3728
  } catch (err) {
3263
3729
  fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', err.message);
3264
3730
  }
3265
- const updated = await applyTransaction(root, flags.feature, flags.transaction, freshPlan, getPatchKind(txn.kind).apply);
3731
+ let updated;
3732
+ try {
3733
+ updated = await applyTransaction(root, flags.feature, flags.transaction, freshPlan, getPatchKind(txn.kind).apply);
3734
+ } catch (err) {
3735
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', describeStaleTransactionError(err, root, txn, freshPlan));
3736
+ }
3266
3737
 
3267
3738
  const evidence = { transaction_id: updated.transaction_id, kind: updated.kind, applied_at: updated.apply.at };
3268
3739
  const gateState = passNamedGate(root, 'patch_transactions', flags.feature, evidence);
@@ -4415,6 +4886,7 @@ async function dispatchCommand(cmd, rest) {
4415
4886
  if (rest[0] === 'repair') return cmdScanRepair(rest.slice(1));
4416
4887
  if (rest[0] === 'cross-feature-check') return cmdScanCrossFeatureCheck(rest.slice(1));
4417
4888
  if (rest[0] === 'cross-feature-waive') return cmdScanCrossFeatureWaive(rest.slice(1));
4889
+ if (rest[0] === 'cross-feature-unwaive') return cmdScanCrossFeatureUnwaive(rest.slice(1));
4418
4890
  await cmdScan(rest);
4419
4891
  break;
4420
4892
  }
@@ -4439,6 +4911,7 @@ async function dispatchCommand(cmd, rest) {
4439
4911
  if (sub === 'validate') return cmdContractValidate(subArgs);
4440
4912
  if (sub === 'tool-schema') return cmdContractToolSchema(subArgs);
4441
4913
  if (sub === 'waive') return cmdContractWaive(subArgs);
4914
+ if (sub === 'unwaive') return cmdContractUnwaive(subArgs);
4442
4915
  usage();
4443
4916
  process.exit(14);
4444
4917
  break;
@@ -4453,6 +4926,18 @@ async function dispatchCommand(cmd, rest) {
4453
4926
  process.exit(14);
4454
4927
  break;
4455
4928
  }
4929
+ case 'impact': {
4930
+ const sub = rest[0];
4931
+ const subArgs = rest.slice(1);
4932
+ if (sub === 'check') return cmdImpactCheck(subArgs);
4933
+ if (sub === 'accept') return cmdImpactAccept(subArgs);
4934
+ if (sub === 'disposition') return cmdImpactDisposition(subArgs);
4935
+ if (sub === 'ack') return cmdImpactAck(subArgs);
4936
+ if (sub === 'export') return cmdImpactExport(subArgs);
4937
+ usage();
4938
+ process.exit(14);
4939
+ break;
4940
+ }
4456
4941
  case 'rules': {
4457
4942
  const sub = rest[0];
4458
4943
  const subArgs = rest.slice(1);
@@ -4490,6 +4975,7 @@ async function dispatchCommand(cmd, rest) {
4490
4975
  if (rest[0] === 'plan') return cmdHandlesPlan(rest.slice(1));
4491
4976
  if (rest[0] === 'emit') return cmdHandlesEmit(rest.slice(1));
4492
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));
4493
4979
  if (rest[0] === 'audit') return await cmdHandlesAudit(rest.slice(1));
4494
4980
  usage();
4495
4981
  process.exit(14);