@githolon/dsl 0.105.5 → 0.105.6

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.
@@ -0,0 +1,123 @@
1
+ import type { StableIds } from "./stable_ids_types.js";
2
+ import type { WireSchema } from "./wire.js";
3
+ import { mintGlobalAggregateSid } from "./stable_ids.js";
4
+ import {
5
+ LAW_FRAMEWORK_PARENT,
6
+ emptyLawIdentityRegistry,
7
+ lawEntityKey,
8
+ validateLawIdentityRegistry,
9
+ type LawIdentityEntity,
10
+ type LawIdentityRegistry,
11
+ } from "./law_identity.js";
12
+
13
+ export interface CompilerIdentityDomain {
14
+ stableIds: StableIds;
15
+ schemas?: Record<string, WireSchema>;
16
+ }
17
+
18
+ /** The materialized lock shape consumed inside the compiler and authoring tools. */
19
+ export interface CompilerIdentityModel {
20
+ v: 1;
21
+ domains: Record<string, CompilerIdentityDomain>;
22
+ /** Compiler-owned entity identity, including retirements. Semantics remain exclusively in USDA. */
23
+ identity?: LawIdentityRegistry;
24
+ }
25
+
26
+ /**
27
+ * Derive the compiler entity registry from domain/aggregate/field SIDs when a first-generation
28
+ * identity layer has no explicit registry yet. Once present, that registry retains every retirement.
29
+ */
30
+ export function identityRegistryFromModel(
31
+ model: CompilerIdentityModel | undefined,
32
+ ): LawIdentityRegistry {
33
+ if (model?.identity !== undefined) {
34
+ validateLawIdentityRegistry(model.identity);
35
+ return model.identity;
36
+ }
37
+ if (model === undefined) return emptyLawIdentityRegistry();
38
+
39
+ const entities: Record<string, LawIdentityEntity> = {};
40
+ const aggregateOccurrences = new Map<string, number>();
41
+ for (const domain of Object.values(model.domains)) {
42
+ for (const aggregate of Object.values(domain.stableIds.aggregates)) {
43
+ aggregateOccurrences.set(aggregate.sid, (aggregateOccurrences.get(aggregate.sid) ?? 0) + 1);
44
+ }
45
+ }
46
+
47
+ for (const [domainName, domain] of Object.entries(model.domains).sort(([a], [b]) =>
48
+ compareText(a, b),
49
+ )) {
50
+ const domainKey = importIdentityEntity(entities, {
51
+ kind: "domain",
52
+ sid: domain.stableIds.domain,
53
+ label: domainName,
54
+ generation: 0,
55
+ status: "active",
56
+ });
57
+ for (const [aggregateName, aggregate] of Object.entries(domain.stableIds.aggregates).sort(
58
+ ([a], [b]) => compareText(a, b),
59
+ )) {
60
+ const parent =
61
+ (aggregateOccurrences.get(aggregate.sid) ?? 0) > 1 ||
62
+ aggregate.sid === mintGlobalAggregateSid(aggregateName)
63
+ ? LAW_FRAMEWORK_PARENT
64
+ : domainKey;
65
+ const aggregateKey = importIdentityEntity(entities, {
66
+ kind: "aggregate",
67
+ sid: aggregate.sid,
68
+ parent,
69
+ label: aggregateName,
70
+ ...(aggregate.was === undefined ? {} : { priorLabels: aggregate.was }),
71
+ generation: 0,
72
+ status: "active",
73
+ });
74
+ for (const [fieldName, field] of Object.entries(aggregate.fields).sort(([a], [b]) =>
75
+ compareText(a, b),
76
+ )) {
77
+ importIdentityEntity(entities, {
78
+ kind: "field",
79
+ sid: field.sid,
80
+ parent: aggregateKey,
81
+ label: fieldName,
82
+ ...(field.was === undefined ? {} : { priorLabels: field.was }),
83
+ generation: 0,
84
+ status: "active",
85
+ });
86
+ }
87
+ }
88
+ }
89
+
90
+ const registry: LawIdentityRegistry = {
91
+ v: 1,
92
+ entities: Object.fromEntries(Object.entries(entities).sort(([a], [b]) => compareText(a, b))),
93
+ };
94
+ validateLawIdentityRegistry(registry);
95
+ return registry;
96
+ }
97
+
98
+ function importIdentityEntity(
99
+ entities: Record<string, LawIdentityEntity>,
100
+ entity: LawIdentityEntity,
101
+ ): string {
102
+ const key = lawEntityKey(entity.kind, entity.sid);
103
+ const prior = entities[key];
104
+ if (prior === undefined) {
105
+ entities[key] = entity;
106
+ return key;
107
+ }
108
+ if (
109
+ prior.kind !== entity.kind ||
110
+ prior.parent !== entity.parent ||
111
+ prior.label !== entity.label ||
112
+ JSON.stringify(prior.priorLabels ?? []) !== JSON.stringify(entity.priorLabels ?? [])
113
+ ) {
114
+ throw new Error(
115
+ `NMS-ID-002 legacy SID ${key} aliases incompatible ${prior.kind} '${prior.label}' and ${entity.kind} '${entity.label}'`,
116
+ );
117
+ }
118
+ return key;
119
+ }
120
+
121
+ function compareText(a: string, b: string): number {
122
+ return a < b ? -1 : a > b ? 1 : 0;
123
+ }
@@ -0,0 +1,548 @@
1
+ // NOMOS — COMPILER IDENTITY CUSTODY, IN TYPED USDA (W4).
2
+ //
3
+ // This is the committed compiler identity layer. The compiler pipeline is
4
+ // `.ts -> .usda` with nothing else in between: the only committed artifact carrying identity
5
+ // forward across compiles is this layer, and it is USDA like everything else Nomos remembers.
6
+ //
7
+ // WHY A COMMITTED LAYER AT ALL. The emitted law closure encodes every ACTIVE entity's identity, but
8
+ // `build/` is deliberately ignored and therefore cannot carry author-time continuity through a clean
9
+ // clone. This small committed layer carries the complete current identity plus every retirement. It
10
+ // is not executable law and no runtime consumes it; it is the compiler's one durable identity source.
11
+ //
12
+ // NO JSON, INCLUDING FOR OPAQUE DESCRIPTORS. A field's canonical value `shape` and merge `driver`
13
+ // are compiler-derived structured values. They are written here through the generic typed-USDA value
14
+ // codec below rather than stringified, so this file has no JSON in it at any depth.
15
+ import {
16
+ emptyLawIdentityRegistry,
17
+ lawEntityKeyParts,
18
+ validateLawIdentityRegistry,
19
+ type LawIdentityEntity,
20
+ type LawIdentityRegistry,
21
+ type LawIdentityStatus,
22
+ } from "./law_identity.js";
23
+ import type { StableAggregateId, StableFieldId, StableIds } from "./stable_ids_types.js";
24
+ import type { CompilerIdentityModel } from "./identity_model.js";
25
+ import type { WireSchema } from "./wire.js";
26
+
27
+ export const NOMOS_IDENTITY_LAYER_FORMAT = "nomos.identity.v1";
28
+ /** The committed identity layer's filename, beside the package config. */
29
+ export const NOMOS_IDENTITY_FILENAME = "nomos.identity.usda";
30
+
31
+ function quote(value: string): string {
32
+ return `"${value.replace(/[\\"\u0000-\u001f]/gu, (character) => {
33
+ switch (character) {
34
+ case "\\": return "\\\\";
35
+ case '"': return '\\"';
36
+ case "\n": return "\\n";
37
+ case "\r": return "\\r";
38
+ case "\t": return "\\t";
39
+ case "\b": return "\\b";
40
+ case "\f": return "\\f";
41
+ default: return `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`;
42
+ }
43
+ })}"`;
44
+ }
45
+
46
+ function parseQuoted(raw: string): string {
47
+ const value = raw.trim();
48
+ if (!value.startsWith('"') || !value.endsWith('"')) {
49
+ throw new Error(`expected a quoted USDA string, got ${value}`);
50
+ }
51
+ let out = "";
52
+ for (let index = 1; index < value.length - 1; index += 1) {
53
+ const character = value[index]!;
54
+ if (character !== "\\") {
55
+ out += character;
56
+ continue;
57
+ }
58
+ index += 1;
59
+ const escaped = value[index];
60
+ if (escaped === undefined || index >= value.length - 1) throw new Error("unterminated USDA escape");
61
+ if (escaped === "u") {
62
+ const digits = value.slice(index + 1, index + 5);
63
+ if (!/^[0-9a-fA-F]{4}$/u.test(digits)) throw new Error(`invalid USDA unicode escape \\u${digits}`);
64
+ out += String.fromCharCode(Number.parseInt(digits, 16));
65
+ index += 4;
66
+ continue;
67
+ }
68
+ const decoded: Record<string, string> = {
69
+ "\\": "\\",
70
+ '"': '"',
71
+ n: "\n",
72
+ r: "\r",
73
+ t: "\t",
74
+ b: "\b",
75
+ f: "\f",
76
+ };
77
+ const replacement = decoded[escaped];
78
+ if (replacement === undefined) throw new Error(`unsupported USDA escape \\${escaped}`);
79
+ out += replacement;
80
+ }
81
+ return out;
82
+ }
83
+
84
+ function parseStringList(raw: string): string[] {
85
+ const value = raw.trim();
86
+ if (!value.startsWith("[") || !value.endsWith("]")) throw new Error(`expected a USDA array, got ${value}`);
87
+ const body = value.slice(1, -1).trim();
88
+ if (body === "") return [];
89
+ const items: string[] = [];
90
+ let start = 0;
91
+ let quoted = false;
92
+ let escaped = false;
93
+ for (let index = 0; index < body.length; index += 1) {
94
+ const character = body[index]!;
95
+ if (escaped) {
96
+ escaped = false;
97
+ continue;
98
+ }
99
+ if (quoted && character === "\\") {
100
+ escaped = true;
101
+ continue;
102
+ }
103
+ if (character === '"') quoted = !quoted;
104
+ if (!quoted && character === ",") {
105
+ items.push(parseQuoted(body.slice(start, index)));
106
+ start = index + 1;
107
+ }
108
+ }
109
+ if (quoted) throw new Error("unterminated USDA string array");
110
+ items.push(parseQuoted(body.slice(start)));
111
+ return items;
112
+ }
113
+ const stringList = (items: readonly string[]): string =>
114
+ `[${items.map((item) => quote(item)).join(", ")}]`;
115
+ const pad = (indent: number): string => " ".repeat(indent);
116
+
117
+ // ── generic typed-USDA value codec ─────────────────────────────────────────────────────────────
118
+ // A scalar becomes a typed `nomos:value`; a container becomes a Scope whose children are its
119
+ // members. `nomos:valueKind` names the shape so the reader never guesses, and member NAMES are
120
+ // carried in `nomos:key` rather than in the prim name — a JSON object key is arbitrary text and
121
+ // must not be forced through prim-name sanitisation, which is lossy and can collide.
122
+
123
+ type PlainValue = unknown;
124
+
125
+ function emitValue(value: PlainValue, primName: string, indent: number, key?: string): string[] {
126
+ const p = pad(indent);
127
+ const head = [`${p}def Scope ${quote(primName)}`, `${p}{`];
128
+ const keyLine = key === undefined ? [] : [`${p} custom uniform string nomos:key = ${quote(key)}`];
129
+ const close = [`${p}}`];
130
+
131
+ if (value === null || value === undefined) {
132
+ return [...head, ...keyLine, `${p} custom uniform string nomos:valueKind = "null"`, ...close];
133
+ }
134
+ if (typeof value === "string") {
135
+ return [
136
+ ...head, ...keyLine,
137
+ `${p} custom uniform string nomos:valueKind = "string"`,
138
+ `${p} custom uniform string nomos:value = ${quote(value)}`,
139
+ ...close,
140
+ ];
141
+ }
142
+ if (typeof value === "boolean") {
143
+ return [
144
+ ...head, ...keyLine,
145
+ `${p} custom uniform string nomos:valueKind = "bool"`,
146
+ `${p} custom uniform bool nomos:value = ${value ? "true" : "false"}`,
147
+ ...close,
148
+ ];
149
+ }
150
+ if (typeof value === "number") {
151
+ const isInt = Number.isInteger(value);
152
+ return [
153
+ ...head, ...keyLine,
154
+ `${p} custom uniform string nomos:valueKind = ${isInt ? '"int"' : '"double"'}`,
155
+ `${p} custom uniform ${isInt ? "int64" : "double"} nomos:value = ${isInt ? String(value) : String(value)}`,
156
+ ...close,
157
+ ];
158
+ }
159
+ if (Array.isArray(value)) {
160
+ return [
161
+ ...head, ...keyLine,
162
+ `${p} custom uniform string nomos:valueKind = "array"`,
163
+ ...value.flatMap((item, index) => emitValue(item, `i_${index}`, indent + 4)),
164
+ ...close,
165
+ ];
166
+ }
167
+ const entries = Object.entries(value as Record<string, PlainValue>)
168
+ .filter(([, member]) => member !== undefined)
169
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
170
+ return [
171
+ ...head, ...keyLine,
172
+ `${p} custom uniform string nomos:valueKind = "object"`,
173
+ ...entries.flatMap(([memberKey, member], index) => emitValue(member, `m_${index}`, indent + 4, memberKey)),
174
+ ...close,
175
+ ];
176
+ }
177
+
178
+ // ── reader ─────────────────────────────────────────────────────────────────────────────────────
179
+ // A deliberately small structural reader for the exact subset this compiler writes. It is not a
180
+ // general USD parser: it reads `def Scope "<name>" { … }` nesting and `custom uniform <type>
181
+ // nomos:<attr> = <literal>` attributes, which is all this layer ever contains.
182
+
183
+ interface Prim {
184
+ readonly name: string;
185
+ readonly attrs: Record<string, unknown>;
186
+ readonly children: Prim[];
187
+ }
188
+
189
+ // Attributes are written bare when the `NomosEntity` schema declares their type, and with
190
+ // `custom uniform` only where a prim carries something the schema does not describe.
191
+ const ATTR = /^\s*(?:custom\s+)?(?:uniform\s+)?(\S+)\s+nomos:([A-Za-z0-9_]+)\s*=\s*(.+?)\s*$/;
192
+ const REL = /^\s*rel\s+nomos:([A-Za-z0-9_]+)\s*=\s*<([^>]+)>\s*$/;
193
+ // `def Scope "X"`, `def NomosEntity "X"` — a typed prim names its schema between the keyword and the name.
194
+ const OPEN = /^\s*(?:def|over)\s+(?:[A-Za-z_][A-Za-z0-9_]*\s+)?"((?:[^"\\]|\\.)*)"\s*$/;
195
+
196
+ function parseLiteral(type: string, raw: string): unknown {
197
+ if (type.endsWith("[]")) {
198
+ return parseStringList(raw);
199
+ }
200
+ if (type === "bool") return raw === "true";
201
+ if (type === "int" || type === "int64" || type === "double") return Number(raw);
202
+ return parseQuoted(raw);
203
+ }
204
+
205
+ function parsePrims(lines: readonly string[], start: number, out: Prim[]): number {
206
+ let index = start;
207
+ while (index < lines.length) {
208
+ const line = lines[index] ?? "";
209
+ if (/^\s*}\s*$/.test(line)) return index + 1;
210
+ const open = OPEN.exec(line);
211
+ if (open === null) { index += 1; continue; }
212
+ let cursor = index + 1;
213
+ while (cursor < lines.length && !/^\s*\{\s*$/.test(lines[cursor] ?? "")) cursor += 1;
214
+ const prim: Prim = { name: parseQuoted(`"${open[1] ?? ""}"`), attrs: {}, children: [] };
215
+ cursor += 1;
216
+ // consume this prim's own attributes, then recurse into nested prims
217
+ while (cursor < lines.length) {
218
+ const relation = REL.exec(lines[cursor] ?? "");
219
+ if (relation !== null) {
220
+ prim.attrs[relation[1] ?? ""] = relation[2] ?? "";
221
+ cursor += 1;
222
+ continue;
223
+ }
224
+ const attr = ATTR.exec(lines[cursor] ?? "");
225
+ if (attr !== null) {
226
+ prim.attrs[attr[2] ?? ""] = parseLiteral(attr[1] ?? "", attr[3] ?? "");
227
+ cursor += 1;
228
+ continue;
229
+ }
230
+ break;
231
+ }
232
+ cursor = parsePrims(lines, cursor, prim.children);
233
+ out.push(prim);
234
+ index = cursor;
235
+ }
236
+ return index;
237
+ }
238
+
239
+ function readValue(prim: Prim): PlainValue {
240
+ const kind = prim.attrs.valueKind as string | undefined;
241
+ if (kind === "null" || kind === undefined) return null;
242
+ if (kind === "string" || kind === "bool" || kind === "int" || kind === "double") return prim.attrs.value;
243
+ if (kind === "array") return prim.children.map(readValue);
244
+ const record: Record<string, PlainValue> = {};
245
+ for (const child of prim.children) {
246
+ const key = child.attrs.key as string | undefined;
247
+ if (key !== undefined) record[key] = readValue(child);
248
+ }
249
+ return record;
250
+ }
251
+
252
+ function childNamed(prim: Prim | undefined, name: string): Prim | undefined {
253
+ return prim?.children.find((child) => child.name === name);
254
+ }
255
+
256
+ // ── identity layer ─────────────────────────────────────────────────────────────────────────────
257
+
258
+ function emitField(label: string, field: StableFieldId, indent: number): string[] {
259
+ const p = pad(indent);
260
+ const inner = pad(indent + 4);
261
+ return [
262
+ `${p}def Scope ${quote(`f_${field.sid}`)}`,
263
+ `${p}{`,
264
+ `${inner}custom uniform string nomos:label = ${quote(label)}`,
265
+ `${inner}custom uniform string nomos:sid = ${quote(field.sid)}`,
266
+ ...(field.was === undefined || field.was.length === 0 ? [] : [`${inner}custom uniform string[] nomos:was = ${stringList(field.was)}`]),
267
+ ...(field.kind === undefined ? [] : [`${inner}custom uniform string nomos:kind = ${quote(field.kind)}`]),
268
+ ...(field.contract === undefined ? [] : [`${inner}rel nomos:valueContract = </NomosIdentity/ValueContracts/c_${field.contract}>`]),
269
+ ...(field.schema === undefined ? [] : [`${inner}custom uniform string nomos:schemaIdentity = ${quote(field.schema)}`]),
270
+ ...(field.fromBirth === undefined ? [] : [`${inner}custom uniform bool nomos:fromBirth = true`]),
271
+ ...(field.driver === undefined ? [] : emitValue(field.driver, "Driver", indent + 4)),
272
+ ...(field.evolutions === undefined || field.evolutions.length === 0
273
+ ? []
274
+ : emitValue(field.evolutions, "Evolutions", indent + 4)),
275
+ `${p}}`,
276
+ ];
277
+ }
278
+
279
+ function readField(
280
+ prim: Prim,
281
+ contracts: ReadonlyMap<string, unknown>,
282
+ ): { label: string; field: StableFieldId } {
283
+ const driver = childNamed(prim, "Driver");
284
+ const evolutions = childNamed(prim, "Evolutions");
285
+ const field: Record<string, unknown> = { sid: prim.attrs.sid };
286
+ if (Array.isArray(prim.attrs.was) && prim.attrs.was.length > 0) field.was = prim.attrs.was;
287
+ if (prim.attrs.kind !== undefined) field.kind = prim.attrs.kind;
288
+ const contractPath = prim.attrs.valueContract as string | undefined;
289
+ if (contractPath !== undefined) {
290
+ const contract = contractPath.split("/").at(-1)?.replace(/^c_/u, "");
291
+ if (!contract) throw new Error(`${NOMOS_IDENTITY_FILENAME} field ${prim.name} has an invalid value-contract relationship`);
292
+ const shape = contracts.get(contract);
293
+ if (shape === undefined) throw new Error(`${NOMOS_IDENTITY_FILENAME} field ${prim.name} references missing value contract ${contract}`);
294
+ field.contract = contract;
295
+ field.shape = shape;
296
+ }
297
+ if (prim.attrs.schemaIdentity !== undefined) field.schema = prim.attrs.schemaIdentity;
298
+ if (prim.attrs.fromBirth === true) field.fromBirth = true;
299
+ if (driver !== undefined) field.driver = readValue(driver);
300
+ if (evolutions !== undefined) field.evolutions = readValue(evolutions);
301
+ return { label: prim.attrs.label as string, field: field as unknown as StableFieldId };
302
+ }
303
+
304
+ function emitAggregate(label: string, aggregate: StableAggregateId, indent: number): string[] {
305
+ const p = pad(indent);
306
+ const inner = pad(indent + 4);
307
+ const fields = Object.entries(aggregate.fields).sort(([l], [r]) => (l < r ? -1 : l > r ? 1 : 0));
308
+ return [
309
+ `${p}def Scope ${quote(`a_${aggregate.sid}`)}`,
310
+ `${p}{`,
311
+ `${inner}custom uniform string nomos:label = ${quote(label)}`,
312
+ `${inner}custom uniform string nomos:sid = ${quote(aggregate.sid)}`,
313
+ ...(aggregate.was === undefined || aggregate.was.length === 0 ? [] : [`${inner}custom uniform string[] nomos:was = ${stringList(aggregate.was)}`]),
314
+ ...(fields.length === 0 ? [] : [
315
+ `${inner}def Scope "Fields"`,
316
+ `${inner}{`,
317
+ ...fields.flatMap(([fieldLabel, field]) => emitField(fieldLabel, field, indent + 8)),
318
+ `${inner}}`,
319
+ ]),
320
+ `${p}}`,
321
+ ];
322
+ }
323
+
324
+ // ENCODE WHAT USDA ALREADY ENCODES, ONCE. The entity KEY is `<kind-prefix>_<sid>`, so a prim named
325
+ // by its key carries both kind and sid structurally — restating them as attributes is pure
326
+ // duplication. Parentage is prim NESTING. Types come from the `NomosEntity` schema declared once at
327
+ // the top of the layer, so no attribute needs `custom uniform`. Defaults (`generation = 0`,
328
+ // `status = "active"`) are schema fallbacks and are omitted.
329
+ //
330
+ // An entity whose parent is absent from THIS layer — the ordinary case in a tombstone-only layer,
331
+ // where a retired field's aggregate is usually still alive and therefore not here — cannot nest, so
332
+ // it keeps an explicit `nomos:parent` and sits at the root. Correct either way, compact when it can be.
333
+ function emitEntity(
334
+ key: string,
335
+ entity: LawIdentityEntity,
336
+ children: ReadonlyMap<string, readonly [string, LawIdentityEntity][]>,
337
+ nested: boolean,
338
+ indent: number,
339
+ ): string[] {
340
+ const p = pad(indent);
341
+ const inner = pad(indent + 4);
342
+ return [
343
+ `${p}def NomosEntity ${quote(key)}`,
344
+ `${p}{`,
345
+ `${inner}string nomos:label = ${quote(entity.label)}`,
346
+ ...(nested || entity.parent === undefined ? [] : [`${inner}string nomos:parent = ${quote(entity.parent)}`]),
347
+ ...(entity.priorLabels === undefined || entity.priorLabels.length === 0
348
+ ? []
349
+ : [`${inner}string[] nomos:was = ${stringList(entity.priorLabels)}`]),
350
+ ...(entity.generation === 0 ? [] : [`${inner}int nomos:generation = ${entity.generation}`]),
351
+ // STATUS IS THE POINT OF THIS LAYER — a retired entity is retained forever so its sid can
352
+ // never be handed to a later declaration wearing the same label. "active" is the fallback.
353
+ ...(entity.status === "active" ? [] : [`${inner}token nomos:status = ${quote(entity.status)}`]),
354
+ ...(children.get(key) ?? []).flatMap(([childKey, child]) =>
355
+ emitEntity(childKey, child, children, true, indent + 4),
356
+ ),
357
+ `${p}}`,
358
+ ];
359
+ }
360
+
361
+ /** The schema that lets every entity prim carry bare, typed attributes with fallbacks. */
362
+ const NOMOS_ENTITY_SCHEMA = [
363
+ 'class "NomosEntity"',
364
+ "{",
365
+ ' string nomos:label = ""',
366
+ ' string nomos:parent = ""',
367
+ " string[] nomos:was = []",
368
+ " int nomos:generation = 0",
369
+ ' token nomos:status = "active"',
370
+ "}",
371
+ ].join("\n");
372
+
373
+ function emitEntityForest(entities: Readonly<Record<string, LawIdentityEntity>>, indent: number): string[] {
374
+ const children = new Map<string, [string, LawIdentityEntity][]>();
375
+ const sorted = Object.entries(entities).sort(([l], [r]) => (l < r ? -1 : l > r ? 1 : 0));
376
+ for (const [key, entity] of sorted) {
377
+ const parent = entity.parent;
378
+ if (parent === undefined || entities[parent] === undefined) continue;
379
+ const bucket = children.get(parent) ?? children.set(parent, []).get(parent)!;
380
+ bucket.push([key, entity]);
381
+ }
382
+ return sorted
383
+ .filter(([, entity]) => entity.parent === undefined || entities[entity.parent] === undefined)
384
+ .flatMap(([key, entity]) => emitEntity(key, entity, children, false, indent));
385
+ }
386
+
387
+ function readEntityForest(
388
+ prims: readonly Prim[],
389
+ parentKey: string | undefined,
390
+ out: Record<string, LawIdentityEntity>,
391
+ ): void {
392
+ for (const prim of prims) {
393
+ const { kind, sid } = lawEntityKeyParts(prim.name);
394
+ const declaredParent = prim.attrs.parent as string | undefined;
395
+ const was = prim.attrs.was;
396
+ out[prim.name] = {
397
+ kind,
398
+ sid,
399
+ label: prim.attrs.label as string,
400
+ ...(parentKey !== undefined
401
+ ? { parent: parentKey }
402
+ : declaredParent === undefined || declaredParent === ""
403
+ ? {}
404
+ : { parent: declaredParent }),
405
+ ...(Array.isArray(was) && was.length > 0 ? { priorLabels: was as string[] } : {}),
406
+ generation: Number(prim.attrs.generation ?? 0),
407
+ status: (prim.attrs.status ?? "active") as LawIdentityStatus,
408
+ };
409
+ readEntityForest(prim.children, prim.name, out);
410
+ }
411
+ }
412
+
413
+ /** Render the committed identity layer. Deterministic: every map is emitted in sorted key order. */
414
+ export function serializeIdentityUsda(lock: CompilerIdentityModel): string {
415
+ if (lock.identity !== undefined) validateLawIdentityRegistry(lock.identity);
416
+ const entities = Object.entries(lock.identity?.entities ?? {}).sort(([l], [r]) => (l < r ? -1 : l > r ? 1 : 0));
417
+ const domains = Object.entries(lock.domains).sort(([l], [r]) => (l < r ? -1 : l > r ? 1 : 0));
418
+ const contracts = new Map<string, unknown>();
419
+ const renderedContracts = new Map<string, string>();
420
+ for (const [, domain] of domains) for (const aggregate of Object.values(domain.stableIds.aggregates)) {
421
+ for (const field of Object.values(aggregate.fields)) {
422
+ if (field.contract === undefined || field.shape === undefined) continue;
423
+ const rendered = emitValue(field.shape, "Shape", 0).join("\n");
424
+ const prior = renderedContracts.get(field.contract);
425
+ if (prior !== undefined && prior !== rendered) {
426
+ throw new Error(`${NOMOS_IDENTITY_FILENAME} value contract ${field.contract} has two different shapes`);
427
+ }
428
+ contracts.set(field.contract, field.shape);
429
+ renderedContracts.set(field.contract, rendered);
430
+ }
431
+ }
432
+
433
+ return [
434
+ "#usda 1.0",
435
+ "(",
436
+ ' defaultPrim = "NomosIdentity"',
437
+ " customLayerData = {",
438
+ ` string "nomos:format" = ${quote(NOMOS_IDENTITY_LAYER_FORMAT)}`,
439
+ " }",
440
+ ")",
441
+ "",
442
+ NOMOS_ENTITY_SCHEMA,
443
+ "",
444
+ 'def Scope "NomosIdentity"',
445
+ "{",
446
+ ' def Scope "Entities"',
447
+ " {",
448
+ ...emitEntityForest(Object.fromEntries(entities), 8),
449
+ " }",
450
+ ' def Scope "ValueContracts"',
451
+ " {",
452
+ ...[...contracts.entries()].sort(([l], [r]) => (l < r ? -1 : l > r ? 1 : 0)).flatMap(([identity, shape]) => [
453
+ ` def Scope ${quote(`c_${identity}`)}`,
454
+ " {",
455
+ ...emitValue(shape, "Shape", 12),
456
+ " }",
457
+ ]),
458
+ " }",
459
+ ' def Scope "Domains"',
460
+ " {",
461
+ ...domains.flatMap(([name, domain]) => {
462
+ const aggregates = Object.entries(domain.stableIds.aggregates).sort(([l], [r]) => (l < r ? -1 : l > r ? 1 : 0));
463
+ return [
464
+ ` def Scope ${quote(`d_${domain.stableIds.domain}`)}`,
465
+ " {",
466
+ ` custom uniform string nomos:name = ${quote(name)}`,
467
+ ` custom uniform string nomos:sid = ${quote(domain.stableIds.domain)}`,
468
+ ...(domain.schemas === undefined ? [] : emitValue(domain.schemas, "Schemas", 12)),
469
+ ...(aggregates.length === 0 ? [] : [
470
+ ' def Scope "Aggregates"',
471
+ " {",
472
+ ...aggregates.flatMap(([label, aggregate]) => emitAggregate(label, aggregate, 16)),
473
+ " }",
474
+ ]),
475
+ " }",
476
+ ];
477
+ }),
478
+ " }",
479
+ "}",
480
+ "",
481
+ ].join("\n");
482
+ }
483
+
484
+ /** Read the committed identity layer back into the compiler's in-memory lock model. */
485
+ export function parseIdentityUsda(text: string): CompilerIdentityModel {
486
+ if (!text.startsWith("#usda")) {
487
+ throw new Error(`${NOMOS_IDENTITY_FILENAME} is not a USDA layer`);
488
+ }
489
+ if (!text.includes(NOMOS_IDENTITY_LAYER_FORMAT)) {
490
+ throw new Error(
491
+ `${NOMOS_IDENTITY_FILENAME} is not ${NOMOS_IDENTITY_LAYER_FORMAT} — refusing to guess an identity encoding`,
492
+ );
493
+ }
494
+ const roots: Prim[] = [];
495
+ // Skip the layer header before scanning. `customLayerData = { … }` closes with a braced line of
496
+ // its own, which the prim reader would otherwise treat as the end of the enclosing scope and stop
497
+ // before reaching the first prim. Root prims sit at column 0, so that is where parsing starts.
498
+ const lines = text.split(/\r?\n/);
499
+ const firstPrim = lines.findIndex((line) => /^def\s+Scope\s/.test(line));
500
+ if (firstPrim === -1) throw new Error(`${NOMOS_IDENTITY_FILENAME} has no NomosIdentity prim`);
501
+ parsePrims(lines.slice(firstPrim), 0, roots);
502
+ const root = roots.find((prim) => prim.name === "NomosIdentity");
503
+ if (root === undefined) throw new Error(`${NOMOS_IDENTITY_FILENAME} has no NomosIdentity prim`);
504
+
505
+ const entities: Record<string, LawIdentityEntity> = {};
506
+ readEntityForest(childNamed(root, "Entities")?.children ?? [], undefined, entities);
507
+ const identity: LawIdentityRegistry = { v: 1, entities };
508
+ validateLawIdentityRegistry(identity);
509
+
510
+ const contracts = new Map<string, unknown>();
511
+ for (const prim of childNamed(root, "ValueContracts")?.children ?? []) {
512
+ const identity = prim.name.replace(/^c_/u, "");
513
+ const shape = childNamed(prim, "Shape");
514
+ if (!identity || shape === undefined) throw new Error(`${NOMOS_IDENTITY_FILENAME} has an invalid value-contract prim ${prim.name}`);
515
+ contracts.set(identity, readValue(shape));
516
+ }
517
+
518
+ const domains: CompilerIdentityModel["domains"] = {};
519
+ for (const prim of childNamed(root, "Domains")?.children ?? []) {
520
+ const aggregates: Record<string, StableAggregateId> = {};
521
+ for (const aggregatePrim of childNamed(prim, "Aggregates")?.children ?? []) {
522
+ const fields: Record<string, StableFieldId> = {};
523
+ for (const fieldPrim of childNamed(aggregatePrim, "Fields")?.children ?? []) {
524
+ const { label, field } = readField(fieldPrim, contracts);
525
+ fields[label] = field;
526
+ }
527
+ const was = aggregatePrim.attrs.was;
528
+ aggregates[aggregatePrim.attrs.label as string] = {
529
+ sid: aggregatePrim.attrs.sid as string,
530
+ ...(Array.isArray(was) && was.length > 0 ? { was: was as string[] } : {}),
531
+ fields,
532
+ };
533
+ }
534
+ const schemas = childNamed(prim, "Schemas");
535
+ const stableIds: StableIds = { v: 1, domain: prim.attrs.sid as string, aggregates };
536
+ domains[prim.attrs.name as string] = {
537
+ stableIds,
538
+ ...(schemas === undefined ? {} : { schemas: readValue(schemas) as Record<string, WireSchema> }),
539
+ };
540
+ }
541
+
542
+ return { v: 1, domains, ...(Object.keys(entities).length === 0 ? {} : { identity }) };
543
+ }
544
+
545
+ /** An empty identity layer — the first compile of a package that has never been compiled. */
546
+ export function emptyIdentityLock(): CompilerIdentityModel {
547
+ return { v: 1, domains: {}, identity: emptyLawIdentityRegistry() };
548
+ }
@@ -115,6 +115,21 @@ export function lawEntityKey(kind: LawEntityKind, sid: string): string {
115
115
  return `${KIND_PREFIX[kind]}_${sid}`;
116
116
  }
117
117
 
118
+ /** Invert `lawEntityKey`. A durable encoding can carry identity in the KEY alone — the kind prefix
119
+ * and the sid — instead of restating both as attributes beside it. */
120
+ export function lawEntityKeyParts(key: string): { kind: LawEntityKind; sid: string } {
121
+ const split = key.indexOf("_");
122
+ const prefix = split === -1 ? "" : key.slice(0, split);
123
+ const sid = split === -1 ? "" : key.slice(split + 1);
124
+ const found = (Object.entries(KIND_PREFIX) as [LawEntityKind, string][]).find(
125
+ ([, candidate]) => candidate === prefix,
126
+ );
127
+ if (found === undefined || !SID_PATTERN.test(sid)) {
128
+ throw new Error(`NMS-ID-004 '${key}' is not a law entity key`);
129
+ }
130
+ return { kind: found[0], sid };
131
+ }
132
+
118
133
  /** Identity for a compiler-owned slot whose lifecycle is exactly its stable owner's. */
119
134
  export function mintOwnedLawSid(ownerSid: string, slotKind: string, slot: string): string {
120
135
  assertSid(ownerSid, "owner");