backend-skeleton 1.0.0 → 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 (48) hide show
  1. package/README.md +66 -4
  2. package/bin/bskel.mjs +125 -18
  3. package/contracts/export.mjs +39 -4
  4. package/contracts/openapi.mjs +292 -27
  5. package/contracts/validate.mjs +23 -4
  6. package/handles/_engine.mjs +75 -32
  7. package/handles/capability-codec.mjs +94 -0
  8. package/handles/codec.mjs +13 -3
  9. package/handles/providers/java-spring/emit.mjs +78 -33
  10. package/handles/providers/java-spring/observe.mjs +4 -3
  11. package/handles/providers/java-spring/plan.mjs +51 -7
  12. package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +19 -1
  13. package/handles/providers/java-spring/templates/HandleController.java.tmpl +13 -7
  14. package/handles/providers/java-spring/templates/HandleService.java.tmpl +21 -2
  15. package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +1 -1
  16. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +24 -3
  17. package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +8 -2
  18. package/handles/providers/java-spring.mjs +8 -0
  19. package/handles/providers/python-fastapi/emit.mjs +21 -26
  20. package/handles/providers/python-fastapi/observe.mjs +6 -5
  21. package/handles/providers/python-fastapi/templates/codec.py.tmpl +18 -3
  22. package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +32 -4
  23. package/handles/providers/python-fastapi.mjs +3 -3
  24. package/handles/providers/typescript-express/emit.mjs +135 -46
  25. package/handles/providers/typescript-express/observe.mjs +7 -6
  26. package/handles/providers/typescript-express/templates/codec.ts.tmpl +13 -3
  27. package/handles/providers/typescript-express/templates/handleEntities.ts.tmpl +89 -0
  28. package/handles/providers/typescript-express/templates/handleService.ts.tmpl +81 -0
  29. package/handles/providers/typescript-express/templates/migration.sql.tmpl +36 -0
  30. package/handles/providers/typescript-express/templates/recordSnapshotWrapper.ts.tmpl +123 -0
  31. package/handles/providers/typescript-express/templates/registry.ts.tmpl +19 -10
  32. package/handles/providers/typescript-express/templates/resolver.ts.tmpl +13 -0
  33. package/handles/providers/typescript-express/templates/resolverPolicy.ts.tmpl +20 -0
  34. package/handles/providers/typescript-express/templates/router.ts.tmpl +113 -2
  35. package/handles/providers/typescript-express.mjs +7 -4
  36. package/lib/cli.mjs +11 -2
  37. package/lib/exit-codes.mjs +21 -0
  38. package/lib/verify.mjs +23 -6
  39. package/package.json +5 -2
  40. package/scanners/adapters/_java-spring-analyzer.mjs +9 -1
  41. package/scanners/adapters/java-spring.mjs +108 -10
  42. package/scanners/adapters/javascript-express.mjs +46 -13
  43. package/scanners/adapters/typescript-express.mjs +13 -2
  44. package/schemas/feature-contract.schema.json +3 -3
  45. package/schemas/handles-plan.schema.json +2 -0
  46. package/schemas/oracle-manifest.schema.json +58 -0
  47. package/schemas/stack-record.schema.json +6 -1
  48. package/stack/apply.mjs +47 -6
package/README.md CHANGED
@@ -23,6 +23,29 @@ of just another thing to double-check by hand.
23
23
  commits behind the real default branch and never noticed. Every gate in this tool is a regression
24
24
  check for a specific failure mode found the same way — see `DECISIONS.md` for the full record.
25
25
 
26
+ ![bskel run against a real fixture repo: preflight, a brownfield collision scan, and a feature status gate table](https://raw.githubusercontent.com/popixoxipop-collab/backend-skeleton/main/docs/demo.gif)
27
+
28
+ *A real terminal, a real `bskel` binary, a real fixture repo — not a scripted transcript. Source:
29
+ [`docs/demo.tape`](docs/demo.tape), regenerated with [`docs/record-demo.sh`](docs/record-demo.sh).*
30
+
31
+ ## Contents
32
+
33
+ - [Status: 1.0.0](#status-100)
34
+ - [Quickstart](#quickstart)
35
+ - [Starting from nothing (greenfield)](#starting-from-nothing-greenfield)
36
+ - [Publishing a feature's contract as OpenAPI (optional)](#publishing-a-features-contract-as-openapi-optional)
37
+ - [Database schema (optional)](#database-schema-optional)
38
+ - [Applying DDL to a live database (optional)](#applying-ddl-to-a-live-database-optional)
39
+ - [Declaring field-to-field dependencies (optional)](#declaring-field-to-field-dependencies-optional)
40
+ - [Patching a config file (optional)](#patching-a-config-file-optional)
41
+ - [Signed gate attestations (optional)](#signed-gate-attestations-optional)
42
+ - [Compatibility](#compatibility)
43
+ - [Generated-file policy](#generated-file-policy)
44
+ - [Security model](#security-model)
45
+ - [Troubleshooting](#troubleshooting)
46
+ - [What ships in the package](#what-ships-in-the-package)
47
+ - [License](#license)
48
+
26
49
  ## Status: 1.0.0
27
50
 
28
51
  As of `1.0.0`, this project makes an explicit API-stability promise: **`bskel`'s CLI surface --
@@ -50,10 +73,13 @@ production -- which still splits the same way it always has:
50
73
  gaps remain and are explicitly still open, not closed: registry enforcement is opt-in, off by
51
74
  default; authorization inference now recognizes both `@PreAuthorize(hasRole(...))` and
52
75
  `hasAuthority(...)` (see `DECISIONS.md`'s `D-resolver-authorization-action-aware`), but
53
- `hasAnyRole`/`hasAnyAuthority` (list-shape), ownership, and tenant policy are still unaddressed;
54
- and Java/Python are the only providers either applies to -- TypeScript Express has no persistent
55
- handle table at all. Treat `handles emit`'s output as a scaffold to finish by hand, not a
56
- production-ready subsystem, until a real deployment happens.
76
+ `hasAnyRole`/`hasAnyAuthority` (list-shape), ownership, and tenant policy are still unaddressed.
77
+ All three providers (Java/Python/TypeScript) now generate a real `sbf_handle`/
78
+ `sbf_handle_snapshot` schema and support `--enforce-registry`/`recover()` -- TypeScript's own
79
+ registration mechanism is a higher-order wrapper function, not a decorator (no Java-AOP or
80
+ Python-decorator equivalent exists in this ecosystem the templates could safely rely on; see
81
+ `D-typescript-express-registry-parity` in `DECISIONS.md`). Treat `handles emit`'s output as a
82
+ scaffold to finish by hand, not a production-ready subsystem, until a real deployment happens.
57
83
 
58
84
  ## Quickstart
59
85
 
@@ -86,6 +112,36 @@ bskel verify --feature 001-organization-management --build
86
112
  # target repo's own build wrapper (gradlew/mvnw/npm), if present
87
113
  ```
88
114
 
115
+ `bskel status`/`bskel next` are what you actually run over and over — real output, captured against
116
+ a fixture repo partway through the flow above, not written by hand:
117
+
118
+ ```text
119
+ $ bskel status --feature 001-organization-management
120
+ # Status: 001-organization-management
121
+
122
+ ## Gates
123
+ - [PASS] preflight
124
+ - [PASS] scan
125
+ - [(not_run)] cross_feature (required-when-present, feature-scoped)
126
+ - [BLOCKING] contract
127
+ - [(not_run)] dependencies (required-when-present, feature-scoped)
128
+ - [(not_run)] handles (required-when-present, feature-scoped)
129
+ - [(not_run)] stack (required-when-present, repo-scoped)
130
+ - [(not_run)] patch_transactions (required-when-present, feature-scoped)
131
+ - [(not_run)] conformance (required-when-present, feature-scoped)
132
+
133
+ ## Artifacts
134
+ - [OK] contract: specs/001-organization-management/contracts/001-organization-management.schema.json
135
+
136
+ ## Next
137
+ - bskel contract waive --feature 001-organization-management --code <CODE> (--subject "..."|--all) --reason "..." # or: bskel gate force contract --feature 001-organization-management --reason "..." if intentional # contract gate is awaiting disposition
138
+
139
+ ## Optional, not yet run: handles, stack
140
+ ```
141
+
142
+ Every gate line is a real, disk-verified check — the `Next` line is always the exact command to
143
+ unblock whatever's currently `BLOCKING`, so there's no separate doc to cross-reference mid-workflow.
144
+
89
145
  ### Starting from nothing (greenfield)
90
146
 
91
147
  Every command above assumes an existing Spring Boot or FastAPI repo. If you don't have one yet:
@@ -246,6 +302,12 @@ between the two — and, like every other mutating command in this project, both
246
302
  not query parameters. `GET`/`HEAD` responses carry `Access-Control-Allow-Origin: *`; the mutating
247
303
  routes never do, so only same-origin requests (the bundled UI itself) can write.
248
304
 
305
+ ![bskel serve's dependency-graph UI, showing a real declared field-to-field dependency resolved as synced](https://raw.githubusercontent.com/popixoxipop-collab/backend-skeleton/main/docs/serve-ui.png)
306
+
307
+ *The page's own header describes it honestly: "Not a redesign of the original Fieldwire mockup --
308
+ this exists to prove the API actually works, nothing more." It's a minimal read-only check page, not
309
+ a polished dashboard — every table on it comes straight from `GET /api/graph`.*
310
+
249
311
  ### Patching a config file (optional)
250
312
 
251
313
  `bskel stack apply`'s `config_check` sometimes reports `needs-manual-patch` — a target file exists
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,
@@ -56,6 +56,7 @@ import { loadCatalogEntry, listCatalogChoices, planApply, applyPlan } from '../s
56
56
  import { PROVIDERS, PROVIDER_LOAD_ERRORS, providerById } from '../handles/registry.mjs';
57
57
  import { detectAstHelperAvailable, runAstClassify } from '../handles/providers/java-spring/ast-bridge.mjs';
58
58
  import { detectBasePackage } from '../handles/providers/java-spring/plan.mjs';
59
+ import { hasSpringAopDependency, springAopArtifactName } from '../handles/providers/java-spring/emit.mjs';
59
60
  import { emitObserveJavaSpring } from '../handles/providers/java-spring/observe.mjs';
60
61
  import { plan as planPythonFastApi } from '../handles/providers/python-fastapi/plan.mjs';
61
62
  import { emitObservePythonFastApi } from '../handles/providers/python-fastapi/observe.mjs';
@@ -96,12 +97,12 @@ function usage() {
96
97
  bskel dependency declare --feature <id> --resource <Type> --field <name> --source-feature <id> --source-resource <Type> --source-field <name> --reason "..." [--memo "..."]
97
98
  bskel dependency remove --feature <id> --resource <Type> --field <name> --source-feature <id> --source-resource <Type> --source-field <name> --reason "..."
98
99
  bskel dependency list --feature <id> [--json]
99
- bskel stack apply --choice <id> [--apply] [--port N] [--json]
100
+ bskel stack apply --choice <id> [--apply] [--port N] [--force --reason "..."] [--json]
100
101
  bskel catalog lint [<choice>] [--json]
101
102
  bskel handles plan --feature <id> [--module <name>] [--resource type1,type2] [--diff] [--ast]
102
103
  bskel handles emit --feature <id> [--module <name>] [--resource type1,type2] [--force --reason "..."] [--check] [--diff] [--enforce-registry on|off --reason "..."]
103
104
  bskel handles patch approve --feature <id> [--module <name>] --resource <Type> --field <name> --strategy patch-wrapper|null-means-unchanged --reason "..." [--json]
104
- 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]
105
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]
106
107
  bskel patch approve --feature <id> --transaction <id> --reason "..." [--json]
107
108
  bskel patch apply --feature <id> --transaction <id> [--confirm <id-or-dropped-table-name>] [--json]
@@ -1867,14 +1868,29 @@ function cmdContractToolSchema(args) {
1867
1868
  }
1868
1869
 
1869
1870
  // Anthropic tool-use `input_schema` is a JSON Schema subset -- the operation's payload
1870
- // schema (already plain JSON Schema, no $ref/$defs) is directly usable as-is. A2: when `op`
1871
- // carries a projected `requestBodySchema`, it flows through here for free -- this function
1872
- // changed not at all; contracts/openapi.mjs's inlineSchema() is what guarantees the no-$ref
1873
- // 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
+ }
1874
1890
  const toolSchema = {
1875
1891
  name: flags.operation,
1876
1892
  description: `${op.verb} ${op.path} (feature ${flags.feature})`,
1877
- input_schema: operationPayloadSchema(op),
1893
+ input_schema: inputSchema,
1878
1894
  };
1879
1895
  console.log(JSON.stringify(toolSchema, null, 2));
1880
1896
  process.exit(0);
@@ -1904,7 +1920,13 @@ function cmdStackApply(args) {
1904
1920
  const root = requireRepoRoot();
1905
1921
  requirePreflightPassed(root);
1906
1922
  if (!flags.choice) {
1907
- 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');
1908
1930
  }
1909
1931
 
1910
1932
  let entry;
@@ -1928,12 +1950,21 @@ function cmdStackApply(args) {
1928
1950
  process.exit(0);
1929
1951
  }
1930
1952
 
1931
- let written;
1953
+ let written, conflicts, fileHashes;
1932
1954
  try {
1933
- written = applyPlan(root, plan);
1955
+ ({ written, conflicts, fileHashes } = applyPlan(root, plan, { force: flags.force }));
1934
1956
  } catch (err) {
1935
1957
  fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', err.message);
1936
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
+ }
1937
1968
  // S2: `applied_files` must be this choice's FULL file set in this repo (its desired state),
1938
1969
  // not just whatever `applyPlan()` happened to write THIS run -- applyPlan() skips files whose
1939
1970
  // action is 'unchanged', so a second, idempotent `--apply` used to overwrite this with `[]`,
@@ -1944,17 +1975,22 @@ function cmdStackApply(args) {
1944
1975
  ...plan.files.map((f) => f.path),
1945
1976
  ...(plan.envExampleActions.length > 0 ? ['.env.example'] : []),
1946
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'));
1947
1984
  const stackRecord = {
1948
1985
  schema: 'sbf.stack/1', choice: flags.choice, applied_files: appliedFiles,
1949
1986
  env_example_keys: plan.envExampleActions.map((e) => e.key), at: new Date().toISOString(),
1987
+ file_hashes: { ...(priorRecord?.file_hashes ?? {}), ...fileHashes },
1950
1988
  };
1951
1989
  // S5 (D-persistence-integrity): schemas/stack-record.schema.json is new -- this record had NO
1952
1990
  // schema at all before (not the same file as stack-choice.schema.json, which validates a
1953
1991
  // stack/catalog/<id>.yml CATALOG ENTRY, a completely different persistence boundary). Validated
1954
1992
  // before it touches disk, same "fail loud here" reasoning as every other write site this item
1955
- // touched. No corresponding read helper -- nothing in this codebase reads .sbf/stack.json back
1956
- // (confirmed by grep before adding this), so there's no read boundary to close yet; adding an
1957
- // unused loadStackRecord() export would just be dead code.
1993
+ // touched.
1958
1994
  {
1959
1995
  const { ok, errors } = validateAgainstSchema('stack-record.schema.json', stackRecord);
1960
1996
  if (!ok) {
@@ -2078,8 +2114,10 @@ function writeScanReportOrExit(reportPath, report) {
2078
2114
  }
2079
2115
 
2080
2116
  // D4 (D-handles-dryrun): the marker vocabulary a human report uses for classifyFile()'s 6
2081
- // possible actions (+ the java-spring-only 'spec' kind, which reuses the same 3 labels since it's
2082
- // 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).
2083
2121
  const ACTION_MARKERS = { create: '+', unchanged: '=', update: '~', 'adopt-unchanged': '=', 'adopt-update': '~', conflict: '!' };
2084
2122
 
2085
2123
  // D4: shared between `handles plan`'s preview and `handles emit --check`'s report -- both show
@@ -2327,6 +2365,21 @@ function cmdHandlesEmit(args) {
2327
2365
  requireCapabilitiesOrExit(scanReport, 'handles emit', { featureId: flags.feature, scanReportPath });
2328
2366
  const provider = selectProviderOrExit(scanReport);
2329
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
+
2330
2383
  const resourceFilter = flags.resource ? flags.resource.split(',').map((s) => s.trim()).filter(Boolean) : null;
2331
2384
 
2332
2385
  let plan;
@@ -2339,7 +2392,7 @@ function cmdHandlesEmit(args) {
2339
2392
  // a diff" that also means "and actually write it", so --diff forces dryRun the same as --check
2340
2393
  // does, without requiring both flags together.
2341
2394
  const dryRun = flags.check || flags.diff;
2342
- 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({
2343
2396
  repoRoot: root, featureId: flags.feature, plan, resourceFilter, force: flags.force, reason: flags.reason, dryRun, computeDiff: flags.diff, enforceRegistry,
2344
2397
  });
2345
2398
 
@@ -2400,6 +2453,25 @@ function cmdHandlesEmit(args) {
2400
2453
  process.exit(EXIT_CODES.HANDLES_CONFLICT);
2401
2454
  }
2402
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
+
2403
2475
  // D4: dryRun never marks the gate passed -- nothing real happened this run.
2404
2476
  const gateState = dryRun ? null : passNamedGate(root, 'handles', flags.feature, { resolverStubs });
2405
2477
 
@@ -2718,6 +2790,35 @@ async function cmdHandlesAudit(args) {
2718
2790
  // D-openapi-extraction-hint's own precedent for "the CLI itself carries this warning, not
2719
2791
  // just documentation").
2720
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
+
2721
2822
  const report = {
2722
2823
  schema: 'sbf.handle-audit/1',
2723
2824
  feature_id: flags.feature,
@@ -2725,6 +2826,7 @@ async function cmdHandlesAudit(args) {
2725
2826
  generated_at: new Date().toISOString(),
2726
2827
  summary,
2727
2828
  handles: rows,
2829
+ registry_coverage: registryCoverage,
2728
2830
  caveat,
2729
2831
  };
2730
2832
 
@@ -2738,6 +2840,11 @@ async function cmdHandlesAudit(args) {
2738
2840
  const pointerNote = h.pointer ? `#${h.pointer}` : '';
2739
2841
  console.log(` ${h.kind} ${h.resource_type}/${h.resource_uid}${pointerNote} -- ${h.snapshot_count} snapshot(s), last ${h.last_recorded_at ?? 'never'}${revokedNote}`);
2740
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
+ }
2741
2848
  console.error(`\nnote: ${caveat}`);
2742
2849
  }
2743
2850
  process.exit(0);
@@ -82,8 +82,18 @@ const ERROR_RESPONSE_DESCRIPTION = 'Error. The source contract records the union
82
82
  // meaning moves from "never built" to "content-AND-flag-conditional"), while `title`/`examples`
83
83
  // (plural)/`externalDocs`/`xml`/`deprecated` move to a new, narrower structural entry
84
84
  // (`field-metadata`) -- measured 0 real occurrences each against the Team-IZ-Backend oracle, so
85
- // they stay permanently unbuilt on the same "don't build for zero real cases" grounds as the two
86
- // A8 entries below, not this item's scope. What else stays structural: `vendor-extensions` (x-*
85
+ // they stayed unbuilt on the same "don't build for zero real cases" grounds as the two A8 entries
86
+ // below, not this item's scope.
87
+ // A14 (D-openapi-field-metadata-passthrough): D-oracle-corpus-openapi-remeasurement (ROADMAP
88
+ // Phase 5c) found `title`/plural `examples`/`deprecated` DO occur for real against a much larger
89
+ // second corpus (polarsource/polar, 1046 component schemas vs 308) -- those 3 of the original 5
90
+ // `field-metadata` keywords are now conditionally copied (contracts/openapi.mjs's
91
+ // DOCUMENTATION_KEYWORDS), same "gated on --descriptions" doctrine as A11's own description/
92
+ // example. `field-metadata` migrates from a STRUCTURAL (always-present) omission to an ANY-based
93
+ // one below -- the exact same migration A10 made for `operation-descriptions` when it left the
94
+ // original single `descriptions` structural entry. `externalDocs`/`xml` remain 0 real occurrences
95
+ // even at Polar's scale, so THOSE two keep the narrower structural entry
96
+ // `external-docs-and-xml-metadata`. What else stays structural: `vendor-extensions` (x-*
87
97
  // keys on an operation are never copied -- excluded in principle, not by cap or failure, since
88
98
  // their semantics are tool-specific), and two A8 additions: `non-json-response-schemas` (a non-JSON
89
99
  // response media type's NAME is copied via a per-status entry's `mediaTypes`, but its SHAPE is never
@@ -92,7 +102,7 @@ const ERROR_RESPONSE_DESCRIPTION = 'Error. The source contract records the union
92
102
  // -- 0/694 real occurrences, a genuinely visible gap only now that per-status responses look
93
103
  // complete).
94
104
  const STRUCTURAL_OMISSIONS = Object.freeze([
95
- 'field-metadata',
105
+ 'external-docs-and-xml-metadata',
96
106
  'non-json-response-schemas',
97
107
  'response-headers',
98
108
  'vendor-extensions',
@@ -102,7 +112,8 @@ const OMISSION_PROSE = Object.freeze({
102
112
  'cookie-parameters': 'cookie parameters, for at least one operation that does not carry a fully-copied set (never emitted at all when --openapi-file was not given, or the source document declared none)',
103
113
  'error-schemas': 'a JSON error-body schema for at least one operation',
104
114
  'field-descriptions': 'a schema field\'s own `description`/`example` (a property\'s own annotation, distinct from the operation-level `description` field -- see `operation-descriptions` below), for at least one field in the request-body/response/error schema of at least one operation -- copied only when `contract emit --descriptions` was used (the same flag as operation-level description) AND the source declared one for that exact field AND it did not exceed the length/size cap; otherwise the field carries no `description`/`example` key, never synthesized. Not tracked separately for per-status responses, non-JSON request media types, or path-parameter schemas -- those may carry field docs when the flag is on, but their presence is not reflected in this specific omission entry',
105
- 'field-metadata': 'a schema field\'s `title`, plural `examples`, `externalDocs`, `xml`, or `deprecated` keyword -- dropped unconditionally while inlining a schema (contracts/openapi.mjs\'s DROPPED_KEYWORDS), regardless of `--descriptions`. Permanently unbuilt: 0 real occurrences of any of these five measured against the Team-IZ-Backend oracle',
115
+ 'field-metadata': 'a schema field\'s `title`, plural `examples`, or `deprecated` keyword, for at least one field in the request-body/response/error schema of at least one operation -- copied only when `--descriptions` is passed (contracts/openapi.mjs\'s DOCUMENTATION_KEYWORDS, A14/D-openapi-field-metadata-passthrough), same doctrine as `field-descriptions`. Present whenever the flag was not used at all, none of this operation\'s projected schemas carry any of the three, or the value exceeded its cap (MAX_TITLE_LENGTH/MAX_EXAMPLES_ARRAY_LENGTH/MAX_EXAMPLE_LENGTH)',
116
+ 'external-docs-and-xml-metadata': 'a schema field\'s `externalDocs` or `xml` keyword -- dropped unconditionally while inlining a schema (contracts/openapi.mjs\'s DROPPED_KEYWORDS), regardless of `--descriptions`. Permanently unbuilt: 0 real occurrences of either keyword measured against either real corpus (the original Team-IZ-Backend oracle or the larger polarsource/polar re-measurement) -- see D-oracle-corpus-openapi-remeasurement in DECISIONS.md',
106
117
  'header-parameters': 'header parameters, for at least one operation that does not carry a fully-copied set (never emitted at all when --openapi-file was not given, or the source document declared none)',
107
118
  'non-json-request-media-types': 'the media type of the request body, for at least one operation that takes one -- a non-application/json request media type is emitted only when a real source document declared one for that exact operation, copied byte-for-byte; otherwise this document shows a JSON media-type entry because that is all the contract knows, never because the real body is known to be JSON',
108
119
  'non-json-response-schemas': 'a JSON Schema for any response body in a media type other than application/json -- the media type is named where a source document declared one for that status, but its shape is never projected',
@@ -145,6 +156,26 @@ function schemaHasFieldDocs(node, seen = new Set()) {
145
156
  return false;
146
157
  }
147
158
 
159
+ // A14 (D-openapi-field-metadata-passthrough): the exact same recursive shape as schemaHasFieldDocs
160
+ // above, checking the OTHER three DOCUMENTATION_KEYWORDS (title/examples/deprecated) instead of
161
+ // description/example -- kept as a separate function rather than merged into schemaHasFieldDocs
162
+ // so the two disclosure keys (`field-descriptions` vs `field-metadata`) stay independently
163
+ // derived from what's ACTUALLY in the projected schema, not conflated into one flag a caller
164
+ // can't tell apart.
165
+ function schemaHasFieldMetadata(node, seen = new Set()) {
166
+ if (node === null || typeof node !== 'object' || Array.isArray(node) || seen.has(node)) return false;
167
+ seen.add(node);
168
+ if (typeof node.title === 'string' || Object.hasOwn(node, 'examples') || Object.hasOwn(node, 'deprecated')) return true;
169
+ if (node.properties && typeof node.properties === 'object' && !Array.isArray(node.properties)) {
170
+ for (const propSchema of Object.values(node.properties)) {
171
+ if (schemaHasFieldMetadata(propSchema, seen)) return true;
172
+ }
173
+ }
174
+ if (node.items && typeof node.items === 'object' && schemaHasFieldMetadata(node.items, seen)) return true;
175
+ if (node.additionalProperties && typeof node.additionalProperties === 'object' && schemaHasFieldMetadata(node.additionalProperties, seen)) return true;
176
+ return false;
177
+ }
178
+
148
179
  // Derived from the contract's ACTUAL content, not hardcoded -- an operation that takes a body but
149
180
  // has no projected schema, or has no response/error schema, each add their own entry, so the list
150
181
  // says what is missing from THIS document rather than reciting a fixed disclaimer.
@@ -196,6 +227,10 @@ export function collectOmissions(contract) {
196
227
  // not gated on whether a schema exists first.
197
228
  const fieldSchemas = [op.requestBodySchema, op.responseSchema, op.errorSchema].filter(Boolean);
198
229
  if (!fieldSchemas.some((s) => schemaHasFieldDocs(s))) omissions.add('field-descriptions');
230
+ // A14: same ANY-based doctrine as field-descriptions immediately above -- added whenever
231
+ // NONE of this operation's projected schemas carry a field-level title/examples/deprecated,
232
+ // including the case where the operation has no projected schema at all.
233
+ if (!fieldSchemas.some((s) => schemaHasFieldMetadata(s))) omissions.add('field-metadata');
199
234
  }
200
235
  return [...omissions].sort();
201
236
  }