@githolon/dsl 0.2.1 → 0.2.3

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.2.1",
3
+ "version": "0.2.3",
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",
@@ -19,6 +19,8 @@
19
19
  "./manifest": "./src/manifest.ts",
20
20
  "./compose": "./src/compose.ts",
21
21
  "./usd": "./src/usd.ts",
22
+ "./usd-layers": "./src/usd_layers.ts",
23
+ "./usd-state": "./src/usd_state.ts",
22
24
  "./engine-entry": "./src/engine_entry.ts",
23
25
  "./build-package": "./src/build_package.ts",
24
26
  "./compile-engine": "./src/compile_engine.ts",
@@ -55,7 +55,13 @@ import path from "node:path";
55
55
  import { fileURLToPath, pathToFileURL } from "node:url";
56
56
 
57
57
  import type { AggregateHandle } from "./aggregate.js";
58
- import { generateDartDomain, type DomainModule } from "./codegen_dart.js";
58
+ import {
59
+ generateDartDomain,
60
+ type DartImport,
61
+ type DartRefImport,
62
+ type DomainModule,
63
+ type PermissionVocabulary,
64
+ } from "./codegen_dart.js";
59
65
  import type { AnyCount } from "./count.js";
60
66
  import type { QueryDecl } from "./query.js";
61
67
  import type { SpatialDecl } from "./spatial.js";
@@ -64,15 +70,25 @@ import type { DerivedDecl } from "./derived.js";
64
70
  import type { CombinedDecl } from "./combined.js";
65
71
  import type { ImpureCapabilityDecl } from "./codegen_provider_dart.js";
66
72
  import {
73
+ aggregatesOf,
67
74
  buildIdentity,
68
75
  buildReadManifest,
69
76
  composeDomainModule,
77
+ directivesOf,
70
78
  emitUsdJsonForModules,
71
79
  packageUsda,
72
80
  sha256HexUtf8,
73
81
  writeIdentity,
74
82
  type Mod,
75
83
  } from "./build_package.js";
84
+ import { collectEngineRouting } from "./engine_entry.js";
85
+ import {
86
+ emitLayeredDomain,
87
+ flattenLayeredUsd,
88
+ layeredRootUsda,
89
+ type LayeredModuleInput,
90
+ } from "./usd_layers.js";
91
+ import type { UsdLayer } from "./usd.js";
76
92
  import { generateTsClient, tsClientFactoryName } from "./codegen_ts.js";
77
93
  import { generateTsProof } from "./codegen_proof.js";
78
94
 
@@ -92,6 +108,23 @@ interface DomainConfig {
92
108
  readonly impureCapabilities?: readonly string[];
93
109
  readonly extraAggregates?: readonly string[];
94
110
  readonly readModelAggregates?: readonly string[];
111
+ // DART CODEGEN HINTS (#codegen_dart; NEVER law: none of these enter the USD IR,
112
+ // the package bytes, or the identity manifest — they shape ONLY the generated
113
+ // `dart/` frontend, so adding them does not move the domainHash):
114
+ /** LITERAL extra imports for the generated `<domain>.dart` (e.g. a sibling
115
+ * domain file whose types this domain's payloads reference). */
116
+ readonly dartImports?: readonly DartImport[];
117
+ /** LITERAL cross-domain read-model routing: an aggregate TYPE this domain
118
+ * references resolves through the named import prefix instead of a local decl. */
119
+ readonly dartRefImports?: readonly DartRefImport[];
120
+ /** EXPORT-NAME escape hatch (resolved on the merged module exports, like
121
+ * `impureCapabilities`): `resourceTypes` names a `readonly string[]` export and
122
+ * `roleCatalogue` a `Record<string, string[]>` export; together they emit the
123
+ * frontend permission vocabulary (PermissionRoles/PermissionCapabilities). */
124
+ readonly permissionVocabulary?: {
125
+ readonly resourceTypes: string;
126
+ readonly roleCatalogue: string;
127
+ };
95
128
  }
96
129
 
97
130
  interface PackageConfig {
@@ -111,6 +144,17 @@ interface PackageConfig {
111
144
  * can vendor the directory as-is, no pub deps.
112
145
  */
113
146
  readonly dart?: boolean | { readonly out?: string };
147
+ /**
148
+ * OPT-IN layered USD emission (also `--layered` on the CLI). When true, ALSO emit
149
+ * `build/layers/<domain>--<module>.usda` (+ `.layer.json`) — one REAL USD layer per
150
+ * authored MODULE — and `build/<name>.layered.usda`, a root whose subLayers stack
151
+ * them (USD strongest-first == Nomos module order reversed). The flattened
152
+ * `.package.usda` emission and the domainHash DO NOT MOVE; the compile asserts
153
+ * fail-closed that the layer stack flattens BYTE-IDENTICALLY to the canonical
154
+ * composed IR (the monoid homomorphism — see `usd_layers.ts` /
155
+ * `docs/usd_layered_composition.md`).
156
+ */
157
+ readonly layered?: boolean;
114
158
  }
115
159
 
116
160
  function fail(msg: string): never {
@@ -207,6 +251,66 @@ function discover<T>(merged: Mod, match: (v: unknown) => v is T): T[] {
207
251
  return out;
208
252
  }
209
253
 
254
+ /** Validate the LITERAL dart-codegen import hints (config data, not module exports). */
255
+ function checkDartImports(domainKey: string, imports: readonly DartImport[] | undefined): void {
256
+ for (const imp of imports ?? []) {
257
+ if (typeof imp?.uri !== "string" || (imp.prefix !== undefined && typeof imp.prefix !== "string")) {
258
+ fail(`domain '${domainKey}': each dartImports entry must be { uri, prefix? }; got ${JSON.stringify(imp)}`);
259
+ }
260
+ }
261
+ }
262
+
263
+ function checkDartRefImports(domainKey: string, imports: readonly DartRefImport[] | undefined): void {
264
+ for (const imp of imports ?? []) {
265
+ if (
266
+ typeof imp?.aggregateType !== "string" ||
267
+ typeof imp?.uri !== "string" ||
268
+ typeof imp?.prefix !== "string"
269
+ ) {
270
+ fail(
271
+ `domain '${domainKey}': each dartRefImports entry must be { aggregateType, uri, prefix }; got ${JSON.stringify(imp)}`,
272
+ );
273
+ }
274
+ }
275
+ }
276
+
277
+ /** Resolve the permissionVocabulary EXPORT NAMES on the merged exports, fail-closed. */
278
+ function resolvePermissionVocabulary(
279
+ domainKey: string,
280
+ merged: Mod,
281
+ cfg: DomainConfig["permissionVocabulary"],
282
+ ): PermissionVocabulary | undefined {
283
+ if (cfg === undefined) return undefined;
284
+ if (typeof cfg?.resourceTypes !== "string" || typeof cfg?.roleCatalogue !== "string") {
285
+ fail(
286
+ `domain '${domainKey}': permissionVocabulary must be { resourceTypes: "<exportName>", roleCatalogue: "<exportName>" }; got ${JSON.stringify(cfg)}`,
287
+ );
288
+ }
289
+ const resourceTypes = merged[cfg.resourceTypes];
290
+ if (!Array.isArray(resourceTypes) || !resourceTypes.every((t) => typeof t === "string")) {
291
+ fail(
292
+ `domain '${domainKey}': permissionVocabulary.resourceTypes names export '${cfg.resourceTypes}', which the modules do not export as a string[]`,
293
+ );
294
+ }
295
+ const roleCatalogue = merged[cfg.roleCatalogue];
296
+ if (
297
+ !roleCatalogue ||
298
+ typeof roleCatalogue !== "object" ||
299
+ Array.isArray(roleCatalogue) ||
300
+ !Object.values(roleCatalogue).every(
301
+ (caps) => Array.isArray(caps) && caps.every((c) => typeof c === "string"),
302
+ )
303
+ ) {
304
+ fail(
305
+ `domain '${domainKey}': permissionVocabulary.roleCatalogue names export '${cfg.roleCatalogue}', which the modules do not export as a Record<string, string[]>`,
306
+ );
307
+ }
308
+ return {
309
+ resourceTypes: resourceTypes as string[],
310
+ roleCatalogue: roleCatalogue as Record<string, string[]>,
311
+ };
312
+ }
313
+
210
314
  /** Resolve explicit EXPORT NAMES (the config escape hatch) on the merged exports. */
211
315
  function resolveNamed<T>(merged: Mod, names: readonly string[] | undefined, what: string): T[] {
212
316
  const out: T[] = [];
@@ -283,7 +387,9 @@ function emitDart(modules: readonly DomainModule[], dartOut: string): string[] {
283
387
  // ── main ──────────────────────────────────────────────────────────────────────────
284
388
 
285
389
  async function main(): Promise<void> {
286
- const configArg = process.argv[2] ?? "nomos.package.mjs";
390
+ const argv = process.argv.slice(2);
391
+ const layeredFlag = argv.includes("--layered");
392
+ const configArg = argv.find((a) => !a.startsWith("--")) ?? "nomos.package.mjs";
287
393
  const configPath = path.resolve(process.cwd(), configArg);
288
394
  if (!existsSync(configPath)) fail(`config not found: ${configPath}`);
289
395
  const cfgDir = path.dirname(configPath);
@@ -304,9 +410,21 @@ async function main(): Promise<void> {
304
410
  mkdirSync(outDir, { recursive: true });
305
411
 
306
412
  // ── import + compose each domain ──
413
+ // LAZY PER-DOMAIN BOOT (#34): the generated entry registers each domain's modules
414
+ // as `() => require("…")` THUNKS — esbuild lazy-wraps every required module
415
+ // (`__esm` init at the require call, not at bundle top level), so a fresh
416
+ // per-plan sandbox boots ONLY the domain the dispatched intent touches. The
417
+ // routing table for type/relation-keyed dispatches is computed below by the
418
+ // SAME scans the engine registries run (`collectEngineRouting` — one machinery).
419
+ // `NOMOS_LUMP_BOOT=eager` keeps the pre-#34 whole-law top-level boot (static
420
+ // imports, no routing) — the equivalence baseline the #34 suite compares against.
421
+ const eagerLump = process.env.NOMOS_LUMP_BOOT === "eager";
307
422
  const entryImports: string[] = [];
308
423
  const entryDomains: string[] = [];
424
+ const routingInput: Record<string, Mod[]> = {};
309
425
  const domainModules: DomainModule[] = [];
426
+ const layered = layeredFlag || cfg.layered === true;
427
+ const layeredDomains: { key: string; inputs: LayeredModuleInput[] }[] = [];
310
428
  let importSeq = 0;
311
429
 
312
430
  for (const d of cfg.domains) {
@@ -314,17 +432,22 @@ async function main(): Promise<void> {
314
432
  fail(`each domain needs { key, modules: [path, ...] }; got ${JSON.stringify(d)}`);
315
433
  }
316
434
  const mods: Mod[] = [];
317
- const varNames: string[] = [];
435
+ const sourceExprs: string[] = [];
318
436
  for (const rel of d.modules) {
319
437
  const abs = path.resolve(cfgDir, rel);
320
438
  if (!existsSync(abs)) fail(`domain '${d.key}': module not found: ${abs}`);
321
439
  const m = (await import(pathToFileURL(abs).href)) as Mod;
322
440
  mods.push(m);
323
- const v = `m${importSeq++}`;
324
- varNames.push(v);
325
- entryImports.push(`import * as ${v} from ${JSON.stringify(abs)};`);
441
+ if (eagerLump) {
442
+ const v = `m${importSeq++}`;
443
+ sourceExprs.push(v);
444
+ entryImports.push(`import * as ${v} from ${JSON.stringify(abs)};`);
445
+ } else {
446
+ sourceExprs.push(`() => require(${JSON.stringify(abs)})`);
447
+ }
326
448
  }
327
- entryDomains.push(` ${JSON.stringify(d.key)}: [${varNames.join(", ")}],`);
449
+ entryDomains.push(` ${JSON.stringify(d.key)}: [${sourceExprs.join(", ")}],`);
450
+ routingInput[d.key] = mods;
328
451
 
329
452
  let merged: Mod = {};
330
453
  for (const m of mods) merged = { ...merged, ...m };
@@ -357,6 +480,11 @@ async function main(): Promise<void> {
357
480
  );
358
481
  const extraAggregates = resolveNamed<AggregateHandle>(merged, d.extraAggregates, "extraAggregates");
359
482
 
483
+ // Dart-codegen hints (frontend-only — never law; see DomainConfig):
484
+ checkDartImports(d.key, d.dartImports);
485
+ checkDartRefImports(d.key, d.dartRefImports);
486
+ const permissionVocabulary = resolvePermissionVocabulary(d.key, merged, d.permissionVocabulary);
487
+
360
488
  domainModules.push(
361
489
  composeDomainModule({
362
490
  name: d.name ?? d.key,
@@ -373,8 +501,60 @@ async function main(): Promise<void> {
373
501
  ...(impureCapabilities.length > 0 ? { impureCapabilities } : {}),
374
502
  ...(sums.length > 0 ? { sums } : {}),
375
503
  ...(spatials.length > 0 ? { spatials } : {}),
504
+ ...(d.dartImports !== undefined && d.dartImports.length > 0
505
+ ? { dartImports: d.dartImports }
506
+ : {}),
507
+ ...(d.dartRefImports !== undefined && d.dartRefImports.length > 0
508
+ ? { dartRefImports: d.dartRefImports }
509
+ : {}),
510
+ ...(permissionVocabulary !== undefined ? { permissionVocabulary } : {}),
376
511
  }),
377
512
  );
513
+
514
+ // ── opt-in: ONE USD LAYER PER MODULE (Φ applied per module, not once over the
515
+ // composed fold) — the layered emission `usd_layers.ts` proves flattens back
516
+ // to the EXACT canonical IR (asserted below, fail-closed). Per-module decls
517
+ // are discovered the same way as above, on each module's OWN exports; config
518
+ // export-names resolve on whichever module actually exports them; config
519
+ // `extraAggregates` ride the FIRST (weakest) layer, prelude-style. Counts /
520
+ // sums / spatials never enter the USD IR, so they have no layer placement.
521
+ if (layered) {
522
+ const usedStems = new Set<string>();
523
+ const inputs: LayeredModuleInput[] = mods.map((m, i) => {
524
+ let stem = path.basename(d.modules[i]!).replace(/\.[^.]*$/, "");
525
+ for (let n = 2; usedStems.has(stem); n++) stem = `${stem}-${n}`;
526
+ usedStems.add(stem);
527
+
528
+ const presentNames = (names: readonly string[] | undefined) =>
529
+ (names ?? []).filter((n) => (m as Record<string, unknown>)[n] !== undefined);
530
+ const layerQueries = [
531
+ ...discover<QueryDecl>(m, isQueryDecl),
532
+ ...resolveNamed<QueryDecl>(m, presentNames(d.queries), "queries"),
533
+ ];
534
+ const layerDeriveds = [
535
+ ...discover<DerivedDecl>(m, isDerivedDecl),
536
+ ...resolveNamed<DerivedDecl>(m, presentNames(d.deriveds), "deriveds"),
537
+ ];
538
+ const layerCombineds = [
539
+ ...discover<CombinedDecl>(m, isCombinedDecl),
540
+ ...resolveNamed<CombinedDecl>(m, presentNames(d.combineds), "combineds"),
541
+ ];
542
+ const byId = new Map<string, AggregateHandle>();
543
+ for (const a of [...aggregatesOf(m), ...(i === 0 ? extraAggregates : [])]) byId.set(a.id, a);
544
+
545
+ const module: DomainModule = {
546
+ name: d.name ?? d.key,
547
+ domain: d.key,
548
+ aggregates: [...byId.values()],
549
+ directives: directivesOf(m),
550
+ ...(layerQueries.length > 0 ? { queries: layerQueries } : {}),
551
+ ...(layerDeriveds.length > 0 ? { deriveds: layerDeriveds } : {}),
552
+ ...(layerCombineds.length > 0 ? { combineds: layerCombineds } : {}),
553
+ };
554
+ return { name: stem, module };
555
+ });
556
+ layeredDomains.push({ key: d.key, inputs });
557
+ }
378
558
  }
379
559
 
380
560
  // ── reports ──
@@ -384,14 +564,76 @@ async function main(): Promise<void> {
384
564
  if (!rel || !exportName) fail(`report '${reportId}' must be "modulePath#exportName"; got '${ref}'`);
385
565
  const abs = path.resolve(cfgDir, rel);
386
566
  if (!existsSync(abs)) fail(`report '${reportId}': module not found: ${abs}`);
387
- const v = `r${importSeq++}`;
388
- entryImports.push(`import * as ${v} from ${JSON.stringify(abs)};`);
389
- entryReports.push(` ${JSON.stringify(reportId)}: ${v}.${exportName},`);
567
+ if (eagerLump) {
568
+ const v = `r${importSeq++}`;
569
+ entryImports.push(`import * as ${v} from ${JSON.stringify(abs)};`);
570
+ entryReports.push(` ${JSON.stringify(reportId)}: ${v}.${exportName},`);
571
+ } else {
572
+ // A lazy report factory: the module's `__esm` init runs on first render call.
573
+ entryReports.push(
574
+ ` ${JSON.stringify(reportId)}: (actor: string) => (require(${JSON.stringify(abs)}) as any).${exportName}(actor),`,
575
+ );
576
+ }
390
577
  }
391
578
 
392
579
  // ── 1. generate + bundle the engine entry ──
580
+ // The routing table is computed by the SAME duck-type scans the engine registries
581
+ // run, over the SAME imported modules (collectEngineRouting — one machinery, so a
582
+ // law whose invariant declarations are inconsistent refuses to COMPILE, and the
583
+ // table can never disagree with what a full force would register).
584
+ const routing = collectEngineRouting(routingInput);
585
+ // AMBIENT-SLOT SEEDING (lazy boot only): zod v4 writes
586
+ // `globalThis.__zod_global{Config,Registry}` at MODULE INIT. The sealed sandbox
587
+ // freezes globalThis after the lump's top level — an eager lump's zod init ran
588
+ // pre-freeze, but a lazy lump would init zod inside the dispatch, post-freeze,
589
+ // and the frozen global refuses the new slots. So the entry eagerly imports the
590
+ // TWO tiny zod core modules that own those writes (resolved to the same files
591
+ // the domain modules' `zod` import reaches — same esbuild module instances);
592
+ // the heavy classic schema graph stays deferred. The frozen-sandbox boot probe
593
+ // below fail-closes the compile if any OTHER module writes globals at lazy init.
594
+ // Namespace imports + a `seeds` call argument (NOT bare side-effect imports):
595
+ // zod ships `sideEffects: false`, so esbuild would tree-shake a bare import away;
596
+ // passing the namespaces into the registerEngine CALL forces their init at the
597
+ // entry's top level — pre-freeze, exactly where the eager lump ran them.
598
+ const zodSeedImports: string[] = [];
599
+ const zodSeedNames: string[] = [];
600
+ if (!eagerLump) {
601
+ const zodPkgJson = [cfgDir, DSL_DIR]
602
+ .map((dir) => {
603
+ try {
604
+ return createRequire(pathToFileURL(path.join(dir, "noop.js"))).resolve("zod/package.json");
605
+ } catch {
606
+ return undefined;
607
+ }
608
+ })
609
+ .find((p): p is string => p !== undefined);
610
+ const zodCoreFiles = zodPkgJson
611
+ ? ["core.js", "registries.js"].map((f) => path.join(path.dirname(zodPkgJson), "v4", "core", f))
612
+ : [];
613
+ const seedFiles =
614
+ zodCoreFiles.length > 0 && zodCoreFiles.every((f) => existsSync(f))
615
+ ? zodCoreFiles
616
+ : zodPkgJson !== undefined
617
+ ? // Unknown zod layout — seed by initializing the whole library at top
618
+ // level (loses some laziness, never correctness).
619
+ ["zod"]
620
+ : [];
621
+ for (const f of seedFiles) {
622
+ const v = `__seed${zodSeedNames.length}`;
623
+ zodSeedImports.push(`import * as ${v} from ${JSON.stringify(f)};`);
624
+ zodSeedNames.push(v);
625
+ }
626
+ }
393
627
  const entrySource = [
394
628
  `// AUTO-GENERATED by nomos-compile from ${path.basename(configPath)} — do not edit.`,
629
+ ...(eagerLump
630
+ ? []
631
+ : [
632
+ `// LAZY PER-DOMAIN BOOT (#34): each domain registers as require-thunks; esbuild`,
633
+ `// defers every module's init to its first dispatch touch inside the fresh sandbox.`,
634
+ `declare function require(id: string): Record<string, unknown>;`,
635
+ ...zodSeedImports,
636
+ ]),
395
637
  ...entryImports,
396
638
  `import { registerEngine } from "@githolon/dsl/engine-entry";`,
397
639
  ``,
@@ -400,6 +642,8 @@ async function main(): Promise<void> {
400
642
  ...entryDomains,
401
643
  ` },`,
402
644
  ...(entryReports.length > 0 ? [` reports: {`, ...entryReports, ` },`] : []),
645
+ ...(eagerLump ? [] : [` routing: ${JSON.stringify(routing)},`]),
646
+ ...(zodSeedNames.length > 0 ? [` seeds: [${zodSeedNames.join(", ")}],`] : []),
403
647
  `});`,
404
648
  ``,
405
649
  ].join("\n");
@@ -436,6 +680,57 @@ async function main(): Promise<void> {
436
680
  fail(`bundled engine entry does not assign globalThis.plan — refusing to package`);
437
681
  }
438
682
 
683
+ // ── 1b. THE FROZEN-SANDBOX BOOT PROBE (lazy boot only, fail-closed) ──
684
+ // The sealed engine freezes globalThis after the lump's top level; a lazy
685
+ // domain's module init runs LATER, inside the dispatch. Any module that writes
686
+ // an ambient global at init (the zod slots are pre-seeded above; this guards
687
+ // everything else) would deterministically throw at FIRST DISPATCH in
688
+ // production. Reproduce the sandbox order here — eval the real bundle in a
689
+ // fresh frozen context, then force every domain — and refuse to package on any
690
+ // failure that is not the expected unknown-directive refusal.
691
+ if (!eagerLump) {
692
+ // A CHILD process: the probe must freeze a REAL global object (a `vm` context
693
+ // global refuses Object.freeze), and the lump must run STRICT (the engine
694
+ // evals it with the STRICT flag — sloppy mode would silently no-op the very
695
+ // frozen-global writes the probe exists to catch).
696
+ const lumpPath = path.join(outDir, ".nomos_lump.js");
697
+ writeFileSync(lumpPath, javascript, "utf8");
698
+ const probePath = path.join(outDir, ".nomos_boot_probe.cjs");
699
+ writeFileSync(
700
+ probePath,
701
+ [
702
+ `// AUTO-GENERATED by nomos-compile — the frozen-sandbox lazy-boot probe.`,
703
+ `const fs = require("node:fs");`,
704
+ `const lump = fs.readFileSync(process.argv[2], "utf8");`,
705
+ `const domains = JSON.parse(process.argv[3]);`,
706
+ `(0, eval)('"use strict";\\n' + lump); // indirect eval → real global scope, strict like the engine`,
707
+ `Object.freeze(globalThis); // the sealed sandbox freezes before dispatch`,
708
+ `for (const key of domains) {`,
709
+ ` let msg;`,
710
+ ` try { globalThis.plan({ intent: { domain: key, directiveId: "__nomos_boot_probe__" } }); msg = "unexpected-success"; }`,
711
+ ` catch (e) { msg = String((e && e.message) || e); }`,
712
+ ` if (!/no directive registered/.test(msg)) { console.error(key + "\\u0000" + msg); process.exit(3); }`,
713
+ `}`,
714
+ ``,
715
+ ].join("\n"),
716
+ "utf8",
717
+ );
718
+ try {
719
+ execFileSync(process.execPath, [probePath, lumpPath, JSON.stringify(cfg.domains.map((d) => d.key))], {
720
+ encoding: "utf8",
721
+ stdio: ["ignore", "ignore", "pipe"],
722
+ });
723
+ } catch (e) {
724
+ const stderr = String((e as { stderr?: string }).stderr ?? "").trim();
725
+ const [domainKey, msg] = stderr.includes("\u0000") ? stderr.split("\u0000") : ["?", stderr];
726
+ fail(
727
+ `domain '${domainKey}' failed its frozen-sandbox lazy-boot probe — a module ` +
728
+ `initializer touches ambient globals after the freeze (the engine would ` +
729
+ `refuse its first dispatch): ${msg}`,
730
+ );
731
+ }
732
+ }
733
+
439
734
  // ── 2. USD IR + the package envelope ──
440
735
  const usdJson = emitUsdJsonForModules(domainModules);
441
736
  const text = packageUsda(usdJson, javascript);
@@ -443,6 +738,48 @@ async function main(): Promise<void> {
443
738
  writeFileSync(pkgPath, text, "utf8");
444
739
  const domainHash = sha256HexUtf8(text);
445
740
 
741
+ // ── 2b. opt-in LAYERED emission — additive artifacts ONLY (the flattened package
742
+ // above and its domainHash are already written and do not move). The
743
+ // homomorphism is ASSERTED fail-closed on every layered compile: the
744
+ // per-module layer stack must flatten BYTE-IDENTICALLY to the canonical
745
+ // composed IR embedded in the package, or nothing layered is emitted.
746
+ let layeredRootPath: string | undefined;
747
+ let layeredFileCount = 0;
748
+ if (layered) {
749
+ const safe = (s: string) => s.replace(/[^A-Za-z0-9._-]/g, "_");
750
+ const layersDir = path.join(outDir, "layers");
751
+ mkdirSync(layersDir, { recursive: true });
752
+ const allLayers: UsdLayer[] = [];
753
+ const relPaths: string[] = [];
754
+ const pendingWrites: { path: string; text: string }[] = [];
755
+ for (const ld of layeredDomains) {
756
+ for (const emitted of emitLayeredDomain(ld.key, ld.inputs)) {
757
+ const base = `${safe(ld.key)}--${safe(emitted.name)}`;
758
+ pendingWrites.push({ path: path.join(layersDir, `${base}.usda`), text: emitted.usda });
759
+ pendingWrites.push({
760
+ path: path.join(layersDir, `${base}.layer.json`),
761
+ text: JSON.stringify(emitted.layer) + "\n",
762
+ });
763
+ allLayers.push(emitted.layer);
764
+ relPaths.push(`layers/${base}.usda`);
765
+ }
766
+ }
767
+ const flatJson = JSON.stringify(flattenLayeredUsd(allLayers));
768
+ if (flatJson !== usdJson) {
769
+ fail(
770
+ `layered emission does not flatten to the canonical composed IR — the module fold and ` +
771
+ `the layer-stack fold DISAGREE for this package (usually a re-declared directive ` +
772
+ `silently dropping an omit-when-empty reads/emits boundary, or a re-declared ` +
773
+ `aggregate dropping a field: USD shows the weaker opinion through; the module fold ` +
774
+ `does not). Refusing to emit the layered artifact (the flattened package is unaffected).`,
775
+ );
776
+ }
777
+ for (const w of pendingWrites) writeFileSync(w.path, w.text, "utf8");
778
+ layeredRootPath = path.join(outDir, `${cfg.name}.layered.usda`);
779
+ writeFileSync(layeredRootPath, layeredRootUsda(relPaths), "utf8");
780
+ layeredFileCount = relPaths.length;
781
+ }
782
+
446
783
  // ── 3. the READ manifest + IDENTITY artifacts ──
447
784
  const readManifest = buildReadManifest(domainModules);
448
785
  const readPath = path.join(outDir, "domain_manifests.json");
@@ -536,6 +873,11 @@ async function main(): Promise<void> {
536
873
  console.log(`nomos-compile: ${cfg.name}`);
537
874
  console.log(` package ${rel(pkgPath)} (${text.length} bytes)`);
538
875
  console.log(` domainHash ${domainHash}`);
876
+ if (layeredRootPath !== undefined) {
877
+ console.log(
878
+ ` layered ${rel(layeredRootPath)} (${layeredFileCount} module layer(s) in build/layers/ — flatten-verified byte-identical to the canonical IR)`,
879
+ );
880
+ }
539
881
  console.log(` read ${rel(readPath)} (${Object.keys(readManifest.aggregateFieldKinds).length} aggregate(s), ${readManifest.queries.length} quer(y/ies), ${readManifest.counts.length} count(s))`);
540
882
  console.log(` identity ${rel(manifestsPath)} (${Object.keys(identity.manifests).length} domain(s)${identity.excluded.length ? `, ${identity.excluded.length} EXCLUDED (palette gap)` : ""})`);
541
883
  for (const [dom, h] of Object.entries(identity.hashes)) console.log(` ${dom.padEnd(20)} ${h}`);