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
@@ -31,7 +31,9 @@ const DIFFABLE_ACTIONS = new Set(['update', 'conflict', 'adopt-update']);
31
31
  // overwrite is only ever reversible if the content it destroys is already committed. Fails
32
32
  // closed (treats git errors, or a repo where the path can't be resolved, as "dirty") since the
33
33
  // whole point is to never make an irreversible action look safe by default.
34
- function isDirtyOrUntracked(repoRoot, absPath) {
34
+ // D-write-safety-phase0 (item 2): exported so stack/apply.mjs can apply the identical
35
+ // git-recoverability check before a --force overwrite -- previously private to this file.
36
+ export function isDirtyOrUntracked(repoRoot, absPath) {
35
37
  try {
36
38
  const out = execFileSync('git', ['status', '--porcelain', '--', absPath], { cwd: repoRoot, encoding: 'utf8' });
37
39
  return out.trim().length > 0;
@@ -58,16 +60,22 @@ function isDirtyOrUntracked(repoRoot, absPath) {
58
60
  // 'update'/'conflict'/'adopt-update' action -- the only 3 where content actually
59
61
  // differs. Off by default since it shells out to git per diffable file.
60
62
  // postResolverUnit: D-patch-transactions (Continued) -- an OPTIONAL single unit, { id,
61
- // templatePath, targetAbs, render() => string }, whose correct content can only
62
- // be computed AFTER resolverUnits above have been written (e.g. typescript-
63
- // express's resolvers_index.ts barrel -- its import list must reflect the
64
- // resolvers directory's REAL final on-disk listing, including this run's own
65
- // just-written resolver files, not just this run's own resolverUnits -- a 4th
66
- // infraUnits entry can't do this, since infra is processed BEFORE resolvers).
67
- // Reuses the exact same classify/conflict/force/manifest logic the infra loop
68
- // above already implements, applied to exactly one repo-owned (kind: 'infra')
69
- // unit. null (the default) is a true no-op.
70
- export function emitUnits({ repoRoot, featureId, provider, force = false, reason = '', infraUnits, resolverUnits, orphanScan, dryRun = false, computeDiff = false, postResolverUnit = null }) {
63
+ // templatePath, targetAbs, render() => string, kind?, ownership?, owner? },
64
+ // whose correct content can only be computed AFTER resolverUnits above have been
65
+ // written (e.g. typescript-express's resolvers_index.ts barrel -- its import list
66
+ // must reflect the resolvers directory's REAL final on-disk listing, including
67
+ // this run's own just-written resolver files, not just this run's own
68
+ // resolverUnits -- a 4th infraUnits entry can't do this, since infra is processed
69
+ // BEFORE resolvers). Reuses the exact same classify/conflict/force/manifest logic
70
+ // the infra loop above already implements. `kind`/`ownership`/`owner` default to
71
+ // `'infra'`/`'repo'`/`'_repo'` (the barrel's own shape, unchanged);
72
+ // D-write-safety-phase0 (item 1) is the first caller to override them, for a
73
+ // feature-owned unit (java-spring/python-fastapi's migration.sql,
74
+ // kind: 'migration', ownership: 'feature') whose content does NOT actually depend
75
+ // on the resolver loop's post-write state -- it just reuses this slot rather than
76
+ // adding a third one.
77
+ // null (the default) is a true no-op.
78
+ export function emitUnits({ repoRoot, featureId, provider, force = false, reason = '', infraUnits, resolverUnits, orphanScan, dryRun = false, computeDiff = false, postResolverUnits = [] }) {
71
79
  const manifest = loadManifest(repoRoot);
72
80
  const nowIso = new Date().toISOString();
73
81
 
@@ -125,6 +133,10 @@ export function emitUnits({ repoRoot, featureId, provider, force = false, reason
125
133
  };
126
134
  manifestChanged = true;
127
135
  writeUnit(u.targetAbs, u.rendered);
136
+ // D-write-safety-phase0 (item 3): persist per-unit, not once at the end of the whole
137
+ // loop -- a crash after this write but before a later unit's own write must not
138
+ // leave THIS file's real provenance unrecorded.
139
+ saveManifest(repoRoot, manifest);
128
140
  }
129
141
  written.push(u.relPath);
130
142
  forced.push(u.relPath);
@@ -150,6 +162,8 @@ export function emitUnits({ repoRoot, featureId, provider, force = false, reason
150
162
  updated_at: nowIso,
151
163
  };
152
164
  manifestChanged = true;
165
+ // D-write-safety-phase0 (item 3): see the comment at this loop's first saveManifest() call.
166
+ saveManifest(repoRoot, manifest);
153
167
  }
154
168
  recordAction({ relPath: u.relPath, kind: 'infra', action: u.action, diskContent: u.diskContent, rendered: u.rendered });
155
169
  }
@@ -197,6 +211,11 @@ export function emitUnits({ repoRoot, featureId, provider, force = false, reason
197
211
  updated_at: nowIso, last_force: { reason, at: nowIso, overwritten_hash: diskHash },
198
212
  };
199
213
  manifestChanged = true;
214
+ // D-write-safety-phase0 (item 3): persist per-unit -- the resolver loop is where this
215
+ // matters most (potentially many resolvers per feature, one saveManifest() call at
216
+ // the end previously meant a crash partway through left every already-written
217
+ // resolver this run unrecorded, not just the interrupted one).
218
+ saveManifest(repoRoot, manifest);
200
219
  }
201
220
  written.push(relPath);
202
221
  forced.push(relPath);
@@ -228,23 +247,37 @@ export function emitUnits({ repoRoot, featureId, provider, force = false, reason
228
247
  updated_at: nowIso,
229
248
  };
230
249
  manifestChanged = true;
250
+ // D-write-safety-phase0 (item 3): see the comment at this loop's first saveManifest() call.
251
+ saveManifest(repoRoot, manifest);
231
252
  }
232
253
  }
233
254
  recordAction({ relPath, kind: 'resolver', action, resourceType: u.resourceType, diskContent, rendered: u.rendered });
234
255
  }
235
256
 
236
- // ---- post-resolver unit: an OPTIONAL single unit whose correct content can only be computed
237
- // AFTER the resolver loop above has finished writing (e.g. typescript-express's
238
- // resolvers_index.ts barrel -- its import list must reflect the resolvers directory's REAL
239
- // on-disk listing, including THIS run's own just-written resolver files, not just this run's
240
- // own resolverUnits). Reuses the EXACT SAME classify/conflict/force/manifest logic the infra
241
- // loop above already implements, applied to exactly one unit. `render()` is called HERE, not
242
- // earlier, precisely so it observes this run's own writes. null (the default) is a true no-op
243
- // -- java-spring/python-fastapi never pass this, so this block never executes for them. See
244
- // D-patch-transactions (Continued) in DECISIONS.md for why this couldn't just be a 4th
245
- // infraUnits entry. ----
246
- if (postResolverUnit) {
247
- const u = postResolverUnit;
257
+ // ---- post-resolver units: an array of units whose correct content can only be computed AFTER
258
+ // the resolver loop above has finished writing (e.g. typescript-express's resolvers_index.ts
259
+ // barrel -- its import list must reflect the resolvers directory's REAL on-disk listing,
260
+ // including THIS run's own just-written resolver files, not just this run's own
261
+ // resolverUnits). Reuses the EXACT SAME classify/conflict/force/manifest logic the infra loop
262
+ // above already implements, applied to each unit independently. `render()` is called HERE, not
263
+ // earlier, precisely so it observes this run's own writes (still true for a unit whose content
264
+ // doesn't actually need that timing, like migration.sql -- it just reuses this array rather
265
+ // than adding a third mechanism). Empty array (the default) is a true no-op.
266
+ // D-typescript-express-registry-parity: was a single optional `postResolverUnit` object
267
+ // (D-write-safety-phase0's own kind/ownership/owner generalization) until typescript-express
268
+ // needed TWO units here at once (resolvers_index.ts, repo-owned, AND migration.sql,
269
+ // feature-owned, once this item gave it one) -- widened to an array, same per-unit logic,
270
+ // nothing else about the mechanism changed. See D-patch-transactions (Continued) in
271
+ // DECISIONS.md for why this couldn't just be infraUnits/resolverUnits entries. ----
272
+ for (const u of postResolverUnits) {
273
+ // D-write-safety-phase0 (item 1): generalized from a hardcoded infra/repo/_repo triple so a
274
+ // feature-owned single unit (java-spring/python-fastapi's migration.sql) can reuse this exact
275
+ // classify/conflict/force/manifest cycle too, not just typescript-express's repo-owned
276
+ // resolvers_index.ts barrel. Defaults preserve the original hardcoded values byte-for-byte, so
277
+ // the barrel caller (which never sets these) is unaffected.
278
+ const unitKind = u.kind ?? 'infra';
279
+ const unitOwnership = u.ownership ?? 'repo';
280
+ const unitOwner = u.owner ?? '_repo';
248
281
  const rendered = u.render();
249
282
  const relPath = path.relative(repoRoot, u.targetAbs);
250
283
  const diskContent = readIfExists(u.targetAbs);
@@ -256,28 +289,30 @@ export function emitUnits({ repoRoot, featureId, provider, force = false, reason
256
289
  const action = classifyFile({ exists, diskHash, manifestEntryHash: entry?.generated_hash ?? null, freshRenderHash, matchesPristineRender });
257
290
 
258
291
  if (action === 'conflict' && !force) {
259
- conflicts.push({ path: relPath, kind: 'infra', reason: 'diverged from the last content backend-skeleton generated -- see notes for remediation' });
260
- recordAction({ relPath, kind: 'infra', action, diskContent, rendered });
292
+ conflicts.push({ path: relPath, kind: unitKind, reason: 'diverged from the last content backend-skeleton generated -- see notes for remediation' });
293
+ recordAction({ relPath, kind: unitKind, action, diskContent, rendered });
261
294
  } else if (action === 'conflict') {
262
295
  if (isDirtyOrUntracked(repoRoot, u.targetAbs)) {
263
- conflicts.push({ path: relPath, kind: 'infra', reason: 'refusing --force: this file has uncommitted/untracked changes -- commit or stash it first so the overwrite is recoverable' });
264
- recordAction({ relPath, kind: 'infra', action, diskContent, rendered });
296
+ conflicts.push({ path: relPath, kind: unitKind, reason: 'refusing --force: this file has uncommitted/untracked changes -- commit or stash it first so the overwrite is recoverable' });
297
+ recordAction({ relPath, kind: unitKind, action, diskContent, rendered });
265
298
  } else {
266
299
  if (!dryRun) {
267
300
  manifest.files[relPath] = {
268
- kind: 'infra', ownership: 'repo', owner: '_repo', provider, template: u.id,
301
+ kind: unitKind, ownership: unitOwnership, owner: unitOwner, provider, template: u.id,
269
302
  template_hash: sha256File(u.templatePath), generated_hash: freshRenderHash,
270
303
  updated_at: nowIso, last_force: { reason, at: nowIso },
271
304
  };
272
305
  manifestChanged = true;
273
306
  writeUnit(u.targetAbs, rendered);
307
+ // D-write-safety-phase0 (item 3): see the infra loop's first saveManifest() call above.
308
+ saveManifest(repoRoot, manifest);
274
309
  }
275
310
  written.push(relPath);
276
311
  forced.push(relPath);
277
- recordAction({ relPath, kind: 'infra', action, diskContent, rendered });
312
+ recordAction({ relPath, kind: unitKind, action, diskContent, rendered });
278
313
  }
279
314
  } else if (action === 'unchanged') {
280
- recordAction({ relPath, kind: 'infra', action });
315
+ recordAction({ relPath, kind: unitKind, action });
281
316
  } else {
282
317
  if (action !== 'adopt-unchanged') {
283
318
  if (!dryRun) writeUnit(u.targetAbs, rendered);
@@ -285,13 +320,15 @@ export function emitUnits({ repoRoot, featureId, provider, force = false, reason
285
320
  }
286
321
  if (!dryRun) {
287
322
  manifest.files[relPath] = {
288
- kind: 'infra', ownership: 'repo', owner: '_repo', provider, template: u.id,
323
+ kind: unitKind, ownership: unitOwnership, owner: unitOwner, provider, template: u.id,
289
324
  template_hash: sha256File(u.templatePath), generated_hash: freshRenderHash,
290
325
  updated_at: nowIso,
291
326
  };
292
327
  manifestChanged = true;
328
+ // D-write-safety-phase0 (item 3): see the infra loop's first saveManifest() call above.
329
+ saveManifest(repoRoot, manifest);
293
330
  }
294
- recordAction({ relPath, kind: 'infra', action, diskContent, rendered });
331
+ recordAction({ relPath, kind: unitKind, action, diskContent, rendered });
295
332
  }
296
333
  }
297
334
 
@@ -325,6 +362,12 @@ export function emitUnits({ repoRoot, featureId, provider, force = false, reason
325
362
  }
326
363
  }
327
364
 
365
+ // D-write-safety-phase0 (item 3): every mutation site above already calls saveManifest()
366
+ // itself, incrementally, right after its own write -- this is now a defensive backstop, not
367
+ // the primary persistence mechanism (a no-op resave of already-current content in the normal
368
+ // case), kept so a future mutation site added without its own incremental save still ends up
369
+ // correct at the end of a run that completes normally. It is NOT what makes this item's own
370
+ // crash-safety property hold -- that comes entirely from the per-unit calls above.
328
371
  if (!dryRun && manifestChanged) saveManifest(repoRoot, manifest);
329
372
 
330
373
  // D-resolver-policy-split: two units (Resolver + Policy) now share one resourceType, so
@@ -0,0 +1,94 @@
1
+ // ROADMAP.md Phase 6, item 1 (D-sbf2-capability-codec): the `sbf2_` token FORMAT and its
2
+ // Ed25519 sign/verify mechanics -- deliberately INFRASTRUCTURE ONLY. Not wired into
3
+ // `handles/codec.mjs`'s `HANDLE_DECODERS` dispatch table, not called from `HandleController`/
4
+ // `ResourceResolver`, no CLI command issues or consumes one. ROADMAP's own Phase 6 text is explicit
5
+ // about why: `sbf1_` conveys no authority by design (all authority comes from the target app's own
6
+ // `@PreAuthorize`-derived check) -- a capability token is a genuinely DIFFERENT design, and this
7
+ // project's own Phase 4 pilot (D-handles-pilot-cohort) never exercised a single real cross-tenant
8
+ // or delegated-access use case (one resource, one org, straightforward CRUD). Building the actual
9
+ // delegation MODEL -- what "scope" grants, how a capability token interacts with the real
10
+ // authorization flow, key rotation/multiple-signers/revocation -- against zero real delegation
11
+ // requirements is exactly what this project has refused to do everywhere else (D-security-8's own
12
+ // "never guess, always explicit" boundary, D-write-safety-phase1's non-UUID refusal, this whole
13
+ // project's Data-First Numerics discipline). This module exists so that work, once real
14
+ // requirements exist, has real, tested crypto/envelope plumbing to start from rather than a blank
15
+ // page -- not because the delegation model itself is considered designed.
16
+ //
17
+ // Reuses lib/attest.mjs's Ed25519 primitives directly (Node built-in `crypto`, zero new
18
+ // dependencies) -- the same canonicalize/sign/verify this project's own gate attestations already
19
+ // use, not a second, independently-written crypto path.
20
+ import { signPayload, verifyPayload } from '../lib/attest.mjs';
21
+
22
+ // D-security-10 precedent (handles/codec.mjs's own MAX_HANDLE_TOKEN_LENGTH): a defense-in-depth
23
+ // cap before attempting to decode, not a functional requirement. Larger than sbf1's 2048 -- this
24
+ // envelope carries a JSON payload plus a base64 Ed25519 signature (~88 bytes), not a bare
25
+ // kind:type:uuid:pointer address.
26
+ const MAX_CAPABILITY_TOKEN_LENGTH = 4096;
27
+ const PREFIX = 'sbf2_';
28
+ const BASE64URL_CHARSET_RE = /^[A-Za-z0-9_-]*$/;
29
+
30
+ // Byte-for-byte the same base64url encode/decode handles/codec.mjs's own encodeHandle/
31
+ // decodeSbf1Handle use (duplicated rather than imported -- these are ~3-line helpers with no
32
+ // handle-specific meaning, and importing them would wire this file into handles/codec.mjs's own
33
+ // module for no real reason beyond avoiding a duplicate).
34
+ function base64url(buf) {
35
+ return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
36
+ }
37
+
38
+ function base64urlDecode(str) {
39
+ if (!BASE64URL_CHARSET_RE.test(str)) throw new Error('not valid base64url after the sbf2_ prefix');
40
+ const pad = (4 - (str.length % 4)) % 4;
41
+ const padded = str.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat(pad);
42
+ return Buffer.from(padded, 'base64');
43
+ }
44
+
45
+ // The payload shape ROADMAP.md Phase 6 itself names: issuer, audience, expiry, and a scope.
46
+ // `scope`'s internal shape is deliberately UNINTERPRETED by this module -- it's carried through
47
+ // verbatim, JSON-serializable, nothing more. This module has no opinion about what a "scope"
48
+ // value means or how it should be checked against a real request; that's exactly the delegation-
49
+ // model design this module deliberately does not make.
50
+ export function encodeCapabilityToken({ iss, aud, exp, scope }, privateKeyPem) {
51
+ if (typeof iss !== 'string' || !iss) throw new Error('encodeCapabilityToken requires a non-empty "iss" (issuer)');
52
+ if (typeof aud !== 'string' || !aud) throw new Error('encodeCapabilityToken requires a non-empty "aud" (audience)');
53
+ if (typeof exp !== 'number' || !Number.isFinite(exp)) throw new Error('encodeCapabilityToken requires a numeric "exp" (Unix seconds)');
54
+ if (scope === undefined) throw new Error('encodeCapabilityToken requires a "scope" (any JSON-serializable value, uninterpreted by this codec)');
55
+
56
+ const payload = { iss, aud, exp, scope };
57
+ const sig = signPayload(payload, privateKeyPem);
58
+ const envelope = { payload, sig };
59
+ return `${PREFIX}${base64url(Buffer.from(JSON.stringify(envelope), 'utf8'))}`;
60
+ }
61
+
62
+ // Returns { ok: true, payload } or { ok: false, reason } -- never throws on a malformed/tampered/
63
+ // expired token. Matches this project's own established convention for routine, expected
64
+ // verification failures (contracts/openapi.mjs's loadOpenApiDocument/inlineSchema,
65
+ // lib/schema-validate.mjs's validateAgainstSchema), not handles/codec.mjs's own throw-based
66
+ // decodeHandle() -- an invalid capability token is an ordinary, expected outcome a caller needs
67
+ // to branch on cleanly, not an exceptional program error.
68
+ export function decodeCapabilityToken(token, publicKeyPem, { now = Math.floor(Date.now() / 1000) } = {}) {
69
+ if (typeof token !== 'string' || !token.startsWith(PREFIX)) return { ok: false, reason: 'missing sbf2_ prefix' };
70
+ if (token.length > MAX_CAPABILITY_TOKEN_LENGTH) return { ok: false, reason: `token exceeds the maximum length of ${MAX_CAPABILITY_TOKEN_LENGTH} characters` };
71
+
72
+ let envelope;
73
+ try {
74
+ const raw = base64urlDecode(token.slice(PREFIX.length)).toString('utf8');
75
+ envelope = JSON.parse(raw);
76
+ } catch (err) {
77
+ return { ok: false, reason: `malformed token: ${err.message}` };
78
+ }
79
+ if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) return { ok: false, reason: 'malformed token: not an object' };
80
+ const { payload, sig } = envelope;
81
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return { ok: false, reason: 'malformed token: missing payload' };
82
+ if (typeof sig !== 'string' || !sig) return { ok: false, reason: 'malformed token: missing signature' };
83
+
84
+ if (!verifyPayload(payload, sig, publicKeyPem)) return { ok: false, reason: 'invalid signature' };
85
+
86
+ const { iss, aud, exp, scope } = payload;
87
+ if (typeof iss !== 'string' || !iss) return { ok: false, reason: 'malformed payload: missing iss' };
88
+ if (typeof aud !== 'string' || !aud) return { ok: false, reason: 'malformed payload: missing aud' };
89
+ if (typeof exp !== 'number' || !Number.isFinite(exp)) return { ok: false, reason: 'malformed payload: missing exp' };
90
+ if (scope === undefined) return { ok: false, reason: 'malformed payload: missing scope' };
91
+ if (exp <= now) return { ok: false, reason: 'expired' };
92
+
93
+ return { ok: true, payload: { iss, aud, exp, scope } };
94
+ }
package/handles/codec.mjs CHANGED
@@ -53,10 +53,20 @@ export function encodeHandle({ kind, type, uuid, pointer = null }) {
53
53
  return `sbf1_${base64url(Buffer.from(raw, 'utf8'))}`;
54
54
  }
55
55
 
56
+ // D-handle-identity-contract-freeze (Phase 3): scheme-dispatch table, not a hardcoded 'sbf1_'
57
+ // check -- reserves the discriminant for a future `sbf2_` scheme (Phase 6, capability-scoped
58
+ // handles) to register itself here additively, without ever changing how an existing 'sbf1_'
59
+ // token decodes. encodeHandle() is untouched -- it still only ever emits 'sbf1_' tokens.
60
+ const HANDLE_DECODERS = { sbf1: decodeSbf1Handle };
61
+
56
62
  export function decodeHandle(token) {
57
- if (typeof token !== 'string' || !token.startsWith('sbf1_')) {
58
- throw new Error('not an sbf1 handle (missing "sbf1_" prefix)');
59
- }
63
+ const scheme = typeof token === 'string' && token.includes('_') ? token.slice(0, token.indexOf('_')) : token;
64
+ const decoder = typeof scheme === 'string' ? HANDLE_DECODERS[scheme] : undefined;
65
+ if (!decoder) throw new Error('not an sbf1 handle (missing "sbf1_" prefix)');
66
+ return decoder(token);
67
+ }
68
+
69
+ function decodeSbf1Handle(token) {
60
70
  if (token.length > MAX_HANDLE_TOKEN_LENGTH) {
61
71
  throw new Error(`handle token exceeds the maximum length of ${MAX_HANDLE_TOKEN_LENGTH} characters`);
62
72
  }
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
- import { emitUnits, unifiedDiff } from '../../_engine.mjs';
4
+ import { emitUnits } from '../../_engine.mjs';
5
5
  import { loadPatchApprovals, approvedStrategyFor } from '../../../lib/patch-approvals.mjs';
6
6
  import { computeCodegenNeeds, renderPatchFieldBody } from './patch-strategy.mjs';
7
7
  import { sha256File } from '../../../lib/fsutil.mjs';
@@ -56,12 +56,54 @@ function lowerFirst(s) {
56
56
  // the primary `ObjectMapper` bean, which is what `@RequiredArgsConstructor` injection needs.
57
57
  // Defaults to the classic package when build.gradle/the plugin version can't be found -- covers
58
58
  // the far more common Spring Boot <=3.x case, and matches this project's own CI fixture corpus.
59
- export function detectJacksonPackage(repoRoot) {
59
+ // D-write-safety-phase1 (item 1): a disk-verified grep for `spring-boot-starter-aop`, the ONE
60
+ // dependency `HandleAspect.java` needs to actually intercept anything (Spring AOP is not enabled
61
+ // by any other starter) -- `emit.mjs`'s own postEmitNotes has said so since O4, but only AFTER
62
+ // writing code that silently can't work without it. Checks the same 3 build-file names
63
+ // `scanners/adapters/java-spring.mjs`'s own `JAVA_BUILD_FILE_GLOBS` uses (not re-imported --
64
+ // that constant is scanner-internal; duplicating 3 literal filenames here is simpler than adding
65
+ // a cross-module export for them), since a Kotlin DSL or Maven repo is just as real a target as a
66
+ // Groovy one. A simple substring check, not a real dependency-graph parse -- matches this
67
+ // codebase's own "good-enough regex, not a real parser" convention throughout, and a
68
+ // transitively-pulled-in `spring-boot-starter-aop` (via some other starter) is not a real shape
69
+ // this project's own oracle or fixtures have ever exhibited.
70
+ // D-handles-pilot-cohort follow-up: shared by detectJacksonPackage (Jackson 3 vs 2) and
71
+ // springAopArtifactName (spring-boot-starter-aspectj vs -aop) -- both decisions hinge on the same
72
+ // single signal, the Spring Boot Gradle plugin's own declared major version. Returns null (not a
73
+ // guessed default) when build.gradle is absent or the plugin version can't be found -- callers
74
+ // each decide their own "can't tell, assume the more common case" default, since that default
75
+ // differs per caller (Jackson 2 vs the pre-4.x -aop artifact name).
76
+ function detectSpringBootMajorVersion(repoRoot) {
60
77
  const buildGradlePath = path.join(repoRoot, 'build.gradle');
61
- if (!fs.existsSync(buildGradlePath)) return 'com.fasterxml.jackson.databind';
78
+ if (!fs.existsSync(buildGradlePath)) return null;
62
79
  const text = fs.readFileSync(buildGradlePath, 'utf8');
63
80
  const match = text.match(/id\s+['"]org\.springframework\.boot['"]\s+version\s+['"](\d+)\./);
64
- const majorVersion = match ? Number(match[1]) : null;
81
+ return match ? Number(match[1]) : null;
82
+ }
83
+
84
+ // D-handles-pilot-cohort: found live, deploying against a real Spring Boot 4.1.0 target --
85
+ // `spring-boot-starter-aop` does not exist as an artifact for Spring Boot 4.x (confirmed against
86
+ // the real Maven Central BOM POM for spring-boot-dependencies:4.1.0, a 404 for the old artifact);
87
+ // it was renamed to `spring-boot-starter-aspectj`. `hasSpringAopDependency()`'s old hardcoded
88
+ // check would have "passed" a target repo that added the WRONG (nonexistent, for its own Boot
89
+ // version) artifact name, only ever caught by a real compile failure.
90
+ export function springAopArtifactName(repoRoot) {
91
+ const majorVersion = detectSpringBootMajorVersion(repoRoot);
92
+ return majorVersion !== null && majorVersion >= 4 ? 'spring-boot-starter-aspectj' : 'spring-boot-starter-aop';
93
+ }
94
+
95
+ export function hasSpringAopDependency(repoRoot) {
96
+ const artifactName = springAopArtifactName(repoRoot);
97
+ for (const name of ['build.gradle', 'build.gradle.kts', 'pom.xml']) {
98
+ const buildFilePath = path.join(repoRoot, name);
99
+ if (!fs.existsSync(buildFilePath)) continue;
100
+ if (fs.readFileSync(buildFilePath, 'utf8').includes(artifactName)) return true;
101
+ }
102
+ return false;
103
+ }
104
+
105
+ export function detectJacksonPackage(repoRoot) {
106
+ const majorVersion = detectSpringBootMajorVersion(repoRoot);
65
107
  return majorVersion !== null && majorVersion >= 4 ? 'tools.jackson.databind' : 'com.fasterxml.jackson.databind';
66
108
  }
67
109
 
@@ -99,11 +141,6 @@ function currentlyApprovedFields(approvals, resourceType, patchable) {
99
141
  return approved;
100
142
  }
101
143
 
102
- function writeUnit(target, content) {
103
- fs.mkdirSync(path.dirname(target), { recursive: true });
104
- fs.writeFileSync(target, content);
105
- }
106
-
107
144
  // O3 follow-up (D-handle-registry-enforcement, "Continued"): a coarse, per-resource, emit-time,
108
145
  // source-only proxy for "has this resource's own create-flow ever been wired to register a
109
146
  // HandleRegistry row" -- see DECISIONS.md for why this stays static and never queries a live
@@ -136,6 +173,7 @@ export function emitJavaSpring({ repoRoot, featureId, plan, basePackage, resourc
136
173
  // now needs jacksonPackage too, for the same reason patch codegen already did).
137
174
  const patchApprovals = loadPatchApprovals(repoRoot, featureId);
138
175
  const jacksonPackage = detectJacksonPackage(repoRoot);
176
+ const aopArtifactName = springAopArtifactName(repoRoot);
139
177
 
140
178
  const infraUnits = INFRA_FILES.map((f) => ({
141
179
  id: f.template,
@@ -145,7 +183,9 @@ export function emitJavaSpring({ repoRoot, featureId, plan, basePackage, resourc
145
183
  // HandleController.java.tmpl -- applied to every infra file uniformly anyway, matching
146
184
  // how BASE_PACKAGE/JACKSON_PACKAGE already do (render()'s replaceAll is a harmless no-op
147
185
  // for a token absent from a given template, e.g. JACKSON_PACKAGE inside HandleCodec.java.tmpl).
148
- rendered: render(path.join(TEMPLATES_DIR, f.template), { BASE_PACKAGE: basePackage, JACKSON_PACKAGE: jacksonPackage, ENFORCE_REGISTRY: String(enforceRegistry) }),
186
+ // AOP_ARTIFACT_NAME (D-handles-pilot-cohort): same no-op-when-absent reasoning, only
187
+ // referenced by RecordHandleSnapshot.java.tmpl's own javadoc today.
188
+ rendered: render(path.join(TEMPLATES_DIR, f.template), { BASE_PACKAGE: basePackage, JACKSON_PACKAGE: jacksonPackage, AOP_ARTIFACT_NAME: aopArtifactName, ENFORCE_REGISTRY: String(enforceRegistry) }),
149
189
  }));
150
190
 
151
191
  // O4 (D-handle-lifecycle): every resource in THIS feature shares the same contract file and
@@ -245,25 +285,25 @@ export function emitJavaSpring({ repoRoot, featureId, plan, basePackage, resourc
245
285
  resourceTypeOf: (file, _content) => file.replace(/ResolverPolicy\.java$|Resolver\.java$/, ''),
246
286
  } : null;
247
287
 
248
- const result = emitUnits({ repoRoot, featureId, provider: 'java-spring', force, reason, infraUnits, resolverUnits, orphanScan, dryRun, computeDiff });
249
-
250
- // The migration file is regenerated fresh every run, unconditionally, regardless of the
251
- // resolver/infra conflict-block state above -- it has never been manifest-tracked (no
252
- // conflict detection for it at all), matching the pre-G4 behavior exactly. D4: this is exactly
253
- // the `outputs.spec` category P4's conformance harness already had to special-case (see
254
- // handles/conformance.mjs) -- classifyFile() never runs against it, so its create/unchanged/
255
- // update action is derived locally here, tagged `kind: 'spec'` in the actions report so it
256
- // reads distinctly from the manifest-tracked infra/resolver kinds.
257
- const migrationContent = render(MIGRATION_TEMPLATE, { FEATURE_ID: featureId });
288
+ // D-write-safety-phase0 (item 1): migration.sql used to be regenerated fresh every run,
289
+ // unconditionally, with no manifest tracking and no conflict detection at all -- a hand-edited
290
+ // migration (an added index, a NOT NULL, a tenant column) was silently overwritten on the next
291
+ // `handles emit`. It's feature-scoped (specs/<featureId>/handles/migration.sql), not a
292
+ // repo-wide singleton, but its content depends only on featureId (not on the resolver loop's
293
+ // post-write state) -- so it reuses emitUnits()'s postResolverUnits slot (previously
294
+ // typescript-express-only, for resolvers_index.ts; widened to an array in
295
+ // D-typescript-express-registry-parity once that provider needed two units here at once)
296
+ // rather than adding a third write path, with kind/ownership/owner overridden to reflect real
297
+ // feature ownership.
258
298
  const migrationPath = path.join(repoRoot, 'specs', featureId, 'handles', 'migration.sql');
259
- const migrationRelPath = path.relative(repoRoot, migrationPath);
260
- const migrationDiskContent = fs.existsSync(migrationPath) ? fs.readFileSync(migrationPath, 'utf8') : null;
261
- const migrationAction = migrationDiskContent === null ? 'create' : (migrationDiskContent === migrationContent ? 'unchanged' : 'update');
262
- if (!dryRun) writeUnit(migrationPath, migrationContent);
263
- result.written.push(migrationRelPath);
264
- const migrationActionEntry = { path: migrationRelPath, kind: 'spec', action: migrationAction };
265
- if (computeDiff && migrationAction === 'update') migrationActionEntry.diff = unifiedDiff(migrationRelPath, migrationDiskContent, migrationContent);
266
- result.actions.push(migrationActionEntry);
299
+ const result = emitUnits({
300
+ repoRoot, featureId, provider: 'java-spring', force, reason, infraUnits, resolverUnits, orphanScan, dryRun, computeDiff,
301
+ postResolverUnits: [{
302
+ id: 'migration.sql.tmpl', templatePath: MIGRATION_TEMPLATE, targetAbs: migrationPath,
303
+ render: () => render(MIGRATION_TEMPLATE, { FEATURE_ID: featureId }),
304
+ kind: 'migration', ownership: 'feature', owner: featureId,
305
+ }],
306
+ });
267
307
 
268
308
  const postEmitNotes = [
269
309
  'NOT done automatically: applying specs/<id>/handles/migration.sql to any database. Review it and apply yourself.',
@@ -271,20 +311,25 @@ export function emitJavaSpring({ repoRoot, featureId, plan, basePackage, resourc
271
311
  // human applies @RecordHandleSnapshot to a real service method AND the target repo has
272
312
  // this dependency -- never auto-added to build.gradle, same "review and apply yourself"
273
313
  // boundary as the migration note above.
274
- 'NOT done automatically: HandleAspect.java requires spring-boot-starter-aop on your own build.gradle classpath (Spring AOP is not enabled by any other starter). Add it yourself before applying @RecordHandleSnapshot to any service method.',
314
+ `NOT done automatically: HandleAspect.java requires ${aopArtifactName} on your own build.gradle classpath (Spring AOP is not enabled by any other starter; the artifact is named spring-boot-starter-aop before Spring Boot 4, spring-boot-starter-aspectj from Spring Boot 4 on -- ${aopArtifactName} matches what this repo's own detected Spring Boot version needs). Add it yourself before applying @RecordHandleSnapshot to any service method.`,
275
315
  ];
276
316
  // O3 follow-up (D-handle-registry-enforcement, "Continued"): per-resource, conditional on
277
317
  // enforceRegistry actually being on -- see hasRecordHandleSnapshot() above.
318
+ // D-write-safety-phase1 (item 2): now ALSO collected into registrationGaps (a real, structured
319
+ // array), not just prose -- the postEmitNote stays unconditional (still visible even when
320
+ // --force acknowledges it) so the CLI layer (bin/bskel.mjs) can decide whether an unacknowledged
321
+ // gap should block the command, without this function knowing anything about --force itself.
322
+ const registrationGaps = [];
278
323
  if (enforceRegistry) {
279
324
  for (const resource of plan.resources) {
280
325
  if (!resource.willGenerateResolver) continue;
281
326
  if (hasRecordHandleSnapshot(resource.service.file)) continue;
282
327
  const relServiceFile = path.relative(repoRoot, resource.service.file);
283
- postEmitNotes.push(
284
- `${resource.type}: --enforce-registry is on, but no @RecordHandleSnapshot(...) was found anywhere in ${relServiceFile} -- this resource may never get its first HandleRegistry row, and every fetch()/patch() call against it will 404 until something registers it. Apply @RecordHandleSnapshot to ${resource.service.serviceType}'s own create-flow method (or call HandleService.register() by hand at least once per resource), then re-emit. See D-handle-registry-enforcement in DECISIONS.md for the full bootstrapping explanation.`,
285
- );
328
+ const note = `${resource.type}: --enforce-registry is on, but no @RecordHandleSnapshot(...) was found anywhere in ${relServiceFile} -- this resource may never get its first HandleRegistry row, and every fetch()/patch() call against it will 404 until something registers it. Apply @RecordHandleSnapshot to ${resource.service.serviceType}'s own create-flow method (or call HandleService.register() by hand at least once per resource), then re-emit. See D-handle-registry-enforcement in DECISIONS.md for the full bootstrapping explanation.`;
329
+ postEmitNotes.push(note);
330
+ registrationGaps.push({ resourceType: resource.type, file: relServiceFile, note });
286
331
  }
287
332
  }
288
333
 
289
- return { ...result, postEmitNotes };
334
+ return { ...result, postEmitNotes, registrationGaps };
290
335
  }
@@ -62,9 +62,10 @@ export function emitObserveJavaSpring({ repoRoot, featureId, contract, basePacka
62
62
  const result = emitUnits({ repoRoot, featureId, provider: 'java-spring', force, reason, infraUnits, resolverUnits: [], orphanScan: null, dryRun, computeDiff });
63
63
 
64
64
  // The projected observed-schema.json classpath resource -- regenerated unconditionally every
65
- // run, like handles' own migration.sql, and for the identical reason: nobody hand-finishes a
66
- // generated data file the way they hand-finish a resolver stub, so O2-style conflict tracking
67
- // buys nothing here. `kind: 'spec'` matches migration.sql's own action-reporting convention.
65
+ // run (unlike handles' own migration.sql, which moved to manifest tracking in
66
+ // D-write-safety-phase0 -- this file stays unconditional: nobody hand-finishes a generated
67
+ // data file the way they hand-finish a resolver stub, so O2-style conflict tracking buys
68
+ // nothing here). `kind: 'spec'` still means "always regenerated, not conflict-tracked".
68
69
  const operations = {};
69
70
  for (const [opId, opContract] of Object.entries(contract.operations)) {
70
71
  operations[opId] = projectOperation(opContract);
@@ -77,9 +77,18 @@ function findRequestBodyTypeName(controllerFilePath, methodName) {
77
77
  // during this item's grounding). A DTO living somewhere else is a documented gap: patchable stays
78
78
  // empty for that resource, exactly like findServiceFile()'s own "resolver NOT generated" fallback
79
79
  // for a service that can't be found.
80
+ // D-write-safety-phase1 (item 4b): a real, bounded fallback -- after the domain/<module>/
81
+ // presentation/dto/ convention (Team-IZ-Backend's own shape) fails, also try the DTO directly
82
+ // under <module>/ with no domain/presentation/dto middle segments, the natural flat-package
83
+ // variant of the same convention (javaSrcRoot is already base-package-anchored -- see
84
+ // detectBasePackage() -- so this is `<basePackage>/<module>/<Type>.java`, not a second guessed
85
+ // base). Does not attempt any other shape: no second real oracle has ever validated one, and
86
+ // guessing further risks W9-style overfitting to an imagined repo rather than a confirmed one.
80
87
  function findUpdateDtoFile(javaSrcRoot, module, dtoTypeName) {
81
- const guessedPath = path.join(javaSrcRoot, 'domain', module, 'presentation', 'dto', `${dtoTypeName}.java`);
82
- return fs.existsSync(guessedPath) ? guessedPath : null;
88
+ const conventional = path.join(javaSrcRoot, 'domain', module, 'presentation', 'dto', `${dtoTypeName}.java`);
89
+ if (fs.existsSync(conventional)) return conventional;
90
+ const flat = path.join(javaSrcRoot, module, `${dtoTypeName}.java`);
91
+ return fs.existsSync(flat) ? flat : null;
83
92
  }
84
93
 
85
94
  // The full patchable-field pipeline for one entity: find its update endpoint -> find the
@@ -194,10 +203,20 @@ function findRequiredAuthority(controllerFilePath, methodName) {
194
203
  // guaranteed for every entity): <Entity>Service under domain/<module>/application/. Only
195
204
  // trusted if the file actually exists -- see D-resolver-scope in DECISIONS.md for why a
196
205
  // resolver is only generated when this resolves to a real file, not a guessed import.
206
+ // D-write-safety-phase1 (item 4b): falls back to <module>/<Entity>Service.java directly under
207
+ // javaSrcRoot (no domain/application segments) when the conventional path doesn't exist -- the
208
+ // flat-package variant of the same convention. Confirmed this does NOT close the real-world case
209
+ // that motivated it (spring-projects/spring-petclinic): petclinic has no *Service.java at all
210
+ // (controllers call a Spring Data repository directly), and its entities are Integer-keyed, not
211
+ // UUID (see idFieldIsUuid's own gate in planHandles() below, which fires first regardless). This
212
+ // is a real, independent improvement for a different, plausible shape -- a UUID-keyed entity with
213
+ // a Service layer, just not nested under domain/ -- not a claim that it closes the petclinic gap.
197
214
  function findServiceFile(javaSrcRoot, module, entityClassName) {
198
215
  const guessedType = `${entityClassName}Service`;
199
- const guessedPath = path.join(javaSrcRoot, 'domain', module, 'application', `${guessedType}.java`);
200
- return fs.existsSync(guessedPath) ? { serviceType: guessedType, file: guessedPath } : null;
216
+ const conventional = path.join(javaSrcRoot, 'domain', module, 'application', `${guessedType}.java`);
217
+ if (fs.existsSync(conventional)) return { serviceType: guessedType, file: conventional };
218
+ const flat = path.join(javaSrcRoot, module, `${guessedType}.java`);
219
+ return fs.existsSync(flat) ? { serviceType: guessedType, file: flat } : null;
201
220
  }
202
221
 
203
222
  // Counts top-level commas in a captured argument list, treating `<...>` (generics) as non-
@@ -246,10 +265,26 @@ export function planHandles({ javaSrcRoot, scanReport, module: moduleName, resou
246
265
 
247
266
  for (const entity of targetModule.entities) {
248
267
  if (resourceFilter && !resourceFilter.includes(entity.className)) continue;
268
+
269
+ // D-write-safety-phase1 (item 4a): a non-UUID primary key disqualifies resolver generation
270
+ // entirely, independent of whether a Service class can be found -- fetch(UUID resourceUid)
271
+ // and the sbf1_ handle token format both hard-assume a UUID identity. Checked BEFORE the
272
+ // service lookup so the note names the real, decisive reason instead of the generic "no
273
+ // XService found" note below, which would be true but misleading here (implies the fix is
274
+ // finding/writing a service, when no service could ever make this entity addressable).
275
+ // `=== false` (not just falsy) deliberately excludes `null` (idField itself was never
276
+ // found, e.g. inherited from an unindexed superclass) -- that stays the existing, separate
277
+ // "no XService found" path unchanged, since this scanner genuinely doesn't know the type
278
+ // there, not that it's confirmed non-UUID.
279
+ const pkIsNonUuid = entity.idFieldIsUuid === false;
280
+ if (pkIsNonUuid) {
281
+ notes.push(`${entity.className}: primary key is declared \`${entity.idFieldType}\`, not UUID -- the handles subsystem only generates UUID-addressable resolvers (fetch(UUID resourceUid); the sbf1_ handle token format encodes a UUID). Resolver NOT generated for this entity, and cannot be regardless of where its service file lives. See D-handle-uid-type-binding in DECISIONS.md.`);
282
+ }
283
+
249
284
  const fetchOp = findFetchOperation(targetModule.controllers, entity.className);
250
285
  const authorityResult = findRequiredAuthority(fetchOp?.controllerFile ?? null, fetchOp?.method ?? null);
251
286
  const requiredAuthority = authorityResult.authority;
252
- const service = findServiceFile(javaSrcRoot, targetModule.module, entity.className);
287
+ const service = pkIsNonUuid ? null : findServiceFile(javaSrcRoot, targetModule.module, entity.className);
253
288
  const serviceParamCount = (service && fetchOp) ? countServiceMethodParams(service.file, fetchOp.method) : null;
254
289
 
255
290
  // O5 (D-resolver-authorization-action-aware): the SAME extraction, run again against the
@@ -276,12 +311,16 @@ export function planHandles({ javaSrcRoot, scanReport, module: moduleName, resou
276
311
  notes.push(`${entity.className}: no method-level or class-level @PreAuthorize(hasRole(...)/hasAuthority(...)) found for ${updateOpForAuthority.controllerClassName}.${updateOpForAuthority.method} -- requiredAuthorityForPatch() defaults to "TODO_ROLE", fix before relying on it`);
277
312
  }
278
313
  if (!service) {
279
- notes.push(`${entity.className}: no ${entity.className}Service found under domain/${targetModule.module}/application/ -- resolver NOT generated for this entity (would produce a broken import). Emit it by hand once the right service is identified.`);
314
+ // pkIsNonUuid already explained the real reason above -- this note would be true but
315
+ // redundant (and misleading: it implies finding a service would fix it).
316
+ if (!pkIsNonUuid) {
317
+ notes.push(`${entity.className}: no ${entity.className}Service found under domain/${targetModule.module}/application/ or ${targetModule.module}/ -- resolver NOT generated for this entity (would produce a broken import). Emit it by hand once the right service is identified.`);
318
+ }
280
319
  } else if (fetchOp && serviceParamCount !== 1) {
281
320
  const reason = serviceParamCount === null
282
321
  ? `could not find a ${fetchOp.method}(...) method on ${service.serviceType} to confirm its argument count`
283
322
  : `${service.serviceType}.${fetchOp.method} takes ${serviceParamCount} argument(s), not the single resource UUID the generated resolver always passes`;
284
- notes.push(`${entity.className}: ${reason} -- resolver NOT generated (would either fail to compile or silently call the wrong overload and drop a required scoping argument, e.g. an organization/cohort id). Wire it by hand.`);
323
+ notes.push(`${entity.className}: ${reason} -- resolver NOT generated (would either fail to compile or silently call the wrong overload and drop a required scoping argument, e.g. an organization/cohort id). Wire it by hand -- ResourceResolver#fetch/#patchField receive the request's Authentication (D-resolver-authentication-context) for exactly this case, e.g. deriving a tenant/org id the same way the resource's own controller already does.`);
285
324
  }
286
325
 
287
326
  // A3 (D-patch-strategy): only worth computing once fetch()/the resolver itself is actually
@@ -319,6 +358,11 @@ export function planHandles({ javaSrcRoot, scanReport, module: moduleName, resou
319
358
  type: entity.className,
320
359
  table: entity.table,
321
360
  idField: entity.idField,
361
+ // D-write-safety-phase1 (item 4a): surfaced in --json output too, not just the note --
362
+ // null/null when the type genuinely couldn't be determined (not the same as confirmed
363
+ // non-UUID; see pkIsNonUuid's own `=== false` check above).
364
+ idFieldType: entity.idFieldType,
365
+ idFieldIsUuid: entity.idFieldIsUuid,
322
366
  fetchOperation: fetchOp,
323
367
  updateOperation: patchResult.updateOperation,
324
368
  patchable: patchResult.patchable,