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
@@ -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
@@ -204,8 +207,11 @@ public class HandleController {
204
207
  // the ROLE_ prefix Spring Security implicitly applies for hasRole('X') (but not hasAuthority('X'))
205
208
  // is already baked into requiredAuthority by plan.mjs's extractPreAuthorize() at handles-plan
206
209
  // time, so this method never needs to know which source annotation shape produced the value.
207
- private void requireAuthority(String requiredAuthority) {
208
- boolean granted = SecurityContextHolder.getContext().getAuthentication().getAuthorities().stream()
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()
209
215
  .map(GrantedAuthority::getAuthority)
210
216
  .anyMatch(authority -> authority.equals(requiredAuthority));
211
217
  if (!granted) {
@@ -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,8 +47,11 @@ 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
57
  * The literal Spring Security granted-authority string required to {@code fetch}/{@code
@@ -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
@@ -45,6 +45,8 @@ import inspect
45
45
  import logging
46
46
  import uuid
47
47
 
48
+ from sqlmodel import Session
49
+
48
50
  from {{PKG}}.handles import handle_service
49
51
  from {{PKG}}.handles.codec import derive_handle_uid
50
52
  from {{PKG}}.handles.registry import resolver_for
@@ -52,6 +54,24 @@ from {{PKG}}.handles.registry import resolver_for
52
54
  logger = logging.getLogger(__name__)
53
55
 
54
56
 
57
+ def _side_channel_session(caller_session: Session) -> Session:
58
+ """D-handles-pilot-cohort (python-fastapi follow-up to the java-spring transaction-isolation
59
+ finding, D-handle-aspect-transaction-isolation): `handle_service.register`/`record_snapshot`
60
+ each call `session.commit()` -- fine when a human calls them directly on their OWN session
61
+ (handle_service.py's own documented use case), genuinely dangerous when called from THIS
62
+ decorator on the WRAPPED function's session, whose transaction semantics this decorator
63
+ cannot know. Real, concrete risk: a wrapped function that does multiple related writes
64
+ without committing (expecting ITS OWN caller to commit-or-rollback atomically) raises
65
+ mid-transaction -- this decorator's own error-path snapshot recording would call
66
+ `session.commit()` on that SAME session, silently persisting the should-have-rolled-back
67
+ partial write instead of ever reaching a real rollback. A genuinely separate session --
68
+ sharing only the caller's engine/connection pool via `get_bind()`, never its in-flight
69
+ transaction -- makes handle registration/snapshot recording a real side channel, matching
70
+ what `Propagation.REQUIRES_NEW` achieves on the Java side for the identical reason.
71
+ """
72
+ return Session(caller_session.get_bind())
73
+
74
+
55
75
  def _redact(obj, pointer: str) -> None:
56
76
  """Walks to `pointer`'s parent container and blanks the leaf value in place -- genuinely
57
77
  simpler than Java's hand-rolled ObjectNode navigator, since Python containers are natively
@@ -152,8 +172,12 @@ def record_snapshot(*, resource_type: str, operation_id: str, resource_uid_param
152
172
  contract_ref = resolver.contract_ref
153
173
 
154
174
  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)
175
+ # D-handles-pilot-cohort: a genuinely separate session -- see
176
+ # _side_channel_session's own docstring for why this must never be the
177
+ # wrapped function's own `session`.
178
+ with _side_channel_session(session) as side_session:
179
+ handle_service.register(side_session, "r", resource_type, resource_uid, None, resolver.feature_uid, operation_id, contract_ref)
180
+ handle_service.record_snapshot(side_session, handle_uid, envelope_dir, operation_id, contract_ref, payload)
157
181
 
158
182
  def _record(envelope_dir, payload):
159
183
  if isinstance(payload, (dict, list)):
@@ -192,8 +216,12 @@ def record_snapshot(*, resource_type: str, operation_id: str, resource_uid_param
192
216
  contract_ref = resolver.contract_ref
193
217
 
194
218
  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)
219
+ # D-handles-pilot-cohort: a genuinely separate session -- see
220
+ # _side_channel_session's own docstring for why this must never be the
221
+ # wrapped function's own `session`.
222
+ with _side_channel_session(session) as side_session:
223
+ handle_service.register(side_session, "r", resource_type, resource_uid, None, resolver.feature_uid, operation_id, contract_ref)
224
+ handle_service.record_snapshot(side_session, handle_uid, envelope_dir, operation_id, contract_ref, payload)
197
225
 
198
226
  def _record(envelope_dir, payload):
199
227
  if isinstance(payload, (dict, list)):
@@ -11,9 +11,9 @@ export const provider = {
11
11
  id: 'python-fastapi',
12
12
  title: 'Python / FastAPI / SQLModel',
13
13
  requiresCapabilities: ['resource.fetch'],
14
- // G4 follow-up (D-handles-providers): this provider now generates a real recover()
15
- // lifecycle + sbf_handle/sbf_handle_snapshot migration, mirroring java-spring's own O4 work --
16
- // the EXCLUDED reasoning that used to justify an empty outputs.spec here is stale.
14
+ // D-write-safety-phase0 (item 1): mirrors java-spring.mjs's own updated comment exactly -- kept
15
+ // unchanged rather than emptied. See that file for the full reasoning (checkArtifacts()'s S6
16
+ // safety net for a `handles ran` but manifest-less state still needs this declared).
17
17
  outputs: { spec: ['handles/migration.sql'] },
18
18
  plan,
19
19
  emit(args) {