@githolon/dsl 0.4.0 → 0.5.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 (32) hide show
  1. package/package.json +2 -1
  2. package/src/build_package.ts +4 -0
  3. package/src/capability_exports.ts +55 -0
  4. package/src/codegen_dart.ts +9 -0
  5. package/src/codegen_proof.ts +72 -11
  6. package/src/compile_package_main.ts +241 -13
  7. package/src/directive.ts +35 -10
  8. package/src/engine_entry.ts +5 -1
  9. package/src/framework/capability.ts +215 -0
  10. package/src/framework/impure_capability.ts +25 -3
  11. package/src/framework/workspaces.ts +129 -0
  12. package/src/index.ts +9 -0
  13. package/src/manifest.ts +103 -0
  14. package/src/read.ts +29 -0
  15. package/src/stable_ids.ts +226 -0
  16. package/src/stable_ids_types.ts +40 -0
  17. package/src/usd.ts +54 -0
  18. package/src/usd_layers.ts +65 -1
  19. package/src/wire_encode.ts +15 -0
  20. package/dart/.dart_tool/package_config.json +0 -328
  21. package/dart/.dart_tool/package_graph.json +0 -485
  22. package/dart/.dart_tool/pub/bin/test/test.dart-3.11.5.snapshot +0 -0
  23. package/dart/.dart_tool/test/incremental_kernel.Ly9AZGFydD0zLjU= +0 -0
  24. package/dart/.dart_tool/version +0 -1
  25. package/dart/build/native_assets/macos/native_assets.json +0 -1
  26. package/dart/build/test_cache/build/89a6598c8854ed031dfc25d83c80860e.cache.dill.track.dill +0 -0
  27. package/dart/build/unit_test_assets/AssetManifest.bin +0 -0
  28. package/dart/build/unit_test_assets/FontManifest.json +0 -1
  29. package/dart/build/unit_test_assets/NOTICES.Z +0 -0
  30. package/dart/build/unit_test_assets/NativeAssetsManifest.json +0 -1
  31. package/dart/build/unit_test_assets/shaders/ink_sparkle.frag +0 -0
  32. package/dart/build/unit_test_assets/shaders/stretch_effect.frag +0 -0
package/src/manifest.ts CHANGED
@@ -48,6 +48,14 @@ import {
48
48
  canonicalTaxonomyFragment,
49
49
  type CanonicalDirectiveRoute,
50
50
  } from "./workspace_routing.js";
51
+ import {
52
+ mintAggregateSid,
53
+ mintDomainSid,
54
+ mintFieldSid,
55
+ type StableAggregateId,
56
+ type StableFieldId,
57
+ type StableIds,
58
+ } from "./stable_ids.js";
51
59
 
52
60
  /** One captured `[fieldName, zodKind]` payload field, with optionality. */
53
61
  export interface CanonicalPayloadField {
@@ -126,6 +134,15 @@ export interface CanonicalDirective {
126
134
  * before this key existed (pinned golden hashes stay UNCHANGED).
127
135
  */
128
136
  readonly certifiedReads?: CanonicalCertifiedRead[];
137
+ /**
138
+ * The directive's DECLARED CAPTURED-READ queries (#58 — the captured-read lane):
139
+ * the O(1) DSL queries its `plan` may `read()` on the write path, each carried as
140
+ * `{queryId, key, returns}` (the read RECIPE is the contract — a different key or
141
+ * returns-type is a different read, so it moves the hash). SORTED by queryId.
142
+ * OMITTED ENTIRELY when the directive declares none — a captured-read-free
143
+ * directive is byte-identical in the manifest to before this key existed.
144
+ */
145
+ readonly capturedReads?: CanonicalCapturedRead[];
129
146
  /**
130
147
  * The directive's DECLARED required HOST CAPABILITY ports (`TARGET_deps.dot` cluster_ports;
131
148
  * the HOST/PORT axis, invariant 3): the capability ports the host must PROVIDE for the
@@ -178,6 +195,15 @@ export interface CanonicalRelationEvidence {
178
195
  readonly kind: string;
179
196
  }
180
197
 
198
+ /** One DECLARED captured-read query (#58) — the write-path `read()` recipe in the
199
+ * manifest: the query id, its index key (DECLARED order — index column order), and the
200
+ * aggregate TYPE it returns. */
201
+ export interface CanonicalCapturedRead {
202
+ readonly queryId: string;
203
+ readonly key: string[];
204
+ readonly returns: string;
205
+ }
206
+
181
207
  /** One captured DECLARED CertifiedRead — the profiled write-path read identity in the manifest.
182
208
  * `queryId` is the gate's required-read key; `sql`/`multiRow`/`uniqueTieBreakers` are the recipe
183
209
  * + shape the admission re-run + the profile pin (a recipe change moves the domain hash). */
@@ -378,6 +404,18 @@ export interface CanonicalManifest {
378
404
  readonly domain: string;
379
405
  readonly aggregates: CanonicalAggregate[];
380
406
  readonly directives: CanonicalDirective[];
407
+ /**
408
+ * STABLE IDENTIFIERS (#58 — the foundation: names are LABELS, identity is MINTED by
409
+ * the compiler at first appearance, never dev-assigned). Carries `{stableId, name}`
410
+ * for the domain, every aggregate, and every field — keyed by CURRENT name, each
411
+ * entry holding its `sid` plus the label lineage `was` (prior names, oldest first;
412
+ * compiler-derived from continuity, never dev-written). ALWAYS EMITTED by the post-
413
+ * #58 compiler — this key MOVES every enforcement-era identity hash (named and
414
+ * expected; inert-era artifacts already built stay byte-identical untouched). The
415
+ * EVOLVE GATE diffs old vs new law BY SID; the projection folds via the sid lineage
416
+ * so a renamed entity's old-era data coheres under the new label.
417
+ */
418
+ readonly stableIds?: StableIds;
381
419
  /**
382
420
  * The domain's WORKSPACE INVARIANTS (#266), SORTED by id, PRESENCE ONLY (id + `on`).
383
421
  * OMITTED ENTIRELY when the domain declares none — so an invariant-free domain is
@@ -636,6 +674,23 @@ function canonicalEmits(
636
674
  * NAME (the `reads:{}` key) is incidental to identity (it is `decide`'s ergonomics), so it is
637
675
  * NOT captured — only the `query_id` + recipe.
638
676
  */
677
+ /**
678
+ * Canonicalize a directive's DECLARED captured-read queries (#58) into a manifest
679
+ * fragment. OMIT-WHEN-EMPTY: returns `{}` (no `capturedReads` key) when the directive
680
+ * declares none — the omission keeps read-free hashes unchanged. When non-empty,
681
+ * returns a `capturedReads` array SORTED by queryId, each carrying the read recipe
682
+ * `{queryId, key, returns}` (key in DECLARED order — index column order).
683
+ */
684
+ function canonicalCapturedReads(
685
+ declared: QueryDecl[],
686
+ ): { capturedReads?: CanonicalCapturedRead[] } {
687
+ if (declared.length === 0) return {};
688
+ const capturedReads: CanonicalCapturedRead[] = declared
689
+ .map((q) => ({ queryId: q.id, key: [...q.key], returns: q.returns }))
690
+ .sort((a, b) => (a.queryId < b.queryId ? -1 : a.queryId > b.queryId ? 1 : 0));
691
+ return { capturedReads };
692
+ }
693
+
639
694
  function canonicalCertifiedReads(
640
695
  declared: Record<string, CertifiedReadDecl>,
641
696
  ): { certifiedReads?: CanonicalCertifiedRead[] } {
@@ -888,6 +943,50 @@ function canonicalOrderedReads(
888
943
  return { orderedReads: items };
889
944
  }
890
945
 
946
+ /**
947
+ * The domain's STABLE-ID surface (#58): continuity-carried when the compiler attached
948
+ * `mod.stableIdContinuity` (renames resolved, sids + lineage carried), deterministic
949
+ * first-appearance minting otherwise — so the manifest stays a pure function of the
950
+ * module (+ its attached continuity). Any aggregate/field the continuity does not
951
+ * cover (a genuinely new appearance) mints fresh.
952
+ */
953
+ function stableIdsForModule(
954
+ mod: DomainModule,
955
+ aggregates: CanonicalAggregate[],
956
+ ): StableIds {
957
+ const continuity = mod.stableIdContinuity;
958
+ const domainSid = continuity?.domain ?? mintDomainSid(mod.name);
959
+ // The CURRENT field KINDS off the live handles (the retype arm's identity input —
960
+ // a `t.string()` → `t.int()` retype moves `kind` while the driver stays Lww).
961
+ const handlesById = new Map(mod.aggregates.map((a) => [a.id, a]));
962
+ const out: Record<string, StableAggregateId> = {};
963
+ for (const agg of aggregates) {
964
+ const prior = continuity?.aggregates[agg.id];
965
+ const aggSid = prior?.sid ?? mintAggregateSid(domainSid, agg.id);
966
+ const handleFields = (handlesById.get(agg.id)?.fields ?? {}) as Record<
967
+ string,
968
+ { kind?: string } | undefined
969
+ >;
970
+ const fields: Record<string, StableFieldId> = {};
971
+ for (const fieldName of Object.keys(agg.schema).sort()) {
972
+ const pf = prior?.fields[fieldName];
973
+ const kind = handleFields[fieldName]?.kind;
974
+ fields[fieldName] = {
975
+ sid: pf?.sid ?? mintFieldSid(aggSid, fieldName),
976
+ ...(pf?.was !== undefined && pf.was.length > 0 ? { was: pf.was } : {}),
977
+ // The CURRENT kind, always from the live module (continuity never supplies it).
978
+ ...(kind !== undefined ? { kind } : {}),
979
+ };
980
+ }
981
+ out[agg.id] = {
982
+ sid: aggSid,
983
+ ...(prior?.was !== undefined && prior.was.length > 0 ? { was: prior.was } : {}),
984
+ fields,
985
+ };
986
+ }
987
+ return { v: 1, domain: domainSid, aggregates: out };
988
+ }
989
+
891
990
  /**
892
991
  * Build the canonical semantic manifest for a domain module — purely from the
893
992
  * in-memory DSL objects. No file reads, no bundle, no esbuild: the manifest is a
@@ -919,6 +1018,7 @@ export function domainManifest(mod: DomainModule): CanonicalManifest {
919
1018
  // Omit-when-empty: a directive declaring NO boundary contributes neither key,
920
1019
  // so its canonical manifest is byte-identical to before the boundary existed.
921
1020
  ...canonicalReads(d.declaredReads),
1021
+ ...canonicalCapturedReads(d.declaredQueryReads ?? []),
922
1022
  ...canonicalEmits(d.declaredEmits),
923
1023
  ...canonicalCertifiedReads(d.declaredCertifiedReads),
924
1024
  ...canonicalRelations(d.declaredRelations),
@@ -939,6 +1039,9 @@ export function domainManifest(mod: DomainModule): CanonicalManifest {
939
1039
  domain: mod.name,
940
1040
  aggregates,
941
1041
  directives,
1042
+ // STABLE IDS (#58): always emitted by this compiler — the one deliberate
1043
+ // hash-mover (enforcement-era identity hashes move; named + expected).
1044
+ stableIds: stableIdsForModule(mod, aggregates),
942
1045
  ...canonicalWorkspaceInvariants(mod.workspaceInvariants),
943
1046
  ...canonicalQueries(mod.queries),
944
1047
  ...canonicalCounts(allCounts.length > 0 ? allCounts : undefined),
package/src/read.ts CHANGED
@@ -46,9 +46,38 @@ function host(): NomosRead {
46
46
  * result}` into the intent's read footprint — so the read is replayable and the write's premise is
47
47
  * committed. `query` is a declared, indexed {@link QueryDecl} (e.g. a `t.hasMany` inverse from
48
48
  * `hasManyIndexes`); `args` are its index-key values.
49
+ *
50
+ * DEPLOYABLE SINCE #58 — WHEN DECLARED: the sealed wasm engine provides `nomos.read` for law that
51
+ * DECLARES the captured-read lane — the directive whose plan reads must declare each query with
52
+ * `.reads(theQuery)` (which lands the `nomosReadGate` key in the law's USD-IR). The engine then
53
+ * serves the read LIVE from the PRE-APPLY committed state at author, captures `{queryId, args,
54
+ * result}` into the intent's `captured_ports.reads`, replays the capture at verify, and the one
55
+ * gate RE-DERIVES each captured read at the pre-apply position and byte-compares — a typed
56
+ * read-conflict refusal on drift (CAS as law). Undeclared `read()` in law with NO declared
57
+ * captured-read query still REFUSES TO PACKAGE (`nomos-compile` greps the bundled lump for
58
+ * {@link CAPTURED_READ_LUMP_MARKER}, which lives ONLY in this function's body and is tree-shaken
59
+ * away with it) — and an inert host (no `nomos.read`) still halts with the named error below.
49
60
  */
50
61
  export function read<T = unknown>(query: QueryDecl, args: Record<string, string>): T {
62
+ const h = host() as { read?: unknown } | undefined;
63
+ if (typeof h?.read !== "function") {
64
+ // The string below IS the compile gate's marker — keep it byte-identical to
65
+ // CAPTURED_READ_LUMP_MARKER (compile_package_main.ts greps the bundled lump for it).
66
+ throw new Error(
67
+ `read('${query.id}'): nomos.read is not provided by this host — the engine serves ` +
68
+ `captured reads only for law that DECLARES them: add .reads(${query.id}'s QueryDecl) ` +
69
+ `to the directive and recompile (#58).`,
70
+ );
71
+ }
51
72
  const result = host().read(query.id, args) as T;
52
73
  footprint.push({ queryId: query.id, args, result });
53
74
  return result;
54
75
  }
76
+
77
+ /**
78
+ * The compile gate's lump sentinel — the EXACT substring of the refusal `read()` throws above.
79
+ * It exists in a bundled engine lump iff `read` itself survived tree-shaking, i.e. iff some
80
+ * domain module actually references the captured-read lane. `nomos-compile` greps the lump for
81
+ * it and refuses to package (fail-closed: better a named compile refusal than a runtime halt).
82
+ */
83
+ export const CAPTURED_READ_LUMP_MARKER = "nomos.read is not provided by this host";
@@ -0,0 +1,226 @@
1
+ // NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
2
+ // remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
3
+ // wasm32-wasip1 artifact {kernel · projection · embedded
4
+ // QuickJS engine} on V8 + WASI-shim, byte-identical everywhere. V8 = portability; the one
5
+ // wasm = determinism. No native, no wasmtime, no 2nd artifact, no domain-JS on bare V8.
6
+ // If a file isn't this / hosting this / authoring for this / proving this — it's gone.
7
+
8
+ /**
9
+ * STABLE IDENTIFIERS (#58) — names are LABELS; identity is MINTED by the compiler at
10
+ * FIRST APPEARANCE and never dev-assigned.
11
+ *
12
+ * Every aggregate, every field, and the domain itself carry a compiler-minted stable
13
+ * id (`sid`). The sid is the entity's IDENTITY across law upgrades: the evolve gate
14
+ * diffs old vs new law BY SID (a rename — same sid, new label — is metadata and
15
+ * admits silently; a true removal/retype is a typed refusal unless the upgrade intent
16
+ * carries a disposition), and the projection folds via the sid lineage so a renamed
17
+ * aggregate's old-era rows cohere under the new label automatically.
18
+ *
19
+ * MINTING (first appearance, deterministic): a sid is the first 16 hex chars of
20
+ * sha256 over a versioned scope string — so a clean checkout re-mints the SAME id
21
+ * for the same first appearance (no lockfile needed for the common case):
22
+ *
23
+ * domain sid("domain:<domainName>")
24
+ * aggregate sid("aggregate:<domainSid>:<aggName>")
25
+ * field sid("field:<aggSid>:<fieldName>")
26
+ *
27
+ * Note the NESTING: field sids mint under the AGGREGATE SID (not its name), so a
28
+ * renamed aggregate's fields keep their identities for free.
29
+ *
30
+ * CONTINUITY (across compiles): `nomos-compile` reads the PRIOR build's identity
31
+ * manifests / the committed `nomos.stable-ids.json` lockfile and derives a continuity
32
+ * table per domain — match by name first; then the UNAMBIGUOUS-PAIR rule: exactly ONE
33
+ * disappeared name + exactly ONE same-shaped appeared name is an inferred RENAME (the
34
+ * sid is carried, the old label recorded in `was`, and the compile summary prints the
35
+ * inference). Anything still unmatched is left for THE EVOLVE GATE's typed question
36
+ * at deploy — the dev answers in the upgrade intent's disposition
37
+ * ({retired | retyped | rebinds}).
38
+ *
39
+ * `was` is the LABEL LINEAGE: every label this sid previously wore, oldest first.
40
+ * It is compiler-derived (never dev-written) and HASH-BEARING (it is law): the
41
+ * projection reads it to fold old-era field/type labels into the current ones.
42
+ *
43
+ * BUILD-TIME ONLY (imports `node:crypto`): reached via `@githolon/dsl/stable-ids` /
44
+ * `@githolon/dsl/manifest`, never the runtime barrel. The pure TYPE surface lives in
45
+ * `stable_ids_types.ts` so the runtime type graph stays node-free.
46
+ */
47
+ import { createHash } from "node:crypto";
48
+ import type { WireSchema } from "./wire.js";
49
+ import type { StableAggregateId, StableFieldId, StableIds } from "./stable_ids_types.js";
50
+
51
+ export type { StableAggregateId, StableFieldId, StableIds } from "./stable_ids_types.js";
52
+
53
+ /** Mint one stable id: first 16 hex chars of sha256 over the versioned scope. */
54
+ export function mintStableId(scope: string): string {
55
+ return createHash("sha256").update(`nomos-sid:v1:${scope}`, "utf8").digest("hex").slice(0, 16);
56
+ }
57
+
58
+ export function mintDomainSid(domainName: string): string {
59
+ return mintStableId(`domain:${domainName}`);
60
+ }
61
+
62
+ export function mintAggregateSid(domainSid: string, aggName: string): string {
63
+ return mintStableId(`aggregate:${domainSid}:${aggName}`);
64
+ }
65
+
66
+ export function mintFieldSid(aggSid: string, fieldName: string): string {
67
+ return mintStableId(`field:${aggSid}:${fieldName}`);
68
+ }
69
+
70
+ /** One inferred rename, printed by the compile summary (visibility for the silent lane). */
71
+ export interface InferredRename {
72
+ /** `"<Agg>"` for an aggregate rename, `"<Agg>.<field>"` for a field rename. */
73
+ readonly what: string;
74
+ readonly from: string;
75
+ readonly to: string;
76
+ readonly sid: string;
77
+ }
78
+
79
+ /**
80
+ * Derive the stable-id CONTINUITY for one domain from its PRIOR manifest's stableIds
81
+ * (the chain/build holds v's sids) against the CURRENT compile's names.
82
+ *
83
+ * 1. match by NAME — same name carries its sid + lineage forward;
84
+ * 2. the UNAMBIGUOUS-PAIR rule — exactly one disappeared + one appeared name of the
85
+ * same kind AND shape (aggregate: same field-name set; field: same driver AND
86
+ * same read kind, with exactly ONE matching appearance) is an inferred RENAME:
87
+ * the sid is carried, `was` extended;
88
+ * 3. anything else mints fresh at first appearance (a leftover disappearance is the
89
+ * EVOLVE GATE's question at deploy — never silently resolved here).
90
+ *
91
+ * Returns the continuity keyed by CURRENT names (exactly the shape `domainManifest`
92
+ * consumes) plus the inferred renames for the compile summary.
93
+ */
94
+ export function deriveStableIdContinuity(
95
+ domainName: string,
96
+ currentAggregates: { id: string; schema: WireSchema; kinds?: Record<string, string> }[],
97
+ prior: StableIds | undefined,
98
+ /** The prior canonical manifest's aggregate schemas (keyed by PRIOR name) — when
99
+ * supplied, tightens the field-pair rule to same-driver only. */
100
+ priorSchemas?: Record<string, WireSchema>,
101
+ ): { continuity: StableIds; inferred: InferredRename[] } {
102
+ const inferred: InferredRename[] = [];
103
+ const domainSid = prior?.domain ?? mintDomainSid(domainName);
104
+
105
+ const priorAggs = prior?.aggregates ?? {};
106
+ const currentNames = new Set(currentAggregates.map((a) => a.id));
107
+
108
+ // Pass 1 — name matches.
109
+ const resolved: Record<
110
+ string,
111
+ { sid: string; was?: string[]; priorFields?: Record<string, StableFieldId>; priorSchema?: WireSchema }
112
+ > = {};
113
+ for (const agg of currentAggregates) {
114
+ const p = priorAggs[agg.id];
115
+ if (p !== undefined) {
116
+ resolved[agg.id] = {
117
+ sid: p.sid,
118
+ ...(p.was !== undefined ? { was: p.was } : {}),
119
+ priorFields: p.fields,
120
+ ...(priorSchemas?.[agg.id] !== undefined ? { priorSchema: priorSchemas[agg.id] } : {}),
121
+ };
122
+ }
123
+ }
124
+
125
+ // Pass 2 — the unambiguous aggregate pair (one disappeared, one appeared, same shape).
126
+ const disappeared = Object.keys(priorAggs).filter((n) => !currentNames.has(n));
127
+ const appeared = currentAggregates.filter((a) => resolved[a.id] === undefined);
128
+ if (disappeared.length === 1 && appeared.length === 1) {
129
+ const oldName = disappeared[0]!;
130
+ const oldAgg = priorAggs[oldName]!;
131
+ const newAgg = appeared[0]!;
132
+ // Shape gate: the renamed aggregate must keep its field-name set (a rename is a
133
+ // LABEL move, not a remodel) — a remodel goes to the evolve gate's typed question.
134
+ const oldFields = Object.keys(oldAgg.fields).sort().join(" ");
135
+ const newFields = Object.keys(newAgg.schema).sort().join(" ");
136
+ if (oldFields === newFields) {
137
+ resolved[newAgg.id] = {
138
+ sid: oldAgg.sid,
139
+ was: [...(oldAgg.was ?? []), oldName],
140
+ priorFields: oldAgg.fields,
141
+ ...(priorSchemas?.[oldName] !== undefined ? { priorSchema: priorSchemas[oldName] } : {}),
142
+ };
143
+ inferred.push({ what: newAgg.id, from: oldName, to: newAgg.id, sid: oldAgg.sid });
144
+ }
145
+ }
146
+
147
+ // Per-aggregate field continuity (name match + the unambiguous field pair).
148
+ const aggregates: Record<string, StableAggregateId> = {};
149
+ for (const agg of currentAggregates) {
150
+ const r = resolved[agg.id];
151
+ const aggSid = r?.sid ?? mintAggregateSid(domainSid, agg.id);
152
+ const priorFields = r?.priorFields ?? {};
153
+ const fieldNames = Object.keys(agg.schema);
154
+ const fields: Record<string, StableFieldId> = {};
155
+ for (const f of fieldNames) {
156
+ const pf = priorFields[f];
157
+ if (pf !== undefined) {
158
+ fields[f] = { sid: pf.sid, ...(pf.was !== undefined ? { was: pf.was } : {}) };
159
+ }
160
+ }
161
+ const fDisappeared = Object.keys(priorFields).filter((n) => !fieldNames.includes(n));
162
+ const fAppeared = fieldNames.filter((n) => fields[n] === undefined);
163
+ if (fDisappeared.length === 1 && fAppeared.length >= 1) {
164
+ const oldF = fDisappeared[0]!;
165
+ // Shape gate for a field rename: the DRIVER (merge law) AND the KIND (read
166
+ // type) must be unchanged — a rename is a LABEL move. Among the appeared
167
+ // names, the pair binds IFF EXACTLY ONE matches the disappeared field's
168
+ // shape (so "rename + add a field" in one release still infers; two
169
+ // same-shaped appearances stay ambiguous — the evolve gate asks).
170
+ const priorDriver = priorFieldDriver(r, oldF);
171
+ const priorKind = priorFields[oldF]?.kind;
172
+ const candidates = fAppeared.filter((newF) => {
173
+ const driverOk =
174
+ priorDriver === undefined || priorDriver === JSON.stringify(agg.schema[newF]);
175
+ const newKind = agg.kinds?.[newF];
176
+ const kindOk = priorKind === undefined || newKind === undefined || priorKind === newKind;
177
+ return driverOk && kindOk;
178
+ });
179
+ if (candidates.length === 1) {
180
+ const newF = candidates[0]!;
181
+ const pf = priorFields[oldF]!;
182
+ fields[newF] = { sid: pf.sid, was: [...(pf.was ?? []), oldF] };
183
+ inferred.push({ what: `${agg.id}.${newF}`, from: oldF, to: newF, sid: pf.sid });
184
+ }
185
+ }
186
+ for (const f of fieldNames) {
187
+ if (fields[f] === undefined) fields[f] = { sid: mintFieldSid(aggSid, f) };
188
+ }
189
+ aggregates[agg.id] = {
190
+ sid: aggSid,
191
+ ...(r?.was !== undefined ? { was: r.was } : {}),
192
+ fields,
193
+ };
194
+ }
195
+
196
+ return { continuity: { v: 1, domain: domainSid, aggregates }, inferred };
197
+ }
198
+
199
+ /** The prior driver snapshot for a field, when the caller threaded the prior canonical
200
+ * manifest's schemas through `priorSchemas` (the stableIds block itself carries no
201
+ * drivers). Returns `undefined` (pair allowed on name-cardinality alone) when no
202
+ * snapshot is available. */
203
+ function priorFieldDriver(
204
+ resolved: { priorSchema?: WireSchema } | undefined,
205
+ oldField: string,
206
+ ): string | undefined {
207
+ const schema = resolved?.priorSchema;
208
+ if (schema === undefined) return undefined;
209
+ const d = schema[oldField];
210
+ return d === undefined ? undefined : JSON.stringify(d);
211
+ }
212
+
213
+ /** Parse the `stableIds` block out of one PRIOR canonical-manifest JSON string —
214
+ * tolerant (absent/era-old manifests return `undefined`, never a throw). */
215
+ export function stableIdsFromManifestJson(manifestJson: string): StableIds | undefined {
216
+ try {
217
+ const parsed = JSON.parse(manifestJson) as { stableIds?: StableIds };
218
+ const s = parsed.stableIds;
219
+ if (s && s.v === 1 && typeof s.domain === "string" && s.aggregates && typeof s.aggregates === "object") {
220
+ return s;
221
+ }
222
+ return undefined;
223
+ } catch {
224
+ return undefined;
225
+ }
226
+ }
@@ -0,0 +1,40 @@
1
+ // NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
2
+ // remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
3
+ // wasm32-wasip1 artifact {kernel · projection · embedded
4
+ // QuickJS engine}. If a file isn't this / hosting this / authoring for this / proving this — it's gone.
5
+
6
+ /**
7
+ * STABLE-ID TYPES (#58) — the pure type surface of `stable_ids.ts`, split out so the
8
+ * RUNTIME type graph (DomainModule's optional `stableIdContinuity`) never drags
9
+ * `node:crypto` into a tenant scaffold's typecheck (scaffolds compile without
10
+ * `@types/node`). The minting/derivation machinery lives in `stable_ids.ts`
11
+ * (build-time only — `nomos-compile` + `@githolon/dsl/manifest`).
12
+ */
13
+
14
+ /** One field's stable identity: the minted sid + the labels it previously wore. */
15
+ export interface StableFieldId {
16
+ readonly sid: string;
17
+ /** Prior labels of this sid, oldest first. OMITTED when the label never changed. */
18
+ readonly was?: string[];
19
+ /** The field's READ KIND (`string`/`int`/`json`/…): the retype arm of the evolve
20
+ * gate diffs it per sid (a driver change alone misses `t.string()` → `t.int()`).
21
+ * Always set at emission; continuity input may omit it (the current module is the
22
+ * source of the CURRENT kind). */
23
+ readonly kind?: string;
24
+ }
25
+
26
+ /** One aggregate's stable identity: sid + label lineage + per-field sids (keyed by CURRENT field name). */
27
+ export interface StableAggregateId {
28
+ readonly sid: string;
29
+ readonly was?: string[];
30
+ readonly fields: Record<string, StableFieldId>;
31
+ }
32
+
33
+ /** A domain's full stable-id surface — the `stableIds` canonical-manifest key / `nomosStableIds` USD key. */
34
+ export interface StableIds {
35
+ readonly v: 1;
36
+ /** The domain's own minted sid. */
37
+ readonly domain: string;
38
+ /** Aggregates keyed by CURRENT name (the label). */
39
+ readonly aggregates: Record<string, StableAggregateId>;
40
+ }
package/src/usd.ts CHANGED
@@ -188,6 +188,31 @@ export interface UsdLayer {
188
188
  /** Declared workspace invariants `{id, on}` the gate executes, sorted by id. */
189
189
  readonly workspaceInvariants?: { id: string; on: string }[];
190
190
  };
191
+ /**
192
+ * STABLE IDENTIFIERS (#58 — names are labels, identity is minted). The layer's
193
+ * domain/aggregate/field sids + label lineages, carried verbatim from the canonical
194
+ * manifest's `stableIds`. ALWAYS present on post-#58-compiled law (the one
195
+ * deliberate hash-mover); ABSENT on every bundle compiled before — the era key the
196
+ * EVOLVE GATE and the projection's sid-lineage fold switch on. A law-upgrade deploy
197
+ * whose old AND new packages both carry this key is DIFFED BY SID at the one gate.
198
+ */
199
+ readonly nomosStableIds?: import("./stable_ids_types.js").StableIds;
200
+ /**
201
+ * THE CAPTURED-READ GATE DECLARATION (#58). Present IFF ≥1 directive of this layer
202
+ * declares a captured-read query (`.reads(someQuery)`): the law's EXPLICIT opt-in
203
+ * to `nomos.read` at the one gate — the engine serves declared reads LIVE from the
204
+ * pre-apply state at author, replays the captured footprint at verify, and the gate
205
+ * RE-DERIVES each captured read at the pre-apply position (typed read-conflict on
206
+ * drift — CAS as law). OMITTED for read-free law (byte-identical hash — the exact
207
+ * `nomosInvariantGate` era discipline; bundles without the key behave as before).
208
+ */
209
+ readonly nomosReadGate?: {
210
+ readonly v: 1;
211
+ /** Every declared read query's recipe, keyed by query id. */
212
+ readonly queries: Record<string, { key: string[]; returns: string }>;
213
+ /** directive id → the SORTED query ids its plan may read. */
214
+ readonly directives: Record<string, string[]>;
215
+ };
191
216
  }
192
217
 
193
218
  /** The reserved variant name declaring a set's DEFAULT (USD has a default variant). */
@@ -267,12 +292,41 @@ export function emitUsd(
267
292
  const wsInvariants = [...(l.module.workspaceInvariants ?? [])]
268
293
  .map((w) => ({ id: w.id, on: w.on }))
269
294
  .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
295
+ // THE CAPTURED-READ GATE DECLARATION (#58): collected from the canonical
296
+ // manifest's per-directive `capturedReads` (omit-when-empty — read-free law's
297
+ // USD is byte-identical apart from the always-on stableIds key).
298
+ const rgQueries = new Map<string, { key: string[]; returns: string }>();
299
+ const rgDirectives = new Map<string, string[]>();
300
+ for (const d of manifest.directives) {
301
+ if (d.capturedReads === undefined || d.capturedReads.length === 0) continue;
302
+ rgDirectives.set(d.id, d.capturedReads.map((r) => r.queryId));
303
+ for (const r of d.capturedReads) {
304
+ rgQueries.set(r.queryId, { key: [...r.key], returns: r.returns });
305
+ }
306
+ }
307
+ // SORTED-KEY records (byte-determinism — the layered flatten must reproduce
308
+ // these bytes exactly; see usd_layers.ts).
309
+ const readGateQueries: Record<string, { key: string[]; returns: string }> = {};
310
+ for (const k of [...rgQueries.keys()].sort()) readGateQueries[k] = rgQueries.get(k)!;
311
+ const readGateDirectives: Record<string, string[]> = {};
312
+ for (const k of [...rgDirectives.keys()].sort()) readGateDirectives[k] = rgDirectives.get(k)!;
270
313
  return {
271
314
  path: l.path,
272
315
  prims: encodeModuleToPrims(l.path, l.module),
273
316
  ...(manifest.queries !== undefined ? { queries: manifest.queries } : {}),
274
317
  ...(manifest.deriveds !== undefined ? { deriveds: manifest.deriveds } : {}),
275
318
  ...(manifest.combineds !== undefined ? { combineds: manifest.combineds } : {}),
319
+ // STABLE IDS (#58): every post-#58 compile carries them (the era key).
320
+ ...(manifest.stableIds !== undefined ? { nomosStableIds: manifest.stableIds } : {}),
321
+ ...(Object.keys(readGateDirectives).length > 0
322
+ ? {
323
+ nomosReadGate: {
324
+ v: 1 as const,
325
+ queries: readGateQueries,
326
+ directives: readGateDirectives,
327
+ },
328
+ }
329
+ : {}),
276
330
  ...(invariantAggregates.length > 0 || wsInvariants.length > 0
277
331
  ? {
278
332
  nomosInvariantGate: {
package/src/usd_layers.ts CHANGED
@@ -340,6 +340,14 @@ export function usdaLayerText(
340
340
  ` {`,
341
341
  ` custom string nomos:kind = "domain"`,
342
342
  ` custom string nomos:domain = ${usdaString(domain)}`,
343
+ // STABLE IDS + the captured-read gate (#58): layer-level law keys, hex-carried
344
+ // so the .usda round-trips byte-faithfully to the IR layer.
345
+ ...(layer.nomosStableIds !== undefined
346
+ ? [` custom string nomos:stableIdsHex = ${usdaString(hexUtf8(JSON.stringify(layer.nomosStableIds)))}`]
347
+ : []),
348
+ ...(layer.nomosReadGate !== undefined
349
+ ? [` custom string nomos:readGateHex = ${usdaString(hexUtf8(JSON.stringify(layer.nomosReadGate)))}`]
350
+ : []),
343
351
  ``,
344
352
  ...body,
345
353
  ` }`,
@@ -456,12 +464,52 @@ export function flattenLayeredUsd(layers: readonly UsdLayer[]): UsdDocument {
456
464
  const queries = foldById<UsdQuery>(stack.map((l) => l.queries));
457
465
  const deriveds = foldById<UsdDerived>(stack.map((l) => l.deriveds));
458
466
  const combineds = foldById<UsdCombined>(stack.map((l) => l.combineds));
467
+ // STABLE IDS (#58): fold per-module stableIds — domain sid from the latest
468
+ // declaring layer (they coincide by construction: one domain, one mint scope),
469
+ // aggregates merged later-wins per CURRENT name, SORTED keys (byte-determinism
470
+ // against the composed emission's sorted-aggregate insertion order).
471
+ let stableIds: UsdLayer["nomosStableIds"];
472
+ {
473
+ const aggs = new Map<string, NonNullable<UsdLayer["nomosStableIds"]>["aggregates"][string]>();
474
+ let domainSid: string | undefined;
475
+ for (const l of stack) {
476
+ if (l.nomosStableIds === undefined) continue;
477
+ domainSid = l.nomosStableIds.domain;
478
+ for (const [name, a] of Object.entries(l.nomosStableIds.aggregates)) aggs.set(name, a);
479
+ }
480
+ if (domainSid !== undefined) {
481
+ const aggregates: NonNullable<UsdLayer["nomosStableIds"]>["aggregates"] = {};
482
+ for (const k of [...aggs.keys()].sort()) aggregates[k] = aggs.get(k)!;
483
+ stableIds = { v: 1, domain: domainSid, aggregates };
484
+ }
485
+ }
486
+ // The captured-read gate (#58): queries + per-directive read sets merged
487
+ // later-wins, SORTED keys (matching the composed emission).
488
+ let readGate: UsdLayer["nomosReadGate"];
489
+ {
490
+ const q = new Map<string, { key: string[]; returns: string }>();
491
+ const d = new Map<string, string[]>();
492
+ for (const l of stack) {
493
+ if (l.nomosReadGate === undefined) continue;
494
+ for (const [id, spec] of Object.entries(l.nomosReadGate.queries)) q.set(id, spec);
495
+ for (const [id, reads] of Object.entries(l.nomosReadGate.directives)) d.set(id, reads);
496
+ }
497
+ if (d.size > 0) {
498
+ const queriesRec: Record<string, { key: string[]; returns: string }> = {};
499
+ for (const k of [...q.keys()].sort()) queriesRec[k] = q.get(k)!;
500
+ const directivesRec: Record<string, string[]> = {};
501
+ for (const k of [...d.keys()].sort()) directivesRec[k] = d.get(k)!;
502
+ readGate = { v: 1, queries: queriesRec, directives: directivesRec };
503
+ }
504
+ }
459
505
  return {
460
506
  path,
461
507
  prims: [...prims.keys()].sort().map((p) => prims.get(p)!),
462
508
  ...(queries !== undefined ? { queries } : {}),
463
509
  ...(deriveds !== undefined ? { deriveds } : {}),
464
510
  ...(combineds !== undefined ? { combineds } : {}),
511
+ ...(stableIds !== undefined ? { nomosStableIds: stableIds } : {}),
512
+ ...(readGate !== undefined ? { nomosReadGate: readGate } : {}),
465
513
  };
466
514
  });
467
515
 
@@ -579,9 +627,21 @@ function attrStringArray(prim: ParsedPrim, name: string): string[] {
579
627
  export function parseUsdaDocument(text: string): UsdDocument {
580
628
  const prims = parseUsdaPrims(text);
581
629
  const domainByScope = new Map<string, string>();
630
+ const stableIdsByDomain = new Map<string, UsdLayer["nomosStableIds"]>();
631
+ const readGateByDomain = new Map<string, UsdLayer["nomosReadGate"]>();
582
632
  for (const p of prims) {
583
633
  if (p.attrs.get("nomos:kind") === "domain") {
584
- domainByScope.set(p.path, attrString(p, "nomos:domain"));
634
+ const domain = attrString(p, "nomos:domain");
635
+ domainByScope.set(p.path, domain);
636
+ // STABLE IDS + the captured-read gate (#58): the hex-carried layer-level keys.
637
+ const sidHex = p.attrs.get("nomos:stableIdsHex");
638
+ if (typeof sidHex === "string") {
639
+ stableIdsByDomain.set(domain, JSON.parse(unhexUtf8(sidHex)) as UsdLayer["nomosStableIds"]);
640
+ }
641
+ const rgHex = p.attrs.get("nomos:readGateHex");
642
+ if (typeof rgHex === "string") {
643
+ readGateByDomain.set(domain, JSON.parse(unhexUtf8(rgHex)) as UsdLayer["nomosReadGate"]);
644
+ }
585
645
  }
586
646
  }
587
647
 
@@ -669,12 +729,16 @@ export function parseUsdaDocument(text: string): UsdDocument {
669
729
  const layers: UsdLayer[] = [...buckets.keys()]
670
730
  .map((domain) => {
671
731
  const b = buckets.get(domain)!;
732
+ const sids = stableIdsByDomain.get(domain);
733
+ const rg = readGateByDomain.get(domain);
672
734
  return {
673
735
  path: `/Nomos/${domain}`,
674
736
  prims: [...b.prims].sort(byPath),
675
737
  ...(b.queries.length > 0 ? { queries: [...b.queries].sort(byId) } : {}),
676
738
  ...(b.deriveds.length > 0 ? { deriveds: [...b.deriveds].sort(byId) } : {}),
677
739
  ...(b.combineds.length > 0 ? { combineds: [...b.combineds].sort(byId) } : {}),
740
+ ...(sids !== undefined ? { nomosStableIds: sids } : {}),
741
+ ...(rg !== undefined ? { nomosReadGate: rg } : {}),
678
742
  };
679
743
  })
680
744
  .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
@@ -128,6 +128,21 @@ function encodeKernelFieldOp(op: FieldOp, field: Field | undefined): WireFieldOp
128
128
  *
129
129
  * `agg` is the directive's declared target handle, used to resolve field kinds
130
130
  * when encoding values; ops emitted to sibling aggregates encode by value-kind.
131
+ *
132
+ * ⚠️ THIS IS PLAN LOWERING ONLY — NOT A DOMAIN TEST HARNESS. It runs your
133
+ * directive's `plan()` and encodes the resulting ops into the wire Intent shape.
134
+ * It does NOT touch the kernel: it does not admit through the gate (no
135
+ * invariants, no minted-id check, no terminal-once, no captured reads), it does
136
+ * not fold state, and it requires a hand-provided `ctx` (`deterministicPorts`)
137
+ * with a stub `nomos.mint`. Use it to ASSERT THE BYTES a plan lowers to (the
138
+ * `impure_capability`/`capability` lowering tests are the canonical use), never
139
+ * to test domain LIFECYCLE.
140
+ *
141
+ * For a KERNEL-BACKED local harness — admission through the real gate, folds,
142
+ * reads, the whole lifecycle on the same engine plane the cloud edge runs — use
143
+ * the CLI: `githolon proof` (the generated proof's offline legs on a local
144
+ * holon) and `githolon dev` (the watch→recompile→law-live→proof inner loop).
145
+ * Those mint ids, run the gate, and fold — exactly what this function does NOT.
131
146
  */
132
147
  export function executeDirectiveToIntent<P>(
133
148
  directive: Directive<P>,