backend-skeleton 1.0.0-beta.9 → 1.0.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 (45) hide show
  1. package/README.md +122 -12
  2. package/bin/bskel.mjs +564 -15
  3. package/contracts/emit.mjs +5 -1
  4. package/contracts/export.mjs +26 -3
  5. package/contracts/openapi.mjs +29 -3
  6. package/handles/_engine.mjs +79 -29
  7. package/handles/providers/java-spring/plan.mjs +22 -9
  8. package/handles/providers/java-spring/templates/HandleController.java.tmpl +7 -3
  9. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +12 -6
  10. package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +101 -39
  11. package/handles/providers/typescript-express/emit.mjs +23 -23
  12. package/handles/providers/typescript-express/observe.mjs +101 -0
  13. package/handles/providers/typescript-express/templates/contractCheck.ts.tmpl +136 -0
  14. package/handles/providers/typescript-express/templates/observeContract.ts.tmpl +146 -0
  15. package/handles/providers/typescript-express/templates/observedSchema.ts.tmpl +116 -0
  16. package/lib/attest.mjs +40 -0
  17. package/lib/cli.mjs +125 -3
  18. package/lib/cross-feature-collisions.mjs +286 -0
  19. package/lib/diff.mjs +35 -0
  20. package/lib/fsutil.mjs +7 -2
  21. package/lib/gate-definitions.mjs +85 -1
  22. package/lib/gates.mjs +5 -1
  23. package/lib/http-server.mjs +192 -6
  24. package/lib/lock.mjs +68 -15
  25. package/lib/patch-kinds.mjs +52 -0
  26. package/lib/patch-transactions.mjs +206 -0
  27. package/lib/serve-ui.html +211 -0
  28. package/lib/workflow.mjs +31 -3
  29. package/package.json +5 -2
  30. package/scanners/adapters/java-spring.mjs +6 -0
  31. package/scanners/adapters/python-fastapi.mjs +9 -1
  32. package/scanners/adapters/typescript-express.mjs +6 -0
  33. package/scanners/db/ddl-apply.mjs +253 -0
  34. package/scanners/db/introspect.mjs +61 -32
  35. package/scanners/db/migrations.mjs +73 -18
  36. package/schemas/cross-feature-report.schema.json +66 -0
  37. package/schemas/cross-feature-resolution.schema.json +28 -0
  38. package/schemas/gate-attestation.schema.json +22 -0
  39. package/schemas/gate-export.schema.json +58 -0
  40. package/schemas/patch-transaction.schema.json +182 -0
  41. package/schemas/scan-report.schema.json +6 -4
  42. package/schemas/stack-choice.schema.json +12 -1
  43. package/stack/apply.mjs +4 -1
  44. package/stack/catalog/ngrok.yml +8 -2
  45. package/stack/config-apply.mjs +168 -0
package/bin/bskel.mjs CHANGED
@@ -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,
@@ -52,6 +59,8 @@ import { detectBasePackage } from '../handles/providers/java-spring/plan.mjs';
52
59
  import { emitObserveJavaSpring } from '../handles/providers/java-spring/observe.mjs';
53
60
  import { plan as planPythonFastApi } from '../handles/providers/python-fastapi/plan.mjs';
54
61
  import { emitObservePythonFastApi } from '../handles/providers/python-fastapi/observe.mjs';
62
+ import { plan as planTypeScriptExpress } from '../handles/providers/typescript-express/plan.mjs';
63
+ import { emitObserveTypeScriptExpress } from '../handles/providers/typescript-express/observe.mjs';
55
64
  import { collectGateStatuses, runBuildCheck, checkArtifacts, checkResolverConflicts } from '../lib/verify.mjs';
56
65
  import { computeWorkflowState } from '../lib/workflow.mjs';
57
66
  import { computeDoctorChecks, WORKFLOWS as DOCTOR_WORKFLOWS } from '../lib/doctor.mjs';
@@ -70,6 +79,8 @@ function usage() {
70
79
  bskel scan [--feature <id>] [--terms a,b,c] [--json] [--accept-low-confidence] [--db [--database-url-env <NAME>] [--schema public]]
71
80
  bskel scan disposition --feature <id> --mode reuse|extend|replace|parallel [--module <name>] [--note "..."] [--breaking-approved]
72
81
  bskel scan explain <module> --feature <id> [--json]
82
+ bskel scan cross-feature-check --feature <id> [--db [--database-url-env <NAME>] [--schema public]] [--json]
83
+ bskel scan cross-feature-waive --feature <id> --signal resource_type|table|operation_id|db_foreign_key --identifier <name> --other-feature <id> --reason "..."
73
84
  bskel feature init --slug <name>
74
85
  bskel feature list [--all] [--json]
75
86
  bskel feature show <id> [--json]
@@ -91,8 +102,13 @@ function usage() {
91
102
  bskel handles emit --feature <id> [--module <name>] [--resource type1,type2] [--force --reason "..."] [--check] [--diff] [--enforce-registry on|off --reason "..."]
92
103
  bskel handles patch approve --feature <id> [--module <name>] --resource <Type> --field <name> --strategy patch-wrapper|null-means-unchanged --reason "..." [--json]
93
104
  bskel handles audit --feature <id> --database-url-env <NAME> [--resource type1,type2] [--json]
105
+ 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]
106
+ bskel patch approve --feature <id> --transaction <id> --reason "..." [--json]
107
+ bskel patch apply --feature <id> --transaction <id> [--confirm <id-or-dropped-table-name>] [--json]
108
+ bskel patch rollback --feature <id> --transaction <id> --reason "..." [--force] [--json]
109
+ bskel patch list --feature <id> [--json]
94
110
  bskel observe emit --feature <id> [--module <name>] [--force --reason "..."] [--check] [--diff] [--json]
95
- bskel observe import --feature <id> --receipts <path> [--json]
111
+ bskel observe import --feature <id> --receipts <path> [--fail-on-violation] [--json]
96
112
  bskel verify --feature <id> [--build [--allow-skip-build]] [--json]
97
113
  bskel status [--feature <id>] [--json]
98
114
  bskel next [--feature <id>] [--json]
@@ -101,9 +117,11 @@ function usage() {
101
117
  bskel gate revoke <name> --reason "..." [--feature <id>]
102
118
  bskel gate history <name> [--feature <id>] [--json]
103
119
  bskel gate show [<name>] [--feature <id>]
104
- bskel gate export --feature <id> [--out <path>] [--json]
120
+ bskel gate export --feature <id> [--out <path>] [--sign --key <privateKeyPath>] [--json]
121
+ bskel attest keygen --out <dir> [--force] [--json]
122
+ bskel attest verify --file <path> --pubkey <path> [--json]
105
123
  bskel doctor [--workflow ${DOCTOR_WORKFLOWS.join('|')}] [--json]
106
- bskel serve [--port N] [--host <addr>] [--json]
124
+ bskel serve [--port N] [--host <addr>] [--database-url-env <NAME>] [--schema <name>] [--sign-key <path>] [--require-sign-key] [--json]
107
125
  `);
108
126
  }
109
127
 
@@ -393,6 +411,12 @@ function cmdGateExport(args) {
393
411
  setContext('gate export', flags);
394
412
  const root = requireRepoRoot();
395
413
  requireValidFeatureId(flags.feature);
414
+ if (flags.sign && !flags.key) {
415
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel gate export --sign requires --key <privateKeyPath>');
416
+ }
417
+ if (flags.key && !flags.sign) {
418
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', '--key only has an effect together with --sign');
419
+ }
396
420
 
397
421
  const gates = {};
398
422
  for (const name of GATE_NAMES) {
@@ -407,14 +431,45 @@ function cmdGateExport(args) {
407
431
  git: { branch: currentBranch(root), head_sha: headSha(root), dirty: isDirty(root) },
408
432
  gates,
409
433
  };
410
- const rendered = `${JSON.stringify(report, null, 2)}\n`;
434
+ // D-gate-attestation-signing: validated unconditionally, signed or not -- a document that can
435
+ // be exported unsigned should be exactly as trustworthy in shape as one that gets signed later.
436
+ {
437
+ const { ok, errors } = validateAgainstSchema('gate-export.schema.json', report);
438
+ if (!ok) {
439
+ fail(EXIT_CODES.NOT_PASSED, 'INVALID_ARTIFACT', `internal error: the computed gate-export report failed its own schema -- ${formatSchemaErrors(errors).join('; ')}`);
440
+ }
441
+ }
442
+
443
+ let payload = report;
444
+ if (flags.sign) {
445
+ let privateKeyPem;
446
+ try {
447
+ privateKeyPem = fs.readFileSync(path.resolve(process.cwd(), flags.key), 'utf8');
448
+ } catch (err) {
449
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `could not read --key "${flags.key}": ${err.message}`);
450
+ }
451
+ let signatureValue;
452
+ try {
453
+ signatureValue = signPayload(report, privateKeyPem);
454
+ } catch (err) {
455
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--key "${flags.key}" is not a usable Ed25519 private key: ${err.message}`);
456
+ }
457
+ const attestation = { schema: 'sbf.gate-attestation/1', report, signature: { algorithm: 'ed25519', value: signatureValue } };
458
+ const { ok, errors } = validateAgainstSchema('gate-attestation.schema.json', attestation);
459
+ if (!ok) {
460
+ fail(EXIT_CODES.NOT_PASSED, 'INVALID_ARTIFACT', `internal error: the computed gate attestation failed its own schema -- ${formatSchemaErrors(errors).join('; ')}`);
461
+ }
462
+ payload = attestation;
463
+ }
464
+ const rendered = `${JSON.stringify(payload, null, 2)}\n`;
411
465
 
412
466
  if (flags.out) {
413
467
  const outPath = path.resolve(process.cwd(), flags.out);
414
468
  writeFileAtomic(outPath, rendered);
415
469
  if (!flags.quiet) {
416
470
  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)' : ''}`);
471
+ const signedNote = flags.sign ? ' (signed)' : '';
472
+ 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
473
  }
419
474
  } else {
420
475
  console.log(rendered);
@@ -422,6 +477,78 @@ function cmdGateExport(args) {
422
477
  process.exit(0);
423
478
  }
424
479
 
480
+ // D-gate-attestation-signing: --out is always required, never a default/home-directory location --
481
+ // this codebase has zero existing home-directory persistence convention anywhere, and inventing
482
+ // one is explicitly out of scope for this slice (a real fork the user weighed and decided, see
483
+ // DECISIONS.md). Repo-independent -- does not require a git repo at all, matching `attest verify`'s
484
+ // own posture below (both operate purely on files the caller names).
485
+ function cmdAttestKeygen(args) {
486
+ const flags = parseCommand('attest keygen', args);
487
+ if (flags.help) { console.log(renderCommandHelp('attest keygen')); process.exit(0); }
488
+ setContext('attest keygen', flags);
489
+ const outDir = path.resolve(process.cwd(), flags.out);
490
+ const privatePath = path.join(outDir, 'attest-private.pem');
491
+ const publicPath = path.join(outDir, 'attest-public.pem');
492
+ if (!flags.force) {
493
+ const existing = [privatePath, publicPath].filter((p) => fs.existsSync(p));
494
+ if (existing.length > 0) {
495
+ 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`);
496
+ }
497
+ }
498
+ const { publicKeyPem, privateKeyPem } = generateKeypair();
499
+ // Restrictive mode (owner read/write only) from the very first write -- see the
500
+ // `D-gate-attestation-signing` entry's own note in `lib/fsutil.mjs` on why this is a
501
+ // `writeFileAtomic` parameter, not a chmod() called after the fact.
502
+ writeFileAtomic(privatePath, privateKeyPem, 0o600);
503
+ writeFileAtomic(publicPath, publicKeyPem);
504
+
505
+ if (flags.json) {
506
+ console.log(JSON.stringify({ private_key: privatePath, public_key: publicPath }, null, 2));
507
+ } else if (!flags.quiet) {
508
+ console.log(`wrote ${privatePath} (0600) and ${publicPath}`);
509
+ }
510
+ process.exit(0);
511
+ }
512
+
513
+ // Deliberately repo-independent (no requireRepoRoot()) -- verifying a previously-exported
514
+ // attestation has nothing to do with the current directory's own git state; the whole point is to
515
+ // check a document someone else produced, possibly on a different machine, offline. Exit code is
516
+ // driven ONLY by signature validity -- whether the gates INSIDE the report passed is a separate,
517
+ // printed question (see DECISIONS.md for why conflating the two would be actively misleading).
518
+ function cmdAttestVerify(args) {
519
+ const flags = parseCommand('attest verify', args);
520
+ if (flags.help) { console.log(renderCommandHelp('attest verify')); process.exit(0); }
521
+ setContext('attest verify', flags);
522
+
523
+ let attestation;
524
+ try {
525
+ attestation = JSON.parse(fs.readFileSync(path.resolve(process.cwd(), flags.file), 'utf8'));
526
+ } catch (err) {
527
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `could not read/parse --file "${flags.file}": ${err.message}`);
528
+ }
529
+ const { ok: schemaOk, errors: schemaErrors } = validateAgainstSchema('gate-attestation.schema.json', attestation);
530
+ if (!schemaOk) {
531
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `"${flags.file}" is not a valid gate attestation: ${formatSchemaErrors(schemaErrors).join('; ')}`);
532
+ }
533
+ let publicKeyPem;
534
+ try {
535
+ publicKeyPem = fs.readFileSync(path.resolve(process.cwd(), flags.pubkey), 'utf8');
536
+ } catch (err) {
537
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `could not read --pubkey "${flags.pubkey}": ${err.message}`);
538
+ }
539
+
540
+ const valid = verifyPayload(attestation.report, attestation.signature.value, publicKeyPem);
541
+ const passCount = GATE_NAMES.filter((n) => attestation.report.gates[n]?.current?.status === 'pass').length;
542
+
543
+ if (flags.json) {
544
+ 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));
545
+ } else if (!flags.quiet) {
546
+ 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');
547
+ console.log(`report says: feature ${attestation.report.feature_id}, ${passCount}/${GATE_NAMES.length} gate(s) passing, generated ${attestation.report.generated_at}`);
548
+ }
549
+ process.exit(valid ? EXIT_CODES.OK : EXIT_CODES.CHECK_FAILED);
550
+ }
551
+
425
552
  // Structural enforcement of "preflight blocks everything below it" (see the workflow table in
426
553
  // SKILL.md) for every feature-scoped command -- not just documented as a step order, checked.
427
554
  // Ad-hoc `bskel scan` (no --feature) is exempt: it's an explicit side-channel quick-look
@@ -634,6 +761,146 @@ function cmdScanExplain(args) {
634
761
  process.exit(0);
635
762
  }
636
763
 
764
+ // D-cross-feature-collision: mirrors cmdContractEmit's own "always write the artifact, gate
765
+ // blocks only if unresolved issues remain" shape exactly, for a different data source (NAME-
766
+ // identity collisions against every OTHER feature, not this feature's own contract completeness).
767
+ //
768
+ // D-cross-feature-fk-inference: async now -- reuses the EXACT same resolveDbSchemaOrExit() helper
769
+ // cmdScan() already calls for `--db [--database-url-env <NAME>] [--schema public]`, so this command
770
+ // gains those same flags with zero new live-DB code path. If `--db` was never given (the existing,
771
+ // unmodified call shape every current caller -- 5 CI smoke scripts, README's Quickstart -- already
772
+ // uses), findCollisions() falls back to a persisted snapshot or reports fk_check:'unavailable';
773
+ // the first 3 signals' findings and blocking behavior are byte-identical to before this item.
774
+ async function cmdScanCrossFeatureCheck(args) {
775
+ const flags = parseCommand('scan cross-feature-check', args);
776
+ if (flags.help) { console.log(renderCommandHelp('scan cross-feature-check')); process.exit(0); }
777
+ setContext('scan cross-feature-check', flags);
778
+ const root = requireRepoRoot();
779
+ requireValidFeatureId(flags.feature);
780
+
781
+ const liveDbSchema = await resolveDbSchemaOrExit(root, flags);
782
+ const { findings, fk_check, unknowns } = findCollisions(root, flags.feature, { liveDbSchema });
783
+ const report = {
784
+ schema: 'sbf.cross-feature-report/1',
785
+ feature_id: flags.feature,
786
+ generated_at: new Date().toISOString(),
787
+ findings,
788
+ fk_check,
789
+ unknowns,
790
+ };
791
+ const { ok, errors } = validateAgainstSchema('cross-feature-report.schema.json', report);
792
+ if (!ok) {
793
+ fail(EXIT_CODES.NOT_PASSED, 'INVALID_ARTIFACT', `internal error: the computed cross-feature report failed its own schema -- ${formatSchemaErrors(errors).join('; ')}`);
794
+ }
795
+ writeFileAtomic(crossFeatureReportPath(root, flags.feature), `${JSON.stringify(report, null, 2)}\n`);
796
+
797
+ const resolution = loadCrossFeatureResolution(root, flags.feature);
798
+ const evaluation = evaluateCrossFeatureFindings(findings, resolution);
799
+ const evidence = {
800
+ finding_count: findings.length,
801
+ high_confidence_count: findings.filter((f) => f.confidence === 'high').length,
802
+ waived_count: evaluation.waived.length,
803
+ stale_waivers: evaluation.staleWaivers.length,
804
+ };
805
+ const gateState = evaluation.blocking
806
+ ? awaitNamedGateDisposition(root, 'cross_feature', flags.feature, { ...evidence, unwaived: evaluation.unwaived })
807
+ : passNamedGate(root, 'cross_feature', flags.feature, evidence);
808
+
809
+ if (flags.json) {
810
+ console.log(JSON.stringify({ report, gate: gateState.gates.cross_feature }, null, 2));
811
+ } else {
812
+ if (!flags.quiet) {
813
+ const otherCount = new Set(findings.map((f) => f.other_feature)).size;
814
+ console.log(`${findings.length} finding(s) (${evidence.high_confidence_count} high-confidence) across ${otherCount} other feature(s)`);
815
+ for (const f of findings) console.log(` [${f.confidence}] ${f.signal}: "${f.identifier}" also declared by ${f.other_feature}`);
816
+ // D-cross-feature-fk-inference (staleness/freshness token): the actual point of the field
817
+ // -- a human SEEING how stale a persisted/migrations-mode correlation is, not just the
818
+ // JSON carrying it silently.
819
+ const fkCheckDetails = [];
820
+ if (fk_check.source_feature) fkCheckDetails.push(`from ${fk_check.source_feature}'s own persisted snapshot`);
821
+ if (fk_check.generated_at) fkCheckDetails.push(`captured ${fk_check.generated_at}`);
822
+ console.log(`fk_check: ${fk_check.mode}${fkCheckDetails.length ? ` (${fkCheckDetails.join(', ')})` : ''}`);
823
+ for (const u of unknowns) console.log(` note: ${u}`);
824
+ console.log(`gate: cross_feature -> ${gateState.gates.cross_feature.status}`);
825
+ }
826
+ if (evaluation.staleWaivers.length > 0) {
827
+ console.error(`\nnote: ${evaluation.staleWaivers.length} recorded waiver(s) no longer match any current finding (kept as-is, not auto-removed):`);
828
+ for (const w of evaluation.staleWaivers) console.error(` ${w.signal} "${w.identifier}" (${w.other_feature})`);
829
+ }
830
+ if (evaluation.blocking) {
831
+ console.error(`\nblocked: ${evaluation.unwaived.length} unresolved high-confidence collision(s):`);
832
+ for (const f of evaluation.unwaived) {
833
+ console.error(` bskel scan cross-feature-waive --feature ${flags.feature} --signal ${f.signal} --identifier "${f.identifier}" --other-feature ${f.other_feature} --reason "..."`);
834
+ }
835
+ }
836
+ }
837
+ process.exit(evaluation.blocking ? EXIT.AWAITING_DISPOSITION : EXIT.PASS);
838
+ }
839
+
840
+ const CROSS_FEATURE_SIGNALS = ['resource_type', 'table', 'operation_id', 'db_foreign_key'];
841
+
842
+ // Validates against the PERSISTED report from the last `cross-feature-check` run, never a live
843
+ // re-computation -- same precedent `contract waive` already establishes against `loadContract`
844
+ // (contracts/completeness.mjs). If reality moved since that check, the gate's own staleness token
845
+ // (which covers every OTHER feature named in the report) is what surfaces that, not a silent
846
+ // re-check inside this command.
847
+ function cmdScanCrossFeatureWaive(args) {
848
+ const flags = parseCommand('scan cross-feature-waive', args);
849
+ if (flags.help) { console.log(renderCommandHelp('scan cross-feature-waive')); process.exit(0); }
850
+ setContext('scan cross-feature-waive', flags);
851
+ const root = requireRepoRoot();
852
+ requireValidFeatureId(flags.feature);
853
+ requireValidFeatureId(flags['other-feature']);
854
+ if (!flags.reason || !flags.reason.trim()) {
855
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel scan cross-feature-waive requires --reason "..." -- every waiver must be auditable');
856
+ }
857
+ if (!CROSS_FEATURE_SIGNALS.includes(flags.signal)) {
858
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--signal must be one of: ${CROSS_FEATURE_SIGNALS.join(', ')}`);
859
+ }
860
+
861
+ const report = loadCrossFeatureReport(root, flags.feature);
862
+ if (!report) {
863
+ 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`);
864
+ }
865
+ const match = report.findings.find((f) => f.signal === flags.signal && f.identifier === flags.identifier && f.other_feature === flags['other-feature']);
866
+ if (!match) {
867
+ const known = report.findings.map((f) => `${f.signal} "${f.identifier}" (${f.other_feature})`);
868
+ 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)'}`);
869
+ }
870
+
871
+ const updated = withLockSync(root, 'state', () => {
872
+ const resolution = loadCrossFeatureResolution(root, flags.feature);
873
+ const key = waiverKey({ signal: flags.signal, identifier: flags.identifier, other_feature: flags['other-feature'] });
874
+ const entry = { signal: flags.signal, identifier: flags.identifier, other_feature: flags['other-feature'], reason: flags.reason, at: new Date().toISOString() };
875
+ const next = {
876
+ schema: 'sbf.cross-feature-resolution/1',
877
+ feature_id: flags.feature,
878
+ waivers: [...resolution.waivers.filter((w) => waiverKey(w) !== key), entry],
879
+ };
880
+ saveCrossFeatureResolution(root, flags.feature, next);
881
+ return next;
882
+ });
883
+
884
+ const evaluation = evaluateCrossFeatureFindings(report.findings, updated);
885
+ const evidence = {
886
+ finding_count: report.findings.length,
887
+ high_confidence_count: report.findings.filter((f) => f.confidence === 'high').length,
888
+ waived_count: evaluation.waived.length,
889
+ stale_waivers: evaluation.staleWaivers.length,
890
+ };
891
+ const gateState = evaluation.blocking
892
+ ? awaitNamedGateDisposition(root, 'cross_feature', flags.feature, { ...evidence, unwaived: evaluation.unwaived })
893
+ : passNamedGate(root, 'cross_feature', flags.feature, evidence);
894
+
895
+ if (flags.json) {
896
+ console.log(JSON.stringify({ waived: true, gate: gateState.gates.cross_feature }, null, 2));
897
+ } else if (!flags.quiet) {
898
+ console.log(`waived: ${flags.signal} "${flags.identifier}" (${flags['other-feature']})`);
899
+ console.log(`gate: cross_feature -> ${gateState.gates.cross_feature.status}`);
900
+ }
901
+ process.exit(evaluation.blocking ? EXIT.AWAITING_DISPOSITION : EXIT.PASS);
902
+ }
903
+
637
904
  // D6 (D-feature-lifecycle): the whole read-specs/->compute-NNN->write-feature.json->
638
905
  // load-modify-save-feature-index.json sequence runs under one exclusive lock -- confirmed live
639
906
  // during this item's own grounding, the same lost-update shape S5 already fixed for setGate():
@@ -2036,6 +2303,25 @@ function cmdHandlesEmit(args) {
2036
2303
  });
2037
2304
  }
2038
2305
 
2306
+ // D-cross-feature-collision: making "mandatory disposition" actually mandatory, not just
2307
+ // available -- `handles emit` is specifically the step that generates the runtime resolver
2308
+ // code every codegen provider's own resourceType-keyed dispatch (Java/Python/TS, all three)
2309
+ // already implicitly assumes is repo-unique. `cross_feature`'s own gate `verifyPolicy` stays
2310
+ // REQUIRED_WHEN_PRESENT (so `bskel verify` doesn't retroactively fail a contract-only feature
2311
+ // that never touches handles at all) -- this hard-requires it ONLY here, the one command where
2312
+ // the real risk actually lives, mirroring the `contract` gate check immediately above exactly.
2313
+ // Deliberately NOT added to `cmdHandlesPlan` -- that command never writes (dryRun always),
2314
+ // same reasoning it already gives for skipping the `contract` gate entirely.
2315
+ const crossFeatureResult = requireNamedGate(root, 'cross_feature', flags.feature);
2316
+ if (crossFeatureResult.code !== EXIT.PASS) {
2317
+ const cfHint = crossFeatureResult.status === 'awaiting_disposition'
2318
+ ? `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.`
2319
+ : `run \`bskel scan cross-feature-check --feature ${flags.feature}\` first.`;
2320
+ fail(crossFeatureResult.code, gateReasonForCode(crossFeatureResult.code), `blocked: \`cross_feature\` gate for ${flags.feature} is ${crossFeatureResult.status} -- ${cfHint}`, {
2321
+ next_actions: [{ command: `bskel scan cross-feature-check --feature ${flags.feature}`, reason: 'the cross_feature gate has not passed yet', mutating: true }],
2322
+ });
2323
+ }
2324
+
2039
2325
  const scanReport = loadScanReportOrExit(root, flags.feature);
2040
2326
  const scanReportPath = specPath(root, flags.feature, 'brownfield-scan.json');
2041
2327
  requireCapabilitiesOrExit(scanReport, 'handles emit', { featureId: flags.feature, scanReportPath });
@@ -2211,6 +2497,189 @@ function cmdHandlesPatchApprove(args) {
2211
2497
  process.exit(0);
2212
2498
  }
2213
2499
 
2500
+ // D-patch-transactions: Slice 1 (config_check -> config_apply). All four commands only touch
2501
+ // specs/<featureId>/patch-transactions/ except `apply`/`rollback`, which write to the real target
2502
+ // file too -- matching D4's own "propose/approve are specs/-only, apply/rollback touch the repo"
2503
+ // distinction cmdHandlesEmit/cmdHandlesPlan already draw for --check vs a real emit.
2504
+ // D-ddl-apply: `--kind` picks which patch-transaction kind to propose (default 'config-apply',
2505
+ // fully backward compatible -- every existing call site/script that never passed --kind gets the
2506
+ // exact prior behavior). Kind-specific params are validated by hand here (not via COMMANDS'
2507
+ // declarative `required: true`, which is unconditional per-flag) -- matches this file's own
2508
+ // existing convention for kind-conditional requirements (e.g. --reason on approve/rollback).
2509
+ async function cmdPatchPropose(args) {
2510
+ const flags = parseCommand('patch propose', args);
2511
+ if (flags.help) { console.log(renderCommandHelp('patch propose')); process.exit(0); }
2512
+ setContext('patch propose', flags);
2513
+ const root = requireRepoRoot();
2514
+ requireValidFeatureId(flags.feature);
2515
+ const kind = flags.kind || 'config-apply';
2516
+ if (!PATCH_KIND_NAMES.includes(kind)) {
2517
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--kind must be one of: ${PATCH_KIND_NAMES.join(', ')}`);
2518
+ }
2519
+
2520
+ let params;
2521
+ let source;
2522
+ if (kind === 'config-apply') {
2523
+ if (!flags.choice || !flags.target) {
2524
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel patch propose --kind config-apply requires --choice <stackChoiceId> --target <config_check target path>');
2525
+ }
2526
+ params = { choice: flags.choice, target: flags.target };
2527
+ source = { choice: flags.choice };
2528
+ } else {
2529
+ if (!flags['database-url-env'] || !flags['sql-file']) {
2530
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel patch propose --kind ddl-apply requires --database-url-env <NAME> --sql-file <path>');
2531
+ }
2532
+ let sqlText;
2533
+ try {
2534
+ sqlText = fs.readFileSync(flags['sql-file'], 'utf8');
2535
+ } catch (err) {
2536
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `could not read --sql-file "${flags['sql-file']}": ${err.message}`);
2537
+ }
2538
+ params = { databaseUrlEnv: flags['database-url-env'], schema: flags.schema, sqlText };
2539
+ source = { database_url_env: flags['database-url-env'], schema: flags.schema };
2540
+ }
2541
+
2542
+ let plan;
2543
+ try {
2544
+ plan = await getPatchKind(kind).planFresh(root, params);
2545
+ } catch (err) {
2546
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', err.message);
2547
+ }
2548
+ const txn = proposeTransaction(root, flags.feature, kind, plan, source);
2549
+
2550
+ if (flags.json) {
2551
+ console.log(JSON.stringify(txn, null, 2));
2552
+ } else if (!flags.quiet) {
2553
+ console.log(`proposed: ${txn.transaction_id}`);
2554
+ console.log(` ${describePatchTransaction(txn)}`);
2555
+ console.log(`next: bskel patch approve --feature ${flags.feature} --transaction ${txn.transaction_id} --reason "..."`);
2556
+ }
2557
+ process.exit(0);
2558
+ }
2559
+
2560
+ // Kind-aware one-line summary, reused by propose/list's human-readable output -- config-apply's
2561
+ // target is a file+key_path, ddl-apply's is raw SQL text (truncated for a terminal line).
2562
+ function describePatchTransaction(txn) {
2563
+ if (txn.kind === 'config-apply') {
2564
+ return `${txn.target.file} @ ${txn.target.key_path.join('.')}: "${txn.current_value}" -> "${txn.proposed_value}"`;
2565
+ }
2566
+ const sql = txn.target.sql_text.replace(/\s+/g, ' ').trim();
2567
+ return `[${txn.kind}] ${txn.target.database_url_env}/${txn.target.schema}: ${sql.slice(0, 100)}${sql.length > 100 ? '...' : ''}`;
2568
+ }
2569
+
2570
+ function requireTransactionOrExit(root, featureId, transactionId) {
2571
+ const txn = loadTransaction(root, featureId, transactionId);
2572
+ if (!txn) {
2573
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `no patch transaction "${transactionId}" for feature "${featureId}"`);
2574
+ }
2575
+ return txn;
2576
+ }
2577
+
2578
+ async function cmdPatchApprove(args) {
2579
+ const flags = parseCommand('patch approve', args);
2580
+ if (flags.help) { console.log(renderCommandHelp('patch approve')); process.exit(0); }
2581
+ setContext('patch approve', flags);
2582
+ const root = requireRepoRoot();
2583
+ requireValidFeatureId(flags.feature);
2584
+ if (!flags.reason || !flags.reason.trim()) {
2585
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel patch approve requires --reason "..." -- every approval must be auditable');
2586
+ }
2587
+ const txn = requireTransactionOrExit(root, flags.feature, flags.transaction);
2588
+
2589
+ let freshPlan;
2590
+ try {
2591
+ freshPlan = await replanTransaction(root, txn);
2592
+ } catch (err) {
2593
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', err.message);
2594
+ }
2595
+ const updated = approveTransaction(root, flags.feature, flags.transaction, flags.reason, freshPlan);
2596
+
2597
+ console.log(flags.json ? JSON.stringify(updated, null, 2) : `approved: ${flags.transaction}`);
2598
+ process.exit(0);
2599
+ }
2600
+
2601
+ // D-ddl-apply: --confirm is required (and must exactly equal --transaction) for any kind other
2602
+ // than 'config-apply' -- deliberately checked here, at the CLI boundary, before applyTransaction()
2603
+ // is ever called, as human-factors friction layered ON TOP of the engine's own load-bearing
2604
+ // preimage-hash TOCTOU check (this check does not replace it). Optional/ignored for config-apply,
2605
+ // zero behavior change for that kind.
2606
+ async function cmdPatchApply(args) {
2607
+ const flags = parseCommand('patch apply', args);
2608
+ if (flags.help) { console.log(renderCommandHelp('patch apply')); process.exit(0); }
2609
+ setContext('patch apply', flags);
2610
+ const root = requireRepoRoot();
2611
+ requirePreflightPassed(root);
2612
+ requireValidFeatureId(flags.feature);
2613
+ const txn = requireTransactionOrExit(root, flags.feature, flags.transaction);
2614
+
2615
+ const requiredConfirm = getPatchKind(txn.kind).requiredConfirmValue(txn);
2616
+ if (requiredConfirm !== null && flags.confirm !== requiredConfirm) {
2617
+ 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`);
2618
+ }
2619
+
2620
+ let freshPlan;
2621
+ try {
2622
+ freshPlan = await replanTransaction(root, txn);
2623
+ } catch (err) {
2624
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', err.message);
2625
+ }
2626
+ const updated = await applyTransaction(root, flags.feature, flags.transaction, freshPlan, getPatchKind(txn.kind).apply);
2627
+
2628
+ const evidence = { transaction_id: updated.transaction_id, kind: updated.kind, applied_at: updated.apply.at };
2629
+ const gateState = passNamedGate(root, 'patch_transactions', flags.feature, evidence);
2630
+
2631
+ console.log(flags.json ? JSON.stringify({ transaction: updated, gate: gateState.gates.patch_transactions }, null, 2) : `applied: ${flags.transaction} [${updated.kind}]`);
2632
+ process.exit(0);
2633
+ }
2634
+
2635
+ async function cmdPatchRollback(args) {
2636
+ const flags = parseCommand('patch rollback', args);
2637
+ if (flags.help) { console.log(renderCommandHelp('patch rollback')); process.exit(0); }
2638
+ setContext('patch rollback', flags);
2639
+ const root = requireRepoRoot();
2640
+ requirePreflightPassed(root);
2641
+ requireValidFeatureId(flags.feature);
2642
+ if (!flags.reason || !flags.reason.trim()) {
2643
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel patch rollback requires --reason "..." -- every rollback must be auditable');
2644
+ }
2645
+ const txn = requireTransactionOrExit(root, flags.feature, flags.transaction);
2646
+ const updated = await rollbackTransaction(root, flags.feature, flags.transaction, flags.reason, { force: flags.force }, getPatchKind(txn.kind).rollback);
2647
+
2648
+ // Re-passing (not revoking) mirrors cmdScanCrossFeatureWaive's own precedent for this exact
2649
+ // situation -- recompute() only loops status:'applied' transactions, so a rolled-back one
2650
+ // naturally drops out of the gate's own input set on the next pass; a fresh pass here reflects
2651
+ // "still fine, just smaller" immediately rather than leaving the gate to be reactively
2652
+ // discovered stale by the next `bskel verify`.
2653
+ const evidence = { transaction_id: updated.transaction_id, kind: updated.kind, rolled_back_at: updated.rollback.at };
2654
+ const gateState = passNamedGate(root, 'patch_transactions', flags.feature, evidence);
2655
+
2656
+ console.log(flags.json ? JSON.stringify({ transaction: updated, gate: gateState.gates.patch_transactions }, null, 2) : `rolled back: ${flags.transaction} [${updated.kind}]`);
2657
+ process.exit(0);
2658
+ }
2659
+
2660
+ // A deliberately-omitted-until-now, easy read-only convenience (D-patch-transactions' own EXIT
2661
+ // list named it explicitly) -- mirrors `dependency list`/`contract history`'s own pure-reader
2662
+ // posture, gate-independent like both.
2663
+ function cmdPatchList(args) {
2664
+ const flags = parseCommand('patch list', args);
2665
+ if (flags.help) { console.log(renderCommandHelp('patch list')); process.exit(0); }
2666
+ setContext('patch list', flags);
2667
+ const root = requireRepoRoot();
2668
+ requireValidFeatureId(flags.feature);
2669
+ const transactions = listTransactions(root, flags.feature);
2670
+
2671
+ if (flags.json) {
2672
+ console.log(JSON.stringify({ feature: flags.feature, transactions }, null, 2));
2673
+ } else if (!flags.quiet) {
2674
+ console.log(`patch transactions -- feature ${flags.feature}`);
2675
+ for (const t of transactions) {
2676
+ console.log(` ${t.transaction_id} [${t.status}] ${describePatchTransaction(t)}`);
2677
+ }
2678
+ if (transactions.length === 0) console.log(' (none proposed)');
2679
+ }
2680
+ process.exit(0);
2681
+ }
2682
+
2214
2683
  // O7 (D-handle-audit-report): a pure reader, deliberately gate-independent -- matches
2215
2684
  // D-contract-history/D-gate-export's own posture, not `handles plan`/`handles emit`'s capability
2216
2685
  // gating. It never touches adapter-specific codegen (the query is over `feature_uid` alone, the
@@ -2341,8 +2810,23 @@ function cmdObserveEmit(args) {
2341
2810
  } catch (err) {
2342
2811
  fail(EXIT_CODES.NOT_PASSED, 'PLAN_FAILED', err.message);
2343
2812
  }
2813
+ } else if (scanReport.adapter === 'typescript-express') {
2814
+ // typescript-express's own project-root detection needs a module to anchor itself too (same
2815
+ // reasoning as python-fastapi's own --module dependency) -- plan.mjs's detectProjectRoot()
2816
+ // walks up from the module's own scanned files.
2817
+ let tsPlan;
2818
+ try {
2819
+ tsPlan = planTypeScriptExpress({ repoRoot: root, scanReport, module: flags.module, resourceFilter: null });
2820
+ } catch (err) {
2821
+ fail(EXIT_CODES.NOT_PASSED, 'PLAN_FAILED', err.message);
2822
+ }
2823
+ try {
2824
+ result = emitObserveTypeScriptExpress({ repoRoot: root, featureId: flags.feature, contract, plan: tsPlan, force: flags.force, reason: flags.reason, dryRun, computeDiff: flags.diff });
2825
+ } catch (err) {
2826
+ fail(EXIT_CODES.NOT_PASSED, 'PLAN_FAILED', err.message);
2827
+ }
2344
2828
  } 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)`);
2829
+ 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
2830
  }
2347
2831
  const { written, conflicts, orphans, notes, forced, blocked, actions, postEmitNotes = [] } = result;
2348
2832
  const wouldChange = actions.some((a) => a.action !== 'unchanged' && a.action !== 'adopt-unchanged');
@@ -2493,11 +2977,20 @@ function cmdObserveImport(args) {
2493
2977
  const reportPath = specPath(root, flags.feature, 'observe', `${flags.feature}.conformance-report.json`);
2494
2978
  writeFileAtomic(reportPath, `${JSON.stringify(report, null, 2)}\n`);
2495
2979
 
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 });
2980
+ // Evidence-first, not verdict-first by DEFAULT (same as `contract`'s own precedent: a `partial`
2981
+ // contract is still passable via waiver) -- passes on a successful STRUCTURAL import, never on
2982
+ // "zero violations found", unless --fail-on-violation opts into the stricter behavior. Whether
2983
+ // violation counts should block CI was a policy question deliberately left undecided in v1 --
2984
+ // this is that v1.1 layer, see D-runtime-conformance-receipts's own "Continued" entry in
2985
+ // DECISIONS.md. `awaitNamedGateDisposition` is the EXACT same disposition mechanism `contract`
2986
+ // already uses for "evidence exists but isn't good enough to pass silently" -- resolved by the
2987
+ // already-generic `bskel gate force conformance --feature <id> --reason "..."` (no new CLI verb
2988
+ // needed; `bskel next`'s own generic awaitingDispositionCommand() fallback already names it).
2989
+ const evidence = { receipt_count: receipts.length, matched, violations: violationCount };
2990
+ const blocking = flags['fail-on-violation'] && violationCount > 0;
2991
+ const gateState = blocking
2992
+ ? awaitNamedGateDisposition(root, 'conformance', flags.feature, evidence)
2993
+ : passNamedGate(root, 'conformance', flags.feature, evidence);
2501
2994
 
2502
2995
  if (flags.json) {
2503
2996
  console.log(JSON.stringify({ report, noise_lines: noiseLines, gate: gateState.gates.conformance }, null, 2));
@@ -2506,8 +2999,11 @@ function cmdObserveImport(args) {
2506
2999
  console.log(`${violationCount} violation(s), ${unsupportedCount} unsupported field(s) across matched receipts`);
2507
3000
  console.log(`wrote ${path.relative(root, reportPath)}`);
2508
3001
  console.log(`gate: conformance -> ${gateState.gates.conformance.status}`);
3002
+ if (blocking) {
3003
+ 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.`);
3004
+ }
2509
3005
  }
2510
- process.exit(0);
3006
+ process.exit(blocking ? EXIT.AWAITING_DISPOSITION : EXIT.PASS);
2511
3007
  }
2512
3008
 
2513
3009
  // S2: "stale" alone sends a human/agent re-running steps until one happens to stick. Name the
@@ -2532,10 +3028,17 @@ function renderVerifyReport({ featureId, gates, artifacts, conflicts, build, all
2532
3028
  const completenessNote = g.gate === 'contract' && evidence?.completeness
2533
3029
  ? ` (${evidence.completeness}${evidence.waived_count ? `: ${evidence.waived_count} waived` : ''})`
2534
3030
  : '';
3031
+ // D-runtime-conformance-receipts (Continued, --fail-on-violation): same "surface the evidence
3032
+ // right in the verify report" precedent as `completenessNote` above -- fires for both `pass`
3033
+ // and `awaiting_disposition` states, since a human should see the count even when it didn't
3034
+ // end up blocking (i.e. --fail-on-violation wasn't used at import time).
3035
+ const conformanceNote = g.gate === 'conformance' && evidence?.violations
3036
+ ? ` (${evidence.violations} violation(s), ${evidence.matched}/${evidence.receipt_count} matched)`
3037
+ : '';
2535
3038
  // S4 (D-gate-history): a revoked gate's reason is exactly the kind of "why is this
2536
3039
  // blocking" detail describeStale() already surfaces for stale gates -- same treatment here.
2537
3040
  const revokedNote = g.status === 'revoked' && g.record?.reason ? ` (revoked: ${g.record.reason})` : '';
2538
- lines.push(`- [${marker}] ${g.gate}${suffix}${completenessNote}${describeStale(g)}${revokedNote}`);
3041
+ lines.push(`- [${marker}] ${g.gate}${suffix}${completenessNote}${conformanceNote}${describeStale(g)}${revokedNote}`);
2539
3042
  }
2540
3043
  lines.push('', '## Artifacts');
2541
3044
  for (const a of artifacts) lines.push(`- [${a.exists ? 'OK' : 'MISSING'}] ${a.artifact}: ${a.path}`);
@@ -2747,19 +3250,46 @@ async function cmdServe(args) {
2747
3250
  const root = requireRepoRoot();
2748
3251
  const port = Number.parseInt(flags.port, 10);
2749
3252
 
3253
+ // D-ddl-apply: eagerly checked here, at startup -- the DB-touching routes must not silently
3254
+ // 404 forever because of a typo'd env var name; missing/unset is BAD_ARGS at the CLI boundary,
3255
+ // same convention as resolveDbSchemaOrExit's own `bskel scan --db` handling. The live
3256
+ // connection itself still only ever opens lazily, per-request (no eager connect-at-startup, no
3257
+ // pool) -- a transient DB outage must not block the unrelated, DB-independent parts of the UI.
3258
+ let dbConfig = null;
3259
+ if (flags['database-url-env']) {
3260
+ if (!process.env[flags['database-url-env']]) {
3261
+ 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)`);
3262
+ }
3263
+ dbConfig = { databaseUrlEnv: flags['database-url-env'], schema: flags.schema, signKeyPath: flags['sign-key'] };
3264
+ // D-ddl-apply: closes this feature's own named "mandatory signing... cheap, well-justified
3265
+ // near-term addition" EXIT item -- an opt-in-to-MORE-strictness knob, letting a cautious team
3266
+ // enforce mandatory signing for themselves without this tool forcing it on every user. A
3267
+ // no-op when --database-url-env wasn't given at all (this whole block never runs then) --
3268
+ // nothing to enforce on a DDL surface that isn't running.
3269
+ if (flags['require-sign-key'] && !dbConfig.signKeyPath) {
3270
+ 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).');
3271
+ }
3272
+ }
3273
+
2750
3274
  let started;
2751
3275
  try {
2752
- started = await createHttpServer(root, { host: flags.host, port });
3276
+ started = await createHttpServer(root, { host: flags.host, port, dbConfig });
2753
3277
  } catch (err) {
2754
3278
  fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `could not start server: ${err.message}`);
2755
3279
  }
2756
3280
 
2757
3281
  if (flags.json) {
2758
- console.log(JSON.stringify({ listening: started.url, host: started.host, port: started.port, repo: root }));
3282
+ console.log(JSON.stringify({ listening: started.url, host: started.host, port: started.port, repo: root, ddlApplyEnabled: Boolean(dbConfig) }));
2759
3283
  } else if (!flags.quiet) {
2760
3284
  console.log(`bskel serve -- listening on ${started.url}`);
2761
3285
  console.log(` UI: ${started.url}/`);
2762
3286
  console.log(` API: ${started.url}/api/...`);
3287
+ if (dbConfig) {
3288
+ console.log(` DDL apply routes: ENABLED (schema "${dbConfig.schema}", connection from $${dbConfig.databaseUrlEnv})`);
3289
+ if (!dbConfig.signKeyPath) {
3290
+ 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.');
3291
+ }
3292
+ }
2763
3293
  console.log('press Ctrl+C to stop');
2764
3294
  }
2765
3295
 
@@ -2988,6 +3518,8 @@ async function dispatchCommand(cmd, rest) {
2988
3518
  case 'scan': {
2989
3519
  if (rest[0] === 'disposition') return cmdScanDisposition(rest.slice(1));
2990
3520
  if (rest[0] === 'explain') return cmdScanExplain(rest.slice(1));
3521
+ if (rest[0] === 'cross-feature-check') return cmdScanCrossFeatureCheck(rest.slice(1));
3522
+ if (rest[0] === 'cross-feature-waive') return cmdScanCrossFeatureWaive(rest.slice(1));
2991
3523
  await cmdScan(rest);
2992
3524
  break;
2993
3525
  }
@@ -3031,6 +3563,16 @@ async function dispatchCommand(cmd, rest) {
3031
3563
  process.exit(14);
3032
3564
  break;
3033
3565
  }
3566
+ case 'patch': {
3567
+ if (rest[0] === 'propose') return cmdPatchPropose(rest.slice(1));
3568
+ if (rest[0] === 'approve') return cmdPatchApprove(rest.slice(1));
3569
+ if (rest[0] === 'apply') return cmdPatchApply(rest.slice(1));
3570
+ if (rest[0] === 'rollback') return cmdPatchRollback(rest.slice(1));
3571
+ if (rest[0] === 'list') return cmdPatchList(rest.slice(1));
3572
+ usage();
3573
+ process.exit(14);
3574
+ break;
3575
+ }
3034
3576
  case 'catalog': {
3035
3577
  if (rest[0] === 'lint') return cmdCatalogLint(rest.slice(1));
3036
3578
  usage();
@@ -3075,6 +3617,13 @@ async function dispatchCommand(cmd, rest) {
3075
3617
  process.exit(14);
3076
3618
  break;
3077
3619
  }
3620
+ case 'attest': {
3621
+ if (rest[0] === 'keygen') return cmdAttestKeygen(rest.slice(1));
3622
+ if (rest[0] === 'verify') return cmdAttestVerify(rest.slice(1));
3623
+ usage();
3624
+ process.exit(14);
3625
+ break;
3626
+ }
3078
3627
  case 'doctor':
3079
3628
  cmdDoctor(rest);
3080
3629
  break;