backend-skeleton 1.0.0-beta.9 → 1.1.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 (77) hide show
  1. package/README.md +185 -13
  2. package/bin/bskel.mjs +689 -33
  3. package/contracts/emit.mjs +5 -1
  4. package/contracts/export.mjs +65 -7
  5. package/contracts/openapi.mjs +321 -30
  6. package/contracts/validate.mjs +23 -4
  7. package/handles/_engine.mjs +123 -30
  8. package/handles/capability-codec.mjs +94 -0
  9. package/handles/codec.mjs +13 -3
  10. package/handles/providers/java-spring/emit.mjs +78 -33
  11. package/handles/providers/java-spring/observe.mjs +4 -3
  12. package/handles/providers/java-spring/plan.mjs +73 -16
  13. package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +19 -1
  14. package/handles/providers/java-spring/templates/HandleController.java.tmpl +19 -9
  15. package/handles/providers/java-spring/templates/HandleService.java.tmpl +21 -2
  16. package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +1 -1
  17. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +36 -9
  18. package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +8 -2
  19. package/handles/providers/java-spring.mjs +8 -0
  20. package/handles/providers/python-fastapi/emit.mjs +21 -26
  21. package/handles/providers/python-fastapi/observe.mjs +6 -5
  22. package/handles/providers/python-fastapi/templates/codec.py.tmpl +18 -3
  23. package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +129 -39
  24. package/handles/providers/python-fastapi.mjs +3 -3
  25. package/handles/providers/typescript-express/emit.mjs +144 -55
  26. package/handles/providers/typescript-express/observe.mjs +102 -0
  27. package/handles/providers/typescript-express/templates/codec.ts.tmpl +13 -3
  28. package/handles/providers/typescript-express/templates/contractCheck.ts.tmpl +136 -0
  29. package/handles/providers/typescript-express/templates/handleEntities.ts.tmpl +89 -0
  30. package/handles/providers/typescript-express/templates/handleService.ts.tmpl +81 -0
  31. package/handles/providers/typescript-express/templates/migration.sql.tmpl +36 -0
  32. package/handles/providers/typescript-express/templates/observeContract.ts.tmpl +146 -0
  33. package/handles/providers/typescript-express/templates/observedSchema.ts.tmpl +116 -0
  34. package/handles/providers/typescript-express/templates/recordSnapshotWrapper.ts.tmpl +123 -0
  35. package/handles/providers/typescript-express/templates/registry.ts.tmpl +19 -10
  36. package/handles/providers/typescript-express/templates/resolver.ts.tmpl +13 -0
  37. package/handles/providers/typescript-express/templates/resolverPolicy.ts.tmpl +20 -0
  38. package/handles/providers/typescript-express/templates/router.ts.tmpl +113 -2
  39. package/handles/providers/typescript-express.mjs +7 -4
  40. package/lib/attest.mjs +40 -0
  41. package/lib/cli.mjs +136 -5
  42. package/lib/cross-feature-collisions.mjs +286 -0
  43. package/lib/diff.mjs +35 -0
  44. package/lib/exit-codes.mjs +21 -0
  45. package/lib/fsutil.mjs +7 -2
  46. package/lib/gate-definitions.mjs +85 -1
  47. package/lib/gates.mjs +5 -1
  48. package/lib/http-server.mjs +192 -6
  49. package/lib/lock.mjs +68 -15
  50. package/lib/patch-kinds.mjs +52 -0
  51. package/lib/patch-transactions.mjs +206 -0
  52. package/lib/serve-ui.html +211 -0
  53. package/lib/verify.mjs +23 -6
  54. package/lib/workflow.mjs +31 -3
  55. package/package.json +8 -2
  56. package/scanners/adapters/_java-spring-analyzer.mjs +9 -1
  57. package/scanners/adapters/java-spring.mjs +114 -10
  58. package/scanners/adapters/javascript-express.mjs +46 -13
  59. package/scanners/adapters/python-fastapi.mjs +9 -1
  60. package/scanners/adapters/typescript-express.mjs +19 -2
  61. package/scanners/db/ddl-apply.mjs +253 -0
  62. package/scanners/db/introspect.mjs +61 -32
  63. package/scanners/db/migrations.mjs +73 -18
  64. package/schemas/cross-feature-report.schema.json +66 -0
  65. package/schemas/cross-feature-resolution.schema.json +28 -0
  66. package/schemas/feature-contract.schema.json +3 -3
  67. package/schemas/gate-attestation.schema.json +22 -0
  68. package/schemas/gate-export.schema.json +58 -0
  69. package/schemas/handles-plan.schema.json +2 -0
  70. package/schemas/oracle-manifest.schema.json +58 -0
  71. package/schemas/patch-transaction.schema.json +182 -0
  72. package/schemas/scan-report.schema.json +6 -4
  73. package/schemas/stack-choice.schema.json +12 -1
  74. package/schemas/stack-record.schema.json +6 -1
  75. package/stack/apply.mjs +51 -7
  76. package/stack/catalog/ngrok.yml +8 -2
  77. package/stack/config-apply.mjs +168 -0
package/bin/bskel.mjs CHANGED
@@ -9,10 +9,10 @@ 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 { writeFileAtomic, sha256File } from '../lib/fsutil.mjs';
12
+ import { writeFileAtomic, sha256File, readJsonIfExists } from '../lib/fsutil.mjs';
13
13
  import { validateAgainstSchema, formatSchemaErrors } from '../lib/schema-validate.mjs';
14
14
  import { withLockSync } from '../lib/lock.mjs';
15
- import { specDir, specPath } from '../lib/paths.mjs';
15
+ import { specDir, specPath, sbfPath } from '../lib/paths.mjs';
16
16
  import { requireValidFeatureId, requireValidSlug, requireValidFeatureOrRepoId, slugWords, nextFeatureNumber } from '../lib/featureid.mjs';
17
17
  import {
18
18
  loadFeatureFile, saveFeatureFile, loadFeatureIndex, saveFeatureIndex,
@@ -30,12 +30,19 @@ import { buildContract, selectModule, CONTRACT_SCHEMA_VERSION } from '../contrac
30
30
  import { validateEnvelope, operationPayloadSchema } from '../contracts/validate.mjs';
31
31
  import { evaluateResolution, loadResolution, saveResolution, requireWarningCode, warningKey, countByCode } from '../contracts/completeness.mjs';
32
32
  import { loadPatchApprovals, savePatchApprovals, approvalKey } from '../lib/patch-approvals.mjs';
33
+ import { proposeTransaction, approveTransaction, applyTransaction, rollbackTransaction, loadTransaction, listTransactions } from '../lib/patch-transactions.mjs';
34
+ import { getPatchKind, replanTransaction, PATCH_KIND_NAMES } from '../lib/patch-kinds.mjs';
35
+ import { generateKeypair, signPayload, verifyPayload } from '../lib/attest.mjs';
33
36
  import { loadManifest, saveManifest } from '../lib/handles-manifest.mjs';
34
37
  import { createHttpServer } from '../lib/http-server.mjs';
35
38
  import {
36
39
  resolveClassFile, listDownstreamDependents, DependencyOperationError,
37
40
  declareDependency, removeDependency, buildDependencyListReport,
38
41
  } from '../lib/field-dependencies.mjs';
42
+ import {
43
+ findCollisions, evaluateCrossFeatureFindings, waiverKey,
44
+ crossFeatureReportPath, loadCrossFeatureReport, loadCrossFeatureResolution, saveCrossFeatureResolution,
45
+ } from '../lib/cross-feature-collisions.mjs';
39
46
  import { STACKS as NEW_STACKS, ALL_STACK_PARAMS, stacksAccepting } from '../new/index.mjs';
40
47
  import {
41
48
  requireSingleLineText, requireValidJavaPackageName, requireValidArtifactId,
@@ -49,9 +56,12 @@ import { loadCatalogEntry, listCatalogChoices, planApply, applyPlan } from '../s
49
56
  import { PROVIDERS, PROVIDER_LOAD_ERRORS, providerById } from '../handles/registry.mjs';
50
57
  import { detectAstHelperAvailable, runAstClassify } from '../handles/providers/java-spring/ast-bridge.mjs';
51
58
  import { detectBasePackage } from '../handles/providers/java-spring/plan.mjs';
59
+ import { hasSpringAopDependency, springAopArtifactName } from '../handles/providers/java-spring/emit.mjs';
52
60
  import { emitObserveJavaSpring } from '../handles/providers/java-spring/observe.mjs';
53
61
  import { plan as planPythonFastApi } from '../handles/providers/python-fastapi/plan.mjs';
54
62
  import { emitObservePythonFastApi } from '../handles/providers/python-fastapi/observe.mjs';
63
+ import { plan as planTypeScriptExpress } from '../handles/providers/typescript-express/plan.mjs';
64
+ import { emitObserveTypeScriptExpress } from '../handles/providers/typescript-express/observe.mjs';
55
65
  import { collectGateStatuses, runBuildCheck, checkArtifacts, checkResolverConflicts } from '../lib/verify.mjs';
56
66
  import { computeWorkflowState } from '../lib/workflow.mjs';
57
67
  import { computeDoctorChecks, WORKFLOWS as DOCTOR_WORKFLOWS } from '../lib/doctor.mjs';
@@ -70,6 +80,8 @@ function usage() {
70
80
  bskel scan [--feature <id>] [--terms a,b,c] [--json] [--accept-low-confidence] [--db [--database-url-env <NAME>] [--schema public]]
71
81
  bskel scan disposition --feature <id> --mode reuse|extend|replace|parallel [--module <name>] [--note "..."] [--breaking-approved]
72
82
  bskel scan explain <module> --feature <id> [--json]
83
+ bskel scan cross-feature-check --feature <id> [--db [--database-url-env <NAME>] [--schema public]] [--json]
84
+ bskel scan cross-feature-waive --feature <id> --signal resource_type|table|operation_id|db_foreign_key --identifier <name> --other-feature <id> --reason "..."
73
85
  bskel feature init --slug <name>
74
86
  bskel feature list [--all] [--json]
75
87
  bskel feature show <id> [--json]
@@ -85,14 +97,19 @@ function usage() {
85
97
  bskel dependency declare --feature <id> --resource <Type> --field <name> --source-feature <id> --source-resource <Type> --source-field <name> --reason "..." [--memo "..."]
86
98
  bskel dependency remove --feature <id> --resource <Type> --field <name> --source-feature <id> --source-resource <Type> --source-field <name> --reason "..."
87
99
  bskel dependency list --feature <id> [--json]
88
- bskel stack apply --choice <id> [--apply] [--port N] [--json]
100
+ bskel stack apply --choice <id> [--apply] [--port N] [--force --reason "..."] [--json]
89
101
  bskel catalog lint [<choice>] [--json]
90
102
  bskel handles plan --feature <id> [--module <name>] [--resource type1,type2] [--diff] [--ast]
91
103
  bskel handles emit --feature <id> [--module <name>] [--resource type1,type2] [--force --reason "..."] [--check] [--diff] [--enforce-registry on|off --reason "..."]
92
104
  bskel handles patch approve --feature <id> [--module <name>] --resource <Type> --field <name> --strategy patch-wrapper|null-means-unchanged --reason "..." [--json]
93
- bskel handles audit --feature <id> --database-url-env <NAME> [--resource type1,type2] [--json]
105
+ bskel handles audit --feature <id> --database-url-env <NAME> [--resource type1,type2] [--module <name>] [--check-registry-coverage] [--json]
106
+ 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]
107
+ bskel patch approve --feature <id> --transaction <id> --reason "..." [--json]
108
+ bskel patch apply --feature <id> --transaction <id> [--confirm <id-or-dropped-table-name>] [--json]
109
+ bskel patch rollback --feature <id> --transaction <id> --reason "..." [--force] [--json]
110
+ bskel patch list --feature <id> [--json]
94
111
  bskel observe emit --feature <id> [--module <name>] [--force --reason "..."] [--check] [--diff] [--json]
95
- bskel observe import --feature <id> --receipts <path> [--json]
112
+ bskel observe import --feature <id> --receipts <path> [--fail-on-violation] [--json]
96
113
  bskel verify --feature <id> [--build [--allow-skip-build]] [--json]
97
114
  bskel status [--feature <id>] [--json]
98
115
  bskel next [--feature <id>] [--json]
@@ -101,9 +118,11 @@ function usage() {
101
118
  bskel gate revoke <name> --reason "..." [--feature <id>]
102
119
  bskel gate history <name> [--feature <id>] [--json]
103
120
  bskel gate show [<name>] [--feature <id>]
104
- bskel gate export --feature <id> [--out <path>] [--json]
121
+ bskel gate export --feature <id> [--out <path>] [--sign --key <privateKeyPath>] [--json]
122
+ bskel attest keygen --out <dir> [--force] [--json]
123
+ bskel attest verify --file <path> --pubkey <path> [--json]
105
124
  bskel doctor [--workflow ${DOCTOR_WORKFLOWS.join('|')}] [--json]
106
- bskel serve [--port N] [--host <addr>] [--json]
125
+ bskel serve [--port N] [--host <addr>] [--database-url-env <NAME>] [--schema <name>] [--sign-key <path>] [--require-sign-key] [--json]
107
126
  `);
108
127
  }
109
128
 
@@ -393,6 +412,12 @@ function cmdGateExport(args) {
393
412
  setContext('gate export', flags);
394
413
  const root = requireRepoRoot();
395
414
  requireValidFeatureId(flags.feature);
415
+ if (flags.sign && !flags.key) {
416
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel gate export --sign requires --key <privateKeyPath>');
417
+ }
418
+ if (flags.key && !flags.sign) {
419
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', '--key only has an effect together with --sign');
420
+ }
396
421
 
397
422
  const gates = {};
398
423
  for (const name of GATE_NAMES) {
@@ -407,14 +432,45 @@ function cmdGateExport(args) {
407
432
  git: { branch: currentBranch(root), head_sha: headSha(root), dirty: isDirty(root) },
408
433
  gates,
409
434
  };
410
- const rendered = `${JSON.stringify(report, null, 2)}\n`;
435
+ // D-gate-attestation-signing: validated unconditionally, signed or not -- a document that can
436
+ // be exported unsigned should be exactly as trustworthy in shape as one that gets signed later.
437
+ {
438
+ const { ok, errors } = validateAgainstSchema('gate-export.schema.json', report);
439
+ if (!ok) {
440
+ fail(EXIT_CODES.NOT_PASSED, 'INVALID_ARTIFACT', `internal error: the computed gate-export report failed its own schema -- ${formatSchemaErrors(errors).join('; ')}`);
441
+ }
442
+ }
443
+
444
+ let payload = report;
445
+ if (flags.sign) {
446
+ let privateKeyPem;
447
+ try {
448
+ privateKeyPem = fs.readFileSync(path.resolve(process.cwd(), flags.key), 'utf8');
449
+ } catch (err) {
450
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `could not read --key "${flags.key}": ${err.message}`);
451
+ }
452
+ let signatureValue;
453
+ try {
454
+ signatureValue = signPayload(report, privateKeyPem);
455
+ } catch (err) {
456
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--key "${flags.key}" is not a usable Ed25519 private key: ${err.message}`);
457
+ }
458
+ const attestation = { schema: 'sbf.gate-attestation/1', report, signature: { algorithm: 'ed25519', value: signatureValue } };
459
+ const { ok, errors } = validateAgainstSchema('gate-attestation.schema.json', attestation);
460
+ if (!ok) {
461
+ fail(EXIT_CODES.NOT_PASSED, 'INVALID_ARTIFACT', `internal error: the computed gate attestation failed its own schema -- ${formatSchemaErrors(errors).join('; ')}`);
462
+ }
463
+ payload = attestation;
464
+ }
465
+ const rendered = `${JSON.stringify(payload, null, 2)}\n`;
411
466
 
412
467
  if (flags.out) {
413
468
  const outPath = path.resolve(process.cwd(), flags.out);
414
469
  writeFileAtomic(outPath, rendered);
415
470
  if (!flags.quiet) {
416
471
  const passCount = GATE_NAMES.filter((n) => gates[n].current?.status === 'pass').length;
417
- console.log(`wrote ${flags.out} -- ${passCount}/${GATE_NAMES.length} gate(s) currently passing, ${report.git.branch}@${report.git.head_sha?.slice(0, 12) ?? '(unknown)'}${report.git.dirty ? ' (dirty)' : ''}`);
472
+ const signedNote = flags.sign ? ' (signed)' : '';
473
+ 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)' : ''}`);
418
474
  }
419
475
  } else {
420
476
  console.log(rendered);
@@ -422,6 +478,78 @@ function cmdGateExport(args) {
422
478
  process.exit(0);
423
479
  }
424
480
 
481
+ // D-gate-attestation-signing: --out is always required, never a default/home-directory location --
482
+ // this codebase has zero existing home-directory persistence convention anywhere, and inventing
483
+ // one is explicitly out of scope for this slice (a real fork the user weighed and decided, see
484
+ // DECISIONS.md). Repo-independent -- does not require a git repo at all, matching `attest verify`'s
485
+ // own posture below (both operate purely on files the caller names).
486
+ function cmdAttestKeygen(args) {
487
+ const flags = parseCommand('attest keygen', args);
488
+ if (flags.help) { console.log(renderCommandHelp('attest keygen')); process.exit(0); }
489
+ setContext('attest keygen', flags);
490
+ const outDir = path.resolve(process.cwd(), flags.out);
491
+ const privatePath = path.join(outDir, 'attest-private.pem');
492
+ const publicPath = path.join(outDir, 'attest-public.pem');
493
+ if (!flags.force) {
494
+ const existing = [privatePath, publicPath].filter((p) => fs.existsSync(p));
495
+ if (existing.length > 0) {
496
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `refusing to overwrite existing key file(s) without --force: ${existing.join(', ')} -- regenerating would orphan any attestation already signed with the old key`);
497
+ }
498
+ }
499
+ const { publicKeyPem, privateKeyPem } = generateKeypair();
500
+ // Restrictive mode (owner read/write only) from the very first write -- see the
501
+ // `D-gate-attestation-signing` entry's own note in `lib/fsutil.mjs` on why this is a
502
+ // `writeFileAtomic` parameter, not a chmod() called after the fact.
503
+ writeFileAtomic(privatePath, privateKeyPem, 0o600);
504
+ writeFileAtomic(publicPath, publicKeyPem);
505
+
506
+ if (flags.json) {
507
+ console.log(JSON.stringify({ private_key: privatePath, public_key: publicPath }, null, 2));
508
+ } else if (!flags.quiet) {
509
+ console.log(`wrote ${privatePath} (0600) and ${publicPath}`);
510
+ }
511
+ process.exit(0);
512
+ }
513
+
514
+ // Deliberately repo-independent (no requireRepoRoot()) -- verifying a previously-exported
515
+ // attestation has nothing to do with the current directory's own git state; the whole point is to
516
+ // check a document someone else produced, possibly on a different machine, offline. Exit code is
517
+ // driven ONLY by signature validity -- whether the gates INSIDE the report passed is a separate,
518
+ // printed question (see DECISIONS.md for why conflating the two would be actively misleading).
519
+ function cmdAttestVerify(args) {
520
+ const flags = parseCommand('attest verify', args);
521
+ if (flags.help) { console.log(renderCommandHelp('attest verify')); process.exit(0); }
522
+ setContext('attest verify', flags);
523
+
524
+ let attestation;
525
+ try {
526
+ attestation = JSON.parse(fs.readFileSync(path.resolve(process.cwd(), flags.file), 'utf8'));
527
+ } catch (err) {
528
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `could not read/parse --file "${flags.file}": ${err.message}`);
529
+ }
530
+ const { ok: schemaOk, errors: schemaErrors } = validateAgainstSchema('gate-attestation.schema.json', attestation);
531
+ if (!schemaOk) {
532
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `"${flags.file}" is not a valid gate attestation: ${formatSchemaErrors(schemaErrors).join('; ')}`);
533
+ }
534
+ let publicKeyPem;
535
+ try {
536
+ publicKeyPem = fs.readFileSync(path.resolve(process.cwd(), flags.pubkey), 'utf8');
537
+ } catch (err) {
538
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `could not read --pubkey "${flags.pubkey}": ${err.message}`);
539
+ }
540
+
541
+ const valid = verifyPayload(attestation.report, attestation.signature.value, publicKeyPem);
542
+ const passCount = GATE_NAMES.filter((n) => attestation.report.gates[n]?.current?.status === 'pass').length;
543
+
544
+ if (flags.json) {
545
+ 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));
546
+ } else if (!flags.quiet) {
547
+ 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');
548
+ console.log(`report says: feature ${attestation.report.feature_id}, ${passCount}/${GATE_NAMES.length} gate(s) passing, generated ${attestation.report.generated_at}`);
549
+ }
550
+ process.exit(valid ? EXIT_CODES.OK : EXIT_CODES.CHECK_FAILED);
551
+ }
552
+
425
553
  // Structural enforcement of "preflight blocks everything below it" (see the workflow table in
426
554
  // SKILL.md) for every feature-scoped command -- not just documented as a step order, checked.
427
555
  // Ad-hoc `bskel scan` (no --feature) is exempt: it's an explicit side-channel quick-look
@@ -634,6 +762,146 @@ function cmdScanExplain(args) {
634
762
  process.exit(0);
635
763
  }
636
764
 
765
+ // D-cross-feature-collision: mirrors cmdContractEmit's own "always write the artifact, gate
766
+ // blocks only if unresolved issues remain" shape exactly, for a different data source (NAME-
767
+ // identity collisions against every OTHER feature, not this feature's own contract completeness).
768
+ //
769
+ // D-cross-feature-fk-inference: async now -- reuses the EXACT same resolveDbSchemaOrExit() helper
770
+ // cmdScan() already calls for `--db [--database-url-env <NAME>] [--schema public]`, so this command
771
+ // gains those same flags with zero new live-DB code path. If `--db` was never given (the existing,
772
+ // unmodified call shape every current caller -- 5 CI smoke scripts, README's Quickstart -- already
773
+ // uses), findCollisions() falls back to a persisted snapshot or reports fk_check:'unavailable';
774
+ // the first 3 signals' findings and blocking behavior are byte-identical to before this item.
775
+ async function cmdScanCrossFeatureCheck(args) {
776
+ const flags = parseCommand('scan cross-feature-check', args);
777
+ if (flags.help) { console.log(renderCommandHelp('scan cross-feature-check')); process.exit(0); }
778
+ setContext('scan cross-feature-check', flags);
779
+ const root = requireRepoRoot();
780
+ requireValidFeatureId(flags.feature);
781
+
782
+ const liveDbSchema = await resolveDbSchemaOrExit(root, flags);
783
+ const { findings, fk_check, unknowns } = findCollisions(root, flags.feature, { liveDbSchema });
784
+ const report = {
785
+ schema: 'sbf.cross-feature-report/1',
786
+ feature_id: flags.feature,
787
+ generated_at: new Date().toISOString(),
788
+ findings,
789
+ fk_check,
790
+ unknowns,
791
+ };
792
+ const { ok, errors } = validateAgainstSchema('cross-feature-report.schema.json', report);
793
+ if (!ok) {
794
+ fail(EXIT_CODES.NOT_PASSED, 'INVALID_ARTIFACT', `internal error: the computed cross-feature report failed its own schema -- ${formatSchemaErrors(errors).join('; ')}`);
795
+ }
796
+ writeFileAtomic(crossFeatureReportPath(root, flags.feature), `${JSON.stringify(report, null, 2)}\n`);
797
+
798
+ const resolution = loadCrossFeatureResolution(root, flags.feature);
799
+ const evaluation = evaluateCrossFeatureFindings(findings, resolution);
800
+ const evidence = {
801
+ finding_count: findings.length,
802
+ high_confidence_count: findings.filter((f) => f.confidence === 'high').length,
803
+ waived_count: evaluation.waived.length,
804
+ stale_waivers: evaluation.staleWaivers.length,
805
+ };
806
+ const gateState = evaluation.blocking
807
+ ? awaitNamedGateDisposition(root, 'cross_feature', flags.feature, { ...evidence, unwaived: evaluation.unwaived })
808
+ : passNamedGate(root, 'cross_feature', flags.feature, evidence);
809
+
810
+ if (flags.json) {
811
+ console.log(JSON.stringify({ report, gate: gateState.gates.cross_feature }, null, 2));
812
+ } else {
813
+ if (!flags.quiet) {
814
+ const otherCount = new Set(findings.map((f) => f.other_feature)).size;
815
+ console.log(`${findings.length} finding(s) (${evidence.high_confidence_count} high-confidence) across ${otherCount} other feature(s)`);
816
+ for (const f of findings) console.log(` [${f.confidence}] ${f.signal}: "${f.identifier}" also declared by ${f.other_feature}`);
817
+ // D-cross-feature-fk-inference (staleness/freshness token): the actual point of the field
818
+ // -- a human SEEING how stale a persisted/migrations-mode correlation is, not just the
819
+ // JSON carrying it silently.
820
+ const fkCheckDetails = [];
821
+ if (fk_check.source_feature) fkCheckDetails.push(`from ${fk_check.source_feature}'s own persisted snapshot`);
822
+ if (fk_check.generated_at) fkCheckDetails.push(`captured ${fk_check.generated_at}`);
823
+ console.log(`fk_check: ${fk_check.mode}${fkCheckDetails.length ? ` (${fkCheckDetails.join(', ')})` : ''}`);
824
+ for (const u of unknowns) console.log(` note: ${u}`);
825
+ console.log(`gate: cross_feature -> ${gateState.gates.cross_feature.status}`);
826
+ }
827
+ if (evaluation.staleWaivers.length > 0) {
828
+ console.error(`\nnote: ${evaluation.staleWaivers.length} recorded waiver(s) no longer match any current finding (kept as-is, not auto-removed):`);
829
+ for (const w of evaluation.staleWaivers) console.error(` ${w.signal} "${w.identifier}" (${w.other_feature})`);
830
+ }
831
+ if (evaluation.blocking) {
832
+ console.error(`\nblocked: ${evaluation.unwaived.length} unresolved high-confidence collision(s):`);
833
+ for (const f of evaluation.unwaived) {
834
+ console.error(` bskel scan cross-feature-waive --feature ${flags.feature} --signal ${f.signal} --identifier "${f.identifier}" --other-feature ${f.other_feature} --reason "..."`);
835
+ }
836
+ }
837
+ }
838
+ process.exit(evaluation.blocking ? EXIT.AWAITING_DISPOSITION : EXIT.PASS);
839
+ }
840
+
841
+ const CROSS_FEATURE_SIGNALS = ['resource_type', 'table', 'operation_id', 'db_foreign_key'];
842
+
843
+ // Validates against the PERSISTED report from the last `cross-feature-check` run, never a live
844
+ // re-computation -- same precedent `contract waive` already establishes against `loadContract`
845
+ // (contracts/completeness.mjs). If reality moved since that check, the gate's own staleness token
846
+ // (which covers every OTHER feature named in the report) is what surfaces that, not a silent
847
+ // re-check inside this command.
848
+ function cmdScanCrossFeatureWaive(args) {
849
+ const flags = parseCommand('scan cross-feature-waive', args);
850
+ if (flags.help) { console.log(renderCommandHelp('scan cross-feature-waive')); process.exit(0); }
851
+ setContext('scan cross-feature-waive', flags);
852
+ const root = requireRepoRoot();
853
+ requireValidFeatureId(flags.feature);
854
+ requireValidFeatureId(flags['other-feature']);
855
+ if (!flags.reason || !flags.reason.trim()) {
856
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel scan cross-feature-waive requires --reason "..." -- every waiver must be auditable');
857
+ }
858
+ if (!CROSS_FEATURE_SIGNALS.includes(flags.signal)) {
859
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--signal must be one of: ${CROSS_FEATURE_SIGNALS.join(', ')}`);
860
+ }
861
+
862
+ const report = loadCrossFeatureReport(root, flags.feature);
863
+ if (!report) {
864
+ fail(EXIT_CODES.NOT_PASSED, 'MISSING_ARTIFACT', `no cross-feature-report.json for feature "${flags.feature}" -- run \`bskel scan cross-feature-check --feature ${flags.feature}\` first`);
865
+ }
866
+ const match = report.findings.find((f) => f.signal === flags.signal && f.identifier === flags.identifier && f.other_feature === flags['other-feature']);
867
+ if (!match) {
868
+ const known = report.findings.map((f) => `${f.signal} "${f.identifier}" (${f.other_feature})`);
869
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `no current finding matches --signal ${flags.signal} --identifier "${flags.identifier}" --other-feature ${flags['other-feature']} -- current findings: ${known.join('; ') || '(none)'}`);
870
+ }
871
+
872
+ const updated = withLockSync(root, 'state', () => {
873
+ const resolution = loadCrossFeatureResolution(root, flags.feature);
874
+ const key = waiverKey({ signal: flags.signal, identifier: flags.identifier, other_feature: flags['other-feature'] });
875
+ const entry = { signal: flags.signal, identifier: flags.identifier, other_feature: flags['other-feature'], reason: flags.reason, at: new Date().toISOString() };
876
+ const next = {
877
+ schema: 'sbf.cross-feature-resolution/1',
878
+ feature_id: flags.feature,
879
+ waivers: [...resolution.waivers.filter((w) => waiverKey(w) !== key), entry],
880
+ };
881
+ saveCrossFeatureResolution(root, flags.feature, next);
882
+ return next;
883
+ });
884
+
885
+ const evaluation = evaluateCrossFeatureFindings(report.findings, updated);
886
+ const evidence = {
887
+ finding_count: report.findings.length,
888
+ high_confidence_count: report.findings.filter((f) => f.confidence === 'high').length,
889
+ waived_count: evaluation.waived.length,
890
+ stale_waivers: evaluation.staleWaivers.length,
891
+ };
892
+ const gateState = evaluation.blocking
893
+ ? awaitNamedGateDisposition(root, 'cross_feature', flags.feature, { ...evidence, unwaived: evaluation.unwaived })
894
+ : passNamedGate(root, 'cross_feature', flags.feature, evidence);
895
+
896
+ if (flags.json) {
897
+ console.log(JSON.stringify({ waived: true, gate: gateState.gates.cross_feature }, null, 2));
898
+ } else if (!flags.quiet) {
899
+ console.log(`waived: ${flags.signal} "${flags.identifier}" (${flags['other-feature']})`);
900
+ console.log(`gate: cross_feature -> ${gateState.gates.cross_feature.status}`);
901
+ }
902
+ process.exit(evaluation.blocking ? EXIT.AWAITING_DISPOSITION : EXIT.PASS);
903
+ }
904
+
637
905
  // D6 (D-feature-lifecycle): the whole read-specs/->compute-NNN->write-feature.json->
638
906
  // load-modify-save-feature-index.json sequence runs under one exclusive lock -- confirmed live
639
907
  // during this item's own grounding, the same lost-update shape S5 already fixed for setGate():
@@ -1600,14 +1868,29 @@ function cmdContractToolSchema(args) {
1600
1868
  }
1601
1869
 
1602
1870
  // Anthropic tool-use `input_schema` is a JSON Schema subset -- the operation's payload
1603
- // schema (already plain JSON Schema, no $ref/$defs) is directly usable as-is. A2: when `op`
1604
- // carries a projected `requestBodySchema`, it flows through here for free -- this function
1605
- // changed not at all; contracts/openapi.mjs's inlineSchema() is what guarantees the no-$ref
1606
- // promise this comment makes.
1871
+ // schema is directly usable as-is UNLESS it's recursive. Confirmed against Anthropic's own
1872
+ // documented JSON Schema limitations (platform.claude.com/docs/en/build-with-claude/
1873
+ // structured-outputs): internal (non-external-URL) $ref/$defs ARE supported, but "recursive
1874
+ // schemas" are explicitly listed as unsupported and return a real 400 error at the API. A2:
1875
+ // when `op` carries a projected `requestBodySchema`, it flows through here for free -- this
1876
+ // function changed not at all. D-openapi-cyclic-refs: contracts/openapi.mjs's inlineSchema()
1877
+ // now emits `$ref`/`$defs` for a genuinely cyclic component (e.g. a real recursive
1878
+ // filter-group tree) rather than failing the whole projection closed -- that's real progress
1879
+ // for `contract validate`/`contract export`, but this ONE consumer genuinely cannot accept
1880
+ // it: refuse explicitly here, citing the real reason, rather than emitting a schema that
1881
+ // would only fail later at the actual Anthropic API call site.
1882
+ const inputSchema = operationPayloadSchema(op);
1883
+ if (inputSchema && Object.hasOwn(inputSchema, '$defs')) {
1884
+ fail(
1885
+ EXIT_CODES.NOT_PASSED,
1886
+ 'RECURSIVE_SCHEMA_UNSUPPORTED',
1887
+ `operation "${flags.operation}"'s payload schema is recursive (a genuinely self-referential real shape, e.g. a nested filter-group tree) -- Anthropic tool-use input_schema does not support recursive schemas (see platform.claude.com/docs/en/build-with-claude/structured-outputs), so no tool-use schema can be generated for this operation`,
1888
+ );
1889
+ }
1607
1890
  const toolSchema = {
1608
1891
  name: flags.operation,
1609
1892
  description: `${op.verb} ${op.path} (feature ${flags.feature})`,
1610
- input_schema: operationPayloadSchema(op),
1893
+ input_schema: inputSchema,
1611
1894
  };
1612
1895
  console.log(JSON.stringify(toolSchema, null, 2));
1613
1896
  process.exit(0);
@@ -1637,7 +1920,13 @@ function cmdStackApply(args) {
1637
1920
  const root = requireRepoRoot();
1638
1921
  requirePreflightPassed(root);
1639
1922
  if (!flags.choice) {
1640
- fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `usage: bskel stack apply --choice <id> [--apply] [--port N] (known choices: ${listCatalogChoices().join(', ') || '(none)'})`);
1923
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `usage: bskel stack apply --choice <id> [--apply] [--port N] [--force --reason "..."] (known choices: ${listCatalogChoices().join(', ') || '(none)'})`);
1924
+ }
1925
+ // D-write-safety-phase0 (item 2): mirrors handles emit's own --force/--reason validation --
1926
+ // every overwrite of a file that diverged from what `stack apply` itself last wrote must be
1927
+ // auditable.
1928
+ if (flags.force && (!flags.reason || !flags.reason.trim())) {
1929
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel stack apply --force requires --reason "..." -- every overwrite of a diverged generated file must be auditable');
1641
1930
  }
1642
1931
 
1643
1932
  let entry;
@@ -1661,12 +1950,21 @@ function cmdStackApply(args) {
1661
1950
  process.exit(0);
1662
1951
  }
1663
1952
 
1664
- let written;
1953
+ let written, conflicts, fileHashes;
1665
1954
  try {
1666
- written = applyPlan(root, plan);
1955
+ ({ written, conflicts, fileHashes } = applyPlan(root, plan, { force: flags.force }));
1667
1956
  } catch (err) {
1668
1957
  fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', err.message);
1669
1958
  }
1959
+ // D-write-safety-phase0 (item 2): a file that diverged from what `stack apply` itself last
1960
+ // wrote is refused outright without --force -- mirrors handles emit's own conflict-refusal
1961
+ // exactly, including the exit code family (a new, dedicated STACK_CONFLICT rather than reusing
1962
+ // HANDLES_CONFLICT, since this is a different write surface).
1963
+ if (conflicts.length > 0) {
1964
+ console.error(`refusing to overwrite ${conflicts.length} file(s) that diverged from what \`bskel stack apply\` last generated${flags.force ? '' : ' -- pass --force --reason "..." to overwrite (only if the divergence is git-recoverable)'}:`);
1965
+ for (const c of conflicts) console.error(` ${c.path}\n ${c.reason}`);
1966
+ process.exit(EXIT_CODES.STACK_CONFLICT);
1967
+ }
1670
1968
  // S2: `applied_files` must be this choice's FULL file set in this repo (its desired state),
1671
1969
  // not just whatever `applyPlan()` happened to write THIS run -- applyPlan() skips files whose
1672
1970
  // action is 'unchanged', so a second, idempotent `--apply` used to overwrite this with `[]`,
@@ -1677,17 +1975,22 @@ function cmdStackApply(args) {
1677
1975
  ...plan.files.map((f) => f.path),
1678
1976
  ...(plan.envExampleActions.length > 0 ? ['.env.example'] : []),
1679
1977
  ])].sort();
1978
+ // D-write-safety-phase0 (item 2): `fileHashes` only covers files applyPlan() actually wrote
1979
+ // THIS run -- an unchanged file isn't in it, so this merges onto the PRIOR record's file_hashes
1980
+ // (now a genuine read boundary -- planApply() reads this same record to classify files, see
1981
+ // stack/apply.mjs) rather than replacing it wholesale, or an unchanged file's provenance would
1982
+ // be lost on every apply after the first.
1983
+ const priorRecord = readJsonIfExists(sbfPath(root, 'stack.json'));
1680
1984
  const stackRecord = {
1681
1985
  schema: 'sbf.stack/1', choice: flags.choice, applied_files: appliedFiles,
1682
1986
  env_example_keys: plan.envExampleActions.map((e) => e.key), at: new Date().toISOString(),
1987
+ file_hashes: { ...(priorRecord?.file_hashes ?? {}), ...fileHashes },
1683
1988
  };
1684
1989
  // S5 (D-persistence-integrity): schemas/stack-record.schema.json is new -- this record had NO
1685
1990
  // schema at all before (not the same file as stack-choice.schema.json, which validates a
1686
1991
  // stack/catalog/<id>.yml CATALOG ENTRY, a completely different persistence boundary). Validated
1687
1992
  // before it touches disk, same "fail loud here" reasoning as every other write site this item
1688
- // touched. No corresponding read helper -- nothing in this codebase reads .sbf/stack.json back
1689
- // (confirmed by grep before adding this), so there's no read boundary to close yet; adding an
1690
- // unused loadStackRecord() export would just be dead code.
1993
+ // touched.
1691
1994
  {
1692
1995
  const { ok, errors } = validateAgainstSchema('stack-record.schema.json', stackRecord);
1693
1996
  if (!ok) {
@@ -1811,8 +2114,10 @@ function writeScanReportOrExit(reportPath, report) {
1811
2114
  }
1812
2115
 
1813
2116
  // D4 (D-handles-dryrun): the marker vocabulary a human report uses for classifyFile()'s 6
1814
- // possible actions (+ the java-spring-only 'spec' kind, which reuses the same 3 labels since it's
1815
- // classified the same 3-way create/unchanged/update, just outside classifyFile() itself).
2117
+ // possible actions (+ the 'spec' kind every observe.mjs provider uses for its always-regenerated
2118
+ // observed-schema.json, which reuses the same 3 labels since it's classified the same 3-way
2119
+ // create/unchanged/update, just outside classifyFile() itself -- migration.sql used to be the
2120
+ // other 'spec' user too, until D-write-safety-phase0 moved it onto real manifest tracking).
1816
2121
  const ACTION_MARKERS = { create: '+', unchanged: '=', update: '~', 'adopt-unchanged': '=', 'adopt-update': '~', conflict: '!' };
1817
2122
 
1818
2123
  // D4: shared between `handles plan`'s preview and `handles emit --check`'s report -- both show
@@ -2036,11 +2341,45 @@ function cmdHandlesEmit(args) {
2036
2341
  });
2037
2342
  }
2038
2343
 
2344
+ // D-cross-feature-collision: making "mandatory disposition" actually mandatory, not just
2345
+ // available -- `handles emit` is specifically the step that generates the runtime resolver
2346
+ // code every codegen provider's own resourceType-keyed dispatch (Java/Python/TS, all three)
2347
+ // already implicitly assumes is repo-unique. `cross_feature`'s own gate `verifyPolicy` stays
2348
+ // REQUIRED_WHEN_PRESENT (so `bskel verify` doesn't retroactively fail a contract-only feature
2349
+ // that never touches handles at all) -- this hard-requires it ONLY here, the one command where
2350
+ // the real risk actually lives, mirroring the `contract` gate check immediately above exactly.
2351
+ // Deliberately NOT added to `cmdHandlesPlan` -- that command never writes (dryRun always),
2352
+ // same reasoning it already gives for skipping the `contract` gate entirely.
2353
+ const crossFeatureResult = requireNamedGate(root, 'cross_feature', flags.feature);
2354
+ if (crossFeatureResult.code !== EXIT.PASS) {
2355
+ const cfHint = crossFeatureResult.status === 'awaiting_disposition'
2356
+ ? `resolve it first -- \`bskel scan cross-feature-waive --feature ${flags.feature} --signal resource_type|table|operation_id|db_foreign_key --identifier <name> --other-feature <id> --reason "..."\`, or \`bskel gate force cross_feature --feature ${flags.feature} --reason "..."\` if intentional.`
2357
+ : `run \`bskel scan cross-feature-check --feature ${flags.feature}\` first.`;
2358
+ fail(crossFeatureResult.code, gateReasonForCode(crossFeatureResult.code), `blocked: \`cross_feature\` gate for ${flags.feature} is ${crossFeatureResult.status} -- ${cfHint}`, {
2359
+ next_actions: [{ command: `bskel scan cross-feature-check --feature ${flags.feature}`, reason: 'the cross_feature gate has not passed yet', mutating: true }],
2360
+ });
2361
+ }
2362
+
2039
2363
  const scanReport = loadScanReportOrExit(root, flags.feature);
2040
2364
  const scanReportPath = specPath(root, flags.feature, 'brownfield-scan.json');
2041
2365
  requireCapabilitiesOrExit(scanReport, 'handles emit', { featureId: flags.feature, scanReportPath });
2042
2366
  const provider = selectProviderOrExit(scanReport);
2043
2367
  requireProviderCapabilitiesOrExit(scanReport, provider, 'handles emit', { featureId: flags.feature, scanReportPath });
2368
+
2369
+ // D-write-safety-phase1 (item 1): HandleAspect.java cannot intercept anything without
2370
+ // spring-boot-starter-aop on the target's own classpath -- refusing here, before any code is
2371
+ // written, rather than letting the operator discover it only after `--enforce-registry on`
2372
+ // silently produces resolvers that will 404 every fetch/patch. java-spring only: python-fastapi's
2373
+ // @record_snapshot decorator needs no extra dependency (see python-fastapi/emit.mjs's own note).
2374
+ if (enforceRegistry && scanReport.adapter === 'java-spring' && !hasSpringAopDependency(root)) {
2375
+ // D-handles-pilot-cohort: the correct artifact name is version-dependent (spring-boot-starter-aop
2376
+ // before Spring Boot 4, spring-boot-starter-aspectj from Spring Boot 4 on -- confirmed against
2377
+ // a real Spring Boot 4.1.0 target, the old artifact is a genuine 404 on Maven Central for it).
2378
+ // springAopArtifactName(root) names the one THIS repo's own detected Boot version actually needs.
2379
+ const artifactName = springAopArtifactName(root);
2380
+ fail(EXIT_CODES.HANDLES_MISSING_DEPENDENCY, 'HANDLES_MISSING_DEPENDENCY', `bskel handles emit --enforce-registry on requires ${artifactName} on this repo's own build.gradle/build.gradle.kts/pom.xml classpath -- HandleAspect.java (the class that actually intercepts @RecordHandleSnapshot-annotated methods) does nothing without it, and no other Spring starter enables AOP. Add the dependency to your build file, then re-run.`);
2381
+ }
2382
+
2044
2383
  const resourceFilter = flags.resource ? flags.resource.split(',').map((s) => s.trim()).filter(Boolean) : null;
2045
2384
 
2046
2385
  let plan;
@@ -2053,7 +2392,7 @@ function cmdHandlesEmit(args) {
2053
2392
  // a diff" that also means "and actually write it", so --diff forces dryRun the same as --check
2054
2393
  // does, without requiring both flags together.
2055
2394
  const dryRun = flags.check || flags.diff;
2056
- const { written, resolverStubs, conflicts, orphans, notes, forced, blocked, actions, postEmitNotes = [] } = provider.emit({
2395
+ const { written, resolverStubs, conflicts, orphans, notes, forced, blocked, actions, postEmitNotes = [], registrationGaps = [] } = provider.emit({
2057
2396
  repoRoot: root, featureId: flags.feature, plan, resourceFilter, force: flags.force, reason: flags.reason, dryRun, computeDiff: flags.diff, enforceRegistry,
2058
2397
  });
2059
2398
 
@@ -2114,6 +2453,25 @@ function cmdHandlesEmit(args) {
2114
2453
  process.exit(EXIT_CODES.HANDLES_CONFLICT);
2115
2454
  }
2116
2455
 
2456
+ // D-write-safety-phase1 (item 2): a registration gap is checked separately from `blocked`
2457
+ // above (conflicts are already handled and exited by this point) -- semantically different
2458
+ // reason (a hand-written file bskel never touches lacking an annotation it cannot add itself,
2459
+ // not a generated file diverging), so its own dedicated exit code and message. Unlike a
2460
+ // conflict, the resolver files ARE still written either way (there is nothing wrong with their
2461
+ // content) -- only the overall command's reported success, and the `handles` gate passing, are
2462
+ // gated on acknowledging the gap with --force --reason.
2463
+ if (enforceRegistry && registrationGaps.length > 0 && !flags.force) {
2464
+ if (flags.json) {
2465
+ console.log(JSON.stringify({ written, resolverStubs, conflicts, orphans, forced, notes: allNotes, actions, registrationGaps, blocked: true, gate: null, check: dryRun }, null, 2));
2466
+ } else {
2467
+ const verb = dryRun ? 'would refuse to report success' : 'refusing to report success';
2468
+ console.error(`${verb}: ${registrationGaps.length} resource(s) have --enforce-registry on but no static registration path found:`);
2469
+ for (const g of registrationGaps) console.error(` ${g.resourceType} (${g.file})\n ${g.note}`);
2470
+ if (!dryRun) console.error(`\nthe resolver file(s) above were still written -- nothing about their content is wrong. Fix the registration gap and re-run, or acknowledge and proceed with: bskel handles emit --feature ${flags.feature}${flags.module ? ` --module ${flags.module}` : ''}${flags.resource ? ` --resource ${flags.resource}` : ''} --enforce-registry on --force --reason "..."`);
2471
+ }
2472
+ process.exit(EXIT_CODES.HANDLES_REGISTRATION_GAP);
2473
+ }
2474
+
2117
2475
  // D4: dryRun never marks the gate passed -- nothing real happened this run.
2118
2476
  const gateState = dryRun ? null : passNamedGate(root, 'handles', flags.feature, { resolverStubs });
2119
2477
 
@@ -2211,6 +2569,189 @@ function cmdHandlesPatchApprove(args) {
2211
2569
  process.exit(0);
2212
2570
  }
2213
2571
 
2572
+ // D-patch-transactions: Slice 1 (config_check -> config_apply). All four commands only touch
2573
+ // specs/<featureId>/patch-transactions/ except `apply`/`rollback`, which write to the real target
2574
+ // file too -- matching D4's own "propose/approve are specs/-only, apply/rollback touch the repo"
2575
+ // distinction cmdHandlesEmit/cmdHandlesPlan already draw for --check vs a real emit.
2576
+ // D-ddl-apply: `--kind` picks which patch-transaction kind to propose (default 'config-apply',
2577
+ // fully backward compatible -- every existing call site/script that never passed --kind gets the
2578
+ // exact prior behavior). Kind-specific params are validated by hand here (not via COMMANDS'
2579
+ // declarative `required: true`, which is unconditional per-flag) -- matches this file's own
2580
+ // existing convention for kind-conditional requirements (e.g. --reason on approve/rollback).
2581
+ async function cmdPatchPropose(args) {
2582
+ const flags = parseCommand('patch propose', args);
2583
+ if (flags.help) { console.log(renderCommandHelp('patch propose')); process.exit(0); }
2584
+ setContext('patch propose', flags);
2585
+ const root = requireRepoRoot();
2586
+ requireValidFeatureId(flags.feature);
2587
+ const kind = flags.kind || 'config-apply';
2588
+ if (!PATCH_KIND_NAMES.includes(kind)) {
2589
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--kind must be one of: ${PATCH_KIND_NAMES.join(', ')}`);
2590
+ }
2591
+
2592
+ let params;
2593
+ let source;
2594
+ if (kind === 'config-apply') {
2595
+ if (!flags.choice || !flags.target) {
2596
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel patch propose --kind config-apply requires --choice <stackChoiceId> --target <config_check target path>');
2597
+ }
2598
+ params = { choice: flags.choice, target: flags.target };
2599
+ source = { choice: flags.choice };
2600
+ } else {
2601
+ if (!flags['database-url-env'] || !flags['sql-file']) {
2602
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel patch propose --kind ddl-apply requires --database-url-env <NAME> --sql-file <path>');
2603
+ }
2604
+ let sqlText;
2605
+ try {
2606
+ sqlText = fs.readFileSync(flags['sql-file'], 'utf8');
2607
+ } catch (err) {
2608
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `could not read --sql-file "${flags['sql-file']}": ${err.message}`);
2609
+ }
2610
+ params = { databaseUrlEnv: flags['database-url-env'], schema: flags.schema, sqlText };
2611
+ source = { database_url_env: flags['database-url-env'], schema: flags.schema };
2612
+ }
2613
+
2614
+ let plan;
2615
+ try {
2616
+ plan = await getPatchKind(kind).planFresh(root, params);
2617
+ } catch (err) {
2618
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', err.message);
2619
+ }
2620
+ const txn = proposeTransaction(root, flags.feature, kind, plan, source);
2621
+
2622
+ if (flags.json) {
2623
+ console.log(JSON.stringify(txn, null, 2));
2624
+ } else if (!flags.quiet) {
2625
+ console.log(`proposed: ${txn.transaction_id}`);
2626
+ console.log(` ${describePatchTransaction(txn)}`);
2627
+ console.log(`next: bskel patch approve --feature ${flags.feature} --transaction ${txn.transaction_id} --reason "..."`);
2628
+ }
2629
+ process.exit(0);
2630
+ }
2631
+
2632
+ // Kind-aware one-line summary, reused by propose/list's human-readable output -- config-apply's
2633
+ // target is a file+key_path, ddl-apply's is raw SQL text (truncated for a terminal line).
2634
+ function describePatchTransaction(txn) {
2635
+ if (txn.kind === 'config-apply') {
2636
+ return `${txn.target.file} @ ${txn.target.key_path.join('.')}: "${txn.current_value}" -> "${txn.proposed_value}"`;
2637
+ }
2638
+ const sql = txn.target.sql_text.replace(/\s+/g, ' ').trim();
2639
+ return `[${txn.kind}] ${txn.target.database_url_env}/${txn.target.schema}: ${sql.slice(0, 100)}${sql.length > 100 ? '...' : ''}`;
2640
+ }
2641
+
2642
+ function requireTransactionOrExit(root, featureId, transactionId) {
2643
+ const txn = loadTransaction(root, featureId, transactionId);
2644
+ if (!txn) {
2645
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `no patch transaction "${transactionId}" for feature "${featureId}"`);
2646
+ }
2647
+ return txn;
2648
+ }
2649
+
2650
+ async function cmdPatchApprove(args) {
2651
+ const flags = parseCommand('patch approve', args);
2652
+ if (flags.help) { console.log(renderCommandHelp('patch approve')); process.exit(0); }
2653
+ setContext('patch approve', flags);
2654
+ const root = requireRepoRoot();
2655
+ requireValidFeatureId(flags.feature);
2656
+ if (!flags.reason || !flags.reason.trim()) {
2657
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel patch approve requires --reason "..." -- every approval must be auditable');
2658
+ }
2659
+ const txn = requireTransactionOrExit(root, flags.feature, flags.transaction);
2660
+
2661
+ let freshPlan;
2662
+ try {
2663
+ freshPlan = await replanTransaction(root, txn);
2664
+ } catch (err) {
2665
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', err.message);
2666
+ }
2667
+ const updated = approveTransaction(root, flags.feature, flags.transaction, flags.reason, freshPlan);
2668
+
2669
+ console.log(flags.json ? JSON.stringify(updated, null, 2) : `approved: ${flags.transaction}`);
2670
+ process.exit(0);
2671
+ }
2672
+
2673
+ // D-ddl-apply: --confirm is required (and must exactly equal --transaction) for any kind other
2674
+ // than 'config-apply' -- deliberately checked here, at the CLI boundary, before applyTransaction()
2675
+ // is ever called, as human-factors friction layered ON TOP of the engine's own load-bearing
2676
+ // preimage-hash TOCTOU check (this check does not replace it). Optional/ignored for config-apply,
2677
+ // zero behavior change for that kind.
2678
+ async function cmdPatchApply(args) {
2679
+ const flags = parseCommand('patch apply', args);
2680
+ if (flags.help) { console.log(renderCommandHelp('patch apply')); process.exit(0); }
2681
+ setContext('patch apply', flags);
2682
+ const root = requireRepoRoot();
2683
+ requirePreflightPassed(root);
2684
+ requireValidFeatureId(flags.feature);
2685
+ const txn = requireTransactionOrExit(root, flags.feature, flags.transaction);
2686
+
2687
+ const requiredConfirm = getPatchKind(txn.kind).requiredConfirmValue(txn);
2688
+ if (requiredConfirm !== null && flags.confirm !== requiredConfirm) {
2689
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `bskel patch apply --kind ${txn.kind} requires --confirm ${JSON.stringify(requiredConfirm)} exactly -- pass --confirm ${requiredConfirm} once you've reviewed this transaction`);
2690
+ }
2691
+
2692
+ let freshPlan;
2693
+ try {
2694
+ freshPlan = await replanTransaction(root, txn);
2695
+ } catch (err) {
2696
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', err.message);
2697
+ }
2698
+ const updated = await applyTransaction(root, flags.feature, flags.transaction, freshPlan, getPatchKind(txn.kind).apply);
2699
+
2700
+ const evidence = { transaction_id: updated.transaction_id, kind: updated.kind, applied_at: updated.apply.at };
2701
+ const gateState = passNamedGate(root, 'patch_transactions', flags.feature, evidence);
2702
+
2703
+ console.log(flags.json ? JSON.stringify({ transaction: updated, gate: gateState.gates.patch_transactions }, null, 2) : `applied: ${flags.transaction} [${updated.kind}]`);
2704
+ process.exit(0);
2705
+ }
2706
+
2707
+ async function cmdPatchRollback(args) {
2708
+ const flags = parseCommand('patch rollback', args);
2709
+ if (flags.help) { console.log(renderCommandHelp('patch rollback')); process.exit(0); }
2710
+ setContext('patch rollback', flags);
2711
+ const root = requireRepoRoot();
2712
+ requirePreflightPassed(root);
2713
+ requireValidFeatureId(flags.feature);
2714
+ if (!flags.reason || !flags.reason.trim()) {
2715
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel patch rollback requires --reason "..." -- every rollback must be auditable');
2716
+ }
2717
+ const txn = requireTransactionOrExit(root, flags.feature, flags.transaction);
2718
+ const updated = await rollbackTransaction(root, flags.feature, flags.transaction, flags.reason, { force: flags.force }, getPatchKind(txn.kind).rollback);
2719
+
2720
+ // Re-passing (not revoking) mirrors cmdScanCrossFeatureWaive's own precedent for this exact
2721
+ // situation -- recompute() only loops status:'applied' transactions, so a rolled-back one
2722
+ // naturally drops out of the gate's own input set on the next pass; a fresh pass here reflects
2723
+ // "still fine, just smaller" immediately rather than leaving the gate to be reactively
2724
+ // discovered stale by the next `bskel verify`.
2725
+ const evidence = { transaction_id: updated.transaction_id, kind: updated.kind, rolled_back_at: updated.rollback.at };
2726
+ const gateState = passNamedGate(root, 'patch_transactions', flags.feature, evidence);
2727
+
2728
+ console.log(flags.json ? JSON.stringify({ transaction: updated, gate: gateState.gates.patch_transactions }, null, 2) : `rolled back: ${flags.transaction} [${updated.kind}]`);
2729
+ process.exit(0);
2730
+ }
2731
+
2732
+ // A deliberately-omitted-until-now, easy read-only convenience (D-patch-transactions' own EXIT
2733
+ // list named it explicitly) -- mirrors `dependency list`/`contract history`'s own pure-reader
2734
+ // posture, gate-independent like both.
2735
+ function cmdPatchList(args) {
2736
+ const flags = parseCommand('patch list', args);
2737
+ if (flags.help) { console.log(renderCommandHelp('patch list')); process.exit(0); }
2738
+ setContext('patch list', flags);
2739
+ const root = requireRepoRoot();
2740
+ requireValidFeatureId(flags.feature);
2741
+ const transactions = listTransactions(root, flags.feature);
2742
+
2743
+ if (flags.json) {
2744
+ console.log(JSON.stringify({ feature: flags.feature, transactions }, null, 2));
2745
+ } else if (!flags.quiet) {
2746
+ console.log(`patch transactions -- feature ${flags.feature}`);
2747
+ for (const t of transactions) {
2748
+ console.log(` ${t.transaction_id} [${t.status}] ${describePatchTransaction(t)}`);
2749
+ }
2750
+ if (transactions.length === 0) console.log(' (none proposed)');
2751
+ }
2752
+ process.exit(0);
2753
+ }
2754
+
2214
2755
  // O7 (D-handle-audit-report): a pure reader, deliberately gate-independent -- matches
2215
2756
  // D-contract-history/D-gate-export's own posture, not `handles plan`/`handles emit`'s capability
2216
2757
  // gating. It never touches adapter-specific codegen (the query is over `feature_uid` alone, the
@@ -2249,6 +2790,35 @@ async function cmdHandlesAudit(args) {
2249
2790
  // D-openapi-extraction-hint's own precedent for "the CLI itself carries this warning, not
2250
2791
  // just documentation").
2251
2792
  const caveat = 'this reports what the target application chose to record via @RecordHandleSnapshot / record_snapshot -- it is NOT, and cannot be, a security control on its own (see O3/O5 in CATALOG.md for revocation enforcement and authorization contracts). Absence of a snapshot does not mean a handle was never used, only that recording was never opted into for that call path.';
2793
+
2794
+ // D-write-safety-phase1 (item 3): the live-database closure of D-handle-registry-enforcement's
2795
+ // own named EXIT gap ("an already-empty registry... would need live target-app database
2796
+ // access, a larger scope than this item's own"). Opt-in: resolves the CURRENT plan the same
2797
+ // way `cmdHandlesPlan` does, then cross-references the rows already fetched above against each
2798
+ // resource type the plan will actually generate a resolver for -- a real answer to "will
2799
+ // --enforce-registry on 404 on its very first fetch for this resource", checked against the
2800
+ // live database rather than a static regex proxy.
2801
+ let registryCoverage = null;
2802
+ if (flags['check-registry-coverage']) {
2803
+ const scanReport = loadScanReportOrExit(root, flags.feature);
2804
+ const scanReportPath = specPath(root, flags.feature, 'brownfield-scan.json');
2805
+ requireCapabilitiesOrExit(scanReport, 'handles audit --check-registry-coverage', { featureId: flags.feature, scanReportPath });
2806
+ const provider = selectProviderOrExit(scanReport);
2807
+ requireProviderCapabilitiesOrExit(scanReport, provider, 'handles audit --check-registry-coverage', { featureId: flags.feature, scanReportPath });
2808
+ let plan;
2809
+ try {
2810
+ plan = provider.plan({ repoRoot: root, scanReport, module: flags.module, resourceFilter: resourceTypes });
2811
+ } catch (err) {
2812
+ fail(EXIT_CODES.NOT_PASSED, 'PLAN_FAILED', err.message);
2813
+ }
2814
+ registryCoverage = plan.resources
2815
+ .filter((r) => r.willGenerateResolver)
2816
+ .map((r) => ({
2817
+ resourceType: r.type,
2818
+ covered: rows.some((row) => row.resource_type === r.type && row.kind === 'r' && row.revoked_at === null),
2819
+ }));
2820
+ }
2821
+
2252
2822
  const report = {
2253
2823
  schema: 'sbf.handle-audit/1',
2254
2824
  feature_id: flags.feature,
@@ -2256,6 +2826,7 @@ async function cmdHandlesAudit(args) {
2256
2826
  generated_at: new Date().toISOString(),
2257
2827
  summary,
2258
2828
  handles: rows,
2829
+ registry_coverage: registryCoverage,
2259
2830
  caveat,
2260
2831
  };
2261
2832
 
@@ -2269,6 +2840,11 @@ async function cmdHandlesAudit(args) {
2269
2840
  const pointerNote = h.pointer ? `#${h.pointer}` : '';
2270
2841
  console.log(` ${h.kind} ${h.resource_type}/${h.resource_uid}${pointerNote} -- ${h.snapshot_count} snapshot(s), last ${h.last_recorded_at ?? 'never'}${revokedNote}`);
2271
2842
  }
2843
+ if (registryCoverage) {
2844
+ console.log('\nregistry coverage (would --enforce-registry on 404 on the first fetch for this resource?):');
2845
+ for (const c of registryCoverage) console.log(` ${c.resourceType}: ${c.covered ? 'covered' : 'NOT COVERED -- no non-revoked kind=r row exists yet'}`);
2846
+ if (registryCoverage.length === 0) console.log(' (no resources in this plan will generate a resolver)');
2847
+ }
2272
2848
  console.error(`\nnote: ${caveat}`);
2273
2849
  }
2274
2850
  process.exit(0);
@@ -2341,8 +2917,23 @@ function cmdObserveEmit(args) {
2341
2917
  } catch (err) {
2342
2918
  fail(EXIT_CODES.NOT_PASSED, 'PLAN_FAILED', err.message);
2343
2919
  }
2920
+ } else if (scanReport.adapter === 'typescript-express') {
2921
+ // typescript-express's own project-root detection needs a module to anchor itself too (same
2922
+ // reasoning as python-fastapi's own --module dependency) -- plan.mjs's detectProjectRoot()
2923
+ // walks up from the module's own scanned files.
2924
+ let tsPlan;
2925
+ try {
2926
+ tsPlan = planTypeScriptExpress({ repoRoot: root, scanReport, module: flags.module, resourceFilter: null });
2927
+ } catch (err) {
2928
+ fail(EXIT_CODES.NOT_PASSED, 'PLAN_FAILED', err.message);
2929
+ }
2930
+ try {
2931
+ result = emitObserveTypeScriptExpress({ repoRoot: root, featureId: flags.feature, contract, plan: tsPlan, force: flags.force, reason: flags.reason, dryRun, computeDiff: flags.diff });
2932
+ } catch (err) {
2933
+ fail(EXIT_CODES.NOT_PASSED, 'PLAN_FAILED', err.message);
2934
+ }
2344
2935
  } else {
2345
- fail(EXIT_CODES.MISSING_CAPABILITY, 'MISSING_CAPABILITY', `bskel observe emit does not support the "${scanReport.adapter}" adapter yet (supported: java-spring, python-fastapi)`);
2936
+ fail(EXIT_CODES.MISSING_CAPABILITY, 'MISSING_CAPABILITY', `bskel observe emit does not support the "${scanReport.adapter}" adapter yet (supported: java-spring, python-fastapi, typescript-express)`);
2346
2937
  }
2347
2938
  const { written, conflicts, orphans, notes, forced, blocked, actions, postEmitNotes = [] } = result;
2348
2939
  const wouldChange = actions.some((a) => a.action !== 'unchanged' && a.action !== 'adopt-unchanged');
@@ -2493,11 +3084,20 @@ function cmdObserveImport(args) {
2493
3084
  const reportPath = specPath(root, flags.feature, 'observe', `${flags.feature}.conformance-report.json`);
2494
3085
  writeFileAtomic(reportPath, `${JSON.stringify(report, null, 2)}\n`);
2495
3086
 
2496
- // Evidence-first, not verdict-first (same as `contract`'s own precedent: a `partial` contract
2497
- // is still passable via waiver) -- passes on a successful STRUCTURAL import, never on "zero
2498
- // violations found". Whether violation counts should block CI is a policy question for whoever
2499
- // reads the report, deliberately not decided here -- see DECISIONS.md's own deferred list.
2500
- const gateState = passNamedGate(root, 'conformance', flags.feature, { receipt_count: receipts.length, matched, violations: violationCount });
3087
+ // Evidence-first, not verdict-first by DEFAULT (same as `contract`'s own precedent: a `partial`
3088
+ // contract is still passable via waiver) -- passes on a successful STRUCTURAL import, never on
3089
+ // "zero violations found", unless --fail-on-violation opts into the stricter behavior. Whether
3090
+ // violation counts should block CI was a policy question deliberately left undecided in v1 --
3091
+ // this is that v1.1 layer, see D-runtime-conformance-receipts's own "Continued" entry in
3092
+ // DECISIONS.md. `awaitNamedGateDisposition` is the EXACT same disposition mechanism `contract`
3093
+ // already uses for "evidence exists but isn't good enough to pass silently" -- resolved by the
3094
+ // already-generic `bskel gate force conformance --feature <id> --reason "..."` (no new CLI verb
3095
+ // needed; `bskel next`'s own generic awaitingDispositionCommand() fallback already names it).
3096
+ const evidence = { receipt_count: receipts.length, matched, violations: violationCount };
3097
+ const blocking = flags['fail-on-violation'] && violationCount > 0;
3098
+ const gateState = blocking
3099
+ ? awaitNamedGateDisposition(root, 'conformance', flags.feature, evidence)
3100
+ : passNamedGate(root, 'conformance', flags.feature, evidence);
2501
3101
 
2502
3102
  if (flags.json) {
2503
3103
  console.log(JSON.stringify({ report, noise_lines: noiseLines, gate: gateState.gates.conformance }, null, 2));
@@ -2506,8 +3106,11 @@ function cmdObserveImport(args) {
2506
3106
  console.log(`${violationCount} violation(s), ${unsupportedCount} unsupported field(s) across matched receipts`);
2507
3107
  console.log(`wrote ${path.relative(root, reportPath)}`);
2508
3108
  console.log(`gate: conformance -> ${gateState.gates.conformance.status}`);
3109
+ if (blocking) {
3110
+ console.error(`\nblocked: ${violationCount} violation(s) found (--fail-on-violation) -- review ${path.relative(root, reportPath)}, then \`bskel gate force conformance --feature ${flags.feature} --reason "..."\` once accounted for.`);
3111
+ }
2509
3112
  }
2510
- process.exit(0);
3113
+ process.exit(blocking ? EXIT.AWAITING_DISPOSITION : EXIT.PASS);
2511
3114
  }
2512
3115
 
2513
3116
  // S2: "stale" alone sends a human/agent re-running steps until one happens to stick. Name the
@@ -2532,10 +3135,17 @@ function renderVerifyReport({ featureId, gates, artifacts, conflicts, build, all
2532
3135
  const completenessNote = g.gate === 'contract' && evidence?.completeness
2533
3136
  ? ` (${evidence.completeness}${evidence.waived_count ? `: ${evidence.waived_count} waived` : ''})`
2534
3137
  : '';
3138
+ // D-runtime-conformance-receipts (Continued, --fail-on-violation): same "surface the evidence
3139
+ // right in the verify report" precedent as `completenessNote` above -- fires for both `pass`
3140
+ // and `awaiting_disposition` states, since a human should see the count even when it didn't
3141
+ // end up blocking (i.e. --fail-on-violation wasn't used at import time).
3142
+ const conformanceNote = g.gate === 'conformance' && evidence?.violations
3143
+ ? ` (${evidence.violations} violation(s), ${evidence.matched}/${evidence.receipt_count} matched)`
3144
+ : '';
2535
3145
  // S4 (D-gate-history): a revoked gate's reason is exactly the kind of "why is this
2536
3146
  // blocking" detail describeStale() already surfaces for stale gates -- same treatment here.
2537
3147
  const revokedNote = g.status === 'revoked' && g.record?.reason ? ` (revoked: ${g.record.reason})` : '';
2538
- lines.push(`- [${marker}] ${g.gate}${suffix}${completenessNote}${describeStale(g)}${revokedNote}`);
3148
+ lines.push(`- [${marker}] ${g.gate}${suffix}${completenessNote}${conformanceNote}${describeStale(g)}${revokedNote}`);
2539
3149
  }
2540
3150
  lines.push('', '## Artifacts');
2541
3151
  for (const a of artifacts) lines.push(`- [${a.exists ? 'OK' : 'MISSING'}] ${a.artifact}: ${a.path}`);
@@ -2747,19 +3357,46 @@ async function cmdServe(args) {
2747
3357
  const root = requireRepoRoot();
2748
3358
  const port = Number.parseInt(flags.port, 10);
2749
3359
 
3360
+ // D-ddl-apply: eagerly checked here, at startup -- the DB-touching routes must not silently
3361
+ // 404 forever because of a typo'd env var name; missing/unset is BAD_ARGS at the CLI boundary,
3362
+ // same convention as resolveDbSchemaOrExit's own `bskel scan --db` handling. The live
3363
+ // connection itself still only ever opens lazily, per-request (no eager connect-at-startup, no
3364
+ // pool) -- a transient DB outage must not block the unrelated, DB-independent parts of the UI.
3365
+ let dbConfig = null;
3366
+ if (flags['database-url-env']) {
3367
+ if (!process.env[flags['database-url-env']]) {
3368
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--database-url-env ${flags['database-url-env']} names an environment variable that isn't set -- export it first (never read from .env directly; see D-db-schema-plane in DECISIONS.md)`);
3369
+ }
3370
+ dbConfig = { databaseUrlEnv: flags['database-url-env'], schema: flags.schema, signKeyPath: flags['sign-key'] };
3371
+ // D-ddl-apply: closes this feature's own named "mandatory signing... cheap, well-justified
3372
+ // near-term addition" EXIT item -- an opt-in-to-MORE-strictness knob, letting a cautious team
3373
+ // enforce mandatory signing for themselves without this tool forcing it on every user. A
3374
+ // no-op when --database-url-env wasn't given at all (this whole block never runs then) --
3375
+ // nothing to enforce on a DDL surface that isn't running.
3376
+ if (flags['require-sign-key'] && !dbConfig.signKeyPath) {
3377
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', '--require-sign-key was given but --sign-key was not -- refusing to start the DDL surface without an audit-signing key. Pass --sign-key <path>, or drop --require-sign-key to allow starting unsigned (with a warning).');
3378
+ }
3379
+ }
3380
+
2750
3381
  let started;
2751
3382
  try {
2752
- started = await createHttpServer(root, { host: flags.host, port });
3383
+ started = await createHttpServer(root, { host: flags.host, port, dbConfig });
2753
3384
  } catch (err) {
2754
3385
  fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `could not start server: ${err.message}`);
2755
3386
  }
2756
3387
 
2757
3388
  if (flags.json) {
2758
- console.log(JSON.stringify({ listening: started.url, host: started.host, port: started.port, repo: root }));
3389
+ console.log(JSON.stringify({ listening: started.url, host: started.host, port: started.port, repo: root, ddlApplyEnabled: Boolean(dbConfig) }));
2759
3390
  } else if (!flags.quiet) {
2760
3391
  console.log(`bskel serve -- listening on ${started.url}`);
2761
3392
  console.log(` UI: ${started.url}/`);
2762
3393
  console.log(` API: ${started.url}/api/...`);
3394
+ if (dbConfig) {
3395
+ console.log(` DDL apply routes: ENABLED (schema "${dbConfig.schema}", connection from $${dbConfig.databaseUrlEnv})`);
3396
+ if (!dbConfig.signKeyPath) {
3397
+ console.log(' WARNING: no --sign-key given -- every propose/approve/apply is recorded in specs/ but NOT cryptographically attested. Pass --sign-key <path> to change this.');
3398
+ }
3399
+ }
2763
3400
  console.log('press Ctrl+C to stop');
2764
3401
  }
2765
3402
 
@@ -2988,6 +3625,8 @@ async function dispatchCommand(cmd, rest) {
2988
3625
  case 'scan': {
2989
3626
  if (rest[0] === 'disposition') return cmdScanDisposition(rest.slice(1));
2990
3627
  if (rest[0] === 'explain') return cmdScanExplain(rest.slice(1));
3628
+ if (rest[0] === 'cross-feature-check') return cmdScanCrossFeatureCheck(rest.slice(1));
3629
+ if (rest[0] === 'cross-feature-waive') return cmdScanCrossFeatureWaive(rest.slice(1));
2991
3630
  await cmdScan(rest);
2992
3631
  break;
2993
3632
  }
@@ -3031,6 +3670,16 @@ async function dispatchCommand(cmd, rest) {
3031
3670
  process.exit(14);
3032
3671
  break;
3033
3672
  }
3673
+ case 'patch': {
3674
+ if (rest[0] === 'propose') return cmdPatchPropose(rest.slice(1));
3675
+ if (rest[0] === 'approve') return cmdPatchApprove(rest.slice(1));
3676
+ if (rest[0] === 'apply') return cmdPatchApply(rest.slice(1));
3677
+ if (rest[0] === 'rollback') return cmdPatchRollback(rest.slice(1));
3678
+ if (rest[0] === 'list') return cmdPatchList(rest.slice(1));
3679
+ usage();
3680
+ process.exit(14);
3681
+ break;
3682
+ }
3034
3683
  case 'catalog': {
3035
3684
  if (rest[0] === 'lint') return cmdCatalogLint(rest.slice(1));
3036
3685
  usage();
@@ -3075,6 +3724,13 @@ async function dispatchCommand(cmd, rest) {
3075
3724
  process.exit(14);
3076
3725
  break;
3077
3726
  }
3727
+ case 'attest': {
3728
+ if (rest[0] === 'keygen') return cmdAttestKeygen(rest.slice(1));
3729
+ if (rest[0] === 'verify') return cmdAttestVerify(rest.slice(1));
3730
+ usage();
3731
+ process.exit(14);
3732
+ break;
3733
+ }
3078
3734
  case 'doctor':
3079
3735
  cmdDoctor(rest);
3080
3736
  break;