@githolon/dsl 0.2.1 → 0.2.2

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.
@@ -29,6 +29,22 @@
29
29
  * `identity = {...identityCore, ...co2_identity}` pattern), so a tenant can compose
30
30
  * framework + tenant modules under one dispatch key.
31
31
  *
32
+ * LAZY PER-DOMAIN BOOT (task #34 — hot-path boot): a domain's module may also be a
33
+ * THUNK `() => moduleExports`. `nomos-compile` emits thunks (`() => require("…")`),
34
+ * which esbuild lazy-wraps (`__esm` init runs at the require call, not at bundle
35
+ * top level) — so the lump's TOP-LEVEL boot no longer executes every domain's zod
36
+ * schema graph + directive construction. Each fresh per-plan sandbox forces ONLY
37
+ * the domain(s) the dispatched intent touches: per-plan boot is O(directives of
38
+ * the touched domain), not O(whole law). Dispatches that are keyed by AGGREGATE
39
+ * TYPE or RELATION (derive / combine / aggregateInvariant / invariant) cannot know
40
+ * which domain to force, so a thunked entry REQUIRES the compiler-emitted
41
+ * `routing` table (built by `collectEngineRouting` over the SAME duck-type scans —
42
+ * one machinery, never a parallel list) mapping each type/relation to the domain
43
+ * keys that declare it. Fail-closed: thunks without routing refuse at top level.
44
+ * The EAGER shape (plain module objects, no routing) keeps the exact pre-#34
45
+ * behaviour — old generated entries remain valid inputs, and old compiled lumps in
46
+ * deployed chains re-verify untouched (they carry their own machinery).
47
+ *
32
48
  * ENGINE-BUNDLE-SAFE: this file (and everything it reaches) imports NO node builtin —
33
49
  * it must bundle under esbuild `--platform=neutral` into the sealed QuickJS lump.
34
50
  * The contracts mirrored here (wire shapes, vacuous-holds, strikes-conditional
@@ -48,20 +64,56 @@ import type { QueryRow } from "./report.js";
48
64
  /** One bundled domain module: a bag of named exports the entry scans by SHAPE. */
49
65
  export type DomainModuleExports = Record<string, unknown>;
50
66
 
67
+ /**
68
+ * A domain module SOURCE: the exports bag itself (eager — the pre-#34 shape), or a
69
+ * thunk producing it (lazy — forced on first dispatch touch inside the fresh
70
+ * sandbox; `nomos-compile` emits `() => require("…")`, which esbuild defers).
71
+ */
72
+ export type DomainModuleSource = DomainModuleExports | (() => DomainModuleExports);
73
+
51
74
  /** A report: declarative query + render; the host feeds rows, the engine renders. */
52
75
  export interface EngineReport {
53
76
  render(rows: QueryRow[]): string;
54
77
  }
55
78
 
79
+ /**
80
+ * THE ROUTING TABLE (lazy boot only): aggregate type / relation id → the domain
81
+ * keys (in `domains` declaration order) whose modules declare it. Emitted by the
82
+ * compiler via {@link collectEngineRouting} — the SAME scans that build the live
83
+ * registries, run once at compile over the same merged modules, so the table
84
+ * cannot drift from what a full force would register. Used by the dispatches that
85
+ * are NOT keyed by domain (derive / combine / aggregateInvariant / invariant) to
86
+ * force only the declaring domain(s). A type/relation ABSENT from its map is one
87
+ * NO domain declares — exactly the eager scan-everything outcome.
88
+ */
89
+ export interface EngineRouting {
90
+ readonly derivedOf?: Record<string, readonly string[]>;
91
+ readonly combinedOf?: Record<string, readonly string[]>;
92
+ readonly aggregateInvariantOf?: Record<string, readonly string[]>;
93
+ readonly relationOf?: Record<string, readonly string[]>;
94
+ }
95
+
56
96
  /** The one declarative input: dispatch key → ORDERED module list (later wins). */
57
97
  export interface EngineEntryConfig {
58
98
  /**
59
99
  * Domain dispatch key → the modules composing it, spread-merged IN ORDER
60
100
  * (later overrides earlier on a name collision — the `identity` union pattern).
101
+ * Values may be thunks (lazy boot — see the module header).
61
102
  */
62
- readonly domains: Record<string, readonly DomainModuleExports[]>;
103
+ readonly domains: Record<string, readonly DomainModuleSource[]>;
63
104
  /** Optional report registry: `reportId` → a factory `(actor) => Report`. */
64
105
  readonly reports?: Record<string, (actor: string) => EngineReport>;
106
+ /** REQUIRED when any domain module is a thunk; ignored otherwise. */
107
+ readonly routing?: EngineRouting;
108
+ /**
109
+ * AMBIENT-SLOT SEEDS (lazy boot): values imported at the entry's TOP LEVEL purely
110
+ * so their modules' init runs PRE-FREEZE (zod v4 writes
111
+ * `globalThis.__zod_global{Config,Registry}` at module init; the frozen sandbox
112
+ * refuses new globals at lazy-init time). The values are never read — passing
113
+ * them as call arguments is what keeps the imports live under a library's
114
+ * `sideEffects: false` (a bare side-effect import would be tree-shaken away).
115
+ */
116
+ readonly seeds?: readonly unknown[];
65
117
  }
66
118
 
67
119
  interface RegistryEntry {
@@ -148,6 +200,126 @@ function mergeModules(mods: readonly DomainModuleExports[]): DomainModuleExports
148
200
  return merged;
149
201
  }
150
202
 
203
+ /**
204
+ * ONE DOMAIN's registries — everything its merged module exports register. Built by
205
+ * the ONE `buildDomainSlice` whether the boot is eager (all domains at top level)
206
+ * or lazy (the touched domain inside the dispatch) — factored, never forked.
207
+ */
208
+ interface DomainSlice {
209
+ /** directiveId → {directive, agg}. */
210
+ registry: Map<string, RegistryEntry>;
211
+ /** aggregate type → its DerivedDecls (module export order). */
212
+ deriveds: Map<string, DerivedDecl[]>;
213
+ /** aggregate type → its CombinedDecls (module export order). */
214
+ combineds: Map<string, CombinedDecl[]>;
215
+ /** relation id → invariant body (off the registered directives' declaredRelations). */
216
+ invariants: Map<string, InvariantBody>;
217
+ /** aggregate type → aggregate-invariant body. */
218
+ aggInvariants: Map<string, AggregateInvariantFn>;
219
+ }
220
+
221
+ /** Build one domain's slice from its merged module exports (the original scans). */
222
+ function buildDomainSlice(mod: DomainModuleExports): DomainSlice {
223
+ // ── directiveId → {directive, agg} ────────────────────────────────────────────
224
+ const registry = new Map<string, RegistryEntry>();
225
+ const aggs = aggregatesOf(mod);
226
+ const dirs = directivesOf(mod);
227
+ for (const [dirId, directive] of dirs) {
228
+ const agg = aggs.get(directive.aggregateId);
229
+ if (agg === undefined) {
230
+ // A directive targeting an aggregate not exported by its module is an
231
+ // authoring bug; surface it lazily (only if that directive is invoked).
232
+ continue;
233
+ }
234
+ registry.set(dirId, { directive, agg });
235
+ }
236
+
237
+ // ── aggregate type → derived / combined decls (fn bodies ship HERE, never the ledger) ──
238
+ const deriveds = new Map<string, DerivedDecl[]>();
239
+ const combineds = new Map<string, CombinedDecl[]>();
240
+ for (const d of derivedsOf(mod)) {
241
+ const list = deriveds.get(d.of) ?? [];
242
+ list.push(d);
243
+ deriveds.set(d.of, list);
244
+ }
245
+ for (const c of combinedsOf(mod)) {
246
+ const list = combineds.get(c.of) ?? [];
247
+ list.push(c);
248
+ combineds.set(c.of, list);
249
+ }
250
+
251
+ // ── relation id → cross-workspace invariant body (off the directives' declaredRelations) ──
252
+ const invariants = new Map<string, InvariantBody>();
253
+ for (const { directive } of registry.values()) {
254
+ const relations = (directive as { declaredRelations?: unknown }).declaredRelations;
255
+ if (!Array.isArray(relations)) continue;
256
+ for (const rel of relations) {
257
+ const r = rel as { id?: unknown; hasInvariant?: unknown; invariant?: unknown };
258
+ if (typeof r.id !== "string" || r.hasInvariant !== true) continue;
259
+ if (typeof r.invariant !== "function") {
260
+ // A relation declaring `hasInvariant` MUST ship an executable body — fail-closed.
261
+ throw new Error(
262
+ `engine bundle: relation "${r.id}" declares hasInvariant but ships no executable ` +
263
+ `invariant body — the gate would have nothing to evaluate (cross_workspace.md §2.1).`,
264
+ );
265
+ }
266
+ invariants.set(r.id, r.invariant as InvariantBody);
267
+ }
268
+ }
269
+
270
+ // ── aggregate type → aggregate-invariant body — from the SAME exports (no second list) ──
271
+ const aggInvariants = new Map<string, AggregateInvariantFn>();
272
+ for (const v of Object.values(mod)) {
273
+ if (
274
+ v &&
275
+ typeof v === "object" &&
276
+ (v as { __isAggregateHandle?: boolean }).__isAggregateHandle === true &&
277
+ (v as { hasInvariant?: boolean }).hasInvariant === true
278
+ ) {
279
+ const h = v as AggregateHandle & { invariant?: AggregateInvariantFn };
280
+ if (typeof h.invariant !== "function") {
281
+ // A handle declaring `hasInvariant` MUST ship an executable body — fail-closed.
282
+ throw new Error(
283
+ `engine bundle: aggregate "${h.id}" declares hasInvariant but ships no executable ` +
284
+ `invariant body — the gate would have nothing to evaluate (#250).`,
285
+ );
286
+ }
287
+ aggInvariants.set(h.id, h.invariant);
288
+ }
289
+ }
290
+
291
+ return { registry, deriveds, combineds, invariants, aggInvariants };
292
+ }
293
+
294
+ /**
295
+ * COMPILE-TIME companion (build lane; also bundle-safe): run the SAME scans the live
296
+ * registries run, over the SAME merged modules, and return the routing table a lazy
297
+ * entry needs. `nomos-compile` calls this with every domain's merged exports and
298
+ * embeds the result as `config.routing` — so the table and the registries can never
299
+ * disagree (one machinery). Throws the same fail-closed errors `buildDomainSlice`
300
+ * throws (a law whose invariant declarations are inconsistent refuses to COMPILE).
301
+ */
302
+ export function collectEngineRouting(
303
+ domains: Record<string, readonly DomainModuleExports[]>,
304
+ ): EngineRouting {
305
+ const derivedOf: Record<string, string[]> = {};
306
+ const combinedOf: Record<string, string[]> = {};
307
+ const aggregateInvariantOf: Record<string, string[]> = {};
308
+ const relationOf: Record<string, string[]> = {};
309
+ const add = (map: Record<string, string[]>, key: string, domainName: string) => {
310
+ const list = map[key] ?? (map[key] = []);
311
+ if (!list.includes(domainName)) list.push(domainName);
312
+ };
313
+ for (const [domainName, mods] of Object.entries(domains)) {
314
+ const slice = buildDomainSlice(mergeModules(mods));
315
+ for (const type of slice.deriveds.keys()) add(derivedOf, type, domainName);
316
+ for (const type of slice.combineds.keys()) add(combinedOf, type, domainName);
317
+ for (const type of slice.aggInvariants.keys()) add(aggregateInvariantOf, type, domainName);
318
+ for (const relationId of slice.invariants.keys()) add(relationOf, relationId, domainName);
319
+ }
320
+ return { derivedOf, combinedOf, aggregateInvariantOf, relationOf };
321
+ }
322
+
151
323
  /**
152
324
  * Build a DSL `ctx` (Ports) from the host-injected `__ports` scalars. `clock()`
153
325
  * synthesises a `WireHlc` from the scalar (the engine leg DISCARDS the produced
@@ -180,89 +352,51 @@ export interface RegisteredEngine {
180
352
  }
181
353
 
182
354
  /**
183
- * Build ALL registries from the one `domains` map, wire the five dispatch paths, and
184
- * assign `globalThis.plan` + `globalThis.planReport` (the lump is eval'd as a classic
185
- * script and `globalThis` is FROZEN before dispatch registration must happen at
186
- * top-level eval, which calling this at module top level does).
355
+ * Build the registries from the one `domains` map (eagerly for plain module objects,
356
+ * on first dispatch touch for thunks), wire the five dispatch paths, and assign
357
+ * `globalThis.plan` + `globalThis.planReport` (the lump is eval'd as a classic
358
+ * script and `globalThis` is FROZEN before dispatch the ASSIGNMENT must happen at
359
+ * top-level eval, which calling this at module top level does; the lazy forcing
360
+ * happens inside the dispatch call and writes no globals).
187
361
  */
188
362
  export function registerEngine(config: EngineEntryConfig): RegisteredEngine {
189
- const mergedByDomain = new Map<string, DomainModuleExports>();
190
- for (const [domainName, mods] of Object.entries(config.domains)) {
191
- mergedByDomain.set(domainName, mergeModules(mods));
363
+ const domainNames = Object.keys(config.domains);
364
+ const lazy = Object.values(config.domains).some((mods) =>
365
+ mods.some((m) => typeof m === "function"),
366
+ );
367
+ // FAIL-CLOSED: a thunked (lazy) entry cannot resolve type/relation-keyed
368
+ // dispatches without the compiler-emitted routing table. Refuse at top level —
369
+ // at lump-build/install time, never silently at dispatch.
370
+ if (lazy && config.routing === undefined) {
371
+ throw new Error(
372
+ "engine bundle: lazy domain thunks require the compiler-emitted routing table " +
373
+ "(registerEngine config.routing) — recompile with nomos-compile.",
374
+ );
192
375
  }
376
+ const routing: EngineRouting = config.routing ?? {};
193
377
 
194
- // ── (domain, directiveId){directive, agg} ──────────────────────────────────
195
- const REGISTRY = new Map<string, RegistryEntry>();
196
- for (const [domainName, mod] of mergedByDomain) {
197
- const aggs = aggregatesOf(mod);
198
- const dirs = directivesOf(mod);
199
- for (const [dirId, directive] of dirs) {
200
- const agg = aggs.get(directive.aggregateId);
201
- if (agg === undefined) {
202
- // A directive targeting an aggregate not exported by its module is an
203
- // authoring bug; surface it lazily (only if that directive is invoked).
204
- continue;
205
- }
206
- REGISTRY.set(`${domainName}\u0000${dirId}`, { directive, agg });
207
- }
378
+ // ── domain keyits slice (forced once per sandbox; eager boot forces ALL now) ──
379
+ const sliceCache = new Map<string, DomainSlice>();
380
+ function sliceOf(domainName: string): DomainSlice | undefined {
381
+ const cached = sliceCache.get(domainName);
382
+ if (cached !== undefined) return cached;
383
+ const sources = config.domains[domainName];
384
+ if (sources === undefined) return undefined;
385
+ const forced = sources.map((m) => (typeof m === "function" ? m() : m));
386
+ const slice = buildDomainSlice(mergeModules(forced));
387
+ sliceCache.set(domainName, slice);
388
+ return slice;
208
389
  }
390
+ if (!lazy) for (const domainName of domainNames) sliceOf(domainName);
209
391
 
210
- // ── aggregate type → derived / combined decls (fn bodies ship HERE, never the ledger) ──
211
- const DERIVED_REGISTRY = new Map<string, DerivedDecl[]>();
212
- const COMBINED_REGISTRY = new Map<string, CombinedDecl[]>();
213
- for (const mod of mergedByDomain.values()) {
214
- for (const d of derivedsOf(mod)) {
215
- const list = DERIVED_REGISTRY.get(d.of) ?? [];
216
- list.push(d);
217
- DERIVED_REGISTRY.set(d.of, list);
218
- }
219
- for (const c of combinedsOf(mod)) {
220
- const list = COMBINED_REGISTRY.get(c.of) ?? [];
221
- list.push(c);
222
- COMBINED_REGISTRY.set(c.of, list);
223
- }
224
- }
225
-
226
- // ── relation id → cross-workspace invariant body (off the directives' declaredRelations) ──
227
- const INVARIANT_REGISTRY = new Map<string, InvariantBody>();
228
- for (const { directive } of REGISTRY.values()) {
229
- const relations = (directive as { declaredRelations?: unknown }).declaredRelations;
230
- if (!Array.isArray(relations)) continue;
231
- for (const rel of relations) {
232
- const r = rel as { id?: unknown; hasInvariant?: unknown; invariant?: unknown };
233
- if (typeof r.id !== "string" || r.hasInvariant !== true) continue;
234
- if (typeof r.invariant !== "function") {
235
- // A relation declaring `hasInvariant` MUST ship an executable body — fail-closed.
236
- throw new Error(
237
- `engine bundle: relation "${r.id}" declares hasInvariant but ships no executable ` +
238
- `invariant body — the gate would have nothing to evaluate (cross_workspace.md §2.1).`,
239
- );
240
- }
241
- INVARIANT_REGISTRY.set(r.id, r.invariant as InvariantBody);
242
- }
243
- }
244
-
245
- // ── aggregate type → aggregate-invariant body — from the SAME map (no second list) ──
246
- const AGG_INVARIANT_REGISTRY = new Map<string, AggregateInvariantFn>();
247
- for (const mod of mergedByDomain.values()) {
248
- for (const v of Object.values(mod)) {
249
- if (
250
- v &&
251
- typeof v === "object" &&
252
- (v as { __isAggregateHandle?: boolean }).__isAggregateHandle === true &&
253
- (v as { hasInvariant?: boolean }).hasInvariant === true
254
- ) {
255
- const h = v as AggregateHandle & { invariant?: AggregateInvariantFn };
256
- if (typeof h.invariant !== "function") {
257
- // A handle declaring `hasInvariant` MUST ship an executable body — fail-closed.
258
- throw new Error(
259
- `engine bundle: aggregate "${h.id}" declares hasInvariant but ships no executable ` +
260
- `invariant body — the gate would have nothing to evaluate (#250).`,
261
- );
262
- }
263
- AGG_INVARIANT_REGISTRY.set(h.id, h.invariant);
264
- }
265
- }
392
+ /**
393
+ * The domain keys a type/relation-keyed dispatch consults, IN DECLARATION ORDER
394
+ * (the fold order the eager registries had): the routed list when a routing map
395
+ * is present (lazy force only the declaring domains), else every domain (eager).
396
+ */
397
+ function domainsFor(map: Record<string, readonly string[]> | undefined, key: string): readonly string[] {
398
+ if (map !== undefined) return map[key] ?? [];
399
+ return domainNames;
266
400
  }
267
401
 
268
402
  const REPORTS: Record<string, (actor: string) => EngineReport> = config.reports ?? {};
@@ -278,7 +412,12 @@ export function registerEngine(config: EngineEntryConfig): RegisteredEngine {
278
412
  `engine invariant: job.intent.invariant must carry {relation}; got ${JSON.stringify(job.intent)}`,
279
413
  );
280
414
  }
281
- const body = INVARIANT_REGISTRY.get(relationId);
415
+ // Later domain wins (the Map.set fold order of the one flat registry).
416
+ let body: InvariantBody | undefined;
417
+ for (const domainName of domainsFor(routing.relationOf, relationId)) {
418
+ const found = sliceOf(domainName)?.invariants.get(relationId);
419
+ if (found !== undefined) body = found;
420
+ }
282
421
  if (body === undefined) {
283
422
  throw new Error(`engine invariant: no invariant registered for relation "${relationId}"`);
284
423
  }
@@ -303,7 +442,12 @@ export function registerEngine(config: EngineEntryConfig): RegisteredEngine {
303
442
  `engine aggregateInvariant: job.intent.aggregateInvariant must carry {of}; got ${JSON.stringify(job.intent)}`,
304
443
  );
305
444
  }
306
- const body = AGG_INVARIANT_REGISTRY.get(aggregateType);
445
+ // Later domain wins (the Map.set fold order of the one flat registry).
446
+ let body: AggregateInvariantFn | undefined;
447
+ for (const domainName of domainsFor(routing.aggregateInvariantOf, aggregateType)) {
448
+ const found = sliceOf(domainName)?.aggInvariants.get(aggregateType);
449
+ if (found !== undefined) body = found;
450
+ }
307
451
  // VACUOUS HOLDS: a type with NO declared invariant trivially holds — `{accept:true}`,
308
452
  // NOT a throw (the oracle fails CLOSED on a throw, wrongly rejecting every create).
309
453
  if (body === undefined) {
@@ -324,7 +468,11 @@ export function registerEngine(config: EngineEntryConfig): RegisteredEngine {
324
468
  `engine derive: job.intent.derive must carry {of}; got ${JSON.stringify(job.intent)}`,
325
469
  );
326
470
  }
327
- const fields = DERIVED_REGISTRY.get(ofType) ?? [];
471
+ // Domain declaration order, then module export order — the eager fold order.
472
+ const fields: DerivedDecl[] = [];
473
+ for (const domainName of domainsFor(routing.derivedOf, ofType)) {
474
+ fields.push(...(sliceOf(domainName)?.deriveds.get(ofType) ?? []));
475
+ }
328
476
  const prior = (job.priorState ?? {}) as Record<string, unknown>;
329
477
  const out: Record<string, unknown> = {};
330
478
  for (const d of fields) {
@@ -345,7 +493,11 @@ export function registerEngine(config: EngineEntryConfig): RegisteredEngine {
345
493
  `engine combine: job.intent.combine must carry {of}; got ${JSON.stringify(job.intent)}`,
346
494
  );
347
495
  }
348
- const fields = COMBINED_REGISTRY.get(ofType) ?? [];
496
+ // Domain declaration order, then module export order — the eager fold order.
497
+ const fields: CombinedDecl[] = [];
498
+ for (const domainName of domainsFor(routing.combinedOf, ofType)) {
499
+ fields.push(...(sliceOf(domainName)?.combineds.get(ofType) ?? []));
500
+ }
349
501
  const owner = (job.priorState ?? {}) as Record<string, unknown>;
350
502
  // Preferred: related rows keyed by combined field id (iteration-order independent);
351
503
  // the array path remains for older callers (declaration order).
@@ -429,7 +581,8 @@ export function registerEngine(config: EngineEntryConfig): RegisteredEngine {
429
581
  `engine plan: job.intent must carry {domain, directiveId}; got ${JSON.stringify(intent)}`,
430
582
  );
431
583
  }
432
- const entry = REGISTRY.get(`${domain}\u0000${directiveId}`);
584
+ // The plan dispatch IS domain-keyed: force only the dispatched domain.
585
+ const entry = sliceOf(domain)?.registry.get(directiveId);
433
586
  if (entry === undefined) {
434
587
  throw new Error(`engine plan: no directive registered for (${domain}, ${directiveId})`);
435
588
  }