backend-skeleton 1.0.0-beta.9 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +122 -12
  2. package/bin/bskel.mjs +564 -15
  3. package/contracts/emit.mjs +5 -1
  4. package/contracts/export.mjs +26 -3
  5. package/contracts/openapi.mjs +29 -3
  6. package/handles/_engine.mjs +79 -29
  7. package/handles/providers/java-spring/plan.mjs +22 -9
  8. package/handles/providers/java-spring/templates/HandleController.java.tmpl +7 -3
  9. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +12 -6
  10. package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +101 -39
  11. package/handles/providers/typescript-express/emit.mjs +23 -23
  12. package/handles/providers/typescript-express/observe.mjs +101 -0
  13. package/handles/providers/typescript-express/templates/contractCheck.ts.tmpl +136 -0
  14. package/handles/providers/typescript-express/templates/observeContract.ts.tmpl +146 -0
  15. package/handles/providers/typescript-express/templates/observedSchema.ts.tmpl +116 -0
  16. package/lib/attest.mjs +40 -0
  17. package/lib/cli.mjs +125 -3
  18. package/lib/cross-feature-collisions.mjs +286 -0
  19. package/lib/diff.mjs +35 -0
  20. package/lib/fsutil.mjs +7 -2
  21. package/lib/gate-definitions.mjs +85 -1
  22. package/lib/gates.mjs +5 -1
  23. package/lib/http-server.mjs +192 -6
  24. package/lib/lock.mjs +68 -15
  25. package/lib/patch-kinds.mjs +52 -0
  26. package/lib/patch-transactions.mjs +206 -0
  27. package/lib/serve-ui.html +211 -0
  28. package/lib/workflow.mjs +31 -3
  29. package/package.json +5 -2
  30. package/scanners/adapters/java-spring.mjs +6 -0
  31. package/scanners/adapters/python-fastapi.mjs +9 -1
  32. package/scanners/adapters/typescript-express.mjs +6 -0
  33. package/scanners/db/ddl-apply.mjs +253 -0
  34. package/scanners/db/introspect.mjs +61 -32
  35. package/scanners/db/migrations.mjs +73 -18
  36. package/schemas/cross-feature-report.schema.json +66 -0
  37. package/schemas/cross-feature-resolution.schema.json +28 -0
  38. package/schemas/gate-attestation.schema.json +22 -0
  39. package/schemas/gate-export.schema.json +58 -0
  40. package/schemas/patch-transaction.schema.json +182 -0
  41. package/schemas/scan-report.schema.json +6 -4
  42. package/schemas/stack-choice.schema.json +12 -1
  43. package/stack/apply.mjs +4 -1
  44. package/stack/catalog/ngrok.yml +8 -2
  45. package/stack/config-apply.mjs +168 -0
@@ -31,8 +31,12 @@ export const CONTRACT_SCHEMA_VERSION = '8';
31
31
  // item. `pathParamsHeuristic` names every segment that still fell back, so a downstream consumer
32
32
  // (contracts/export.mjs's collectOmissions()) can tell, per operation, whether ANY segment is still
33
33
  // a guess -- `null` (never `[]`) when every segment was source-resolved or the route has none.
34
+ // Update (D-openapi-path-params, closing the O8 typescript-express port's own Finding 2): the
35
+ // regex also recognizes Express's own `:name`/`:name(...)` segment syntax now, not just OpenAPI/
36
+ // Spring/FastAPI-style `{name}` -- additive-only for java-spring/python-fastapi (their own route
37
+ // strings never contain a colon in this position).
34
38
  function pathParamsSchema(routePath, sourcePathParamSchemas = null) {
35
- const params = [...routePath.matchAll(/\{(\w+)\}/g)].map((m) => m[1]);
39
+ const params = [...routePath.matchAll(/\{(\w+)\}|:(\w+)(?:\([^)]*\))?/g)].map((m) => m[1] ?? m[2]);
36
40
  const properties = {};
37
41
  const heuristicNames = [];
38
42
  for (const p of params) {
@@ -239,20 +239,37 @@ function renderDescription(contract, omissions, statusCodes) {
239
239
  return lines.join('\n');
240
240
  }
241
241
 
242
+ // Update (D-openapi-export, closing the gap named in D-openapi-reconciliation's own Update note):
243
+ // rewrites Express's own `:name`/`:name(...)` segments into OpenAPI's `{name}` templating -- an
244
+ // OpenAPI Paths Object key must use `{name}` (verified against the real 3.1 meta-schema), but
245
+ // `op.path` for an unmatched/document-less typescript-express operation is still the scan's own
246
+ // colon syntax. A true no-op for java-spring/python-fastapi (their own paths never contain a colon
247
+ // in this position, confirmed live -- no behavior change).
248
+ function colonPathToBraceSyntax(routePath) {
249
+ return routePath.replace(/:(\w+)(?:\([^)]*\))?/g, '{$1}');
250
+ }
251
+
242
252
  // Every `{name}` in the path template, in order, deduplicated (OpenAPI forbids two parameters
243
253
  // sharing name+location). The schema comes from the contract's own `pathParams.properties`; the
244
254
  // `{}` fallback for a name the contract has no property for is "unconstrained", which is both
245
255
  // honest and the minimum the 3.1 meta-schema accepts (`$defs.parameter`'s
246
256
  // `oneOf: [{required:["schema"]}, {required:["content"]}]` means a parameter MUST carry one or the
247
257
  // other -- confirmed by executing the real schema).
258
+ //
259
+ // Update (D-openapi-export, closing the gap named in D-openapi-reconciliation's own Update note):
260
+ // also recognizes Express's own `:name`/`:name(...)` syntax -- `op.path` for a typescript-express
261
+ // operation that never matched/adopted against an OpenAPI document (drift/missing/unresolved/
262
+ // ambiguous, or a document-less scan-only contract) is still the scan's own colon syntax, and this
263
+ // function would otherwise silently list zero path parameters for it. The existing `{name}`
264
+ // branch's own character class (`[^{}/]+`, more permissive than `\w+`) stays untouched.
248
265
  function buildPathParameters(op) {
249
266
  const props = op.pathParams && typeof op.pathParams === 'object' && !Array.isArray(op.pathParams)
250
267
  ? (op.pathParams.properties ?? {})
251
268
  : {};
252
269
  const seen = new Set();
253
270
  const params = [];
254
- for (const match of String(op.path).matchAll(/\{([^{}/]+)\}/g)) {
255
- const name = match[1];
271
+ for (const match of String(op.path).matchAll(/\{([^{}/]+)\}|:(\w+)(?:\([^)]*\))?/g)) {
272
+ const name = match[1] ?? match[2];
256
273
  if (seen.has(name)) continue;
257
274
  seen.add(name);
258
275
  params.push({
@@ -500,7 +517,13 @@ export function buildOpenApiDocument({ contract, snapshot = null, options = {} }
500
517
 
501
518
  for (const operationId of operationIds) {
502
519
  const op = contract.operations[operationId];
503
- const route = String(op.path);
520
+ // Update (D-openapi-export): op.path can still be Express's own colon syntax (a
521
+ // typescript-express operation that never matched/adopted against an OpenAPI document) --
522
+ // an OpenAPI Paths Object key must use {name} templating (verified against the real 3.1
523
+ // meta-schema), so this is rewritten unconditionally here, once, before it becomes both the
524
+ // document's own path key AND every downstream use of `route` in this loop. A no-op for
525
+ // java-spring/python-fastapi (their own paths never contain a colon in this position).
526
+ const route = colonPathToBraceSyntax(String(op.path));
504
527
  if (!route.startsWith('/')) {
505
528
  return { ok: false, error: `operation "${operationId}" has path "${route}", which does not start with "/" -- an OpenAPI Paths Object key must (verified against the official 3.1 meta-schema)` };
506
529
  }
@@ -329,6 +329,32 @@ export function normalizeRoute(routePath) {
329
329
  return normalized;
330
330
  }
331
331
 
332
+ // A1/Finding-1 fix (D-openapi-reconciliation, closing D-runtime-conformance-receipts' own
333
+ // Finding 1, 0047e85): a route MATCH KEY collapses every parameter segment -- both OpenAPI/
334
+ // Spring/FastAPI-style `{name}` and Express's own `:name`/`:name(...)` -- to the identical
335
+ // placeholder, reusing the EXACT SAME two-alternative regex `contracts/emit.mjs`'s
336
+ // pathParamsSchema() already added for the sibling problem (A9, D-openapi-path-params: PARAM-NAME
337
+ // extraction from the scan's own route string). This is for MATCHING two route STRINGS as the same
338
+ // shape, not for naming a parameter -- the name is always discarded (":param" regardless of which
339
+ // name), unlike pathParamsSchema()'s own capture-group use of the same regex. Only ever used to
340
+ // build/read a `byRoute` MAP KEY -- the document's own raw path string (its own `{name}`/`:name`
341
+ // syntax, verbatim) is always what's stored/returned as `entry.path`/`docEntry.path`; no
342
+ // downstream consumer ever sees a literal ":param" segment.
343
+ //
344
+ // Additive-only for java-spring/python-fastapi's existing {name}-only matching: two `{name}`-syntax
345
+ // paths that were string-equal under the old normalizeRoute()-only key stay equal here (every
346
+ // {anyName} collapses to the same placeholder regardless of name); two that were string-UNEQUAL
347
+ // (different LITERAL segments) stay unequal (only parameter segments are touched). The one new
348
+ // theoretical collision -- two really-different real params at the exact same position/verb --
349
+ // was already an inherent REST-design conflict before this fix (no two real routes can coexist
350
+ // that way), and the existing `hits.length > 1 -> kind: 'ambiguous'` handling (reconcileModule,
351
+ // below) already covers a same-key collision safely, never silently picking one -- confirmed no
352
+ // existing fixture in test/contract-openapi.test.mjs or test/contract-cli.test.mjs relies on the
353
+ // opposite.
354
+ export function canonicalRouteShape(routePath) {
355
+ return normalizeRoute(routePath.replace(/\{(\w+)\}|:(\w+)(?:\([^)]*\))?/g, ':param'));
356
+ }
357
+
332
358
  // Reads and parses exactly once. Every failure mode returns {ok:false, error}, never throws --
333
359
  // this is the one function in the module that touches the filesystem, so it's the one place that
334
360
  // has to be defensive about a file that's huge, unreadable, not JSON, or JSON-but-not-an-object.
@@ -446,7 +472,7 @@ export function indexOpenApiDocument(doc) {
446
472
  continue;
447
473
  }
448
474
  stats.path_count++;
449
- const normalizedRoute = normalizeRoute(routeKey);
475
+ const routeShape = canonicalRouteShape(routeKey);
450
476
 
451
477
  for (const methodKey of Object.keys(pathItem)) {
452
478
  const verbLower = methodKey.toLowerCase();
@@ -498,7 +524,7 @@ export function indexOpenApiDocument(doc) {
498
524
  const description = typeof operation.description === 'string' ? operation.description : null;
499
525
  const entry = { verb, path: routeKey, operationId, requestBody, responses, parameters, security, summary, tags, description };
500
526
 
501
- const routeMatchKey = `${verb} ${normalizedRoute}`;
527
+ const routeMatchKey = `${verb} ${routeShape}`;
502
528
  const existingRoute = byRoute.get(routeMatchKey);
503
529
  if (existingRoute) existingRoute.push(entry); else byRoute.set(routeMatchKey, [entry]);
504
530
 
@@ -1475,7 +1501,7 @@ export function reconcileModule({ index, module, pathPrefix = null, includeDescr
1475
1501
  stats.unresolved++;
1476
1502
  } else {
1477
1503
  const candidates = prefix.value === '' ? [ep.path] : [...new Set([prefix.value + ep.path, ep.path])];
1478
- const hits = candidates.flatMap((c) => index.byRoute.get(`${ep.verb} ${normalizeRoute(c)}`) ?? []);
1504
+ const hits = candidates.flatMap((c) => index.byRoute.get(`${ep.verb} ${canonicalRouteShape(c)}`) ?? []);
1479
1505
  if (hits.length === 0) {
1480
1506
  result = { kind: 'unresolved', reason: 'no-candidate', scanVerb: ep.verb, scanPath: ep.path };
1481
1507
  stats.unresolved++;
@@ -5,11 +5,16 @@
5
5
  // D-handles-providers (G4) in DECISIONS.md. `handles/providers/java-spring/emit.mjs` is the
6
6
  // reference caller to compare against if this file's behavior is ever in question.
7
7
  import fs from 'node:fs';
8
- import os from 'node:os';
9
8
  import path from 'node:path';
10
9
  import { execFileSync } from 'node:child_process';
11
10
  import { sha256File, sha256String } from '../lib/fsutil.mjs';
12
11
  import { loadManifest, saveManifest, classifyFile, extractResolverOwnerFeatureId, BSKEL_GENERATED_MARKER } from '../lib/handles-manifest.mjs';
12
+ // D-patch-transactions: unifiedDiff() moved to lib/diff.mjs (stack/config-apply.mjs needs the
13
+ // identical mechanism for its own collateral-diff safety gate) -- re-exported here so every
14
+ // existing caller in this file keeps working unchanged.
15
+ import { unifiedDiff } from '../lib/diff.mjs';
16
+
17
+ export { unifiedDiff };
13
18
 
14
19
  function readIfExists(target) {
15
20
  return fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : null;
@@ -20,33 +25,6 @@ function writeUnit(target, content) {
20
25
  fs.writeFileSync(target, content);
21
26
  }
22
27
 
23
- // D4 (D-handles-dryrun): a real unified diff via `git diff --no-index`, not a hand-rolled diff
24
- // algorithm -- `git` is already a hard dependency (isDirtyOrUntracked below already shells out to
25
- // it on every emit), so this adds zero new dependencies. `cwd: tmpDir` + relative `a/<relPath>`/
26
- // `b/<relPath>` paths (rather than absolute temp paths) keep the diff header clean and
27
- // reproducible -- the random tmpdir name never leaks into the output. `git diff --no-index` exits
28
- // 1 when the two sides differ (the expected, common case here, not a failure) -- only a status
29
- // other than 0/1 is a genuine error worth throwing.
30
- export function unifiedDiff(relPath, before, after) {
31
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bskel-handles-diff-'));
32
- try {
33
- const beforeAbs = path.join(tmpDir, 'a', relPath);
34
- const afterAbs = path.join(tmpDir, 'b', relPath);
35
- fs.mkdirSync(path.dirname(beforeAbs), { recursive: true });
36
- fs.mkdirSync(path.dirname(afterAbs), { recursive: true });
37
- fs.writeFileSync(beforeAbs, before ?? '');
38
- fs.writeFileSync(afterAbs, after ?? '');
39
- try {
40
- return execFileSync('git', ['diff', '--no-index', '--no-color', '--', `a/${relPath}`, `b/${relPath}`], { cwd: tmpDir, encoding: 'utf8' });
41
- } catch (err) {
42
- if (err.status === 1 && typeof err.stdout === 'string') return err.stdout;
43
- throw err;
44
- }
45
- } finally {
46
- fs.rmSync(tmpDir, { recursive: true, force: true });
47
- }
48
- }
49
-
50
28
  const DIFFABLE_ACTIONS = new Set(['update', 'conflict', 'adopt-update']);
51
29
 
52
30
  // O2: refuses --force on a target that isn't safely recoverable from git history -- a --force
@@ -79,7 +57,17 @@ function isDirtyOrUntracked(repoRoot, absPath) {
79
57
  // computeDiff: D4 -- when true, attaches a real unified diff (git diff --no-index) to every
80
58
  // 'update'/'conflict'/'adopt-update' action -- the only 3 where content actually
81
59
  // differs. Off by default since it shells out to git per diffable file.
82
- export function emitUnits({ repoRoot, featureId, provider, force = false, reason = '', infraUnits, resolverUnits, orphanScan, dryRun = false, computeDiff = false }) {
60
+ // 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 }) {
83
71
  const manifest = loadManifest(repoRoot);
84
72
  const nowIso = new Date().toISOString();
85
73
 
@@ -245,6 +233,68 @@ export function emitUnits({ repoRoot, featureId, provider, force = false, reason
245
233
  recordAction({ relPath, kind: 'resolver', action, resourceType: u.resourceType, diskContent, rendered: u.rendered });
246
234
  }
247
235
 
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;
248
+ const rendered = u.render();
249
+ const relPath = path.relative(repoRoot, u.targetAbs);
250
+ const diskContent = readIfExists(u.targetAbs);
251
+ const exists = diskContent !== null;
252
+ const diskHash = exists ? sha256String(diskContent) : null;
253
+ const freshRenderHash = sha256String(rendered);
254
+ const entry = manifest.files[relPath];
255
+ const matchesPristineRender = exists && diskContent === rendered;
256
+ const action = classifyFile({ exists, diskHash, manifestEntryHash: entry?.generated_hash ?? null, freshRenderHash, matchesPristineRender });
257
+
258
+ 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 });
261
+ } else if (action === 'conflict') {
262
+ 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 });
265
+ } else {
266
+ if (!dryRun) {
267
+ manifest.files[relPath] = {
268
+ kind: 'infra', ownership: 'repo', owner: '_repo', provider, template: u.id,
269
+ template_hash: sha256File(u.templatePath), generated_hash: freshRenderHash,
270
+ updated_at: nowIso, last_force: { reason, at: nowIso },
271
+ };
272
+ manifestChanged = true;
273
+ writeUnit(u.targetAbs, rendered);
274
+ }
275
+ written.push(relPath);
276
+ forced.push(relPath);
277
+ recordAction({ relPath, kind: 'infra', action, diskContent, rendered });
278
+ }
279
+ } else if (action === 'unchanged') {
280
+ recordAction({ relPath, kind: 'infra', action });
281
+ } else {
282
+ if (action !== 'adopt-unchanged') {
283
+ if (!dryRun) writeUnit(u.targetAbs, rendered);
284
+ written.push(relPath);
285
+ }
286
+ if (!dryRun) {
287
+ manifest.files[relPath] = {
288
+ kind: 'infra', ownership: 'repo', owner: '_repo', provider, template: u.id,
289
+ template_hash: sha256File(u.templatePath), generated_hash: freshRenderHash,
290
+ updated_at: nowIso,
291
+ };
292
+ manifestChanged = true;
293
+ }
294
+ recordAction({ relPath, kind: 'infra', action, diskContent, rendered });
295
+ }
296
+ }
297
+
248
298
  // ---- orphan detection: a resolver this feature's CURRENT plan no longer generates, left
249
299
  // untouched and never deleted -- same conservative bias as D-migration-scope/D-config-patch.
250
300
  // Suppressed entirely under --resource (orphanScan === null), since every resource outside the
@@ -121,6 +121,7 @@ function planPatchable({ javaSrcRoot, module: moduleName, controllers, entityCla
121
121
  // below already consumes -- findRequiredAuthority()/extractPreAuthorize()/classBodyStart() are
122
122
  // completely unchanged, D-security-7's own region-carving logic untouched.
123
123
  const HAS_ROLE_RE = /@PreAuthorize\(\s*"hasRole\('([^']+)'\)"\s*\)/;
124
+ const HAS_AUTHORITY_RE = /@PreAuthorize\(\s*"hasAuthority\('([^']+)'\)"\s*\)/;
124
125
  const PRE_AUTH_RE = /@PreAuthorize\(/;
125
126
 
126
127
  function methodMappingBoundaries(text) {
@@ -138,14 +139,26 @@ function classBodyStart(text) {
138
139
  }
139
140
 
140
141
  // 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.
142
+ // `unsupported: true` means an @PreAuthorize annotation IS present but isn't one of the two
143
+ // simple shapes this regex-based scanner understands (hasAnyRole, hasAnyAuthority, SpEL, etc.) --
144
+ // the caller must fail closed (TODO_ROLE) rather than silently treating it as "no authority
145
+ // found" and falling back to a weaker/wrong source.
146
+ //
147
+ // O5 (D-resolver-authorization-action-aware, hasAuthority follow-up): hasRole('X') and
148
+ // hasAuthority('X') are NOT interchangeable at the Spring Security level -- hasRole('X') checks
149
+ // for the granted authority "ROLE_X" (an implicit prefix Spring itself applies), hasAuthority('X')
150
+ // checks for "X" verbatim. The returned `authority` string is the LITERAL granted-authority value
151
+ // the generated code must match, decided HERE (plan time), not left for the template to re-derive
152
+ // -- HandleController.java.tmpl's requireAuthority() does one plain equality check regardless of
153
+ // which shape produced the value. This is the real discriminant this item's own EXIT note named:
154
+ // widening the regex alone, without this prefix decision, would generate an incorrect check.
145
155
  function extractPreAuthorize(region) {
146
156
  if (!PRE_AUTH_RE.test(region)) return null;
147
157
  const hasRoleMatch = region.match(HAS_ROLE_RE);
148
- return hasRoleMatch ? { authority: hasRoleMatch[1], unsupported: false } : { authority: null, unsupported: true };
158
+ if (hasRoleMatch) return { authority: `ROLE_${hasRoleMatch[1]}`, unsupported: false };
159
+ const hasAuthorityMatch = region.match(HAS_AUTHORITY_RE);
160
+ if (hasAuthorityMatch) return { authority: hasAuthorityMatch[1], unsupported: false };
161
+ return { authority: null, unsupported: true };
149
162
  }
150
163
 
151
164
  // D-security-7: a controller with more than one method can require DIFFERENT roles per method --
@@ -253,14 +266,14 @@ export function planHandles({ javaSrcRoot, scanReport, module: moduleName, resou
253
266
  if (!fetchOp) {
254
267
  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
268
  } 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`);
269
+ 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
270
  } 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`);
271
+ 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
272
  }
260
273
  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`);
274
+ 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
275
  } 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`);
276
+ 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
277
  }
265
278
  if (!service) {
266
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.`);
@@ -200,12 +200,16 @@ public class HandleController {
200
200
  .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "no active registration for this handle"));
201
201
  }
202
202
 
203
- private void requireAuthority(String requiredRole) {
203
+ // O5 (D-resolver-authorization-action-aware, hasAuthority follow-up): a plain equality check --
204
+ // the ROLE_ prefix Spring Security implicitly applies for hasRole('X') (but not hasAuthority('X'))
205
+ // is already baked into requiredAuthority by plan.mjs's extractPreAuthorize() at handles-plan
206
+ // time, so this method never needs to know which source annotation shape produced the value.
207
+ private void requireAuthority(String requiredAuthority) {
204
208
  boolean granted = SecurityContextHolder.getContext().getAuthentication().getAuthorities().stream()
205
209
  .map(GrantedAuthority::getAuthority)
206
- .anyMatch(authority -> authority.equals("ROLE_" + requiredRole));
210
+ .anyMatch(authority -> authority.equals(requiredAuthority));
207
211
  if (!granted) {
208
- throw new ResponseStatusException(HttpStatus.FORBIDDEN, "requires role " + requiredRole);
212
+ throw new ResponseStatusException(HttpStatus.FORBIDDEN, "requires authority " + requiredAuthority);
209
213
  }
210
214
  }
211
215
  }
@@ -33,12 +33,18 @@ public interface ResourceResolver {
33
33
  void patchField(UUID resourceUid, String pointer, Object value);
34
34
 
35
35
  /**
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.
36
+ * The literal Spring Security granted-authority string required to {@code fetch}/{@code
37
+ * recover} this resource type via a handle -- e.g. {@code "ROLE_ADMIN"} when the source
38
+ * {@code @PreAuthorize} was {@code hasRole('ADMIN')} (Spring implicitly applies the
39
+ * {@code ROLE_} prefix), or a bare value like {@code "ADMIN"} when the source was
40
+ * {@code hasAuthority('ADMIN')} (no prefix). This distinction is decided once, at
41
+ * {@code bskel handles plan} time, by {@code extractPreAuthorize()} -- callers of this method
42
+ * never need to know which source annotation shape produced the value; they compare it
43
+ * verbatim against a granted authority. O5 (D-resolver-authorization-action-aware): derived
44
+ * from the entity's FETCH (GET) endpoint's own {@code @PreAuthorize} -- deliberately NOT
45
+ * reused for {@link #patchField}, which has its own {@link #requiredAuthorityForPatch()}
46
+ * derived from the entity's real UPDATE endpoint instead, since a real app's GET and PATCH
47
+ * endpoints can (and often do) require genuinely different roles.
42
48
  */
43
49
  String requiredAuthority();
44
50
 
@@ -23,6 +23,16 @@ Requires nothing extra installed (unlike Java's spring-boot-starter-aop) -- Pyth
23
23
  no framework support -- but still requires a human to apply it to their own code; codegen never
24
24
  touches an existing business logic file.
25
25
 
26
+ CRITICAL, python-specific correctness requirement (see DECISIONS.md D-runtime-conformance-receipts's
27
+ own async-wrapper note, and observe_contract.py's identical dual-wrapper shape in this same
28
+ directory): detects whether the wrapped function is a coroutine function
29
+ (`inspect.iscoroutinefunction`) at DECORATION time and dispatches to a genuinely separate async
30
+ wrapper (awaiting the wrapped call) vs. sync wrapper (calling it directly). A single wrapper naively
31
+ calling an async function without awaiting it would return an unawaited coroutine object as the
32
+ "result" -- the "response" snapshot would then record that coroutine object instead of the real
33
+ result, and the "error" snapshot path would never fire even when the real async call later raises
34
+ (calling an async function does not raise synchronously; only awaiting it does).
35
+
26
36
  Example:
27
37
  @record_snapshot(resource_type="Organization", operation_id="update_organization",
28
38
  resource_uid_param="organization_id", session_param="session",
@@ -110,45 +120,97 @@ def record_snapshot(*, resource_type: str, operation_id: str, resource_uid_param
110
120
  def decorator(fn):
111
121
  signature = inspect.signature(fn)
112
122
 
113
- @functools.wraps(fn)
114
- def wrapper(*args, **kwargs):
115
- bound = signature.bind(*args, **kwargs)
116
- bound.apply_defaults()
117
-
118
- resolver = resolver_for(resource_type)
119
- if resolver is None:
120
- logger.warning('record_snapshot: no resolver registered for resource_type "%s" on %s -- skipping snapshot recording, the wrapped call proceeds unaffected', resource_type, fn.__qualname__)
121
- return fn(*args, **kwargs)
122
-
123
- resource_uid = bound.arguments.get(resource_uid_param)
124
- if not isinstance(resource_uid, uuid.UUID):
125
- logger.warning('record_snapshot: resource_uid_param "%s" on %s does not resolve to a UUID argument -- skipping snapshot recording, the wrapped call proceeds unaffected', resource_uid_param, fn.__qualname__)
126
- return fn(*args, **kwargs)
127
-
128
- session = bound.arguments.get(session_param)
129
- handle_uid = uuid.UUID(derive_handle_uid("r", resource_type, str(resource_uid), None))
130
- contract_ref = resolver.contract_ref
131
-
132
- def _register_and_record(envelope_dir, payload):
133
- handle_service.register(session, "r", resource_type, resource_uid, None, resolver.feature_uid, operation_id, contract_ref)
134
- handle_service.record_snapshot(session, handle_uid, envelope_dir, operation_id, contract_ref, payload)
135
-
136
- def _record(envelope_dir, payload):
137
- if isinstance(payload, (dict, list)):
138
- for pointer in redact:
139
- _redact(payload, pointer)
140
- _safely(lambda: _register_and_record(envelope_dir, payload), handle_uid, envelope_dir)
141
-
142
- request_payload = _request_payload(bound, resource_uid_param, session_param)
143
- _record("request", request_payload)
144
-
145
- try:
146
- result = fn(*args, **kwargs)
147
- except Exception as exc:
148
- _record("error", {"message": str(exc)})
149
- raise
150
- _record("response", _to_jsonable(result))
151
- return result
123
+ # CRITICAL, python-specific correctness requirement (see DECISIONS.md
124
+ # D-runtime-conformance-receipts's own async-wrapper note, ported here for the same reason):
125
+ # detects whether the wrapped function is a coroutine function (inspect.iscoroutinefunction)
126
+ # at DECORATION time and dispatches to a genuinely separate async wrapper (awaiting the
127
+ # wrapped call) vs. sync wrapper (calling it directly). A single wrapper naively calling an
128
+ # async function without awaiting it returns an unawaited coroutine object as "result" --
129
+ # the "response" snapshot then records that coroutine object instead of the real result, and
130
+ # the "error" snapshot path never fires even when the real async call later raises (calling
131
+ # an async function does not raise synchronously; only awaiting it does). This mirrors
132
+ # observe_contract.py's own dual-wrapper shape exactly, deliberately, so both decorators in
133
+ # this file handle async the same way.
134
+ if inspect.iscoroutinefunction(fn):
135
+ @functools.wraps(fn)
136
+ async def wrapper(*args, **kwargs):
137
+ bound = signature.bind(*args, **kwargs)
138
+ bound.apply_defaults()
139
+
140
+ resolver = resolver_for(resource_type)
141
+ if resolver is None:
142
+ logger.warning('record_snapshot: no resolver registered for resource_type "%s" on %s -- skipping snapshot recording, the wrapped call proceeds unaffected', resource_type, fn.__qualname__)
143
+ return await fn(*args, **kwargs)
144
+
145
+ resource_uid = bound.arguments.get(resource_uid_param)
146
+ if not isinstance(resource_uid, uuid.UUID):
147
+ logger.warning('record_snapshot: resource_uid_param "%s" on %s does not resolve to a UUID argument -- skipping snapshot recording, the wrapped call proceeds unaffected', resource_uid_param, fn.__qualname__)
148
+ return await fn(*args, **kwargs)
149
+
150
+ session = bound.arguments.get(session_param)
151
+ handle_uid = uuid.UUID(derive_handle_uid("r", resource_type, str(resource_uid), None))
152
+ contract_ref = resolver.contract_ref
153
+
154
+ def _register_and_record(envelope_dir, payload):
155
+ handle_service.register(session, "r", resource_type, resource_uid, None, resolver.feature_uid, operation_id, contract_ref)
156
+ handle_service.record_snapshot(session, handle_uid, envelope_dir, operation_id, contract_ref, payload)
157
+
158
+ def _record(envelope_dir, payload):
159
+ if isinstance(payload, (dict, list)):
160
+ for pointer in redact:
161
+ _redact(payload, pointer)
162
+ _safely(lambda: _register_and_record(envelope_dir, payload), handle_uid, envelope_dir)
163
+
164
+ request_payload = _request_payload(bound, resource_uid_param, session_param)
165
+ _record("request", request_payload)
166
+
167
+ try:
168
+ result = await fn(*args, **kwargs)
169
+ except Exception as exc:
170
+ _record("error", {"message": str(exc)})
171
+ raise
172
+ _record("response", _to_jsonable(result))
173
+ return result
174
+ else:
175
+ @functools.wraps(fn)
176
+ def wrapper(*args, **kwargs):
177
+ bound = signature.bind(*args, **kwargs)
178
+ bound.apply_defaults()
179
+
180
+ resolver = resolver_for(resource_type)
181
+ if resolver is None:
182
+ logger.warning('record_snapshot: no resolver registered for resource_type "%s" on %s -- skipping snapshot recording, the wrapped call proceeds unaffected', resource_type, fn.__qualname__)
183
+ return fn(*args, **kwargs)
184
+
185
+ resource_uid = bound.arguments.get(resource_uid_param)
186
+ if not isinstance(resource_uid, uuid.UUID):
187
+ logger.warning('record_snapshot: resource_uid_param "%s" on %s does not resolve to a UUID argument -- skipping snapshot recording, the wrapped call proceeds unaffected', resource_uid_param, fn.__qualname__)
188
+ return fn(*args, **kwargs)
189
+
190
+ session = bound.arguments.get(session_param)
191
+ handle_uid = uuid.UUID(derive_handle_uid("r", resource_type, str(resource_uid), None))
192
+ contract_ref = resolver.contract_ref
193
+
194
+ def _register_and_record(envelope_dir, payload):
195
+ handle_service.register(session, "r", resource_type, resource_uid, None, resolver.feature_uid, operation_id, contract_ref)
196
+ handle_service.record_snapshot(session, handle_uid, envelope_dir, operation_id, contract_ref, payload)
197
+
198
+ def _record(envelope_dir, payload):
199
+ if isinstance(payload, (dict, list)):
200
+ for pointer in redact:
201
+ _redact(payload, pointer)
202
+ _safely(lambda: _register_and_record(envelope_dir, payload), handle_uid, envelope_dir)
203
+
204
+ request_payload = _request_payload(bound, resource_uid_param, session_param)
205
+ _record("request", request_payload)
206
+
207
+ try:
208
+ result = fn(*args, **kwargs)
209
+ except Exception as exc:
210
+ _record("error", {"message": str(exc)})
211
+ raise
212
+ _record("response", _to_jsonable(result))
213
+ return result
152
214
 
153
215
  return wrapper
154
216
 
@@ -95,29 +95,29 @@ export function emitTypeScriptExpress({ repoRoot, featureId, plan, resourceFilte
95
95
  },
96
96
  } : null;
97
97
 
98
- const result = emitUnits({ repoRoot, featureId, provider: 'typescript-express', force, reason, infraUnits, resolverUnits, orphanScan, dryRun, computeDiff });
99
-
100
- // The resolvers barrel's own import list is regenerated from the resolvers directory's REAL
101
- // current contents (not just this run's own resolverUnits) -- an orphaned resolver from a
102
- // different feature/module (O2's "never delete, only report" policy leaves it on disk) still
103
- // needs its own `register(...)` call imported, or that resource type silently stops being
104
- // servable. Unconditional, like migration.sql is for java-spring -- never manifest-tracked.
105
- if (!dryRun) {
106
- fs.mkdirSync(resolversDir, { recursive: true });
107
- }
108
- const currentResolverFiles = fs.existsSync(resolversDir)
109
- ? fs.readdirSync(resolversDir).filter((f) => f.endsWith('.ts') && f !== 'resolvers_index.ts').sort()
110
- : [];
111
- const imports = currentResolverFiles.map((f) => `import './${f.replace(/\.ts$/, '')}';`).join('\n');
112
- const resolversIndexContent = render(RESOLVERS_INDEX_TEMPLATE, { IMPORTS: imports });
113
- const resolversIndexRelPath = path.relative(repoRoot, resolversIndexPath);
114
- const resolversIndexDiskContent = fs.existsSync(resolversIndexPath) ? fs.readFileSync(resolversIndexPath, 'utf8') : null;
115
- const resolversIndexAction = resolversIndexDiskContent === null ? 'create' : (resolversIndexDiskContent === resolversIndexContent ? 'unchanged' : 'update');
116
- if (!dryRun && resolversIndexAction !== 'unchanged') {
117
- fs.writeFileSync(resolversIndexPath, resolversIndexContent);
118
- result.written.push(resolversIndexRelPath);
119
- }
120
- result.actions.push({ path: resolversIndexRelPath, kind: 'spec', action: resolversIndexAction });
98
+ const result = emitUnits({
99
+ repoRoot, featureId, provider: 'typescript-express', force, reason, infraUnits, resolverUnits, orphanScan, dryRun, computeDiff,
100
+ // D-patch-transactions (Continued): the resolvers barrel's own import list is regenerated
101
+ // from the resolvers directory's REAL current contents (not just this run's own
102
+ // resolverUnits) -- an orphaned resolver from a different feature/module (O2's "never
103
+ // delete, only report" policy leaves it on disk) still needs its own `register(...)` call
104
+ // imported, or that resource type silently stops being servable. `render()` is called by
105
+ // emitUnits() itself AFTER its resolver loop writes this run's own files, so this always
106
+ // sees the final on-disk listing -- now conflict-safe/manifest-tracked like every other
107
+ // generated file, no longer unconditional (unlike migration.sql, which stays that way).
108
+ postResolverUnit: {
109
+ id: 'resolvers_index.ts.tmpl',
110
+ templatePath: RESOLVERS_INDEX_TEMPLATE,
111
+ targetAbs: resolversIndexPath,
112
+ render: () => {
113
+ const currentResolverFiles = fs.existsSync(resolversDir)
114
+ ? fs.readdirSync(resolversDir).filter((f) => f.endsWith('.ts') && f !== 'resolvers_index.ts').sort()
115
+ : [];
116
+ const imports = currentResolverFiles.map((f) => `import './${f.replace(/\.ts$/, '')}';`).join('\n');
117
+ return render(RESOLVERS_INDEX_TEMPLATE, { IMPORTS: imports });
118
+ },
119
+ },
120
+ });
121
121
 
122
122
  return {
123
123
  ...result,