@githolon/dsl 0.31.1 → 0.32.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.31.1",
3
+ "version": "0.32.0",
4
4
  "type": "module",
5
5
  "description": "Nomos 2 domain-authoring DSL: aggregates + directives in TS, executed and encoded to the Rust kernel's wire shapes.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -113,7 +113,8 @@ import {
113
113
  type InferredRename,
114
114
  type StableIds,
115
115
  } from "./stable_ids.js";
116
- import type { UsdLayer } from "./usd.js";
116
+ import { emitUsd, type UsdLayer } from "./usd.js";
117
+ import { emitLayeredRelationUsda, type RelationLayerInput } from "./relation_composition.js";
117
118
  import { generateTsClient, tsClientFactoryName } from "./codegen_ts.js";
118
119
  import { generateOpenApiFragment, mergeOpenApiFragments } from "./codegen_openapi.js";
119
120
  import { domainInterface, type DomainInterface } from "./shape_compat.js";
@@ -1315,6 +1316,31 @@ async function main(): Promise<void> {
1315
1316
  writeFileSync(pkgPath, text, "utf8");
1316
1317
  const domainHash = sha256HexUtf8(text);
1317
1318
 
1319
+ // ── 2a. the REAL layered RELATION `.usda` set — an additive, deterministic visualization +
1320
+ // cross-check artifact (usdview / `usdcat --flatten`). One sublayer per domain carrying its
1321
+ // relation opinions as PRIMS + a root with `subLayers` STRONGEST-FIRST; usdcat composes the
1322
+ // shared relation types, the compiler's `composeSharedRelationTypes` is the fail-closed guard
1323
+ // on top. Emitted iff ANY domain declares a relation type. Never feeds the package hash.
1324
+ {
1325
+ const relInputs: RelationLayerInput[] = [];
1326
+ for (const mod of domainModules) {
1327
+ // Per-domain UNCOMPOSED relation types: emit the single module alone (single-domain ⇒ no shared
1328
+ // type ⇒ its own decls, un-unioned — the layers ARE the sublayers usdcat will compose).
1329
+ const one = emitUsd([{ path: `/Nomos/${mod.domain ?? mod.name}`, module: mod }]);
1330
+ const types = one.layers[0]?.nomosRelations?.types;
1331
+ if (types !== undefined && Object.keys(types).length > 0) {
1332
+ relInputs.push({ domain: mod.domain ?? mod.name, types });
1333
+ }
1334
+ }
1335
+ if (relInputs.length > 0) {
1336
+ const emit = emitLayeredRelationUsda(relInputs, cfg.name);
1337
+ const relDir = path.join(outDir, `${cfg.name}.relation-layers`);
1338
+ mkdirSync(relDir, { recursive: true });
1339
+ for (const f of emit.files) writeFileSync(path.join(relDir, f.name), f.text, "utf8");
1340
+ writeFileSync(path.join(relDir, "root.usda"), emit.root, "utf8");
1341
+ }
1342
+ }
1343
+
1318
1344
  // ── 2b. opt-in LAYERED emission — additive artifacts ONLY (the flattened package
1319
1345
  // above and its domainHash are already written and do not move). The
1320
1346
  // homomorphism is ASSERTED fail-closed on every layered compile: the
@@ -28,6 +28,7 @@ export const DOMAIN_INSTALLATION_PHASES = [
28
28
  "Disabled",
29
29
  "Superseded",
30
30
  "Retiring",
31
+ "Retired",
31
32
  "Failed",
32
33
  ] as const;
33
34
  export type DomainInstallationPhase = (typeof DOMAIN_INSTALLATION_PHASES)[number];
@@ -48,6 +49,7 @@ export const DomainInstallation = aggregate("DomainInstallation", {
48
49
  "metadata.generation": t.int().merge(Lww),
49
50
  "status.phase": t.enum(DOMAIN_INSTALLATION_PHASES).merge(Lww),
50
51
  "status.observedGeneration": t.int().merge(Lww),
52
+ "status.supersededBy": t.string().merge(Lww).optional(),
51
53
  "status.conditions": t.map(t.json()).merge(MapOf(Lww)),
52
54
  });
53
55
 
@@ -77,6 +79,13 @@ export const installDomain = directive("installDomain")
77
79
  authorityScope: z.string().default("workspace/root"),
78
80
  dependencies: z.array(z.string()).default([]),
79
81
  supersedes: z.string().optional(),
82
+ // THE FRONTIER TRANSITION (install-lifecycle): `replaces` names a PRIOR install this
83
+ // deploy makes current UNEQUIVOCALLY — the prior install is flipped out of the served
84
+ // frontier (`Superseded`, or `Retired` when `retireReplaced`) and gets the reverse
85
+ // `status.supersededBy` edge to this install. Absent ⇒ install-BESIDE (backward-compat:
86
+ // byte-identical ops to the pre-lifecycle plan). `replaces` also sets `spec.supersedes`.
87
+ replaces: z.string().optional(),
88
+ retireReplaced: z.boolean().default(false),
80
89
  finalizers: z.array(z.string()).default([]),
81
90
  }),
82
91
  )
@@ -97,8 +106,18 @@ export const installDomain = directive("installDomain")
97
106
  if (p.dependencies.length > 0) {
98
107
  ops.push(addToSet(install, "spec.dependencies", p.dependencies));
99
108
  }
100
- if (p.supersedes !== undefined) {
101
- ops.push(set(install, "spec.supersedes", p.supersedes));
109
+ // The predecessor back-link: `supersedes` (explicit) or `replaces` (the actionable form).
110
+ const supersedes = p.supersedes ?? p.replaces;
111
+ if (supersedes !== undefined) {
112
+ ops.push(set(install, "spec.supersedes", supersedes));
113
+ }
114
+ // The transition on the PRIOR install: drop it from the served frontier + point it here.
115
+ if (p.replaces !== undefined) {
116
+ const replaced = domainInstallationInstance(p.replaces);
117
+ ops.push(
118
+ set(replaced, "status.phase", p.retireReplaced ? "Retired" : "Superseded"),
119
+ );
120
+ ops.push(set(replaced, "status.supersededBy", p.domainHash));
102
121
  }
103
122
  if (p.finalizers.length > 0) {
104
123
  ops.push(addToSet(install, "spec.finalizers", p.finalizers));
@@ -0,0 +1,286 @@
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 QuickJS engine}.
4
+ //
5
+ // COMPILE-TIME USD-LAYER COMPOSITION for SHARED (global) relation types + the
6
+ // fail-closed DIVERGENCE GUARD.
7
+ //
8
+ // THE MODEL. When a package bundles multiple domains, each domain lowers its own
9
+ // `relationType` decls into its own `UsdLayer.nomosRelations`. A NON-global type is
10
+ // auto-qualified by its owning domain (`estate` → `estate__workspace`), so two
11
+ // domains that each declare the same bare type NEVER collide — their qualified ids
12
+ // differ, and they stay separate layers. A `.asGlobal()` type (a kernel-coupled
13
+ // framework type, e.g. governance/read-visibility `workspace`) stays a BARE shared
14
+ // name, so MULTIPLE domains that each contribute to one global type land on the SAME
15
+ // canonical id. That cross-domain merge USED to happen only at the KERNEL (runtime
16
+ // `merge_layers`, a blunt per-type last-wins that SILENTLY DROPS the losing layer's
17
+ // relations — the bug that broke co2's `createSite`). We move it to COMPILE time, in
18
+ // TS, using USD's composition MODEL (LIVRPS sublayer strength):
19
+ //
20
+ // * COMPOSE (deliverable #1): for each SHARED global type, UNION the relations
21
+ // across the contributing domains — a prim-set union, so EVERY domain's relations
22
+ // survive (nothing is dropped). The composed union is written back into every
23
+ // contributing layer so the downstream kernel merge (union OR last-wins) yields
24
+ // the full union regardless of its policy. This is idempotent and byte-stable.
25
+ //
26
+ // * GUARD (deliverable #2): when two or more domains declare the SAME relation on a
27
+ // SHARED global type with DIVERGENT rewrites (co2's `workspace.admin`: `self()` in
28
+ // estate/platform vs `union(self, computed(creator))` in home), REFUSE AT COMPILE
29
+ // with a scalpel message naming the type, the relation, and each domain's rewrite.
30
+ // Single-owner relations and byte-identical agreeing opinions PASS (union). This
31
+ // turns the kernel's blunt `None` / "no-relation-schema" into a precise compile
32
+ // refusal — the era rule ("the law is the era") is preserved because a package with
33
+ // no shared global type composes to itself, byte-identical.
34
+ //
35
+ // SUBLAYER STRENGTH ORDER (documented, deliverable #1): the package's domain order is
36
+ // WEAK → STRONG (Nomos module-fold notation — later wins), which maps onto USD
37
+ // `subLayers` STRONGEST-FIRST by reversal (the same left-biased monoid, opposite list
38
+ // notation — the convention `usd_layers.ts::layeredRootUsda` already records). For a
39
+ // NON-divergent shared type the union is order-INDEPENDENT (every domain's opinion on a
40
+ // given relation is identical, so which layer "wins" is immaterial); order matters ONLY
41
+ // to the visualization flatten, never to the composed IR.
42
+
43
+ import type { UsdLayer } from "./usd.js";
44
+
45
+ /** A single relation's definition as it rides in `nomosRelations` (`{rewrite, allowed}`). */
46
+ type RelDef = NonNullable<UsdLayer["nomosRelations"]>["types"][string]["relations"][string];
47
+ /** One relation TYPE's spec (`{relations, caps?}`). */
48
+ type RelSpec = NonNullable<UsdLayer["nomosRelations"]>["types"][string];
49
+
50
+ /** Strip the `/Nomos/` prim-scope prefix to recover the owning domain key from a layer path. */
51
+ export function domainOfLayerPath(path: string): string {
52
+ return path.startsWith("/Nomos/") ? path.slice("/Nomos/".length) : path;
53
+ }
54
+
55
+ /**
56
+ * Render a rewrite AST to the author-facing surface syntax (`self()` / `computed(x)` /
57
+ * `union(...)` / `via(t, c)` / …) — the readable form the scalpel message + the layered
58
+ * `.usda` provenance attr carry. A `null`/absent rewrite is a pure-direct relation (`this`).
59
+ */
60
+ export function renderRewrite(rw: unknown): string {
61
+ if (rw === null || rw === undefined) return "this";
62
+ const r = rw as { kind?: string; [k: string]: unknown };
63
+ switch (r.kind) {
64
+ case "this":
65
+ return "self()";
66
+ case "computed":
67
+ return `computed(${String(r.relation)})`;
68
+ case "ttu":
69
+ return `${r.all ? "viaAll" : "via"}(${String(r.tupleset)}, ${String(r.computed)})`;
70
+ case "union":
71
+ return `union(${(r.children as unknown[]).map(renderRewrite).join(", ")})`;
72
+ case "intersection":
73
+ return `intersection(${(r.children as unknown[]).map(renderRewrite).join(", ")})`;
74
+ case "exclusion":
75
+ return `exclusion(${renderRewrite(r.base)}, ${renderRewrite(r.subtract)})`;
76
+ default:
77
+ return JSON.stringify(rw);
78
+ }
79
+ }
80
+
81
+ /** A per-relation divergence, gathered for the scalpel message. */
82
+ interface RelationConflict {
83
+ readonly type: string;
84
+ readonly relation: string;
85
+ /** Each contributing domain's opinion, in package (weak→strong) order. */
86
+ readonly opinions: { readonly domain: string; readonly rewrite: string }[];
87
+ }
88
+
89
+ /** Format the accumulated divergences into the fail-closed refusal (mirrors `failclosed_check.py`). */
90
+ function scalpelMessage(conflicts: readonly RelationConflict[]): string {
91
+ const lines: string[] = [
92
+ `Refusing to compose the package: shared relation type(s) carry DIVERGENT rewrites across ` +
93
+ `domains — the composed flatten would SILENTLY take the strongest layer's opinion (LIVRPS ` +
94
+ `last-wins), so this is a fail-closed compile refusal, not a silent pick.`,
95
+ ``,
96
+ ];
97
+ for (const c of conflicts) {
98
+ lines.push(` relation type "${c.type}" relation "${c.relation}" — ${c.opinions.length} divergent opinions:`);
99
+ const w = Math.max(...c.opinions.map((o) => o.domain.length));
100
+ for (const o of c.opinions) lines.push(` · ${o.domain.padEnd(w)} ${o.rewrite}`);
101
+ }
102
+ lines.push(
103
+ ``,
104
+ `Fix: give the type ONE canonical definition of each divergent relation (the domains must ` +
105
+ `agree, byte-for-byte), or use DISTINCT / domain-qualified relation types (drop \`.asGlobal()\` ` +
106
+ `so each domain's type auto-qualifies and never shares an id).`,
107
+ );
108
+ return lines.join("\n");
109
+ }
110
+
111
+ /**
112
+ * Compose the SHARED (global, bare-id) relation types across a package's layers — UNION the
113
+ * relations per shared type, GUARD divergent rewrites fail-closed, and write the union back into
114
+ * every contributing layer. Non-shared (single-owner / auto-qualified) types are untouched, so a
115
+ * package with no shared global type returns byte-identical layers (the era rule). Deterministic:
116
+ * sorted keys, sorted diagnostics.
117
+ *
118
+ * @throws a scalpel `Error` naming the type, relation, and each domain's rewrite on any divergence.
119
+ */
120
+ export function composeSharedRelationTypes(layers: readonly UsdLayer[]): UsdLayer[] {
121
+ // type id → the layers (domains) that contribute it, in package (weak→strong) order.
122
+ const contributors = new Map<string, { domain: string; spec: RelSpec }[]>();
123
+ for (const layer of layers) {
124
+ const rels = layer.nomosRelations;
125
+ if (rels === undefined) continue;
126
+ for (const [type, spec] of Object.entries(rels.types)) {
127
+ const arr = contributors.get(type) ?? [];
128
+ arr.push({ domain: domainOfLayerPath(layer.path), spec });
129
+ contributors.set(type, arr);
130
+ }
131
+ }
132
+
133
+ const composed = new Map<string, RelSpec>();
134
+ const conflicts: RelationConflict[] = [];
135
+ for (const type of [...contributors.keys()].sort()) {
136
+ const contribs = contributors.get(type)!;
137
+ // A type contributed by ONE domain is single-owner — nothing to compose (untouched).
138
+ if (contribs.length < 2) continue;
139
+
140
+ // caps must agree across the contributing domains (a divergent bound is a hard error).
141
+ let caps: RelSpec["caps"];
142
+ for (const c of contribs) {
143
+ if (c.spec.caps === undefined) continue;
144
+ if (caps === undefined) caps = c.spec.caps;
145
+ else if (caps.maxDepth !== c.spec.caps.maxDepth || caps.maxFanout !== c.spec.caps.maxFanout) {
146
+ throw new Error(
147
+ `Refusing to compose the package: shared relation type "${type}" is declared with ` +
148
+ `CONFLICTING caps across domains (${contribs.map((x) => x.domain).join(", ")}). Give the ` +
149
+ `type one canonical \`caps\`.`,
150
+ );
151
+ }
152
+ }
153
+
154
+ // Gather every domain's opinion on each relation (in package order).
155
+ const opinions = new Map<string, { domain: string; def: RelDef }[]>();
156
+ for (const c of contribs) {
157
+ for (const [rel, def] of Object.entries(c.spec.relations)) {
158
+ const arr = opinions.get(rel) ?? [];
159
+ arr.push({ domain: c.domain, def });
160
+ opinions.set(rel, arr);
161
+ }
162
+ }
163
+
164
+ const unioned: Record<string, RelDef> = {};
165
+ for (const rel of [...opinions.keys()].sort()) {
166
+ const ops = opinions.get(rel)!;
167
+ const distinct = new Set(ops.map((o) => JSON.stringify(o.def)));
168
+ if (distinct.size > 1) {
169
+ conflicts.push({
170
+ type,
171
+ relation: rel,
172
+ opinions: ops.map((o) => ({ domain: o.domain, rewrite: renderRewrite((o.def as { rewrite: unknown }).rewrite) })),
173
+ });
174
+ } else {
175
+ unioned[rel] = ops[0]!.def; // agreeing opinions / single-owner relation — keep it
176
+ }
177
+ }
178
+ // Sorted relation keys (byte-determinism against the per-layer emission).
179
+ const relations: Record<string, RelDef> = {};
180
+ for (const k of Object.keys(unioned).sort()) relations[k] = unioned[k]!;
181
+ composed.set(type, { relations, ...(caps !== undefined ? { caps } : {}) });
182
+ }
183
+
184
+ if (conflicts.length > 0) throw new Error(scalpelMessage(conflicts));
185
+ if (composed.size === 0) return [...layers]; // nothing shared — byte-identical (era rule)
186
+
187
+ // Write the composed union back into every contributing layer, keeping type keys sorted.
188
+ return layers.map((layer) => {
189
+ const rels = layer.nomosRelations;
190
+ if (rels === undefined) return layer;
191
+ let touched = false;
192
+ const merged: Record<string, RelSpec> = {};
193
+ for (const [type, spec] of Object.entries(rels.types)) {
194
+ const c = composed.get(type);
195
+ if (c !== undefined) {
196
+ merged[type] = c;
197
+ touched = true;
198
+ } else {
199
+ merged[type] = spec;
200
+ }
201
+ }
202
+ if (!touched) return layer;
203
+ const sorted: Record<string, RelSpec> = {};
204
+ for (const k of Object.keys(merged).sort()) sorted[k] = merged[k]!;
205
+ return { ...layer, nomosRelations: { v: 1 as const, types: sorted } };
206
+ });
207
+ }
208
+
209
+ // ── the REAL layered `.usda` emission (deliverable #3 — for usdview / usdcat --flatten) ──────────
210
+
211
+ /** One domain's contribution to the layered relation emission: its bare-id relation types. */
212
+ export interface RelationLayerInput {
213
+ readonly domain: string;
214
+ /** The domain's `nomosRelations.types` (UNCOMPOSED — the layers ARE the sublayers usdcat composes). */
215
+ readonly types: Record<string, RelSpec>;
216
+ }
217
+
218
+ /** `"a"` → escaped USDA string literal (minimal — relation/rewrite tokens are `[A-Za-z0-9_.,()]`). */
219
+ function usda(s: string): string {
220
+ return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
221
+ }
222
+
223
+ /**
224
+ * Emit ONE domain's relation opinions as a real `.usda` sublayer: `def "<Type>" { def "<relation>"
225
+ * { custom string nomos:rewrite = "…"; custom string nomos:from = "<domain>" } }`. Structured so
226
+ * `usdcat --flatten` composes the relation prims across sublayers (a stronger layer's `nomos:rewrite`
227
+ * wins; the relation SET is the union). Deterministic: types + relations sorted.
228
+ */
229
+ export function emitRelationSublayerUsda(input: RelationLayerInput): string {
230
+ const lines: string[] = [`#usda 1.0`, `(`, ` doc = "Nomos relation layer — domain ${input.domain}"`, `)`, ``];
231
+ for (const type of Object.keys(input.types).sort()) {
232
+ lines.push(`def ${usda(type)}`, `{`);
233
+ const relations = input.types[type]!.relations;
234
+ for (const rel of Object.keys(relations).sort()) {
235
+ const def = relations[rel]! as { rewrite: unknown };
236
+ lines.push(
237
+ ` def ${usda(rel)}`,
238
+ ` {`,
239
+ ` custom string nomos:rewrite = ${usda(renderRewrite(def.rewrite))}`,
240
+ ` custom string nomos:from = ${usda(input.domain)}`,
241
+ ` }`,
242
+ );
243
+ }
244
+ lines.push(`}`, ``);
245
+ }
246
+ return lines.join("\n");
247
+ }
248
+
249
+ /** The result of {@link emitLayeredRelationUsda}: a root document + one sublayer file per domain. */
250
+ export interface LayeredRelationEmit {
251
+ /** The root `.usda` (`subLayers = [...]`, STRONGEST-FIRST) that usdcat --flatten composes. */
252
+ readonly root: string;
253
+ /** Per-domain sublayer files (`name` is a bare filename; write beside the root). */
254
+ readonly files: { readonly name: string; readonly text: string }[];
255
+ }
256
+
257
+ /**
258
+ * Emit a REAL layered relation `.usda` set: one sublayer per domain (its relation opinions as prims)
259
+ * + a root with `subLayers` STRONGEST-FIRST, so `usdcat --flatten <root>` composes the package's
260
+ * shared relation types. `inputs` are in package order (WEAK → STRONG); the root reverses them to
261
+ * strongest-first (the documented convention). Deterministic (sorted within each layer, stable file
262
+ * names). Additive build artifact — never feeds the package hash.
263
+ */
264
+ export function emitLayeredRelationUsda(inputs: readonly RelationLayerInput[], packageName: string): LayeredRelationEmit {
265
+ const safe = (s: string) => s.replace(/[^A-Za-z0-9._-]/g, "_");
266
+ const files = inputs.map((input) => ({
267
+ name: `${safe(input.domain)}.usda`,
268
+ text: emitRelationSublayerUsda(input),
269
+ }));
270
+ // subLayers is STRONGEST-FIRST → reverse the weak→strong package order.
271
+ const strongestFirst = [...files].reverse();
272
+ const root = [
273
+ `#usda 1.0`,
274
+ `(`,
275
+ ` doc = """Nomos relation-composition layers for package ${packageName}. subLayers are`,
276
+ `STRONGEST-FIRST (USD layer-stack order); the Nomos domain list composes weak→strong (later`,
277
+ `wins). \`usdcat --flatten\` composes the shared relation types; the compiler's TS composition`,
278
+ `(composeSharedRelationTypes) is the fail-closed guard layered on top."""`,
279
+ ` subLayers = [`,
280
+ ...strongestFirst.map((f, i) => ` @./${f.name}@${i < strongestFirst.length - 1 ? "," : ""}`),
281
+ ` ]`,
282
+ `)`,
283
+ ``,
284
+ ].join("\n");
285
+ return { root, files };
286
+ }
package/src/usd.ts CHANGED
@@ -44,6 +44,7 @@ import type { DomainModule } from "./codegen_dart.js";
44
44
  import { domainInterface } from "./shape_compat.js";
45
45
  import { relationType, user, key, anyUser, qualifiedTypeId, qualifyRelationType } from "./relation_schema.js";
46
46
  import { harvestPrincipalFlows } from "./wire_encode.js";
47
+ import { composeSharedRelationTypes } from "./relation_composition.js";
47
48
 
48
49
  /** The referential marker of a directive prim (kernel `RefMode` wire string). */
49
50
  export type RefMode = WireRefMode;
@@ -1060,7 +1061,11 @@ export function emitUsd(
1060
1061
  };
1061
1062
  })
1062
1063
  .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
1063
- return { layers: out };
1064
+ // COMPILE-TIME COMPOSITION + fail-closed GUARD for SHARED (global) relation types across the
1065
+ // package's per-domain layers: UNION every domain's relations on a shared bare-id type (so nothing
1066
+ // is silently dropped) and REFUSE on a divergent shared rewrite (the scalpel). Non-shared /
1067
+ // auto-qualified types are untouched → a package with no shared global type is byte-identical.
1068
+ return { layers: composeSharedRelationTypes(out) };
1064
1069
  }
1065
1070
 
1066
1071
  /** Two aggregate prims at the same path are identical iff same type + same schema. */
package/src/usd_layers.ts CHANGED
@@ -70,6 +70,7 @@ import {
70
70
  type UsdPrim,
71
71
  type UsdQuery,
72
72
  } from "./usd.js";
73
+ import { composeSharedRelationTypes } from "./relation_composition.js";
73
74
 
74
75
  /** The layer-file format marker (customLayerData `nomos:format`). */
75
76
  export const NOMOS_USD_LAYER_FORMAT = "nomos.usd-layer.v1";
@@ -865,7 +866,10 @@ export function flattenLayeredUsd(layers: readonly UsdLayer[]): UsdDocument {
865
866
  };
866
867
  });
867
868
 
868
- return { layers: out };
869
+ // Apply the SAME cross-layer shared-relation composition + guard the composed emission
870
+ // (`usd.ts::emitUsd`) applies, so the layered fold stays byte-homomorphic with it (a divergent
871
+ // shared global type refuses identically on both paths).
872
+ return { layers: composeSharedRelationTypes(out) };
869
873
  }
870
874
 
871
875
  // ── usda → IR (the round-trip parser for the pxr/usdcat conformance lane) ──────────