backend-skeleton 1.1.0 → 1.2.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.
package/README.md CHANGED
@@ -39,6 +39,7 @@ check for a specific failure mode found the same way — see `DECISIONS.md` for
39
39
  - [Declaring field-to-field dependencies (optional)](#declaring-field-to-field-dependencies-optional)
40
40
  - [Patching a config file (optional)](#patching-a-config-file-optional)
41
41
  - [Signed gate attestations (optional)](#signed-gate-attestations-optional)
42
+ - [Signed observe receipts (optional)](#signed-observe-receipts-optional)
42
43
  - [Compatibility](#compatibility)
43
44
  - [Generated-file policy](#generated-file-policy)
44
45
  - [Security model](#security-model)
@@ -344,6 +345,32 @@ bskel attest verify --file attestation.json --pubkey ~/.bskel-keys/attest-public
344
345
  `attest verify`'s exit code reflects signature validity only — whether the gates inside actually
345
346
  passed is a separate, printed summary. See `D-gate-attestation-signing` in `DECISIONS.md`.
346
347
 
348
+ ### Signed observe receipts (optional)
349
+
350
+ `bskel observe emit` generates opt-in runtime middleware that checks real traffic against a
351
+ feature's contract and logs a verdict-only receipt per call (JSON Pointer + constraint kind, never
352
+ an observed value); `bskel observe import --receipts <path>` turns a stream of those receipts into
353
+ a committed report backing the `conformance` gate. By default a receipts file is trusted at face
354
+ value once it's structurally valid — a human could hand-fabricate one. Add `--pubkey <path>` to
355
+ `observe import` to verify each receipt's optional signature instead, reusing the same
356
+ `bskel attest keygen`-generated keypair signed gate attestations use:
357
+
358
+ ```bash
359
+ bskel attest keygen --out ~/.bskel-keys # same command as above -- one keypair, multiple uses
360
+ # then, per deployed app (one-time, at the app's own startup):
361
+ # TypeScript: import { setSigningKey } from './observe/receiptSign'; setSigningKey(pem);
362
+ # Java: set the bskel.observe.signing-key-pem Spring property (e.g. an env var)
363
+ # Python: receipt_sign.configure(os.environ.get("BSKEL_OBSERVE_SIGNING_KEY_PEM"))
364
+ bskel observe import --feature 001-organization-management --receipts receipts.jsonl \
365
+ --pubkey ~/.bskel-keys/attest-public.pem [--require-signature]
366
+ ```
367
+
368
+ Unset/no key configured means every receipt stays unsigned — fully backward compatible with every
369
+ app already using this feature. `--pubkey` alone verifies signatures where present and tolerates
370
+ unsigned receipts (excluding them from the report's `matched` counts, with a printed warning);
371
+ `--require-signature` makes any unsigned or invalid receipt abort the whole import. See the
372
+ "cryptographic receipt attestation" update in `D-runtime-conformance-receipts` in `DECISIONS.md`.
373
+
347
374
  Every command is read-only until you explicitly run one of the mutating steps above — `bskel
348
375
  status`/`bskel next` (no arguments needed) tell you which gate is next and print the exact
349
376
  copy-pasteable command for it, without touching anything.
package/bin/bskel.mjs CHANGED
@@ -10,6 +10,7 @@ import { forceNamedGate, revokeNamedGate, requireNamedGate, passNamedGate, await
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
12
  import { writeFileAtomic, sha256File, readJsonIfExists } from '../lib/fsutil.mjs';
13
+ import { hydrateScanReportFilePaths, dehydrateScanReportFilePaths } from '../lib/scan-report-paths.mjs';
13
14
  import { validateAgainstSchema, formatSchemaErrors } from '../lib/schema-validate.mjs';
14
15
  import { withLockSync } from '../lib/lock.mjs';
15
16
  import { specDir, specPath, sbfPath } from '../lib/paths.mjs';
@@ -80,6 +81,7 @@ function usage() {
80
81
  bskel scan [--feature <id>] [--terms a,b,c] [--json] [--accept-low-confidence] [--db [--database-url-env <NAME>] [--schema public]]
81
82
  bskel scan disposition --feature <id> --mode reuse|extend|replace|parallel [--module <name>] [--note "..."] [--breaking-approved]
82
83
  bskel scan explain <module> --feature <id> [--json]
84
+ bskel scan repair --feature <id> [--json]
83
85
  bskel scan cross-feature-check --feature <id> [--db [--database-url-env <NAME>] [--schema public]] [--json]
84
86
  bskel scan cross-feature-waive --feature <id> --signal resource_type|table|operation_id|db_foreign_key --identifier <name> --other-feature <id> --reason "..."
85
87
  bskel feature init --slug <name>
@@ -109,7 +111,7 @@ function usage() {
109
111
  bskel patch rollback --feature <id> --transaction <id> --reason "..." [--force] [--json]
110
112
  bskel patch list --feature <id> [--json]
111
113
  bskel observe emit --feature <id> [--module <name>] [--force --reason "..."] [--check] [--diff] [--json]
112
- bskel observe import --feature <id> --receipts <path> [--fail-on-violation] [--json]
114
+ bskel observe import --feature <id> --receipts <path> [--fail-on-violation] [--pubkey <path> [--require-signature]] [--json]
113
115
  bskel verify --feature <id> [--build [--allow-skip-build]] [--json]
114
116
  bskel status [--feature <id>] [--json]
115
117
  bskel next [--feature <id>] [--json]
@@ -668,7 +670,12 @@ async function cmdScan(args) {
668
670
 
669
671
  const dir = specDir(root, flags.feature);
670
672
  fs.mkdirSync(dir, { recursive: true });
671
- writeScanReportOrExit(specPath(root, flags.feature, 'brownfield-scan.json'), report);
673
+ // D-scan-report-portable-paths: `report`'s `.file` fields stay absolute in memory (every
674
+ // consumer's expected shape, including this same function's OWN JSON stdout print below) --
675
+ // only the ON-DISK copy is converted to repo-relative, so the committed artifact survives
676
+ // being checked out somewhere else (a second worktree, a different clone, CI) without
677
+ // requiring any change to how `.file` is read back in memory once re-hydrated.
678
+ writeScanReportOrExit(specPath(root, flags.feature, 'brownfield-scan.json'), dehydrateScanReportFilePaths(report, root));
672
679
  writeFileAtomic(specPath(root, flags.feature, 'brownfield-scan.md'), renderScanMarkdown(report));
673
680
 
674
681
  let gateState;
@@ -748,7 +755,7 @@ function cmdScanExplain(args) {
748
755
  if (!moduleName) {
749
756
  fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'usage: bskel scan explain <module> --feature <id> [--json]');
750
757
  }
751
- const report = loadScanReportOrExit(root, flags.feature);
758
+ const report = loadHydratedScanReportOrExit(root, flags.feature);
752
759
  const mod = report.related_modules.find((m) => m.module === moduleName);
753
760
  if (!mod) {
754
761
  const known = report.related_modules.map((m) => m.module).join(', ') || '(none)';
@@ -762,6 +769,60 @@ function cmdScanExplain(args) {
762
769
  process.exit(0);
763
770
  }
764
771
 
772
+ // D-scan-report-portable-paths: a one-time, non-destructive migration for a committed
773
+ // brownfield-scan.json written BEFORE this fix (schema "sbf.scan-report/1", `.file` absolute) --
774
+ // `bskel scan`'s own re-run is NOT a substitute here: runScan() never carries `disposition`
775
+ // forward, so re-scanning would silently wipe an already-disposed feature's disposition and
776
+ // cascade `scan`/`contract`/`handles` gates back to stale/awaiting_disposition. This command
777
+ // touches ONLY `.file` strings and `schema` -- `disposition` and everything else stay
778
+ // byte-identical. Must run from the SAME location the original `bskel scan` ran from (a fresh
779
+ // worktree's absolute paths won't resolve to anything real) -- fails closed, naming the exact
780
+ // file, rather than guessing at a mapping.
781
+ function cmdScanRepair(args) {
782
+ const flags = parseCommand('scan repair', args);
783
+ if (flags.help) { console.log(renderCommandHelp('scan repair')); process.exit(0); }
784
+ setContext('scan repair', flags);
785
+ const root = requireRepoRoot();
786
+ requireValidFeatureId(flags.feature);
787
+
788
+ // Deliberately NOT loadScanReportOrExit() -- that validates against the CURRENT schema const
789
+ // ("sbf.scan-report/2"), which a genuinely old ("/1") report can never match by definition.
790
+ // This command's whole job is repairing exactly that mismatch, so it reads raw here.
791
+ const reportPath = specPath(root, flags.feature, 'brownfield-scan.json');
792
+ if (!fs.existsSync(reportPath)) {
793
+ fail(EXIT_CODES.NOT_PASSED, 'MISSING_ARTIFACT', `no scan report at ${reportPath} -- run \`bskel scan --feature ${flags.feature}\` first`);
794
+ }
795
+ const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
796
+ if (report.schema !== 'sbf.scan-report/1') {
797
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `${flags.feature}'s scan report is already schema "${report.schema}" -- nothing to repair (this command only migrates a "sbf.scan-report/1" report's absolute \`.file\` paths to repo-relative).`);
798
+ }
799
+
800
+ const unresolvable = [];
801
+ for (const mod of report.related_modules ?? []) {
802
+ for (const key of ['controllers', 'entities', 'enums', 'dtos']) {
803
+ for (const item of mod[key] ?? []) {
804
+ if (item.file && !fs.existsSync(item.file)) unresolvable.push(item.file);
805
+ }
806
+ }
807
+ }
808
+ if (unresolvable.length > 0) {
809
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `refusing to guess: ${unresolvable.length} file(s) referenced in this scan report do not exist under the CURRENT root -- re-run this command from the exact location \`bskel scan\` originally ran from:\n${unresolvable.map((f) => ` ${f}`).join('\n')}`);
810
+ }
811
+
812
+ // D-scan-report-portable-paths: same transform `cmdScan` applies at every real write -- a
813
+ // legacy report is, by definition, exactly the "still absolute" case that needs converting.
814
+ const repaired = dehydrateScanReportFilePaths(report, root);
815
+ repaired.schema = 'sbf.scan-report/2';
816
+ writeScanReportOrExit(reportPath, repaired);
817
+
818
+ if (flags.json) {
819
+ console.log(JSON.stringify({ feature_id: flags.feature, schema: repaired.schema, repaired: true }, null, 2));
820
+ } else {
821
+ console.log(`repaired: ${flags.feature}'s scan report is now schema "${repaired.schema}" (disposition and every other field untouched)`);
822
+ }
823
+ process.exit(0);
824
+ }
825
+
765
826
  // D-cross-feature-collision: mirrors cmdContractEmit's own "always write the artifact, gate
766
827
  // blocks only if unresolved issues remain" shape exactly, for a different data source (NAME-
767
828
  // identity collisions against every OTHER feature, not this feature's own contract completeness).
@@ -1194,11 +1255,11 @@ function cmdContractEmit(args) {
1194
1255
  });
1195
1256
  }
1196
1257
 
1258
+ // D-scan-report-portable-paths: was its own inline fs.existsSync/JSON.parse here, duplicating
1259
+ // (and bypassing) loadScanReportOrExit()'s schema validation -- consolidated onto the shared,
1260
+ // hydrated loader, closing both that gap and the portability bug the hydration itself fixes.
1197
1261
  const scanReportPath = specPath(root, flags.feature, 'brownfield-scan.json');
1198
- if (!fs.existsSync(scanReportPath)) {
1199
- fail(EXIT_CODES.NOT_PASSED, 'MISSING_ARTIFACT', `no scan report at ${scanReportPath} -- run \`bskel scan --feature ${flags.feature}\` first`);
1200
- }
1201
- const scanReport = JSON.parse(fs.readFileSync(scanReportPath, 'utf8'));
1262
+ const scanReport = loadHydratedScanReportOrExit(root, flags.feature);
1202
1263
  requireCapabilitiesOrExit(scanReport, 'contract emit', {
1203
1264
  featureId: flags.feature,
1204
1265
  scanReportPath,
@@ -2101,6 +2162,19 @@ function loadScanReportOrExit(root, featureId) {
2101
2162
  return parsed;
2102
2163
  }
2103
2164
 
2165
+ // D-scan-report-portable-paths: the read-only sibling of loadScanReportOrExit() -- every consumer
2166
+ // that only READS the report (never writes it back) should go through this instead, so its
2167
+ // `related_modules[].{controllers,entities,enums,dtos}[].file` values are correctly re-anchored to
2168
+ // THIS root before anything downstream (handles codegen, contract emission, gate recomputation)
2169
+ // touches them. Deliberately NOT folded into loadScanReportOrExit() itself: cmdScanDisposition()
2170
+ // does load -> mutate `.disposition` -> write back the WHOLE object -- if hydration lived in the
2171
+ // base loader, that round trip would silently re-persist re-absolutized paths to disk, resurrecting
2172
+ // the exact portability bug this closes. Any future read-modify-write command must stay on the raw
2173
+ // loader for the same reason.
2174
+ function loadHydratedScanReportOrExit(root, featureId) {
2175
+ return hydrateScanReportFilePaths(loadScanReportOrExit(root, featureId), root);
2176
+ }
2177
+
2104
2178
  // S5 (D-persistence-integrity): the write-side sibling of loadScanReportOrExit() above -- validated
2105
2179
  // before it ever touches disk, same "fail loud here, not as a confusing error somewhere later"
2106
2180
  // reasoning as lib/state.mjs's saveState(). Used by both cmdScan()'s own write and
@@ -2225,7 +2299,7 @@ async function cmdHandlesPlan(args) {
2225
2299
  if (flags.help) { console.log(renderCommandHelp('handles plan')); process.exit(0); }
2226
2300
  setContext('handles plan', flags);
2227
2301
  const root = requireRepoRoot();
2228
- const scanReport = loadScanReportOrExit(root, flags.feature);
2302
+ const scanReport = loadHydratedScanReportOrExit(root, flags.feature);
2229
2303
  const scanReportPath = specPath(root, flags.feature, 'brownfield-scan.json');
2230
2304
  requireCapabilitiesOrExit(scanReport, 'handles plan', { featureId: flags.feature, scanReportPath });
2231
2305
  const provider = selectProviderOrExit(scanReport);
@@ -2360,7 +2434,7 @@ function cmdHandlesEmit(args) {
2360
2434
  });
2361
2435
  }
2362
2436
 
2363
- const scanReport = loadScanReportOrExit(root, flags.feature);
2437
+ const scanReport = loadHydratedScanReportOrExit(root, flags.feature);
2364
2438
  const scanReportPath = specPath(root, flags.feature, 'brownfield-scan.json');
2365
2439
  requireCapabilitiesOrExit(scanReport, 'handles emit', { featureId: flags.feature, scanReportPath });
2366
2440
  const provider = selectProviderOrExit(scanReport);
@@ -2524,7 +2598,7 @@ function cmdHandlesPatchApprove(args) {
2524
2598
  fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel handles patch approve requires --reason "..." -- every approval must be auditable');
2525
2599
  }
2526
2600
 
2527
- const scanReport = loadScanReportOrExit(root, flags.feature);
2601
+ const scanReport = loadHydratedScanReportOrExit(root, flags.feature);
2528
2602
  const scanReportPath = specPath(root, flags.feature, 'brownfield-scan.json');
2529
2603
  requireCapabilitiesOrExit(scanReport, 'handles patch approve', { featureId: flags.feature, scanReportPath });
2530
2604
  const provider = selectProviderOrExit(scanReport);
@@ -2800,7 +2874,7 @@ async function cmdHandlesAudit(args) {
2800
2874
  // live database rather than a static regex proxy.
2801
2875
  let registryCoverage = null;
2802
2876
  if (flags['check-registry-coverage']) {
2803
- const scanReport = loadScanReportOrExit(root, flags.feature);
2877
+ const scanReport = loadHydratedScanReportOrExit(root, flags.feature);
2804
2878
  const scanReportPath = specPath(root, flags.feature, 'brownfield-scan.json');
2805
2879
  requireCapabilitiesOrExit(scanReport, 'handles audit --check-registry-coverage', { featureId: flags.feature, scanReportPath });
2806
2880
  const provider = selectProviderOrExit(scanReport);
@@ -2875,7 +2949,7 @@ function cmdObserveEmit(args) {
2875
2949
  });
2876
2950
  }
2877
2951
 
2878
- const scanReport = loadScanReportOrExit(root, flags.feature);
2952
+ const scanReport = loadHydratedScanReportOrExit(root, flags.feature);
2879
2953
  const contract = loadContract(root, flags.feature);
2880
2954
  const dryRun = flags.check || flags.diff;
2881
2955
 
@@ -2993,6 +3067,20 @@ function cmdObserveImport(args) {
2993
3067
  const flags = parseCommand('observe import', args);
2994
3068
  if (flags.help) { console.log(renderCommandHelp('observe import')); process.exit(0); }
2995
3069
  setContext('observe import', flags);
3070
+ if (flags['require-signature'] && !flags.pubkey) {
3071
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', '--require-signature was given but --pubkey was not -- refusing to require a signature this command has no way to check. Pass --pubkey <path>, or drop --require-signature to allow unsigned/unverified receipts (with a warning).');
3072
+ }
3073
+ // Mirrors `cmdAttestVerify`'s own pubkey-read pattern exactly. `null` (not given) means: don't
3074
+ // verify at all -- a signature field, if present on a receipt, is simply ignored, matching
3075
+ // today's behavior byte-for-byte (full backward compatibility with every already-deployed app).
3076
+ let pubkeyPem = null;
3077
+ if (flags.pubkey) {
3078
+ try {
3079
+ pubkeyPem = fs.readFileSync(path.resolve(process.cwd(), flags.pubkey), 'utf8');
3080
+ } catch (err) {
3081
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `could not read --pubkey "${flags.pubkey}": ${err.message}`);
3082
+ }
3083
+ }
2996
3084
  const root = requireRepoRoot();
2997
3085
  requirePreflightPassed(root);
2998
3086
 
@@ -3044,12 +3132,50 @@ function cmdObserveImport(args) {
3044
3132
  }
3045
3133
 
3046
3134
  const currentContractHash = sha256File(specPath(root, flags.feature, 'contracts', `${flags.feature}.schema.json`));
3135
+
3136
+ // D-runtime-conformance-receipts (cryptographic receipt attestation): verification always
3137
+ // happens here, in Node, regardless of which language's runtime produced+signed the receipt --
3138
+ // lib/attest.mjs's verifyPayload() is reused completely unmodified (already payload-shape-
3139
+ // agnostic, the same reuse D-ddl-apply's own maybeSignStep() already established for a
3140
+ // different payload shape). `unsigned` is computed regardless of --pubkey (forward visibility);
3141
+ // `signature_invalid` is only ever non-zero when --pubkey was given -- there is no key to judge
3142
+ // a signature against otherwise. Without --pubkey, every receipt is trusted exactly like today
3143
+ // (a signature field, if present, is never even looked at) -- full backward compatibility.
3144
+ let unsignedCount = 0;
3145
+ let signatureInvalidCount = 0;
3146
+ const trustedByIndex = receipts.map((r) => {
3147
+ const hasSignature = Boolean(r.signature);
3148
+ if (!hasSignature) unsignedCount++;
3149
+ if (!pubkeyPem) return true;
3150
+ if (!hasSignature) return false;
3151
+ const { signature, ...unsigned } = r;
3152
+ const valid = verifyPayload(unsigned, signature.value, pubkeyPem);
3153
+ if (!valid) signatureInvalidCount++;
3154
+ return valid;
3155
+ });
3156
+ if (flags['require-signature']) {
3157
+ const badIndex = trustedByIndex.findIndex((trusted) => !trusted);
3158
+ if (badIndex !== -1) {
3159
+ const bad = receipts[badIndex];
3160
+ const reason = bad.signature ? 'its signature does not verify against --pubkey' : 'it has no signature at all';
3161
+ fail(EXIT_CODES.NOT_PASSED, 'INVALID_ARTIFACT', `${flags.receipts}: a receipt for operation "${bad.operation_id}" is untrusted -- ${reason}. Aborting the whole import (--require-signature demands every receipt verify, and a corrupted/untrusted receipts file must not partially land).`);
3162
+ }
3163
+ }
3164
+ if (pubkeyPem && unsignedCount > 0 && !flags.quiet) {
3165
+ console.error(`WARNING: ${unsignedCount} receipt(s) have no signature -- excluded from matched/violation counts now that --pubkey is checking signatures. Pass --require-signature to make this a hard failure instead.`);
3166
+ }
3167
+
3168
+ // Untrusted receipts (unsigned or signature-invalid, only possible when --pubkey was given)
3169
+ // are excluded from EVERY count below -- not "noise" (non-JSON garbage) and not "corruption"
3170
+ // (schema-invalid, aborts the whole import), a genuinely new third tier alongside
3171
+ // stale_contract_ref's own existing "kept on record, excluded from current evidence" precedent.
3047
3172
  let matched = 0;
3048
3173
  let staleContractRef = 0;
3049
3174
  let violationCount = 0;
3050
3175
  let unsupportedCount = 0;
3051
3176
  const byOperation = {};
3052
- for (const r of receipts) {
3177
+ receipts.forEach((r, i) => {
3178
+ if (!trustedByIndex[i]) return;
3053
3179
  const isMatched = r.contract_ref === currentContractHash;
3054
3180
  if (isMatched) matched++; else staleContractRef++;
3055
3181
  const opStats = byOperation[r.operation_id] ?? { matched: 0, stale_contract_ref: 0, violations: 0 };
@@ -3059,7 +3185,7 @@ function cmdObserveImport(args) {
3059
3185
  if (isMatched) opStats.violations++;
3060
3186
  }
3061
3187
  byOperation[r.operation_id] = opStats;
3062
- }
3188
+ });
3063
3189
 
3064
3190
  const report = {
3065
3191
  sbf_conformance_report: '1',
@@ -3073,8 +3199,11 @@ function cmdObserveImport(args) {
3073
3199
  matched, stale_contract_ref: staleContractRef,
3074
3200
  violations: violationCount,
3075
3201
  unsupported: unsupportedCount,
3202
+ unsigned: unsignedCount,
3203
+ signature_invalid: signatureInvalidCount,
3076
3204
  },
3077
3205
  by_operation: byOperation,
3206
+ verification: { pubkey_given: Boolean(pubkeyPem), require_signature: Boolean(flags['require-signature']) },
3078
3207
  };
3079
3208
  const { ok: reportOk, errors: reportErrors } = validateAgainstSchema('conformance-report.schema.json', report);
3080
3209
  if (!reportOk) {
@@ -3103,6 +3232,9 @@ function cmdObserveImport(args) {
3103
3232
  console.log(JSON.stringify({ report, noise_lines: noiseLines, gate: gateState.gates.conformance }, null, 2));
3104
3233
  } else {
3105
3234
  console.log(`imported ${receipts.length} receipt(s) (${matched} matched the current contract, ${staleContractRef} stale, ${noiseLines} noise line(s) skipped)`);
3235
+ if (pubkeyPem) {
3236
+ console.log(`signatures: ${unsignedCount} unsigned, ${signatureInvalidCount} invalid (both excluded from the counts above)`);
3237
+ }
3106
3238
  console.log(`${violationCount} violation(s), ${unsupportedCount} unsupported field(s) across matched receipts`);
3107
3239
  console.log(`wrote ${path.relative(root, reportPath)}`);
3108
3240
  console.log(`gate: conformance -> ${gateState.gates.conformance.status}`);
@@ -3625,6 +3757,7 @@ async function dispatchCommand(cmd, rest) {
3625
3757
  case 'scan': {
3626
3758
  if (rest[0] === 'disposition') return cmdScanDisposition(rest.slice(1));
3627
3759
  if (rest[0] === 'explain') return cmdScanExplain(rest.slice(1));
3760
+ if (rest[0] === 'repair') return cmdScanRepair(rest.slice(1));
3628
3761
  if (rest[0] === 'cross-feature-check') return cmdScanCrossFeatureCheck(rest.slice(1));
3629
3762
  if (rest[0] === 'cross-feature-waive') return cmdScanCrossFeatureWaive(rest.slice(1));
3630
3763
  await cmdScan(rest);
@@ -24,6 +24,10 @@ const INFRA_FILES = [
24
24
  { template: 'ContractCheck.java.tmpl', target: 'global/observe/ContractCheck.java' },
25
25
  { template: 'ObserveSchemaLoader.java.tmpl', target: 'global/observe/ObserveSchemaLoader.java' },
26
26
  { template: 'ContractObservationAspect.java.tmpl', target: 'global/observe/ContractObservationAspect.java' },
27
+ // D-runtime-conformance-receipts (cryptographic receipt attestation): JDK-stdlib-only Ed25519
28
+ // signer, used by ContractObservationAspect -- see that template's own javadoc for why this is
29
+ // a hand-rolled canonicalizer, not a Jackson mapper feature.
30
+ { template: 'ReceiptSigner.java.tmpl', target: 'global/observe/ReceiptSigner.java' },
27
31
  ];
28
32
 
29
33
  function render(templatePath, vars) {
@@ -51,12 +55,21 @@ function writeUnit(target, content) {
51
55
  export function emitObserveJavaSpring({ repoRoot, featureId, contract, basePackage, force = false, reason = '', dryRun = false, computeDiff = false }) {
52
56
  const javaSrcRoot = path.join(repoRoot, 'src', 'main', 'java', ...basePackage.split('.'));
53
57
  const jacksonPackage = detectJacksonPackage(repoRoot);
58
+ // D-runtime-conformance-receipts (Jackson 2/3 JsonNode field-iteration parity): Jackson 3's
59
+ // JsonNode has no #fields()/#fieldNames() at all (confirmed live via javap against real
60
+ // jackson-databind 3.1.5 -- only #properties(), a Set); Jackson 2's #fields() exists across
61
+ // every real version checked (2.14 through 2.21), but #properties() does NOT exist on the
62
+ // older ones (2.14 lacks it, 2.17+ has it) -- so the safe, version-spanning choice per major is
63
+ // #fields() for Jackson 2, #properties() for Jackson 3, never the other way around.
64
+ const jacksonFieldsOfImpl = jacksonPackage === 'tools.jackson.databind'
65
+ ? 'return node.properties();'
66
+ : 'return () -> node.fields();';
54
67
 
55
68
  const infraUnits = INFRA_FILES.map((f) => ({
56
69
  id: f.template,
57
70
  templatePath: path.join(TEMPLATES_DIR, f.template),
58
71
  targetAbs: path.join(javaSrcRoot, f.target),
59
- rendered: render(path.join(TEMPLATES_DIR, f.template), { BASE_PACKAGE: basePackage, JACKSON_PACKAGE: jacksonPackage }),
72
+ rendered: render(path.join(TEMPLATES_DIR, f.template), { BASE_PACKAGE: basePackage, JACKSON_PACKAGE: jacksonPackage, JACKSON_FIELDS_OF_IMPL: jacksonFieldsOfImpl }),
60
73
  }));
61
74
 
62
75
  const result = emitUnits({ repoRoot, featureId, provider: 'java-spring', force, reason, infraUnits, resolverUnits: [], orphanScan: null, dryRun, computeDiff });
@@ -89,6 +102,7 @@ export function emitObserveJavaSpring({ repoRoot, featureId, contract, basePacka
89
102
  'NOT done automatically: route the "bskel.observe.receipts" SLF4J logger to wherever you want receipt lines collected (a dedicated logback/log4j2 appender to a file, your existing log pipeline, etc.) -- bskel never edits your logging config. Point `bskel observe import --receipts <path>` at whatever that logger\'s output ends up as.',
90
103
  `Contract-conformance checking only covers path params always, plus a bounded slice of request/response/error body shape -- and only when this contract was emitted with --openapi-file. See the emitted ${path.relative(repoRoot, schemaPath)}'s own "unsupported" markers for exactly what is skipped for this feature.`,
91
104
  'NOT done automatically: apply @ObserveContract(operationId = "...") to whichever existing controller/service methods you want observed -- nothing is annotated for you (D-resolver-scope: never guess which method implements which operation).',
105
+ 'NOT done automatically: to sign receipts, set the `bskel.observe.signing-key-pem` Spring property (e.g. an env var via Spring\'s own relaxed binding: BSKEL_OBSERVE_SIGNING_KEY_PEM=...) to a PKCS#8 Ed25519 private key PEM -- `bskel attest keygen --out <dir>` already generates one in this exact format. Unset means every receipt stays unsigned (backward compatible). Verify with `bskel observe import --pubkey <path/to/attest-public.pem>`.',
92
106
  ],
93
107
  };
94
108
  }
@@ -5,11 +5,13 @@ import {{JACKSON_PACKAGE}}.ObjectMapper;
5
5
  import {{JACKSON_PACKAGE}}.node.ArrayNode;
6
6
  import {{JACKSON_PACKAGE}}.node.ObjectNode;
7
7
  import {{BASE_PACKAGE}}.global.observe.ObserveSchemaLoader.ObservedOperation;
8
+ import jakarta.annotation.PostConstruct;
8
9
  import lombok.RequiredArgsConstructor;
9
10
  import lombok.extern.slf4j.Slf4j;
10
11
  import org.aspectj.lang.ProceedingJoinPoint;
11
12
  import org.aspectj.lang.annotation.Around;
12
13
  import org.aspectj.lang.annotation.Aspect;
14
+ import org.springframework.beans.factory.annotation.Value;
13
15
  import org.springframework.http.ResponseEntity;
14
16
  import org.springframework.stereotype.Component;
15
17
  import org.springframework.web.bind.annotation.RequestBody;
@@ -49,6 +51,10 @@ import java.util.Map;
49
51
  * deliberately deferred, same unexamined assumption {@code HandleAspect} already carries for
50
52
  * {@code @Around} advice generally. See DECISIONS.md D-runtime-conformance-receipts.
51
53
  *
54
+ * <p>Optionally signs each receipt (Ed25519, via {@link ReceiptSigner}) when {@code
55
+ * bskel.observe.signing-key-pem} is set -- see that class's own javadoc. Unset means every
56
+ * receipt stays unsigned, exactly like before this capability existed.
57
+ *
52
58
  * <p>Generated by backend-skeleton ({@code bskel observe emit}). Requires {@code
53
59
  * spring-boot-starter-aop} on the classpath -- see {@link ObserveContract}'s own javadoc.
54
60
  */
@@ -63,6 +69,21 @@ public class ContractObservationAspect {
63
69
  private final ObserveSchemaLoader schemaLoader;
64
70
  private final ObjectMapper objectMapper;
65
71
 
72
+ // D-runtime-conformance-receipts (cryptographic receipt attestation): a plain, non-final,
73
+ // separately field-injected member -- NOT routed through the @RequiredArgsConstructor-generated
74
+ // constructor above. @Value on a `final` constructor-injected field alongside
75
+ // @RequiredArgsConstructor is a documented Lombok/Spring interop gap (the generated constructor
76
+ // does not reliably propagate the annotation); this is the standard, real-world-safe pattern for
77
+ // @Value-injected simple config even in classes that otherwise use constructor injection.
78
+ // Empty (the default) means every receipt stays unsigned -- see ReceiptSigner's own javadoc.
79
+ @Value("${bskel.observe.signing-key-pem:}")
80
+ private String signingKeyPem;
81
+
82
+ @PostConstruct
83
+ private void configureReceiptSigning() {
84
+ ReceiptSigner.configure(signingKeyPem);
85
+ }
86
+
66
87
  @Around("@annotation(observeContract)")
67
88
  public Object observe(ProceedingJoinPoint joinPoint, ObserveContract observeContract) throws Throwable {
68
89
  String operationId = observeContract.operationId();
@@ -159,9 +180,26 @@ public class ContractObservationAspect {
159
180
  vn.put("keyword", v.keyword());
160
181
  vn.put("message", v.message());
161
182
  }
183
+ safelySign(receipt);
162
184
  safelyLog(receipt);
163
185
  }
164
186
 
187
+ // D-runtime-conformance-receipts (cryptographic receipt attestation): its OWN inner try/catch,
188
+ // separate from safelyLog()'s -- a signing failure (bad/missing key config, malformed PEM) must
189
+ // fall back to logging the receipt UNSIGNED, not silently drop the whole receipt the way sharing
190
+ // safelyLog()'s catch block would.
191
+ private void safelySign(ObjectNode receipt) {
192
+ if (!ReceiptSigner.isConfigured()) return;
193
+ try {
194
+ String signatureValue = ReceiptSigner.sign(receipt);
195
+ ObjectNode signature = receipt.putObject("signature");
196
+ signature.put("algorithm", "ed25519");
197
+ signature.put("value", signatureValue);
198
+ } catch (Exception e) {
199
+ log.warn("ContractObservationAspect: could not sign a receipt -- logging it unsigned instead", e);
200
+ }
201
+ }
202
+
165
203
  private void safelyLog(Object receipt) {
166
204
  try {
167
205
  RECEIPTS.info(objectMapper.writeValueAsString(receipt));
@@ -11,7 +11,6 @@ import java.io.IOException;
11
11
  import java.io.InputStream;
12
12
  import java.util.ArrayList;
13
13
  import java.util.HashMap;
14
- import java.util.Iterator;
15
14
  import java.util.List;
16
15
  import java.util.Map;
17
16
  import java.util.regex.Pattern;
@@ -69,9 +68,7 @@ public class ObserveSchemaLoader {
69
68
  String featureId = root.path("feature_id").asText(null);
70
69
  String featureUid = root.path("feature_uid").asText(null);
71
70
  String contractRef = root.path("contract_ref").asText(null);
72
- Iterator<Map.Entry<String, JsonNode>> ops = root.path("operations").fields();
73
- while (ops.hasNext()) {
74
- Map.Entry<String, JsonNode> entry = ops.next();
71
+ for (Map.Entry<String, JsonNode> entry : fieldsOf(root.path("operations"))) {
75
72
  String operationId = entry.getKey();
76
73
  JsonNode op = entry.getValue();
77
74
  String body = op.path("body").asText("unknown");
@@ -99,9 +96,7 @@ public class ObserveSchemaLoader {
99
96
  List<String> required = parseStringList(node.path("required"));
100
97
  List<String> unsupported = new ArrayList<>(parseStringList(node.path("unsupported")));
101
98
  Map<String, ObservedProperty> properties = new HashMap<>();
102
- Iterator<Map.Entry<String, JsonNode>> props = node.path("properties").fields();
103
- while (props.hasNext()) {
104
- Map.Entry<String, JsonNode> entry = props.next();
99
+ for (Map.Entry<String, JsonNode> entry : fieldsOf(node.path("properties"))) {
105
100
  JsonNode propSchema = entry.getValue();
106
101
  String type = propSchema.path("type").asText(null);
107
102
  String patternText = propSchema.has("pattern") ? propSchema.get("pattern").asText() : null;
@@ -119,6 +114,17 @@ public class ObserveSchemaLoader {
119
114
  return new ObservedObject(required, properties, unsupported);
120
115
  }
121
116
 
117
+ // D-runtime-conformance-receipts (Jackson 2/3 JsonNode field-iteration parity): Jackson 2's
118
+ // JsonNode#fields() (an Iterator) does not exist on Jackson 3's JsonNode at all -- Jackson 3
119
+ // replaced it with #properties() (a Set, no Iterator wrapper needed). Neither API exists on
120
+ // EVERY version of the other major (older Jackson 2.x, e.g. 2.14, has no #properties() either),
121
+ // so this can't be unified into one call safely -- {{JACKSON_PACKAGE}} already tells us which
122
+ // major is on this target's real classpath (detectJacksonPackage() in emit.mjs), so that same
123
+ // signal picks the one real, correct implementation body at emit time.
124
+ private static Iterable<Map.Entry<String, JsonNode>> fieldsOf(JsonNode node) {
125
+ {{JACKSON_FIELDS_OF_IMPL}}
126
+ }
127
+
122
128
  private static List<String> parseStringList(JsonNode node) {
123
129
  List<String> values = new ArrayList<>();
124
130
  if (node != null && node.isArray()) {