@githolon/dsl 0.2.2 → 0.3.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.
@@ -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";
@@ -76,6 +82,11 @@ import {
76
82
  type Mod,
77
83
  } from "./build_package.js";
78
84
  import { collectEngineRouting } from "./engine_entry.js";
85
+ import { resolveWorkspaceTypes, type WorkspaceTypeDecl } from "./workspace_type.js";
86
+ import { canonicalTaxonomyFragment, validateWorkspaceTaxonomyAndRoutes } from "./workspace_routing.js";
87
+ import { shardingLawModule, type ShardingLawSpec } from "./workspace_sharding.js";
88
+ import type { AnyExtremum } from "./extremum.js";
89
+ import type { WorkspaceInvariantDecl } from "./framework/workspace_invariant.js";
79
90
  import {
80
91
  emitLayeredDomain,
81
92
  flattenLayeredUsd,
@@ -96,12 +107,35 @@ interface DomainConfig {
96
107
  readonly queries?: readonly string[];
97
108
  readonly counts?: readonly string[];
98
109
  readonly sums?: readonly string[];
110
+ /** Maintained MIN/MAX reads (#47) — export names, config-named like sums. */
111
+ readonly mins?: readonly string[];
112
+ readonly maxes?: readonly string[];
99
113
  readonly spatials?: readonly string[];
100
114
  readonly deriveds?: readonly string[];
101
115
  readonly combineds?: readonly string[];
116
+ readonly workspaceTypes?: readonly string[];
117
+ /** Export-name escape hatch for workspace invariants (auto-discovered by shape otherwise). */
118
+ readonly workspaceInvariants?: readonly string[];
102
119
  readonly impureCapabilities?: readonly string[];
103
120
  readonly extraAggregates?: readonly string[];
104
121
  readonly readModelAggregates?: readonly string[];
122
+ // DART CODEGEN HINTS (#codegen_dart; NEVER law: none of these enter the USD IR,
123
+ // the package bytes, or the identity manifest — they shape ONLY the generated
124
+ // `dart/` frontend, so adding them does not move the domainHash):
125
+ /** LITERAL extra imports for the generated `<domain>.dart` (e.g. a sibling
126
+ * domain file whose types this domain's payloads reference). */
127
+ readonly dartImports?: readonly DartImport[];
128
+ /** LITERAL cross-domain read-model routing: an aggregate TYPE this domain
129
+ * references resolves through the named import prefix instead of a local decl. */
130
+ readonly dartRefImports?: readonly DartRefImport[];
131
+ /** EXPORT-NAME escape hatch (resolved on the merged module exports, like
132
+ * `impureCapabilities`): `resourceTypes` names a `readonly string[]` export and
133
+ * `roleCatalogue` a `Record<string, string[]>` export; together they emit the
134
+ * frontend permission vocabulary (PermissionRoles/PermissionCapabilities). */
135
+ readonly permissionVocabulary?: {
136
+ readonly resourceTypes: string;
137
+ readonly roleCatalogue: string;
138
+ };
105
139
  }
106
140
 
107
141
  interface PackageConfig {
@@ -153,6 +187,14 @@ function isQueryDecl(v: unknown): v is QueryDecl {
153
187
  );
154
188
  }
155
189
 
190
+ function isWorkspaceInvariantDecl(v: unknown): v is WorkspaceInvariantDecl {
191
+ return (
192
+ !!v &&
193
+ typeof v === "object" &&
194
+ (v as { __isWorkspaceInvariant?: unknown }).__isWorkspaceInvariant === true
195
+ );
196
+ }
197
+
156
198
  function isSpatialDecl(v: unknown): v is SpatialDecl {
157
199
  return (
158
200
  !!v &&
@@ -200,7 +242,8 @@ function isCountDecl(v: unknown): v is AnyCount {
200
242
  typeof o.on !== "string" && // spatial
201
243
  typeof o.fn !== "function" && // derived/combined
202
244
  !Array.isArray(o.key) && // query
203
- !("field" in o) && // sum / extremum
245
+ !("field" in o) && // extremum
246
+ !("sumField" in o) && // sum (the SumDecl/Sum-builder mark — never a count)
204
247
  !("orderBy" in o) && // ordered read
205
248
  !("_existsMarker" in o) && // exists
206
249
  typeof o.refField !== "string" && // combined
@@ -210,6 +253,28 @@ function isCountDecl(v: unknown): v is AnyCount {
210
253
  );
211
254
  }
212
255
 
256
+ /**
257
+ * Scan merged exports for `workspaceType(...)` decls (sharding §10 slice 1) by the
258
+ * `__isWorkspaceType` brand — individual exports AND exported arrays. Dedupe is BY
259
+ * REFERENCE here and BY ID (divergence-refused) in `resolveWorkspaceTypes`; the
260
+ * generic `discover()`'s JSON dedupe key is unusable for these decls (they carry
261
+ * aggregate handles whose zod schemas do not stringify).
262
+ */
263
+ function discoverWorkspaceTypes(merged: Mod): WorkspaceTypeDecl[] {
264
+ const out: WorkspaceTypeDecl[] = [];
265
+ const isDecl = (v: unknown): v is WorkspaceTypeDecl =>
266
+ !!v && typeof v === "object" &&
267
+ (v as { __isWorkspaceType?: unknown }).__isWorkspaceType === true;
268
+ const push = (v: WorkspaceTypeDecl) => {
269
+ if (!out.includes(v)) out.push(v);
270
+ };
271
+ for (const v of Object.values(merged)) {
272
+ if (isDecl(v)) push(v);
273
+ else if (Array.isArray(v)) for (const el of v) if (isDecl(el)) push(el);
274
+ }
275
+ return out;
276
+ }
277
+
213
278
  /** Scan merged exports for decls matching `match`: individual exports AND exported arrays. */
214
279
  function discover<T>(merged: Mod, match: (v: unknown) => v is T): T[] {
215
280
  const seen = new Set<string>();
@@ -228,6 +293,66 @@ function discover<T>(merged: Mod, match: (v: unknown) => v is T): T[] {
228
293
  return out;
229
294
  }
230
295
 
296
+ /** Validate the LITERAL dart-codegen import hints (config data, not module exports). */
297
+ function checkDartImports(domainKey: string, imports: readonly DartImport[] | undefined): void {
298
+ for (const imp of imports ?? []) {
299
+ if (typeof imp?.uri !== "string" || (imp.prefix !== undefined && typeof imp.prefix !== "string")) {
300
+ fail(`domain '${domainKey}': each dartImports entry must be { uri, prefix? }; got ${JSON.stringify(imp)}`);
301
+ }
302
+ }
303
+ }
304
+
305
+ function checkDartRefImports(domainKey: string, imports: readonly DartRefImport[] | undefined): void {
306
+ for (const imp of imports ?? []) {
307
+ if (
308
+ typeof imp?.aggregateType !== "string" ||
309
+ typeof imp?.uri !== "string" ||
310
+ typeof imp?.prefix !== "string"
311
+ ) {
312
+ fail(
313
+ `domain '${domainKey}': each dartRefImports entry must be { aggregateType, uri, prefix }; got ${JSON.stringify(imp)}`,
314
+ );
315
+ }
316
+ }
317
+ }
318
+
319
+ /** Resolve the permissionVocabulary EXPORT NAMES on the merged exports, fail-closed. */
320
+ function resolvePermissionVocabulary(
321
+ domainKey: string,
322
+ merged: Mod,
323
+ cfg: DomainConfig["permissionVocabulary"],
324
+ ): PermissionVocabulary | undefined {
325
+ if (cfg === undefined) return undefined;
326
+ if (typeof cfg?.resourceTypes !== "string" || typeof cfg?.roleCatalogue !== "string") {
327
+ fail(
328
+ `domain '${domainKey}': permissionVocabulary must be { resourceTypes: "<exportName>", roleCatalogue: "<exportName>" }; got ${JSON.stringify(cfg)}`,
329
+ );
330
+ }
331
+ const resourceTypes = merged[cfg.resourceTypes];
332
+ if (!Array.isArray(resourceTypes) || !resourceTypes.every((t) => typeof t === "string")) {
333
+ fail(
334
+ `domain '${domainKey}': permissionVocabulary.resourceTypes names export '${cfg.resourceTypes}', which the modules do not export as a string[]`,
335
+ );
336
+ }
337
+ const roleCatalogue = merged[cfg.roleCatalogue];
338
+ if (
339
+ !roleCatalogue ||
340
+ typeof roleCatalogue !== "object" ||
341
+ Array.isArray(roleCatalogue) ||
342
+ !Object.values(roleCatalogue).every(
343
+ (caps) => Array.isArray(caps) && caps.every((c) => typeof c === "string"),
344
+ )
345
+ ) {
346
+ fail(
347
+ `domain '${domainKey}': permissionVocabulary.roleCatalogue names export '${cfg.roleCatalogue}', which the modules do not export as a Record<string, string[]>`,
348
+ );
349
+ }
350
+ return {
351
+ resourceTypes: resourceTypes as string[],
352
+ roleCatalogue: roleCatalogue as Record<string, string[]>,
353
+ };
354
+ }
355
+
231
356
  /** Resolve explicit EXPORT NAMES (the config escape hatch) on the merged exports. */
232
357
  function resolveNamed<T>(merged: Mod, names: readonly string[] | undefined, what: string): T[] {
233
358
  const out: T[] = [];
@@ -338,6 +463,7 @@ async function main(): Promise<void> {
338
463
  const eagerLump = process.env.NOMOS_LUMP_BOOT === "eager";
339
464
  const entryImports: string[] = [];
340
465
  const entryDomains: string[] = [];
466
+ const entryMintInstalls: string[] = []; // route-tagged in-plan mints (taxonomy packages only)
341
467
  const routingInput: Record<string, Mod[]> = {};
342
468
  const domainModules: DomainModule[] = [];
343
469
  const layered = layeredFlag || cfg.layered === true;
@@ -363,11 +489,127 @@ async function main(): Promise<void> {
363
489
  sourceExprs.push(`() => require(${JSON.stringify(abs)})`);
364
490
  }
365
491
  }
366
- entryDomains.push(` ${JSON.stringify(d.key)}: [${sourceExprs.join(", ")}],`);
367
- routingInput[d.key] = mods;
368
-
369
492
  let merged: Mod = {};
370
493
  for (const m of mods) merged = { ...merged, ...m };
494
+ let shardingSums: AnySum[] = [];
495
+ let shardingModForDomain: Mod | undefined;
496
+ let shardingLazyExpr: string | undefined;
497
+
498
+ // ── THE DERIVED PLACEMENT LAW (sharding slice 2): a taxonomy with PACKED axes
499
+ // appends the framework placement module (`workspace_sharding.ts`) to this
500
+ // domain — in the composed manifests (hash-bearing law) AND the generated
501
+ // entry (the plan ships in the lump). Taxonomy-free domains append NOTHING:
502
+ // their package bytes / hashes do not move (the slice-1 stability law). ──
503
+ const workspaceTypes = [
504
+ ...discoverWorkspaceTypes(merged),
505
+ ...resolveNamed<WorkspaceTypeDecl>(merged, d.workspaceTypes, "workspaceTypes"),
506
+ ];
507
+ if (workspaceTypes.length > 0) {
508
+ let spec: ShardingLawSpec | undefined;
509
+ try {
510
+ const types = resolveWorkspaceTypes(workspaceTypes, d.name ?? d.key);
511
+ const axes = [...types.values()]
512
+ .filter((t) => t.mode === "packed")
513
+ .map((p) => {
514
+ const parent = [...types.values()].find((t) => t.childIds.includes(p.id));
515
+ // SLICE-2 STATIC MAP: shard count = the parent's .pool(n), default 1 (the
516
+ // degenerate coordinator+sole-shard layout, §6's no-flag-day migration).
517
+ return { axisType: p.id, axisRoot: p.rootId, shardCount: parent?.poolSize ?? 1 };
518
+ })
519
+ .sort((a, b) => (a.axisType < b.axisType ? -1 : 1));
520
+ if (axes.length > 0) spec = { axes };
521
+ } catch (e) {
522
+ fail((e as Error).message);
523
+ }
524
+ if (spec !== undefined) {
525
+ // SLICE 3a (#41): derive the directive ROUTES over the TENANT modules (the SAME
526
+ // derivation the canonical manifest records) and feed them into the placement
527
+ // law, so it emits the per-directive HOMING INVARIANT — the wrong-home gate
528
+ // moves from the edge bailiff into THE CHAIN GATE ITSELF.
529
+ // SLICE 3 (#42): also feed the PACKED HOMING TABLE (aggregate → axis) — the
530
+ // engine-side route-tag mint wrapper tags in-plan `create(Agg)` mints of
531
+ // packed-homed aggregates with the dispatched intent's home tag (§4).
532
+ try {
533
+ const fragment = canonicalTaxonomyFragment({
534
+ name: d.name ?? d.key,
535
+ aggregates: aggregatesOf(merged),
536
+ directives: directivesOf(merged),
537
+ workspaceTypes,
538
+ });
539
+ if (fragment.routes !== undefined && fragment.routes.length > 0) {
540
+ spec = { ...spec, routes: fragment.routes };
541
+ }
542
+ const packedAxes = new Set(spec.axes.map((a) => a.axisType));
543
+ const packedHomes = Object.fromEntries(
544
+ Object.entries(fragment.homes ?? {}).filter(([, home]) => packedAxes.has(home)),
545
+ );
546
+ if (Object.keys(packedHomes).length > 0) spec = { ...spec, homes: packedHomes };
547
+ } catch (e) {
548
+ fail((e as Error).message);
549
+ }
550
+ const shardingMod = shardingLawModule(spec) as Mod;
551
+ mods.push(shardingMod);
552
+ merged = { ...merged, ...shardingMod };
553
+ shardingModForDomain = shardingMod;
554
+ // The derived sharding law's MAINTAINED SUMS (`nomosEstateSummary` — the §5.1
555
+ // estate total over subtotal rows) must reach the read manifest + identity:
556
+ // sums are config-named (never auto-discovered — hash stability for existing
557
+ // packages), so the appended module's sums thread through explicitly here.
558
+ shardingSums = Object.values(shardingMod).filter(
559
+ (v): v is AnySum =>
560
+ !!v && typeof v === "object" &&
561
+ typeof (v as { id?: unknown }).id === "string" &&
562
+ typeof (v as { of?: unknown }).of === "string" &&
563
+ typeof (v as { sumField?: unknown }).sumField === "string",
564
+ );
565
+ const specJson = JSON.stringify(spec);
566
+ if (eagerLump) {
567
+ if (!entryImports.some((l) => l.includes("workspace-sharding"))) {
568
+ entryImports.push(
569
+ `import { shardingLawModule as __nomosShardingLaw, installRouteTaggedMint as __nomosInstallMint } from "@githolon/dsl/workspace-sharding";`,
570
+ );
571
+ }
572
+ sourceExprs.push(`__nomosShardingLaw(${specJson})`);
573
+ } else {
574
+ shardingLazyExpr = `() => (require("@githolon/dsl/workspace-sharding") as any).shardingLawModule(${specJson})`;
575
+ sourceExprs.push(shardingLazyExpr);
576
+ }
577
+ // ROUTE-TAGGED IN-PLAN MINTS (§4, slice 3): installed AFTER registerEngine at
578
+ // the lump's top level — pre-freeze, taxonomy packages only (a taxonomy-free
579
+ // lump never imports the module: the slice-1 hash-stability law).
580
+ entryMintInstalls.push(
581
+ eagerLump
582
+ ? `__nomosInstallMint(${specJson});`
583
+ : `(require("@githolon/dsl/workspace-sharding") as any).installRouteTaggedMint(${specJson});`,
584
+ );
585
+ }
586
+ }
587
+
588
+ entryDomains.push(` ${JSON.stringify(d.key)}: [${sourceExprs.join(", ")}],`);
589
+ routingInput[d.key] = mods;
590
+ // ── THE AUX INVARIANT-ROUTING KEY (#47, sharding slice 7 — the invariant-gate
591
+ // flame pass): in a LAZY lump, the derived sharding law registers a SECOND
592
+ // time under its own domain key, and the ROUTING table points the sharding
593
+ // workspace-invariant ids at THAT key only. A per-write homing-invariant
594
+ // dispatch (`workspaceInvariantReads`/`workspaceInvariant`) then forces ONLY
595
+ // the small framework module — never the tenant domain's whole zod/module
596
+ // graph (the measured ~30 ms-per-dispatch floor on co2 estate_structures).
597
+ // Directive dispatch is untouched (the sharding directives stay registered
598
+ // under `d.key` — the wire domain every host lane already speaks); the
599
+ // canonical manifests are untouched (identity hashes do not move); eager
600
+ // lumps are untouched (no routing table — the pre-#34 equivalence shape);
601
+ // taxonomy-free packages emit NOTHING here (the slice-1 byte-stability law).
602
+ if (shardingModForDomain !== undefined && shardingLazyExpr !== undefined && !eagerLump) {
603
+ const auxKey = `${d.key}/nomos-sharding`;
604
+ if (cfg.domains.some((x) => x.key === auxKey) || routingInput[auxKey] !== undefined) {
605
+ fail(
606
+ `domain key '${auxKey}' is reserved for the derived sharding law's invariant routing — rename the domain`,
607
+ );
608
+ }
609
+ entryDomains.push(` ${JSON.stringify(auxKey)}: [${shardingLazyExpr}],`);
610
+ routingInput[d.key] = mods.filter((m) => m !== shardingModForDomain);
611
+ routingInput[auxKey] = [shardingModForDomain];
612
+ }
371
613
 
372
614
  const queries = [
373
615
  ...discover<QueryDecl>(merged, isQueryDecl),
@@ -389,7 +631,13 @@ async function main(): Promise<void> {
389
631
  ...discover<CombinedDecl>(merged, isCombinedDecl),
390
632
  ...resolveNamed<CombinedDecl>(merged, d.combineds, "combineds"),
391
633
  ];
392
- const sums = resolveNamed<AnySum>(merged, d.sums, "sums");
634
+ const workspaceInvariants = [
635
+ ...discover<WorkspaceInvariantDecl>(merged, isWorkspaceInvariantDecl),
636
+ ...resolveNamed<WorkspaceInvariantDecl>(merged, d.workspaceInvariants, "workspaceInvariants"),
637
+ ];
638
+ const sums = [...resolveNamed<AnySum>(merged, d.sums, "sums"), ...shardingSums];
639
+ const mins = resolveNamed<AnyExtremum>(merged, d.mins, "mins");
640
+ const maxes = resolveNamed<AnyExtremum>(merged, d.maxes, "maxes");
393
641
  const impureCapabilities = resolveNamed<ImpureCapabilityDecl>(
394
642
  merged,
395
643
  d.impureCapabilities,
@@ -397,6 +645,11 @@ async function main(): Promise<void> {
397
645
  );
398
646
  const extraAggregates = resolveNamed<AggregateHandle>(merged, d.extraAggregates, "extraAggregates");
399
647
 
648
+ // Dart-codegen hints (frontend-only — never law; see DomainConfig):
649
+ checkDartImports(d.key, d.dartImports);
650
+ checkDartRefImports(d.key, d.dartRefImports);
651
+ const permissionVocabulary = resolvePermissionVocabulary(d.key, merged, d.permissionVocabulary);
652
+
400
653
  domainModules.push(
401
654
  composeDomainModule({
402
655
  name: d.name ?? d.key,
@@ -412,10 +665,82 @@ async function main(): Promise<void> {
412
665
  ...(combineds.length > 0 ? { combineds } : {}),
413
666
  ...(impureCapabilities.length > 0 ? { impureCapabilities } : {}),
414
667
  ...(sums.length > 0 ? { sums } : {}),
668
+ ...(mins.length > 0 ? { mins } : {}),
669
+ ...(maxes.length > 0 ? { maxes } : {}),
670
+ ...(workspaceTypes.length > 0 ? { workspaceTypes } : {}),
671
+ ...(workspaceInvariants.length > 0 ? { workspaceInvariants } : {}),
415
672
  ...(spatials.length > 0 ? { spatials } : {}),
673
+ ...(d.dartImports !== undefined && d.dartImports.length > 0
674
+ ? { dartImports: d.dartImports }
675
+ : {}),
676
+ ...(d.dartRefImports !== undefined && d.dartRefImports.length > 0
677
+ ? { dartRefImports: d.dartRefImports }
678
+ : {}),
679
+ ...(permissionVocabulary !== undefined ? { permissionVocabulary } : {}),
416
680
  }),
417
681
  );
418
682
 
683
+ // ── THE TAXONOMY GATE (sharding §10 slice 1 + slice 2; fail-closed, BEFORE
684
+ // identity emission): resolve the declared workspace types, run the homing
685
+ // walk (nearest packed axis via t.ref chains), the directive cross-home
686
+ // check, AND the slice-2 route derivation (per-directive home keys). A
687
+ // no-path / ambiguous / cross-home / unroutable law is a NAMED COMPILE
688
+ // ERROR here — never a domain silently recorded as identity-excluded.
689
+ const composed = domainModules[domainModules.length - 1]!;
690
+ if (composed.workspaceTypes !== undefined && composed.workspaceTypes.length > 0) {
691
+ try {
692
+ validateWorkspaceTaxonomyAndRoutes(composed);
693
+ } catch (e) {
694
+ fail((e as Error).message);
695
+ }
696
+ }
697
+ // ── THE SCOPE GATE (sharding §5, slice 3; fail-closed): every `scoped(read, Ws)`
698
+ // must name a DECLARED, DEDICATED workspace type of THIS domain's taxonomy
699
+ // (the coordinator type that holds the subtotal rows). A scoped read in a
700
+ // taxonomy-free domain, or scoped to a packed type, is a NAMED compile error.
701
+ {
702
+ const declaredScopes = new Map<string, string>(); // type id -> mode
703
+ const globalAggIds = new Set<string>(); // §5.4 — replicated reference data
704
+ if (workspaceTypes.length > 0) {
705
+ try {
706
+ for (const [id, ty] of resolveWorkspaceTypes(workspaceTypes, d.name ?? d.key)) {
707
+ declaredScopes.set(id, ty.mode);
708
+ for (const g of ty.globalIds) globalAggIds.add(g);
709
+ }
710
+ } catch { /* already failed above */ }
711
+ }
712
+ for (const read of [...(composed.counts ?? []), ...(composed.sums ?? [])]) {
713
+ const scope = (read as { scope?: unknown }).scope;
714
+ if (scope === undefined) continue;
715
+ const id = (read as { id?: unknown }).id;
716
+ if (typeof scope !== "string" || !declaredScopes.has(scope)) {
717
+ fail(
718
+ `domain '${d.key}': read '${String(id)}' is scoped to '${String(scope)}', which is not a ` +
719
+ `declared workspace type of this domain — declare the taxonomy (workspaceType(...)) ` +
720
+ `in the same domain, or drop the scope.`,
721
+ );
722
+ }
723
+ if (declaredScopes.get(scope) !== "dedicated") {
724
+ fail(
725
+ `domain '${d.key}': read '${String(id)}' is scoped to PACKED type '${scope}' — estate ` +
726
+ `scope must name the DEDICATED coordinator type (the holon that holds the subtotal rows).`,
727
+ );
728
+ }
729
+ // §5.4 (slice 4): a `.global(...)` aggregate is coordinator-homed reference
730
+ // data REPLICATED to every shard — an estate-scoped tally over it would sum
731
+ // the SAME rows once per shard. Its home-scope read on the coordinator IS
732
+ // the estate value already.
733
+ const ofType = (read as { of?: unknown }).of;
734
+ if (typeof ofType === "string" && globalAggIds.has(ofType)) {
735
+ fail(
736
+ `domain '${d.key}': read '${String(id)}' is scoped over GLOBAL aggregate '${ofType}' — ` +
737
+ `global reference data is replicated to every shard, so an estate-scoped tally would ` +
738
+ `count it once per shard. Drop the scope: the coordinator's home-scope read IS the estate value.`,
739
+ );
740
+ }
741
+ }
742
+ }
743
+
419
744
  // ── opt-in: ONE USD LAYER PER MODULE (Φ applied per module, not once over the
420
745
  // composed fold) — the layered emission `usd_layers.ts` proves flattens back
421
746
  // to the EXACT canonical IR (asserted below, fail-closed). Per-module decls
@@ -550,6 +875,10 @@ async function main(): Promise<void> {
550
875
  ...(eagerLump ? [] : [` routing: ${JSON.stringify(routing)},`]),
551
876
  ...(zodSeedNames.length > 0 ? [` seeds: [${zodSeedNames.join(", ")}],`] : []),
552
877
  `});`,
878
+ // ROUTE-TAGGED IN-PLAN MINTS (sharding §4, slice 3): wraps globalThis.plan +
879
+ // globalThis.nomos.mint at top-level eval — pre-freeze, after registerEngine's
880
+ // own assignments. Empty (and therefore byte-absent) for taxonomy-free packages.
881
+ ...entryMintInstalls,
553
882
  ``,
554
883
  ].join("\n");
555
884
  const entryPath = path.join(outDir, ".nomos_entry.ts");
@@ -787,6 +1116,13 @@ async function main(): Promise<void> {
787
1116
  console.log(` identity ${rel(manifestsPath)} (${Object.keys(identity.manifests).length} domain(s)${identity.excluded.length ? `, ${identity.excluded.length} EXCLUDED (palette gap)` : ""})`);
788
1117
  for (const [dom, h] of Object.entries(identity.hashes)) console.log(` ${dom.padEnd(20)} ${h}`);
789
1118
  for (const ex of identity.excluded) console.log(` EXCLUDED ${ex.domain}: ${ex.reason}`);
1119
+ for (const m of domainModules) {
1120
+ if (m.workspaceTypes !== undefined && m.workspaceTypes.length > 0) {
1121
+ console.log(
1122
+ ` taxonomy ${m.domain ?? m.name}: ${m.workspaceTypes.length} workspace type(s) — homing derived from t.ref chains; birth lanes typed in the client`,
1123
+ );
1124
+ }
1125
+ }
790
1126
  console.log(` client ${rel(clientPath)} (typed TS client — payloads, read models, query accessors)`);
791
1127
  if (proofPath !== undefined) {
792
1128
  console.log(` proof ${rel(proofPath)} (a runnable e2e GENERATED from your law — run: githolon proof)`);
@@ -59,6 +59,7 @@ import type { WireEvent, WireHlc } from "./wire.js";
59
59
  import type { DerivedDecl } from "./derived.js";
60
60
  import type { CombinedDecl } from "./combined.js";
61
61
  import type { InvariantBody, InvariantEvidence, InvariantVerdict } from "./relation.js";
62
+ import type { WorkspaceInvariantDecl } from "./framework/workspace_invariant.js";
62
63
  import type { QueryRow } from "./report.js";
63
64
 
64
65
  /** One bundled domain module: a bag of named exports the entry scans by SHAPE. */
@@ -91,6 +92,8 @@ export interface EngineRouting {
91
92
  readonly combinedOf?: Record<string, readonly string[]>;
92
93
  readonly aggregateInvariantOf?: Record<string, readonly string[]>;
93
94
  readonly relationOf?: Record<string, readonly string[]>;
95
+ /** workspace-invariant id → declaring domain keys (#266 — the gate's reads/assert dispatches). */
96
+ readonly workspaceInvariantOf?: Record<string, readonly string[]>;
94
97
  }
95
98
 
96
99
  /** The one declarative input: dispatch key → ORDERED module list (later wins). */
@@ -216,6 +219,8 @@ interface DomainSlice {
216
219
  invariants: Map<string, InvariantBody>;
217
220
  /** aggregate type → aggregate-invariant body. */
218
221
  aggInvariants: Map<string, AggregateInvariantFn>;
222
+ /** workspace-invariant id → its declaration (#266 — `reads`/`assert` bodies ship HERE). */
223
+ wsInvariants: Map<string, WorkspaceInvariantDecl>;
219
224
  }
220
225
 
221
226
  /** Build one domain's slice from its merged module exports (the original scans). */
@@ -288,7 +293,28 @@ function buildDomainSlice(mod: DomainModuleExports): DomainSlice {
288
293
  }
289
294
  }
290
295
 
291
- return { registry, deriveds, combineds, invariants, aggInvariants };
296
+ // ── workspace-invariant id declaration — from the SAME exports (#266, by shape) ──
297
+ const wsInvariants = new Map<string, WorkspaceInvariantDecl>();
298
+ for (const v of Object.values(mod)) {
299
+ if (
300
+ v &&
301
+ typeof v === "object" &&
302
+ (v as { __isWorkspaceInvariant?: boolean }).__isWorkspaceInvariant === true
303
+ ) {
304
+ const w = v as WorkspaceInvariantDecl;
305
+ if (typeof w.reads !== "function" || typeof w.assert !== "function") {
306
+ // A declaration MUST ship both executable halves — fail-closed at boot, exactly
307
+ // like a relation/aggregate invariant with a missing body.
308
+ throw new Error(
309
+ `engine bundle: workspace invariant "${w.id}" declares no executable reads/assert ` +
310
+ `body — the gate would have nothing to evaluate (docs/workspace_invariant.md).`,
311
+ );
312
+ }
313
+ wsInvariants.set(w.id, w);
314
+ }
315
+ }
316
+
317
+ return { registry, deriveds, combineds, invariants, aggInvariants, wsInvariants };
292
318
  }
293
319
 
294
320
  /**
@@ -306,6 +332,7 @@ export function collectEngineRouting(
306
332
  const combinedOf: Record<string, string[]> = {};
307
333
  const aggregateInvariantOf: Record<string, string[]> = {};
308
334
  const relationOf: Record<string, string[]> = {};
335
+ const workspaceInvariantOf: Record<string, string[]> = {};
309
336
  const add = (map: Record<string, string[]>, key: string, domainName: string) => {
310
337
  const list = map[key] ?? (map[key] = []);
311
338
  if (!list.includes(domainName)) list.push(domainName);
@@ -316,8 +343,9 @@ export function collectEngineRouting(
316
343
  for (const type of slice.combineds.keys()) add(combinedOf, type, domainName);
317
344
  for (const type of slice.aggInvariants.keys()) add(aggregateInvariantOf, type, domainName);
318
345
  for (const relationId of slice.invariants.keys()) add(relationOf, relationId, domainName);
346
+ for (const wsInvariantId of slice.wsInvariants.keys()) add(workspaceInvariantOf, wsInvariantId, domainName);
319
347
  }
320
- return { derivedOf, combinedOf, aggregateInvariantOf, relationOf };
348
+ return { derivedOf, combinedOf, aggregateInvariantOf, relationOf, workspaceInvariantOf };
321
349
  }
322
350
 
323
351
  /**
@@ -458,6 +486,80 @@ export function registerEngine(config: EngineEntryConfig): RegisteredEngine {
458
486
  return JSON.stringify(verdict);
459
487
  }
460
488
 
489
+ /** Every domain key whose merged module declares a given workspace invariant (routed when lazy). */
490
+ function wsInvariantDomains(id?: string): readonly string[] {
491
+ if (routing.workspaceInvariantOf !== undefined && id !== undefined) {
492
+ return routing.workspaceInvariantOf[id] ?? [];
493
+ }
494
+ return domainNames;
495
+ }
496
+
497
+ /** Resolve ONE declared workspace invariant by id (later domain wins — the one fold order). */
498
+ function wsInvariantById(id: string): WorkspaceInvariantDecl | undefined {
499
+ let found: WorkspaceInvariantDecl | undefined;
500
+ for (const domainName of wsInvariantDomains(id)) {
501
+ const hit = sliceOf(domainName)?.wsInvariants.get(id);
502
+ if (hit !== undefined) found = hit;
503
+ }
504
+ return found;
505
+ }
506
+
507
+ // ── the three WORKSPACE-INVARIANT dispatches (#266 — wire shapes are the gate oracle's
508
+ // contract, EXACT: see admission-peer EngineWorkspaceInvariant + plan_workspace_invariant*) ──
509
+
510
+ /** `{workspaceInvariantList:{}}` → the DECLARED set `[{id,on},…]` sorted by id (discovery). */
511
+ function planWorkspaceInvariantList(): string {
512
+ const declared = new Map<string, { id: string; on: string }>();
513
+ const domains =
514
+ routing.workspaceInvariantOf !== undefined
515
+ ? [...new Set(Object.values(routing.workspaceInvariantOf).flat())]
516
+ : domainNames;
517
+ for (const domainName of domains) {
518
+ const slice = sliceOf(domainName);
519
+ if (slice === undefined) continue;
520
+ for (const w of slice.wsInvariants.values()) declared.set(w.id, { id: w.id, on: w.on });
521
+ }
522
+ return JSON.stringify([...declared.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)));
523
+ }
524
+
525
+ /** `{workspaceInvariantReads:{id}, payload}` → the bounded ref-set `[{name,aggregate,id},…]`. */
526
+ function planWorkspaceInvariantReads(job: {
527
+ intent?: { workspaceInvariantReads?: { id?: string }; payload?: unknown };
528
+ }): string {
529
+ const id = job.intent?.workspaceInvariantReads?.id;
530
+ if (typeof id !== "string") {
531
+ throw new Error(
532
+ `engine workspaceInvariantReads: job.intent.workspaceInvariantReads must carry {id}; got ${JSON.stringify(job.intent)}`,
533
+ );
534
+ }
535
+ const decl = wsInvariantById(id);
536
+ if (decl === undefined) {
537
+ throw new Error(`engine workspaceInvariantReads: no workspace invariant registered for "${id}"`);
538
+ }
539
+ const intent = (job.intent?.payload ?? {}) as Record<string, unknown>;
540
+ return JSON.stringify(decl.reads({ intent }));
541
+ }
542
+
543
+ /** `{workspaceInvariant:{id,on}, payload?}` + priorState (named snapshots) → {accept}|{reject}. */
544
+ function planWorkspaceInvariantAssert(job: {
545
+ intent?: { workspaceInvariant?: { id?: string }; payload?: unknown };
546
+ priorState?: Record<string, unknown>;
547
+ }): string {
548
+ const id = job.intent?.workspaceInvariant?.id;
549
+ if (typeof id !== "string") {
550
+ throw new Error(
551
+ `engine workspaceInvariant: job.intent.workspaceInvariant must carry {id}; got ${JSON.stringify(job.intent)}`,
552
+ );
553
+ }
554
+ const decl = wsInvariantById(id);
555
+ if (decl === undefined) {
556
+ throw new Error(`engine workspaceInvariant: no workspace invariant registered for "${id}"`);
557
+ }
558
+ const snapshots = (job.priorState ?? {}) as Record<string, Record<string, unknown>>;
559
+ const intent = (job.intent?.payload ?? {}) as Record<string, unknown>;
560
+ return JSON.stringify(decl.assert(snapshots, { intent }));
561
+ }
562
+
461
563
  function planDerive(job: {
462
564
  intent?: { derive?: { of?: string } };
463
565
  priorState?: Record<string, unknown>;
@@ -527,8 +629,26 @@ export function registerEngine(config: EngineEntryConfig): RegisteredEngine {
527
629
  queryRows?: unknown[];
528
630
  priorState?: Record<string, unknown>;
529
631
  }): string {
530
- // Branch order mirrors emit_engine.ts: aggregateInvariant invariant derive
531
- // combine → report (the gate's most recent dispatch key wins over older fan-outs).
632
+ // Branch order mirrors emit_engine.ts: workspace-invariant trioaggregateInvariant
633
+ // invariant → derive → combine → report (the gate's most recent dispatch key wins).
634
+ const wsJob = job as {
635
+ intent?: {
636
+ workspaceInvariantList?: unknown;
637
+ workspaceInvariantReads?: { id?: string };
638
+ workspaceInvariant?: { id?: string };
639
+ payload?: unknown;
640
+ };
641
+ priorState?: Record<string, unknown>;
642
+ };
643
+ if (wsJob.intent?.workspaceInvariantList !== undefined) {
644
+ return planWorkspaceInvariantList();
645
+ }
646
+ if (wsJob.intent?.workspaceInvariantReads !== undefined) {
647
+ return planWorkspaceInvariantReads(wsJob);
648
+ }
649
+ if (wsJob.intent?.workspaceInvariant !== undefined) {
650
+ return planWorkspaceInvariantAssert(wsJob);
651
+ }
532
652
  if (job.intent?.aggregateInvariant !== undefined) {
533
653
  return planAggInvariant(
534
654
  job as { intent?: { aggregateInvariant?: { of?: string } }; priorState?: Record<string, unknown> },
@@ -75,9 +75,16 @@ export type WorkspaceInvariantReads = (ctx: { intent: Record<string, unknown> })
75
75
  /**
76
76
  * The post-apply predicate over the resolved snapshots, keyed by each ref's binding `name`.
77
77
  * PURE over the named map (no clock, no IO, no live reads) — runs in the sealed engine.
78
+ *
79
+ * The OPTIONAL second argument carries the authored intent payload (`ctx.intent`) — the
80
+ * SAME committed fact the `reads` body derived its ref-set from (never a live read), so a
81
+ * predicate may compare a payload-carried value against the resolved snapshots (the
82
+ * wrong-home homing invariant compares the payload's home key against the shard's own
83
+ * declared identity). Predicates that ignore it are unchanged (#266 signature, additive).
78
84
  */
79
85
  export type WorkspaceInvariantAssert = (
80
86
  snapshots: Record<string, Record<string, unknown>>,
87
+ ctx?: { intent: Record<string, unknown> },
81
88
  ) => WorkspaceInvariantVerdict;
82
89
 
83
90
  /** A complete, registered workspace-invariant declaration (the `.assert` terminal yields this). */
package/src/index.ts CHANGED
@@ -52,6 +52,12 @@ export {
52
52
  type StrikeOp,
53
53
  } from "./ops.js";
54
54
  export { directive, type Directive, type ReferentialMarker } from "./directive.js";
55
+ // FIRST-CLASS WORKSPACE TYPES (sharding §10 RATIFIED, slice 1) live on the SUBPATH
56
+ // `@githolon/dsl/workspace-type` (`workspaceType("estate").root(Estate).hasMany(...)`),
57
+ // NOT this runtime barrel — the barrel is bundled into EVERY tenant's engine lump, and
58
+ // a taxonomy-free domain's package bytes must not move (the hash-stability law; same
59
+ // reason `build-package` is subpath-only). The decls are compile-lane: the manifest
60
+ // lowering + homing walk + derived birth lanes consume them; the sealed engine never does.
55
61
  // THE AGGREGATE AUTHORING SURFACE (Nomos owns every birth): `create(agg)` mints + records and returns
56
62
  // a DDD-fluent `AggregateRef`; the dev `.set`/`.add`(collection)/`.setEntry`(map)/`.relate`(reference)
57
63
  // — naming no id. A directive's `.plan` calls these and returns `[]` (the recorded graph is the write