backend-skeleton 1.0.0-beta.9 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +185 -13
  2. package/bin/bskel.mjs +689 -33
  3. package/contracts/emit.mjs +5 -1
  4. package/contracts/export.mjs +65 -7
  5. package/contracts/openapi.mjs +321 -30
  6. package/contracts/validate.mjs +23 -4
  7. package/handles/_engine.mjs +123 -30
  8. package/handles/capability-codec.mjs +94 -0
  9. package/handles/codec.mjs +13 -3
  10. package/handles/providers/java-spring/emit.mjs +78 -33
  11. package/handles/providers/java-spring/observe.mjs +4 -3
  12. package/handles/providers/java-spring/plan.mjs +73 -16
  13. package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +19 -1
  14. package/handles/providers/java-spring/templates/HandleController.java.tmpl +19 -9
  15. package/handles/providers/java-spring/templates/HandleService.java.tmpl +21 -2
  16. package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +1 -1
  17. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +36 -9
  18. package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +8 -2
  19. package/handles/providers/java-spring.mjs +8 -0
  20. package/handles/providers/python-fastapi/emit.mjs +21 -26
  21. package/handles/providers/python-fastapi/observe.mjs +6 -5
  22. package/handles/providers/python-fastapi/templates/codec.py.tmpl +18 -3
  23. package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +129 -39
  24. package/handles/providers/python-fastapi.mjs +3 -3
  25. package/handles/providers/typescript-express/emit.mjs +144 -55
  26. package/handles/providers/typescript-express/observe.mjs +102 -0
  27. package/handles/providers/typescript-express/templates/codec.ts.tmpl +13 -3
  28. package/handles/providers/typescript-express/templates/contractCheck.ts.tmpl +136 -0
  29. package/handles/providers/typescript-express/templates/handleEntities.ts.tmpl +89 -0
  30. package/handles/providers/typescript-express/templates/handleService.ts.tmpl +81 -0
  31. package/handles/providers/typescript-express/templates/migration.sql.tmpl +36 -0
  32. package/handles/providers/typescript-express/templates/observeContract.ts.tmpl +146 -0
  33. package/handles/providers/typescript-express/templates/observedSchema.ts.tmpl +116 -0
  34. package/handles/providers/typescript-express/templates/recordSnapshotWrapper.ts.tmpl +123 -0
  35. package/handles/providers/typescript-express/templates/registry.ts.tmpl +19 -10
  36. package/handles/providers/typescript-express/templates/resolver.ts.tmpl +13 -0
  37. package/handles/providers/typescript-express/templates/resolverPolicy.ts.tmpl +20 -0
  38. package/handles/providers/typescript-express/templates/router.ts.tmpl +113 -2
  39. package/handles/providers/typescript-express.mjs +7 -4
  40. package/lib/attest.mjs +40 -0
  41. package/lib/cli.mjs +136 -5
  42. package/lib/cross-feature-collisions.mjs +286 -0
  43. package/lib/diff.mjs +35 -0
  44. package/lib/exit-codes.mjs +21 -0
  45. package/lib/fsutil.mjs +7 -2
  46. package/lib/gate-definitions.mjs +85 -1
  47. package/lib/gates.mjs +5 -1
  48. package/lib/http-server.mjs +192 -6
  49. package/lib/lock.mjs +68 -15
  50. package/lib/patch-kinds.mjs +52 -0
  51. package/lib/patch-transactions.mjs +206 -0
  52. package/lib/serve-ui.html +211 -0
  53. package/lib/verify.mjs +23 -6
  54. package/lib/workflow.mjs +31 -3
  55. package/package.json +8 -2
  56. package/scanners/adapters/_java-spring-analyzer.mjs +9 -1
  57. package/scanners/adapters/java-spring.mjs +114 -10
  58. package/scanners/adapters/javascript-express.mjs +46 -13
  59. package/scanners/adapters/python-fastapi.mjs +9 -1
  60. package/scanners/adapters/typescript-express.mjs +19 -2
  61. package/scanners/db/ddl-apply.mjs +253 -0
  62. package/scanners/db/introspect.mjs +61 -32
  63. package/scanners/db/migrations.mjs +73 -18
  64. package/schemas/cross-feature-report.schema.json +66 -0
  65. package/schemas/cross-feature-resolution.schema.json +28 -0
  66. package/schemas/feature-contract.schema.json +3 -3
  67. package/schemas/gate-attestation.schema.json +22 -0
  68. package/schemas/gate-export.schema.json +58 -0
  69. package/schemas/handles-plan.schema.json +2 -0
  70. package/schemas/oracle-manifest.schema.json +58 -0
  71. package/schemas/patch-transaction.schema.json +182 -0
  72. package/schemas/scan-report.schema.json +6 -4
  73. package/schemas/stack-choice.schema.json +12 -1
  74. package/schemas/stack-record.schema.json +6 -1
  75. package/stack/apply.mjs +51 -7
  76. package/stack/catalog/ngrok.yml +8 -2
  77. package/stack/config-apply.mjs +168 -0
@@ -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
@@ -121,6 +130,7 @@ function planPatchable({ javaSrcRoot, module: moduleName, controllers, entityCla
121
130
  // below already consumes -- findRequiredAuthority()/extractPreAuthorize()/classBodyStart() are
122
131
  // completely unchanged, D-security-7's own region-carving logic untouched.
123
132
  const HAS_ROLE_RE = /@PreAuthorize\(\s*"hasRole\('([^']+)'\)"\s*\)/;
133
+ const HAS_AUTHORITY_RE = /@PreAuthorize\(\s*"hasAuthority\('([^']+)'\)"\s*\)/;
124
134
  const PRE_AUTH_RE = /@PreAuthorize\(/;
125
135
 
126
136
  function methodMappingBoundaries(text) {
@@ -138,14 +148,26 @@ function classBodyStart(text) {
138
148
  }
139
149
 
140
150
  // Returns { authority, unsupported } for an @PreAuthorize search over one region of source text.
141
- // `unsupported: true` means an @PreAuthorize annotation IS present but isn't the simple
142
- // hasRole('X') shape this regex-based scanner understands (hasAnyRole, SpEL, etc.) -- the caller
143
- // must fail closed (TODO_ROLE) rather than silently treating it as "no authority found" and
144
- // falling back to a weaker/wrong source.
151
+ // `unsupported: true` means an @PreAuthorize annotation IS present but isn't one of the two
152
+ // simple shapes this regex-based scanner understands (hasAnyRole, hasAnyAuthority, SpEL, etc.) --
153
+ // the caller must fail closed (TODO_ROLE) rather than silently treating it as "no authority
154
+ // found" and falling back to a weaker/wrong source.
155
+ //
156
+ // O5 (D-resolver-authorization-action-aware, hasAuthority follow-up): hasRole('X') and
157
+ // hasAuthority('X') are NOT interchangeable at the Spring Security level -- hasRole('X') checks
158
+ // for the granted authority "ROLE_X" (an implicit prefix Spring itself applies), hasAuthority('X')
159
+ // checks for "X" verbatim. The returned `authority` string is the LITERAL granted-authority value
160
+ // the generated code must match, decided HERE (plan time), not left for the template to re-derive
161
+ // -- HandleController.java.tmpl's requireAuthority() does one plain equality check regardless of
162
+ // which shape produced the value. This is the real discriminant this item's own EXIT note named:
163
+ // widening the regex alone, without this prefix decision, would generate an incorrect check.
145
164
  function extractPreAuthorize(region) {
146
165
  if (!PRE_AUTH_RE.test(region)) return null;
147
166
  const hasRoleMatch = region.match(HAS_ROLE_RE);
148
- return hasRoleMatch ? { authority: hasRoleMatch[1], unsupported: false } : { authority: null, unsupported: true };
167
+ if (hasRoleMatch) return { authority: `ROLE_${hasRoleMatch[1]}`, unsupported: false };
168
+ const hasAuthorityMatch = region.match(HAS_AUTHORITY_RE);
169
+ if (hasAuthorityMatch) return { authority: hasAuthorityMatch[1], unsupported: false };
170
+ return { authority: null, unsupported: true };
149
171
  }
150
172
 
151
173
  // D-security-7: a controller with more than one method can require DIFFERENT roles per method --
@@ -181,10 +203,20 @@ function findRequiredAuthority(controllerFilePath, methodName) {
181
203
  // guaranteed for every entity): <Entity>Service under domain/<module>/application/. Only
182
204
  // trusted if the file actually exists -- see D-resolver-scope in DECISIONS.md for why a
183
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.
184
214
  function findServiceFile(javaSrcRoot, module, entityClassName) {
185
215
  const guessedType = `${entityClassName}Service`;
186
- const guessedPath = path.join(javaSrcRoot, 'domain', module, 'application', `${guessedType}.java`);
187
- 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;
188
220
  }
189
221
 
190
222
  // Counts top-level commas in a captured argument list, treating `<...>` (generics) as non-
@@ -233,10 +265,26 @@ export function planHandles({ javaSrcRoot, scanReport, module: moduleName, resou
233
265
 
234
266
  for (const entity of targetModule.entities) {
235
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
+
236
284
  const fetchOp = findFetchOperation(targetModule.controllers, entity.className);
237
285
  const authorityResult = findRequiredAuthority(fetchOp?.controllerFile ?? null, fetchOp?.method ?? null);
238
286
  const requiredAuthority = authorityResult.authority;
239
- const service = findServiceFile(javaSrcRoot, targetModule.module, entity.className);
287
+ const service = pkIsNonUuid ? null : findServiceFile(javaSrcRoot, targetModule.module, entity.className);
240
288
  const serviceParamCount = (service && fetchOp) ? countServiceMethodParams(service.file, fetchOp.method) : null;
241
289
 
242
290
  // O5 (D-resolver-authorization-action-aware): the SAME extraction, run again against the
@@ -253,22 +301,26 @@ export function planHandles({ javaSrcRoot, scanReport, module: moduleName, resou
253
301
  if (!fetchOp) {
254
302
  notes.push(`${entity.className}: no single-resource GET endpoint found on a controller whose name contains "${entity.className}" -- fetch() will need to be hand-written`);
255
303
  } else if (authorityResult.unsupported) {
256
- notes.push(`${entity.className}: @PreAuthorize found on ${fetchOp.controllerClassName}.${fetchOp.method} (or its class) but not in the simple hasRole('X') shape this scanner understands (e.g. hasAnyRole/SpEL) -- requiredAuthority() defaults to "TODO_ROLE" (fails closed) until a human fixes it`);
304
+ notes.push(`${entity.className}: @PreAuthorize found on ${fetchOp.controllerClassName}.${fetchOp.method} (or its class) but not in the simple hasRole('X')/hasAuthority('X') shape this scanner understands (e.g. hasAnyRole/SpEL) -- requiredAuthority() defaults to "TODO_ROLE" (fails closed) until a human fixes it`);
257
305
  } else if (!requiredAuthority) {
258
- notes.push(`${entity.className}: no method-level or class-level @PreAuthorize(hasRole(...)) found for ${fetchOp.controllerClassName}.${fetchOp.method} -- requiredAuthority() defaults to "TODO_ROLE", fix before relying on it`);
306
+ notes.push(`${entity.className}: no method-level or class-level @PreAuthorize(hasRole(...)/hasAuthority(...)) found for ${fetchOp.controllerClassName}.${fetchOp.method} -- requiredAuthority() defaults to "TODO_ROLE", fix before relying on it`);
259
307
  }
260
308
  if (updateOpForAuthority && patchAuthorityResult.unsupported) {
261
- notes.push(`${entity.className}: @PreAuthorize found on ${updateOpForAuthority.controllerClassName}.${updateOpForAuthority.method} (or its class) but not in the simple hasRole('X') shape this scanner understands -- requiredAuthorityForPatch() defaults to "TODO_ROLE" (fails closed) until a human fixes it`);
309
+ notes.push(`${entity.className}: @PreAuthorize found on ${updateOpForAuthority.controllerClassName}.${updateOpForAuthority.method} (or its class) but not in the simple hasRole('X')/hasAuthority('X') shape this scanner understands -- requiredAuthorityForPatch() defaults to "TODO_ROLE" (fails closed) until a human fixes it`);
262
310
  } else if (updateOpForAuthority && !requiredAuthorityForPatch) {
263
- notes.push(`${entity.className}: no method-level or class-level @PreAuthorize(hasRole(...)) found for ${updateOpForAuthority.controllerClassName}.${updateOpForAuthority.method} -- requiredAuthorityForPatch() defaults to "TODO_ROLE", fix before relying on it`);
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`);
264
312
  }
265
313
  if (!service) {
266
- 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
+ }
267
319
  } else if (fetchOp && serviceParamCount !== 1) {
268
320
  const reason = serviceParamCount === null
269
321
  ? `could not find a ${fetchOp.method}(...) method on ${service.serviceType} to confirm its argument count`
270
322
  : `${service.serviceType}.${fetchOp.method} takes ${serviceParamCount} argument(s), not the single resource UUID the generated resolver always passes`;
271
- 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.`);
272
324
  }
273
325
 
274
326
  // A3 (D-patch-strategy): only worth computing once fetch()/the resolver itself is actually
@@ -306,6 +358,11 @@ export function planHandles({ javaSrcRoot, scanReport, module: moduleName, resou
306
358
  type: entity.className,
307
359
  table: entity.table,
308
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,
309
366
  fetchOperation: fetchOp,
310
367
  updateOperation: patchResult.updateOperation,
311
368
  patchable: patchResult.patchable,
@@ -64,10 +64,28 @@ public final class HandleCodec {
64
64
  return "sbf1_" + Base64.getUrlEncoder().withoutPadding().encodeToString(raw.getBytes(StandardCharsets.UTF_8));
65
65
  }
66
66
 
67
+ /**
68
+ * D-handle-identity-contract-freeze (Phase 3): scheme-dispatch, not a hardcoded {@code sbf1_}
69
+ * check -- reserves the discriminant for a future {@code sbf2_} scheme (Phase 6,
70
+ * capability-scoped handles) to register itself here additively, without ever changing how an
71
+ * existing {@code sbf1_} token decodes. {@link #encode} is untouched -- it still only ever
72
+ * emits {@code sbf1_} tokens.
73
+ */
67
74
  public static Decoded decode(String token) {
68
- if (token == null || !token.startsWith("sbf1_")) {
75
+ String scheme = scheme(token);
76
+ if (!"sbf1".equals(scheme)) {
69
77
  throw new IllegalArgumentException("not an sbf1 handle (missing \"sbf1_\" prefix)");
70
78
  }
79
+ return decodeSbf1(token);
80
+ }
81
+
82
+ private static String scheme(String token) {
83
+ if (token == null) return null;
84
+ int sep = token.indexOf('_');
85
+ return sep == -1 ? token : token.substring(0, sep);
86
+ }
87
+
88
+ private static Decoded decodeSbf1(String token) {
71
89
  if (token.length() > MAX_HANDLE_TOKEN_LENGTH) {
72
90
  throw new IllegalArgumentException("handle token exceeds the maximum length of " + MAX_HANDLE_TOKEN_LENGTH + " characters");
73
91
  }
@@ -4,6 +4,7 @@ import {{JACKSON_PACKAGE}}.JsonNode;
4
4
  import {{JACKSON_PACKAGE}}.ObjectMapper;
5
5
  import lombok.RequiredArgsConstructor;
6
6
  import org.springframework.http.ResponseEntity;
7
+ import org.springframework.security.core.Authentication;
7
8
  import org.springframework.security.core.GrantedAuthority;
8
9
  import org.springframework.security.core.context.SecurityContextHolder;
9
10
  import org.springframework.http.HttpStatus;
@@ -58,12 +59,13 @@ public class HandleController {
58
59
  public ResponseEntity<Object> fetch(@PathVariable String handle) {
59
60
  HandleCodec.Decoded decoded = decodeOrThrow(handle);
60
61
  ResourceResolver resolver = resolverFor(decoded.type());
61
- requireAuthority(resolver.requiredAuthority());
62
+ Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
63
+ requireAuthority(authentication, resolver.requiredAuthority());
62
64
  if (ENFORCE_REGISTRY) {
63
65
  requireRegisteredOrThrow(decoded);
64
66
  }
65
67
 
66
- Object resource = resolver.fetch(decoded.uuid());
68
+ Object resource = resolver.fetch(decoded.uuid(), authentication);
67
69
  if (decoded.pointer() == null) {
68
70
  return ResponseEntity.ok(resource);
69
71
  }
@@ -95,15 +97,16 @@ public class HandleController {
95
97
  throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "cannot PATCH a resource-level handle (kind=r) -- only field handles (kind=f) support PATCH");
96
98
  }
97
99
  ResourceResolver resolver = resolverFor(decoded.type());
100
+ Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
98
101
  // O5 (D-resolver-authorization-action-aware): the PATCH-specific authority, independently
99
102
  // derived from the entity's own UPDATE endpoint -- NOT resolver.requiredAuthority(), which
100
103
  // is fetch()/recover()'s own value and may legitimately require a different role.
101
- requireAuthority(resolver.requiredAuthorityForPatch());
104
+ requireAuthority(authentication, resolver.requiredAuthorityForPatch());
102
105
  if (ENFORCE_REGISTRY) {
103
106
  requireRegisteredOrThrow(decoded);
104
107
  }
105
108
 
106
- resolver.patchField(decoded.uuid(), decoded.pointer(), value);
109
+ resolver.patchField(decoded.uuid(), decoded.pointer(), value, authentication);
107
110
  return ResponseEntity.noContent().build();
108
111
  }
109
112
 
@@ -111,7 +114,7 @@ public class HandleController {
111
114
  public ResponseEntity<?> recover(@PathVariable String handle, @RequestParam(required = false) Instant at) {
112
115
  HandleCodec.Decoded decoded = decodeOrThrow(handle);
113
116
  ResourceResolver resolver = resolverFor(decoded.type());
114
- requireAuthority(resolver.requiredAuthority());
117
+ requireAuthority(SecurityContextHolder.getContext().getAuthentication(), resolver.requiredAuthority());
115
118
 
116
119
  // D-security-9 / O3 (D-handle-registry-enforcement): recover() structurally REQUIRES a
117
120
  // registry row to find a snapshot's own primary key -- there is no "unenforced" mode for
@@ -200,12 +203,19 @@ public class HandleController {
200
203
  .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "no active registration for this handle"));
201
204
  }
202
205
 
203
- private void requireAuthority(String requiredRole) {
204
- boolean granted = SecurityContextHolder.getContext().getAuthentication().getAuthorities().stream()
206
+ // O5 (D-resolver-authorization-action-aware, hasAuthority follow-up): a plain equality check --
207
+ // the ROLE_ prefix Spring Security implicitly applies for hasRole('X') (but not hasAuthority('X'))
208
+ // is already baked into requiredAuthority by plan.mjs's extractPreAuthorize() at handles-plan
209
+ // time, so this method never needs to know which source annotation shape produced the value.
210
+ // D-resolver-authentication-context: takes the already-fetched Authentication (callers now need
211
+ // it themselves, to thread into resolver.fetch()/patchField() too) rather than re-reading
212
+ // SecurityContextHolder a second time per request.
213
+ private void requireAuthority(Authentication authentication, String requiredAuthority) {
214
+ boolean granted = authentication.getAuthorities().stream()
205
215
  .map(GrantedAuthority::getAuthority)
206
- .anyMatch(authority -> authority.equals("ROLE_" + requiredRole));
216
+ .anyMatch(authority -> authority.equals(requiredAuthority));
207
217
  if (!granted) {
208
- throw new ResponseStatusException(HttpStatus.FORBIDDEN, "requires role " + requiredRole);
218
+ throw new ResponseStatusException(HttpStatus.FORBIDDEN, "requires authority " + requiredAuthority);
209
219
  }
210
220
  }
211
221
  }
@@ -3,6 +3,7 @@ package {{BASE_PACKAGE}}.global.handle;
3
3
  import {{JACKSON_PACKAGE}}.ObjectMapper;
4
4
  import lombok.RequiredArgsConstructor;
5
5
  import org.springframework.stereotype.Service;
6
+ import org.springframework.transaction.annotation.Propagation;
6
7
  import org.springframework.transaction.annotation.Transactional;
7
8
 
8
9
  import java.time.Instant;
@@ -38,8 +39,23 @@ public class HandleService {
38
39
  * unique-constraint check. Returns the encoded handle token ({@link HandleCodec#encode},
39
40
  * already implemented -- "minting" a token was never the missing piece, persisting the row
40
41
  * backing it was).
42
+ *
43
+ * <p>D-handles-pilot-cohort: {@code REQUIRES_NEW}, not the default {@code REQUIRED} --
44
+ * {@link HandleAspect} calls this from WITHIN the wrapped business method's own transaction
45
+ * (e.g. a {@code @Transactional(readOnly = true)} read path, a real, unremarkable shape found
46
+ * live against {@code CohortService}). Under the default propagation this PARTICIPATES in that
47
+ * transaction rather than starting a new one; if it fails (readOnly rejects the INSERT, or any
48
+ * other real constraint), the whole shared transaction/connection is marked aborted by the
49
+ * database, and {@link HandleAspect#safely}'s {@code catch (Exception e)} genuinely swallows
50
+ * THIS method's own exception -- but the WRAPPED business call still fails when it next touches
51
+ * that same aborted connection, directly contradicting {@code safely()}'s own documented
52
+ * promise ("the wrapped call proceeds unaffected"). Confirmed live: a real disposable Postgres
53
+ * reproduced exactly this with {@code CohortService.findCohort} (readOnly) before this fix.
54
+ * {@code REQUIRES_NEW} makes this a genuinely separate transaction/connection, so a failure
55
+ * here can never propagate into the caller's own transaction -- matching what "best-effort,
56
+ * never propagated" already claimed to guarantee.
41
57
  */
42
- @Transactional
58
+ @Transactional(propagation = Propagation.REQUIRES_NEW)
43
59
  public String register(String kind, String type, UUID resourceUid, String pointer, UUID featureUid, String operationId, String contractRef) {
44
60
  String token = HandleCodec.encode(kind, type, resourceUid, pointer);
45
61
  UUID handleUid = HandleCodec.deriveHandleUid(kind, type, resourceUid, pointer);
@@ -58,8 +74,11 @@ public class HandleService {
58
74
  * object (a DTO, a request body, an error shape), never a pre-serialized string, so this is
59
75
  * the one place that decides how it becomes the JSON text {@code sbf_handle_snapshot.payload}
60
76
  * stores.
77
+ *
78
+ * <p>{@code REQUIRES_NEW} for the same reason {@link #register} now uses it -- see that
79
+ * method's own javadoc.
61
80
  */
62
- @Transactional
81
+ @Transactional(propagation = Propagation.REQUIRES_NEW)
63
82
  public void recordSnapshot(UUID handleUid, String envelopeDir, String operationId, String contractHash, Object payload) {
64
83
  // A generic `catch (Exception e)` (not the Jackson-2-specific checked
65
84
  // JsonProcessingException, nor assuming Jackson 3's unchecked JacksonException) compiles
@@ -29,7 +29,7 @@ import java.lang.annotation.Target;
29
29
  * }</pre>
30
30
  *
31
31
  * <p>Generated by backend-skeleton ({@code bskel handles emit}). Requires {@code
32
- * spring-boot-starter-aop} on the target repo's own classpath -- NOT added automatically (see
32
+ * {{AOP_ARTIFACT_NAME}}} on the target repo's own classpath -- NOT added automatically (see
33
33
  * {@code handles emit}'s own post-emit notes), matching this project's established boundary of
34
34
  * never auto-editing a target's build file.
35
35
  */
@@ -1,5 +1,7 @@
1
1
  package {{BASE_PACKAGE}}.global.handle;
2
2
 
3
+ import org.springframework.security.core.Authentication;
4
+
3
5
  import java.util.UUID;
4
6
 
5
7
  /**
@@ -19,8 +21,24 @@ public interface ResourceResolver {
19
21
  /** Must match the {@code type} segment used when this resource's handles were minted (e.g. "Organization"). */
20
22
  String type();
21
23
 
22
- /** Fetches the whole resource, serialized the same shape its normal API response uses. */
23
- Object fetch(UUID resourceUid);
24
+ /**
25
+ * Fetches the whole resource, serialized the same shape its normal API response uses.
26
+ *
27
+ * <p>{@code authentication} is the SAME object {@link HandleController} already reads via
28
+ * {@code SecurityContextHolder.getContext().getAuthentication()} for its own {@code
29
+ * requireAuthority()} check, passed through verbatim -- for the common case (role-only
30
+ * authorization, already fully covered by {@link #requiredAuthority()}) implementations
31
+ * ignore this parameter entirely. It exists for resources whose real read path is scoped by
32
+ * something {@code requiredAuthority()}'s single role string cannot express -- tenant/
33
+ * organization/ownership scoping derived from the caller's own identity (e.g. an org id
34
+ * carried in {@code authentication.getDetails()}), the same information the resource's real,
35
+ * hand-written controller already reads from {@code Authentication} to enforce that scoping
36
+ * itself. The framework interprets nothing here and never will -- same "never guess-modify
37
+ * hand-written code" boundary {@link #patchField} already follows; a hand-written resolver
38
+ * derives whatever it needs from this object using the target app's own existing helper (e.g.
39
+ * mirroring the source controller's own extraction logic), never a guess this generator makes.
40
+ */
41
+ Object fetch(UUID resourceUid, Authentication authentication);
24
42
 
25
43
  /**
26
44
  * Applies a single field-level patch. Implementations route through the resource's EXISTING
@@ -29,16 +47,25 @@ public interface ResourceResolver {
29
47
  * this feature's target module for the specific pattern to follow (some DTOs use {@code
30
48
  * PatchField<T>} for null-has-meaning fields, most just treat null-vs-absent as "unchanged",
31
49
  * and some require re-submitting the whole object; check the target DTO before assuming).
50
+ *
51
+ * <p>{@code authentication}: see {@link #fetch}'s own javadoc -- same object, same purpose,
52
+ * ignored by implementations that don't need it.
32
53
  */
33
- void patchField(UUID resourceUid, String pointer, Object value);
54
+ void patchField(UUID resourceUid, String pointer, Object value, Authentication authentication);
34
55
 
35
56
  /**
36
- * Spring Security role name (no "ROLE_" prefix) required to {@code fetch}/{@code recover}
37
- * this resource type via a handle. O5 (D-resolver-authorization-action-aware): derived from
38
- * the entity's FETCH (GET) endpoint's own {@code @PreAuthorize} -- deliberately NOT reused
39
- * for {@link #patchField}, which has its own {@link #requiredAuthorityForPatch()} derived
40
- * from the entity's real UPDATE endpoint instead, since a real app's GET and PATCH endpoints
41
- * can (and often do) require genuinely different roles.
57
+ * The literal Spring Security granted-authority string required to {@code fetch}/{@code
58
+ * recover} this resource type via a handle -- e.g. {@code "ROLE_ADMIN"} when the source
59
+ * {@code @PreAuthorize} was {@code hasRole('ADMIN')} (Spring implicitly applies the
60
+ * {@code ROLE_} prefix), or a bare value like {@code "ADMIN"} when the source was
61
+ * {@code hasAuthority('ADMIN')} (no prefix). This distinction is decided once, at
62
+ * {@code bskel handles plan} time, by {@code extractPreAuthorize()} -- callers of this method
63
+ * never need to know which source annotation shape produced the value; they compare it
64
+ * verbatim against a granted authority. O5 (D-resolver-authorization-action-aware): derived
65
+ * from the entity's FETCH (GET) endpoint's own {@code @PreAuthorize} -- deliberately NOT
66
+ * reused for {@link #patchField}, which has its own {@link #requiredAuthorityForPatch()}
67
+ * derived from the entity's real UPDATE endpoint instead, since a real app's GET and PATCH
68
+ * endpoints can (and often do) require genuinely different roles.
42
69
  */
43
70
  String requiredAuthority();
44
71
 
@@ -3,6 +3,7 @@ package {{BASE_PACKAGE}}.domain.{{MODULE}}.infrastructure;
3
3
  import {{BASE_PACKAGE}}.global.handle.ResourceResolver;
4
4
  import {{SERVICE_IMPORT}};
5
5
  {{PATCH_IMPORTS}}import lombok.RequiredArgsConstructor;
6
+ import org.springframework.security.core.Authentication;
6
7
  import org.springframework.stereotype.Component;
7
8
 
8
9
  import java.util.UUID;
@@ -48,13 +49,18 @@ public class {{RESOURCE_TYPE}}Resolver implements ResourceResolver {
48
49
  return {{RESOURCE_TYPE}}ResolverPolicy.type();
49
50
  }
50
51
 
52
+ // D-resolver-authentication-context: `authentication` is unused here -- this stub is only ever
53
+ // generated for the single-resource-UUID-arg case (D-security-8), where the real service
54
+ // method needs nothing beyond resourceUid. It's still part of the signature (ResourceResolver
55
+ // requires it) so a hand-edit that later needs tenant/ownership scoping has it available
56
+ // without changing the interface again.
51
57
  @Override
52
- public Object fetch(UUID resourceUid) {
58
+ public Object fetch(UUID resourceUid, Authentication authentication) {
53
59
  return {{SERVICE_FIELD}}.{{FETCH_METHOD}}(resourceUid);
54
60
  }
55
61
 
56
62
  @Override
57
- public void patchField(UUID resourceUid, String pointer, Object value) {
63
+ public void patchField(UUID resourceUid, String pointer, Object value, Authentication authentication) {
58
64
  {{PATCH_FIELD_BODY}}
59
65
  }
60
66
 
@@ -13,6 +13,14 @@ export const provider = {
13
13
  id: 'java-spring',
14
14
  title: 'Java / Spring Boot',
15
15
  requiresCapabilities: ['resource.fetch'],
16
+ // D-write-safety-phase0 (item 1): migration.sql is manifest-tracked now (classifyFile()-
17
+ // classified, conflict-blocked, --force --reason-audited), so it no longer needs
18
+ // handles/conformance.mjs's idempotence-check exclusion for correctness -- but this field is
19
+ // ALSO lib/verify.mjs's S6 safety net (a `handles ran` check that fires even with no manifest
20
+ // entry at all, e.g. a `gate force`d handles gate that never really emitted anything), which is
21
+ // still real and still needed. Left unchanged rather than emptied -- checkArtifacts() dedupes
22
+ // against the manifest-based check by path, so this doesn't produce a duplicate when a real
23
+ // manifest entry exists; it only fires as the fallback when one doesn't.
16
24
  outputs: { spec: ['handles/migration.sql'] },
17
25
  plan,
18
26
  emit({ repoRoot, featureId, plan: handlesPlan, resourceFilter = null, force = false, reason = '', dryRun = false, computeDiff = false, enforceRegistry = false }) {
@@ -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 { sha256File } from '../../../lib/fsutil.mjs';
6
6
  import { specPath } from '../../../lib/paths.mjs';
7
7
  import { loadFeatureFile } from '../../../lib/featurelifecycle.mjs';
@@ -16,11 +16,6 @@ const RESOLVER_TEMPLATE = path.join(TEMPLATES_DIR, 'resolver.py.tmpl');
16
16
  const POLICY_TEMPLATE = path.join(TEMPLATES_DIR, 'resolver_policy.py.tmpl');
17
17
  const MIGRATION_TEMPLATE = path.join(TEMPLATES_DIR, 'migration.sql.tmpl');
18
18
 
19
- function writeUnit(target, content) {
20
- fs.mkdirSync(path.dirname(target), { recursive: true });
21
- fs.writeFileSync(target, content);
22
- }
23
-
24
19
  function render(templatePath, vars) {
25
20
  let content = fs.readFileSync(templatePath, 'utf8');
26
21
  for (const [key, value] of Object.entries(vars)) {
@@ -175,23 +170,20 @@ export function emitPythonFastApi({ repoRoot, featureId, plan, resourceFilter =
175
170
  },
176
171
  } : null;
177
172
 
178
- const result = emitUnits({ repoRoot, featureId, provider: 'python-fastapi', force, reason, infraUnits, resolverUnits, orphanScan, dryRun, computeDiff });
179
-
180
- // G4 follow-up (D-handles-providers): mirrors java-spring/emit.mjs's own migration.sql
181
- // handling exactly -- regenerated fresh every run, unconditionally, never manifest-tracked
182
- // (no conflict detection for it at all). `kind: 'spec'` tags it distinctly from the
183
- // manifest-tracked infra/resolver kinds, same D4/outputs.spec category P4's conformance
184
- // harness already special-cases.
185
- const migrationContent = render(MIGRATION_TEMPLATE, { FEATURE_ID: featureId });
173
+ // D-write-safety-phase0 (item 1): mirrors java-spring/emit.mjs's own fix exactly -- migration.sql
174
+ // used to be regenerated fresh every run, unconditionally, never manifest-tracked. Reuses
175
+ // emitUnits()'s postResolverUnits slot (kind/ownership/owner overridden for feature ownership;
176
+ // widened to an array in D-typescript-express-registry-parity) instead of a separate,
177
+ // untracked write path.
186
178
  const migrationPath = path.join(repoRoot, 'specs', featureId, 'handles', 'migration.sql');
187
- const migrationRelPath = path.relative(repoRoot, migrationPath);
188
- const migrationDiskContent = fs.existsSync(migrationPath) ? fs.readFileSync(migrationPath, 'utf8') : null;
189
- const migrationAction = migrationDiskContent === null ? 'create' : (migrationDiskContent === migrationContent ? 'unchanged' : 'update');
190
- if (!dryRun) writeUnit(migrationPath, migrationContent);
191
- result.written.push(migrationRelPath);
192
- const migrationActionEntry = { path: migrationRelPath, kind: 'spec', action: migrationAction };
193
- if (computeDiff && migrationAction === 'update') migrationActionEntry.diff = unifiedDiff(migrationRelPath, migrationDiskContent, migrationContent);
194
- result.actions.push(migrationActionEntry);
179
+ const result = emitUnits({
180
+ repoRoot, featureId, provider: 'python-fastapi', force, reason, infraUnits, resolverUnits, orphanScan, dryRun, computeDiff,
181
+ postResolverUnits: [{
182
+ id: 'migration.sql.tmpl', templatePath: MIGRATION_TEMPLATE, targetAbs: migrationPath,
183
+ render: () => render(MIGRATION_TEMPLATE, { FEATURE_ID: featureId }),
184
+ kind: 'migration', ownership: 'feature', owner: featureId,
185
+ }],
186
+ });
195
187
 
196
188
  const postEmitNotes = [
197
189
  'NOT done automatically: applying specs/<id>/handles/migration.sql to any database. Review it and apply yourself.',
@@ -209,16 +201,19 @@ export function emitPythonFastApi({ repoRoot, featureId, plan, resourceFilter =
209
201
 
210
202
  // O3 follow-up (D-handle-registry-enforcement, "Continued"): per-resource, conditional on
211
203
  // enforceRegistry.
204
+ // D-write-safety-phase1 (item 2): mirrors java-spring/emit.mjs's own registrationGaps exactly --
205
+ // see that file's comment for the full reasoning.
206
+ const registrationGaps = [];
212
207
  if (enforceRegistry) {
213
208
  for (const resource of plan.resources) {
214
209
  if (!resource.willGenerateResolver) continue;
215
210
  if (hasRecordSnapshot(resource.fetchRoute.file)) continue;
216
211
  const relRouteFile = path.relative(repoRoot, resource.fetchRoute.file);
217
- postEmitNotes.push(
218
- `${resource.type}: --enforce-registry is on, but no @record_snapshot(...) was found anywhere in ${relRouteFile} -- this resource may never get its first registry row, and every fetch/patch call against it will 404 until something registers it. Apply @record_snapshot to its own create-flow route function (or call handle_service.register() by hand at least once per resource), then re-emit. See D-handle-registry-enforcement in DECISIONS.md for the full bootstrapping explanation.`,
219
- );
212
+ const note = `${resource.type}: --enforce-registry is on, but no @record_snapshot(...) was found anywhere in ${relRouteFile} -- this resource may never get its first registry row, and every fetch/patch call against it will 404 until something registers it. Apply @record_snapshot to its own create-flow route function (or call handle_service.register() by hand at least once per resource), then re-emit. See D-handle-registry-enforcement in DECISIONS.md for the full bootstrapping explanation.`;
213
+ postEmitNotes.push(note);
214
+ registrationGaps.push({ resourceType: resource.type, file: relRouteFile, note });
220
215
  }
221
216
  }
222
217
 
223
- return { ...result, postEmitNotes };
218
+ return { ...result, postEmitNotes, registrationGaps };
224
219
  }
@@ -62,11 +62,12 @@ export function emitObservePythonFastApi({ repoRoot, featureId, contract, plan,
62
62
 
63
63
  const result = emitUnits({ repoRoot, featureId, provider: 'python-fastapi', force, reason, infraUnits, resolverUnits: [], orphanScan: null, dryRun, computeDiff });
64
64
 
65
- // The projected observed-schema.json resource -- regenerated unconditionally every run, like
66
- // handles' own migration.sql (and java observe's own schema resource), and for the identical
67
- // reason: nobody hand-finishes a generated data file the way they hand-finish a resolver stub,
68
- // so O2-style conflict tracking buys nothing here. `kind: 'spec'` matches migration.sql's own
69
- // action-reporting convention. Discovered at runtime via a plain glob under observe/schemas/
65
+ // The projected observed-schema.json resource -- regenerated unconditionally every run (unlike
66
+ // handles' own migration.sql, which moved to manifest tracking in D-write-safety-phase0; this
67
+ // file, and java observe's own schema resource, stay unconditional: nobody hand-finishes a
68
+ // generated data file the way they hand-finish a resolver stub, so O2-style conflict tracking
69
+ // buys nothing here). `kind: 'spec'` still means "always regenerated, not conflict-tracked".
70
+ // Discovered at runtime via a plain glob under observe/schemas/
70
71
  // (see observed_schema.py.tmpl's own docstring for why -- no importlib.resources needed since
71
72
  // this ecosystem only ever runs from source).
72
73
  const operations = {};
@@ -63,9 +63,7 @@ class DecodedHandle:
63
63
  self.pointer = pointer
64
64
 
65
65
 
66
- def decode_handle(token: str) -> DecodedHandle:
67
- if not isinstance(token, str) or not token.startswith("sbf1_"):
68
- raise ValueError('not an sbf1 handle (missing "sbf1_" prefix)')
66
+ def _decode_sbf1(token: str) -> DecodedHandle:
69
67
  if len(token) > MAX_HANDLE_TOKEN_LENGTH:
70
68
  raise ValueError(f"handle token exceeds the maximum length of {MAX_HANDLE_TOKEN_LENGTH} characters")
71
69
  raw = _base64url_decode(token[len("sbf1_"):]).decode("utf-8")
@@ -76,6 +74,23 @@ def decode_handle(token: str) -> DecodedHandle:
76
74
  return DecodedHandle(kind.lower(), type_, resource_uuid.lower(), pointer)
77
75
 
78
76
 
77
+ # D-handle-identity-contract-freeze (Phase 3): scheme-dispatch table, not a hardcoded "sbf1_"
78
+ # check -- reserves the discriminant for a future "sbf2_" scheme (Phase 6, capability-scoped
79
+ # handles) to register itself here additively, without ever changing how an existing "sbf1_"
80
+ # token decodes. encode_handle() is untouched -- it still only ever emits "sbf1_" tokens.
81
+ _HANDLE_DECODERS = {"sbf1": _decode_sbf1}
82
+
83
+
84
+ def decode_handle(token: str) -> DecodedHandle:
85
+ if not isinstance(token, str):
86
+ raise ValueError('not an sbf1 handle (missing "sbf1_" prefix)')
87
+ scheme = token.split("_", 1)[0]
88
+ decoder = _HANDLE_DECODERS.get(scheme)
89
+ if decoder is None:
90
+ raise ValueError('not an sbf1 handle (missing "sbf1_" prefix)')
91
+ return decoder(token)
92
+
93
+
79
94
  # O3 (D-handle-uid-type-binding): ALL three kinds hash a "type:uuid[:...]" discriminant through
80
95
  # UUIDv5 -- kind="r" used to return resource_uuid verbatim (no type binding at all), so two
81
96
  # different resource TYPES sharing the same resourceUid derived the SAME handle_uid and collided