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.
- package/README.md +66 -4
- package/bin/bskel.mjs +125 -18
- package/contracts/export.mjs +39 -4
- package/contracts/openapi.mjs +292 -27
- package/contracts/validate.mjs +23 -4
- package/handles/_engine.mjs +75 -32
- package/handles/capability-codec.mjs +94 -0
- package/handles/codec.mjs +13 -3
- package/handles/providers/java-spring/emit.mjs +78 -33
- package/handles/providers/java-spring/observe.mjs +4 -3
- package/handles/providers/java-spring/plan.mjs +51 -7
- package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +19 -1
- package/handles/providers/java-spring/templates/HandleController.java.tmpl +13 -7
- package/handles/providers/java-spring/templates/HandleService.java.tmpl +21 -2
- package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +1 -1
- package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +24 -3
- package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +8 -2
- package/handles/providers/java-spring.mjs +8 -0
- package/handles/providers/python-fastapi/emit.mjs +21 -26
- package/handles/providers/python-fastapi/observe.mjs +6 -5
- package/handles/providers/python-fastapi/templates/codec.py.tmpl +18 -3
- package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +32 -4
- package/handles/providers/python-fastapi.mjs +3 -3
- package/handles/providers/typescript-express/emit.mjs +135 -46
- package/handles/providers/typescript-express/observe.mjs +7 -6
- package/handles/providers/typescript-express/templates/codec.ts.tmpl +13 -3
- package/handles/providers/typescript-express/templates/handleEntities.ts.tmpl +89 -0
- package/handles/providers/typescript-express/templates/handleService.ts.tmpl +81 -0
- package/handles/providers/typescript-express/templates/migration.sql.tmpl +36 -0
- package/handles/providers/typescript-express/templates/recordSnapshotWrapper.ts.tmpl +123 -0
- package/handles/providers/typescript-express/templates/registry.ts.tmpl +19 -10
- package/handles/providers/typescript-express/templates/resolver.ts.tmpl +13 -0
- package/handles/providers/typescript-express/templates/resolverPolicy.ts.tmpl +20 -0
- package/handles/providers/typescript-express/templates/router.ts.tmpl +113 -2
- package/handles/providers/typescript-express.mjs +7 -4
- package/lib/cli.mjs +11 -2
- package/lib/exit-codes.mjs +21 -0
- package/lib/verify.mjs +23 -6
- package/package.json +5 -2
- package/scanners/adapters/_java-spring-analyzer.mjs +9 -1
- package/scanners/adapters/java-spring.mjs +108 -10
- package/scanners/adapters/javascript-express.mjs +46 -13
- package/scanners/adapters/typescript-express.mjs +13 -2
- package/schemas/feature-contract.schema.json +3 -3
- package/schemas/handles-plan.schema.json +2 -0
- package/schemas/oracle-manifest.schema.json +58 -0
- package/schemas/stack-record.schema.json +6 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
208
|
-
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
23
|
-
|
|
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
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
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
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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
|
|
66
|
-
// handles' own migration.sql
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
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
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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) {
|