@githolon/dsl 0.2.3 → 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.
@@ -0,0 +1,609 @@
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
4
+ // QuickJS engine} on V8 + WASI-shim, byte-identical everywhere. V8 = portability; the one
5
+ // wasm = determinism. No native, no wasmtime, no 2nd artifact, no domain-JS on bare V8.
6
+ // If a file isn't this / hosting this / authoring for this / proving this — it's gone.
7
+
8
+ /**
9
+ * `workspaceType(...)` — FIRST-CLASS WORKSPACE TYPES, the tenant DSL surface
10
+ * (`architecture/workspace_types_and_sharding.md`, §10 RATIFIED; slice 1).
11
+ *
12
+ * A workspace type is a law-declared node kind in the workspace tree — the
13
+ * genericization of the hand-written governance taxonomy (`framework/workspaces.ts`
14
+ * `CloudWorkspace.kind: "workspace" | "platform"`, `birthPlatform`, pools, `cap(n)`)
15
+ * into a feature any tenant declares. IMPORTED FROM THE SUBPATH
16
+ * `@githolon/dsl/workspace-type` — NOT the runtime barrel: the barrel is bundled
17
+ * into every tenant's engine lump, and a taxonomy-free domain's package bytes must
18
+ * not move (the hash-stability law; the `build-package` precedent). The decls are
19
+ * COMPILE-LANE — manifest lowering, the homing walk, and the derived birth lanes
20
+ * consume them; the sealed engine never does:
21
+ *
22
+ * import { workspaceType } from "@githolon/dsl/workspace-type";
23
+ *
24
+ * export const EstateWs = workspaceType("estate")
25
+ * .root(Estate) // exactly one Estate aggregate per instance
26
+ * .hasMany(() => SiteHome) // the taxonomy: an Estate has Sites
27
+ * .global(Catalogue); // estate-wide reference data
28
+ *
29
+ * export const SiteHome = workspaceType("site")
30
+ * .root(Site)
31
+ * .packed(); // the shard axis (vs .dedicated())
32
+ *
33
+ * Everything else is DERIVED — Jack's ratified requirement is a FULLY TYPESAFE
34
+ * surface: a domain dev NEVER sees or manages a compound key. Homing is derived
35
+ * from the aggregates' existing `t.ref` chains (zero annotation); birth lanes are
36
+ * derived from `hasMany` over the EXISTING platform machinery
37
+ * (`POST /v1/platforms/...` — derived, never forked); minted-id home keys are
38
+ * internal plumbing (slice 2).
39
+ *
40
+ * THE HOMING WALK (this file): `home(aggregate)` = the NEAREST PACKED AXIS its
41
+ * `t.ref` chain reaches (`TrackableAsset.siteId → Site` ⇒ assets home on their
42
+ * site; `Room → Building → Site` transitively). An aggregate whose chain stops at
43
+ * a dedicated root homes on that root's type (the coordinator). FAIL-CLOSED, with
44
+ * named remedies:
45
+ * * no path to any axis → compile error (give it a ref / `.global(...)`
46
+ * / `.coordinatorLocal(...)`);
47
+ * * ambiguous nearest axis → compile error (pin it, or break a ref chain);
48
+ * * a directive whose declared surface (target + `.reads(...)`) spans two homes
49
+ * → compile error (model it as the Order/Receipt
50
+ * PR pair — `cross_workspace.md`).
51
+ *
52
+ * MANIFEST LOWERING (`manifest.ts` calls {@link canonicalWorkspaceTypesFragment}):
53
+ * the taxonomy AND the derived homing table are HASH-BEARING law — and OMITTED
54
+ * ENTIRELY when the domain declares no workspace type, so a taxonomy-free domain
55
+ * is byte-identical in the canonical manifest to before this feature existed
56
+ * (the `cap(n)` discipline; guestbook + co2 hashes PROVEN unmoved).
57
+ */
58
+ import type { AggregateHandle } from "./aggregate.js";
59
+ import type { Directive } from "./directive.js";
60
+
61
+ /** A directive of any payload type (`Directive<P>` is invariant in `P` — same alias
62
+ * convention as `codegen_dart.ts`). */
63
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
64
+ type AnyDirective = Directive<any>;
65
+
66
+ /** How a workspace type's instances are hosted: one holon each, or packed into shards. */
67
+ export type WorkspaceTypeMode = "dedicated" | "packed";
68
+
69
+ /** A child reference — the decl itself, or a thunk for forward/circular declaration order. */
70
+ export type WorkspaceTypeRef = WorkspaceTypeDecl | (() => WorkspaceTypeDecl);
71
+
72
+ /**
73
+ * One declared workspace type. Chainable + immutable (every method returns a NEW
74
+ * decl), and discoverable by SHAPE at any stage via `__isWorkspaceType` — the same
75
+ * duck-typing discipline as `AggregateHandle`/`Directive`. Validation (a missing
76
+ * `.root`, a packed type with children, …) is FAIL-CLOSED at compile/lowering,
77
+ * never silently defaulted.
78
+ */
79
+ export interface WorkspaceTypeDecl {
80
+ /** Brand for by-shape discovery (compile auto-discovery, like aggregates). */
81
+ readonly __isWorkspaceType: true;
82
+ /** The type's id — instance workspace names are composed from it by the lanes. */
83
+ readonly id: string;
84
+ /** The ROOT aggregate: exactly one instance per workspace of this type. */
85
+ readonly rootAggregate?: AggregateHandle;
86
+ /** `dedicated` (one holon per instance — default) or `packed` (the shard axis). */
87
+ readonly mode: WorkspaceTypeMode;
88
+ /** The taxonomy's `hasMany` edges (child workspace types). */
89
+ readonly childRefs: readonly WorkspaceTypeRef[];
90
+ /** Reference data homed HERE and replicated to descendant shards (slice 4 lane). */
91
+ readonly globalAggregates: readonly AggregateHandle[];
92
+ /** Aggregates PINNED to this (coordinator) type — the no-path named remedy. */
93
+ readonly coordinatorLocalAggregates: readonly AggregateHandle[];
94
+ /** The pool: how many live children this type's instances may parent. */
95
+ readonly poolSize?: number;
96
+ /** The law-declared instance limit for this type (the `cap(n)` discipline). */
97
+ readonly capCount?: number;
98
+ /** Declare the root aggregate (required — validated fail-closed at compile). */
99
+ root(agg: AggregateHandle): WorkspaceTypeDecl;
100
+ /** Declare a `hasMany` child type (the taxonomy edge a birth lane derives from). */
101
+ hasMany(child: WorkspaceTypeRef): WorkspaceTypeDecl;
102
+ /** Mark instances PACKED into shard holons (the shard axis). */
103
+ packed(): WorkspaceTypeDecl;
104
+ /** Mark instances DEDICATED (one holon each — the default, explicit form). */
105
+ dedicated(): WorkspaceTypeDecl;
106
+ /** Declare reference-data aggregates homed here, replicated to descendant shards. */
107
+ global(...aggs: AggregateHandle[]): WorkspaceTypeDecl;
108
+ /** PIN aggregates to this type (the named remedy for a no-path/ambiguous home). */
109
+ coordinatorLocal(...aggs: AggregateHandle[]): WorkspaceTypeDecl;
110
+ /** Declare the child pool (quota law: how many live children instances may parent). */
111
+ pool(n: number): WorkspaceTypeDecl;
112
+ /** Declare the instance cap (law-declared instance limit, like aggregate `cap`). */
113
+ cap(n: number): WorkspaceTypeDecl;
114
+ }
115
+
116
+ interface WorkspaceTypeState {
117
+ readonly id: string;
118
+ readonly rootAggregate?: AggregateHandle;
119
+ readonly mode: WorkspaceTypeMode;
120
+ readonly childRefs: readonly WorkspaceTypeRef[];
121
+ readonly globalAggregates: readonly AggregateHandle[];
122
+ readonly coordinatorLocalAggregates: readonly AggregateHandle[];
123
+ readonly poolSize?: number;
124
+ readonly capCount?: number;
125
+ }
126
+
127
+ function makeWorkspaceType(state: WorkspaceTypeState): WorkspaceTypeDecl {
128
+ return {
129
+ __isWorkspaceType: true,
130
+ ...state,
131
+ root(agg: AggregateHandle): WorkspaceTypeDecl {
132
+ return makeWorkspaceType({ ...state, rootAggregate: agg });
133
+ },
134
+ hasMany(child: WorkspaceTypeRef): WorkspaceTypeDecl {
135
+ return makeWorkspaceType({ ...state, childRefs: [...state.childRefs, child] });
136
+ },
137
+ packed(): WorkspaceTypeDecl {
138
+ return makeWorkspaceType({ ...state, mode: "packed" });
139
+ },
140
+ dedicated(): WorkspaceTypeDecl {
141
+ return makeWorkspaceType({ ...state, mode: "dedicated" });
142
+ },
143
+ global(...aggs: AggregateHandle[]): WorkspaceTypeDecl {
144
+ return makeWorkspaceType({
145
+ ...state,
146
+ globalAggregates: [...state.globalAggregates, ...aggs],
147
+ });
148
+ },
149
+ coordinatorLocal(...aggs: AggregateHandle[]): WorkspaceTypeDecl {
150
+ return makeWorkspaceType({
151
+ ...state,
152
+ coordinatorLocalAggregates: [...state.coordinatorLocalAggregates, ...aggs],
153
+ });
154
+ },
155
+ pool(n: number): WorkspaceTypeDecl {
156
+ if (!Number.isInteger(n) || n < 1) {
157
+ throw new Error(`workspaceType '${state.id}': pool must be a positive integer (got ${n})`);
158
+ }
159
+ return makeWorkspaceType({ ...state, poolSize: n });
160
+ },
161
+ cap(n: number): WorkspaceTypeDecl {
162
+ if (!Number.isInteger(n) || n < 1) {
163
+ throw new Error(`workspaceType '${state.id}': cap must be a positive integer (got ${n})`);
164
+ }
165
+ return makeWorkspaceType({ ...state, capCount: n });
166
+ },
167
+ };
168
+ }
169
+
170
+ /**
171
+ * Declare a workspace type. The id must be a lawful workspace-name segment
172
+ * (instances ride the existing `p--ws` name composition), so the same shape the
173
+ * cloud's name law accepts — and never `--`, the composer's reserved separator.
174
+ */
175
+ export function workspaceType(id: string): WorkspaceTypeDecl {
176
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(id) || id.includes("--")) {
177
+ throw new Error(
178
+ `workspaceType '${id}': the id must start alphanumeric and use only letters, ` +
179
+ `digits, '-' and '_' (it seeds instance workspace names), and must not ` +
180
+ `contain '--' (the reserved name-composition separator). Rename the type.`,
181
+ );
182
+ }
183
+ return makeWorkspaceType({
184
+ id,
185
+ mode: "dedicated",
186
+ childRefs: [],
187
+ globalAggregates: [],
188
+ coordinatorLocalAggregates: [],
189
+ });
190
+ }
191
+
192
+ // ── resolution ────────────────────────────────────────────────────────────────────
193
+
194
+ /** One RESOLVED workspace type: thunks forced, aggregates reduced to wire ids. */
195
+ export interface ResolvedWorkspaceType {
196
+ readonly id: string;
197
+ readonly rootId: string;
198
+ readonly mode: WorkspaceTypeMode;
199
+ readonly childIds: readonly string[];
200
+ readonly globalIds: readonly string[];
201
+ readonly coordinatorLocalIds: readonly string[];
202
+ readonly poolSize?: number;
203
+ readonly capCount?: number;
204
+ }
205
+
206
+ /**
207
+ * Resolve a set of declared workspace types into the closed taxonomy: force child
208
+ * thunks (forward refs), union in referenced child decls, dedupe by id, and
209
+ * validate FAIL-CLOSED with named remedies:
210
+ * * a type without `.root(...)` → error (declare the root aggregate);
211
+ * * two divergent decls under one id → error (one id, one law);
212
+ * * a PACKED type with `hasMany` children → error (shard-hosted subtrees are not
213
+ * law yet — make it `.dedicated()` or flatten the taxonomy);
214
+ * * a child id colliding with its parent → error.
215
+ */
216
+ export function resolveWorkspaceTypes(
217
+ declared: readonly WorkspaceTypeDecl[],
218
+ domainName: string,
219
+ ): Map<string, ResolvedWorkspaceType> {
220
+ // Force the closure over child refs first (a thunked child need not be exported).
221
+ const seen = new Map<string, WorkspaceTypeDecl>();
222
+ const queue: WorkspaceTypeDecl[] = [...declared];
223
+ while (queue.length > 0) {
224
+ const decl = queue.shift()!;
225
+ const prior = seen.get(decl.id);
226
+ if (prior !== undefined) {
227
+ if (prior !== decl && !sameDecl(prior, decl)) {
228
+ throw new Error(
229
+ `domain '${domainName}': workspaceType '${decl.id}' is declared twice with ` +
230
+ `DIVERGENT shapes — one id is one law. Reconcile the declarations.`,
231
+ );
232
+ }
233
+ continue;
234
+ }
235
+ seen.set(decl.id, decl);
236
+ for (const ref of decl.childRefs) queue.push(typeof ref === "function" ? ref() : ref);
237
+ }
238
+
239
+ const out = new Map<string, ResolvedWorkspaceType>();
240
+ for (const decl of seen.values()) {
241
+ if (decl.rootAggregate === undefined) {
242
+ throw new Error(
243
+ `domain '${domainName}': workspaceType '${decl.id}' declares no root aggregate — ` +
244
+ `every workspace type needs exactly one root. Fix: chain .root(<Aggregate>) ` +
245
+ `on the declaration.`,
246
+ );
247
+ }
248
+ const childIds = decl.childRefs.map((ref) => (typeof ref === "function" ? ref() : ref).id);
249
+ if (decl.mode === "packed" && childIds.length > 0) {
250
+ throw new Error(
251
+ `domain '${domainName}': workspaceType '${decl.id}' is .packed() but declares ` +
252
+ `hasMany children (${childIds.join(", ")}) — a packed (shard-axis) type cannot ` +
253
+ `parent child workspaces. Fix: make '${decl.id}' .dedicated(), or flatten the ` +
254
+ `taxonomy so the children hang off a dedicated ancestor.`,
255
+ );
256
+ }
257
+ if (childIds.includes(decl.id)) {
258
+ throw new Error(
259
+ `domain '${domainName}': workspaceType '${decl.id}' declares itself as its own ` +
260
+ `child — the taxonomy is a tree. Remove the self-edge.`,
261
+ );
262
+ }
263
+ out.set(decl.id, {
264
+ id: decl.id,
265
+ rootId: decl.rootAggregate.id,
266
+ mode: decl.mode,
267
+ childIds: [...new Set(childIds)].sort(),
268
+ globalIds: [...new Set(decl.globalAggregates.map((a) => a.id))].sort(),
269
+ coordinatorLocalIds: [...new Set(decl.coordinatorLocalAggregates.map((a) => a.id))].sort(),
270
+ ...(decl.poolSize !== undefined ? { poolSize: decl.poolSize } : {}),
271
+ ...(decl.capCount !== undefined ? { capCount: decl.capCount } : {}),
272
+ });
273
+ }
274
+
275
+ // One root aggregate ⇒ one type (two types claiming one root is two laws on one record).
276
+ const byRoot = new Map<string, string>();
277
+ for (const t of out.values()) {
278
+ const prior = byRoot.get(t.rootId);
279
+ if (prior !== undefined) {
280
+ throw new Error(
281
+ `domain '${domainName}': aggregate '${t.rootId}' is the root of BOTH workspaceType ` +
282
+ `'${prior}' and '${t.id}' — one root aggregate anchors one type. Split the roots.`,
283
+ );
284
+ }
285
+ byRoot.set(t.rootId, t.id);
286
+ }
287
+ return out;
288
+ }
289
+
290
+ /** Structural equality of two decls under one id (reference dedupe's slow path). */
291
+ function sameDecl(a: WorkspaceTypeDecl, b: WorkspaceTypeDecl): boolean {
292
+ const ids = (refs: readonly WorkspaceTypeRef[]) =>
293
+ refs.map((r) => (typeof r === "function" ? r() : r).id).sort().join(",");
294
+ return (
295
+ a.rootAggregate?.id === b.rootAggregate?.id &&
296
+ a.mode === b.mode &&
297
+ ids(a.childRefs) === ids(b.childRefs) &&
298
+ a.globalAggregates.map((x) => x.id).sort().join(",") ===
299
+ b.globalAggregates.map((x) => x.id).sort().join(",") &&
300
+ a.coordinatorLocalAggregates.map((x) => x.id).sort().join(",") ===
301
+ b.coordinatorLocalAggregates.map((x) => x.id).sort().join(",") &&
302
+ a.poolSize === b.poolSize &&
303
+ a.capCount === b.capCount
304
+ );
305
+ }
306
+
307
+ // ── the homing walk ───────────────────────────────────────────────────────────────
308
+
309
+ const NO_PATH_REMEDY =
310
+ "Fix one of: give it a t.ref chain that reaches an axis root; mark it " +
311
+ ".global(...) on the type that owns it (replicated reference data); or pin it " +
312
+ "with .coordinatorLocal(...) on the coordinator type.";
313
+
314
+ /**
315
+ * Derive the homing table: aggregate wire id → workspace-type id. ZERO ANNOTATION
316
+ * in the common case — the walk follows the aggregates' existing same-workspace
317
+ * `t.ref` edges (cross-workspace refs are PR-tier, never homing edges) to the
318
+ * NEAREST PACKED AXIS root; an aggregate reaching only dedicated roots homes on the
319
+ * nearest one (the coordinator). TOTAL over the module's aggregates, FAIL-CLOSED:
320
+ * a no-path or ambiguous aggregate refuses to compile with a named remedy.
321
+ */
322
+ export function deriveHoming(
323
+ aggregates: readonly AggregateHandle[],
324
+ types: ReadonlyMap<string, ResolvedWorkspaceType>,
325
+ domainName: string,
326
+ ): Record<string, string> {
327
+ const byId = new Map<string, AggregateHandle>();
328
+ for (const agg of aggregates) byId.set(agg.id, agg);
329
+
330
+ // Pins first: roots anchor their own type; globals/coordinatorLocals are explicit.
331
+ const homes: Record<string, string> = {};
332
+ const pin = (aggId: string, typeId: string, why: string) => {
333
+ const prior = homes[aggId];
334
+ if (prior !== undefined && prior !== typeId) {
335
+ throw new Error(
336
+ `domain '${domainName}': aggregate '${aggId}' is ${why} of workspaceType ` +
337
+ `'${typeId}' but already homes on '${prior}' — one aggregate, one home. ` +
338
+ `Remove one of the pins.`,
339
+ );
340
+ }
341
+ homes[aggId] = typeId;
342
+ };
343
+ for (const t of types.values()) {
344
+ if (!byId.has(t.rootId)) {
345
+ throw new Error(
346
+ `domain '${domainName}': workspaceType '${t.id}' roots on aggregate '${t.rootId}', ` +
347
+ `which this domain does not declare — the root must be one of the domain's ` +
348
+ `aggregates. Export it from a composed module (or extraAggregates).`,
349
+ );
350
+ }
351
+ pin(t.rootId, t.id, "the root");
352
+ }
353
+ for (const t of types.values()) {
354
+ for (const g of t.globalIds) pin(g, t.id, "a .global(...)");
355
+ for (const c of t.coordinatorLocalIds) pin(c, t.id, "a .coordinatorLocal(...)");
356
+ }
357
+
358
+ // THE FRAMEWORK SHARDING LAW homes on the COORDINATOR (sharding slices 2+3): the
359
+ // derived `Nomos*` aggregates (`workspace_sharding.ts` — the shard map, receipts,
360
+ // registry, policy, the §5.2 subtotal/frontier rows, deep-verify verdicts) carry
361
+ // no homing t.ref chain BY DESIGN (they reference homes as DATA, never as a
362
+ // homing edge), so they pin to the taxonomy's unique top dedicated type. (The
363
+ // receipt + identity rows physically FOLD in shard chains too — placement is
364
+ // custody; the pin only says their DIRECTIVES are coordinator law, never routed.)
365
+ // With NO unique coordinator the pin is a FAIL-CLOSED error, never a guess.
366
+ const FRAMEWORK_SHARDING_AGGREGATES = new Set([
367
+ "NomosShardAssignment", "NomosShardIdentity", "NomosHomeReceipt", "NomosShardRegistry",
368
+ "NomosShardPolicy", "NomosSummarySubtotal", "NomosSummaryFrontier", "NomosDeepVerify",
369
+ "NomosCheckpointSeal",
370
+ ]);
371
+ const childIdsAll = new Set([...types.values()].flatMap((t) => [...t.childIds]));
372
+ const coordinators = [...types.values()]
373
+ .filter((t) => t.mode === "dedicated" && !childIdsAll.has(t.id))
374
+ .map((t) => t.id)
375
+ .sort();
376
+ for (const agg of aggregates) {
377
+ if (!FRAMEWORK_SHARDING_AGGREGATES.has(agg.id) || homes[agg.id] !== undefined) continue;
378
+ if (coordinators.length !== 1) {
379
+ throw new Error(
380
+ `domain '${domainName}': framework aggregate '${agg.id}' homes on the coordinator, ` +
381
+ `but the taxonomy has ${coordinators.length === 0 ? "no" : "more than one"} top ` +
382
+ `dedicated type (${coordinators.join(", ") || "none"}) — pin it with ` +
383
+ `.coordinatorLocal(...) on the intended coordinator type.`,
384
+ );
385
+ }
386
+ pin(agg.id, coordinators[0]!, "the framework placement law (coordinator-pinned)");
387
+ }
388
+
389
+ const rootType = new Map<string, ResolvedWorkspaceType>();
390
+ for (const t of types.values()) rootType.set(t.rootId, t);
391
+
392
+ // BFS per unpinned aggregate over same-workspace ref edges, shortest first.
393
+ for (const agg of aggregates) {
394
+ if (homes[agg.id] !== undefined) continue;
395
+ const dist = new Map<string, number>([[agg.id, 0]]);
396
+ const frontier: string[] = [agg.id];
397
+ const packedHits = new Map<string, number>(); // typeId → distance
398
+ const dedicatedHits = new Map<string, number>();
399
+ while (frontier.length > 0) {
400
+ const cur = frontier.shift()!;
401
+ const d = dist.get(cur)!;
402
+ const t = rootType.get(cur);
403
+ if (t !== undefined && cur !== agg.id) {
404
+ (t.mode === "packed" ? packedHits : dedicatedHits).set(t.id, d);
405
+ continue; // a root is an axis terminus — the walk stops at it
406
+ }
407
+ const handle = byId.get(cur);
408
+ if (handle === undefined) continue; // a ref out of this domain — not a homing edge
409
+ for (const field of Object.values(handle.fields)) {
410
+ if (field.kind !== "ref" || field.refAggregateId === undefined) continue;
411
+ if (field.refWorkspace !== undefined) continue; // PR-tier edge, never homing
412
+ if (!dist.has(field.refAggregateId)) {
413
+ dist.set(field.refAggregateId, d + 1);
414
+ frontier.push(field.refAggregateId);
415
+ }
416
+ }
417
+ }
418
+ const pick = (hits: Map<string, number>, axisKind: string): string | undefined => {
419
+ if (hits.size === 0) return undefined;
420
+ const min = Math.min(...hits.values());
421
+ const nearest = [...hits.entries()].filter(([, d]) => d === min).map(([id]) => id).sort();
422
+ if (nearest.length > 1) {
423
+ throw new Error(
424
+ `domain '${domainName}': aggregate '${agg.id}' reaches ${axisKind} axes ` +
425
+ `${nearest.map((n) => `'${n}'`).join(" and ")} at the same ref distance — ` +
426
+ `its home is ambiguous. Fix: pin it (.coordinatorLocal(...) / .global(...)), ` +
427
+ `or restructure its t.ref chain so one axis is strictly nearer.`,
428
+ );
429
+ }
430
+ return nearest[0];
431
+ };
432
+ const home = pick(packedHits, "packed") ?? pick(dedicatedHits, "dedicated");
433
+ if (home === undefined) {
434
+ throw new Error(
435
+ `domain '${domainName}': aggregate '${agg.id}' has NO t.ref path to any ` +
436
+ `workspace-type root — it cannot be homed. ${NO_PATH_REMEDY}`,
437
+ );
438
+ }
439
+ homes[agg.id] = home;
440
+ }
441
+ return homes;
442
+ }
443
+
444
+ // ── directive cross-home check ────────────────────────────────────────────────────
445
+
446
+ /**
447
+ * FAIL-CLOSED cross-home check over each directive's DECLARED surface (its target
448
+ * aggregate + its `.reads(...)` boundary): every declared-read type must be
449
+ * available at the target's home — same home, or `.global(...)` reference data
450
+ * (replicated to shards), or an ancestor coordinator's globals. A plan spanning two
451
+ * homes must be modeled as the Order/Receipt PR pair (`cross_workspace.md`), never
452
+ * as one intent the sharding layer would have to tear in half. (Instance-level
453
+ * wrong-home is the shard gate's runtime invariant — slice 2.)
454
+ */
455
+ export function checkDirectiveHoming(
456
+ directives: readonly AnyDirective[],
457
+ homes: Readonly<Record<string, string>>,
458
+ types: ReadonlyMap<string, ResolvedWorkspaceType>,
459
+ domainName: string,
460
+ ): void {
461
+ const globalIds = new Set<string>();
462
+ for (const t of types.values()) for (const g of t.globalIds) globalIds.add(g);
463
+ for (const d of directives) {
464
+ const targetHome = homes[d.aggregateId];
465
+ if (targetHome === undefined) continue; // a foreign-domain target — not this law's walk
466
+ for (const read of d.declaredReads) {
467
+ const readHome = homes[read];
468
+ if (readHome === undefined || readHome === targetHome) continue;
469
+ if (globalIds.has(read)) continue; // replicated reference data — readable at any home
470
+ throw new Error(
471
+ `domain '${domainName}': directive '${d.id}' targets '${d.aggregateId}' ` +
472
+ `(home '${targetHome}') but declares a read of '${read}' (home '${readHome}') — ` +
473
+ `a plan cannot span two homes. Fix: mark '${read}' .global(...) on its owning ` +
474
+ `type (replicated reference data), or model the cross-home effect as an ` +
475
+ `Order/Receipt PR pair (cross_workspace.md).`,
476
+ );
477
+ }
478
+ }
479
+ }
480
+
481
+ // ── canonical-manifest lowering (hash-bearing, omitted-when-absent) ───────────────
482
+
483
+ /** One captured workspace type in the canonical manifest — the hashed taxonomy law. */
484
+ export interface CanonicalWorkspaceType {
485
+ readonly id: string;
486
+ /** The root aggregate's wire id. */
487
+ readonly root: string;
488
+ readonly mode: WorkspaceTypeMode;
489
+ /** Child type ids, SORTED. OMITTED when the type declares no children. */
490
+ readonly children?: string[];
491
+ /** Replicated reference-data aggregate ids, SORTED. OMITTED when none. */
492
+ readonly globals?: string[];
493
+ /** Pinned aggregate ids, SORTED. OMITTED when none. */
494
+ readonly coordinatorLocals?: string[];
495
+ /** The child pool. OMITTED when undeclared. */
496
+ readonly pool?: number;
497
+ /** The instance cap. OMITTED when undeclared. */
498
+ readonly cap?: number;
499
+ }
500
+
501
+ /** The manifest fragment the taxonomy lowers to: the types + the derived homing table. */
502
+ export interface CanonicalWorkspaceTypesFragment {
503
+ workspaceTypes?: CanonicalWorkspaceType[];
504
+ homes?: Record<string, string>;
505
+ }
506
+
507
+ /**
508
+ * Lower a module's declared workspace types into its canonical-manifest fragment.
509
+ * OMIT-WHEN-EMPTY: returns `{}` (NEITHER key) when the module declares none — the
510
+ * omission, not an empty array, is what keeps taxonomy-free domains byte-identical
511
+ * to before this feature existed (guestbook + co2 hashes PROVEN unmoved). When
512
+ * declared: `workspaceTypes` SORTED by id (per-type optional keys omitted-when-absent,
513
+ * the `cap(n)` discipline) plus `homes` — the DERIVED homing table (aggregate →
514
+ * type id; `canonicalJson` sorts the record keys). Both are LAW: the domain hash
515
+ * moves when the taxonomy or a homing assignment moves. Runs the full fail-closed
516
+ * validation (resolution, the homing walk, the directive cross-home check) — an
517
+ * unhomeable taxonomy never produces an identity.
518
+ */
519
+ export function canonicalWorkspaceTypesFragment(mod: {
520
+ readonly name: string;
521
+ readonly aggregates: readonly AggregateHandle[];
522
+ readonly directives: readonly AnyDirective[];
523
+ readonly workspaceTypes?: readonly WorkspaceTypeDecl[];
524
+ }): CanonicalWorkspaceTypesFragment {
525
+ if (mod.workspaceTypes === undefined || mod.workspaceTypes.length === 0) return {};
526
+ const types = resolveWorkspaceTypes(mod.workspaceTypes, mod.name);
527
+ const homes = deriveHoming(mod.aggregates, types, mod.name);
528
+ checkDirectiveHoming(mod.directives, homes, types, mod.name);
529
+ const workspaceTypes: CanonicalWorkspaceType[] = [...types.values()]
530
+ .map((t) => ({
531
+ id: t.id,
532
+ root: t.rootId,
533
+ mode: t.mode,
534
+ ...(t.childIds.length > 0 ? { children: [...t.childIds] } : {}),
535
+ ...(t.globalIds.length > 0 ? { globals: [...t.globalIds] } : {}),
536
+ ...(t.coordinatorLocalIds.length > 0
537
+ ? { coordinatorLocals: [...t.coordinatorLocalIds] }
538
+ : {}),
539
+ ...(t.poolSize !== undefined ? { pool: t.poolSize } : {}),
540
+ ...(t.capCount !== undefined ? { cap: t.capCount } : {}),
541
+ }))
542
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
543
+ return { workspaceTypes, homes };
544
+ }
545
+
546
+ /**
547
+ * Validate a module's taxonomy FAIL-CLOSED (the compile gate `nomos-compile` runs
548
+ * BEFORE identity emission, so a homing error is a named COMPILE error — never a
549
+ * domain silently recorded as identity-excluded). Same machinery as the lowering;
550
+ * throwing is the verdict.
551
+ */
552
+ export function validateWorkspaceTaxonomy(mod: {
553
+ readonly name: string;
554
+ readonly aggregates: readonly AggregateHandle[];
555
+ readonly directives: readonly AnyDirective[];
556
+ readonly workspaceTypes?: readonly WorkspaceTypeDecl[];
557
+ }): void {
558
+ canonicalWorkspaceTypesFragment(mod);
559
+ }
560
+
561
+ // ── estate-scope reads (sharding §2/§5 — slice 3) ─────────────────────────────────
562
+
563
+ /**
564
+ * Lift a declared `count(...)`/`sum(...)` read to ESTATE SCOPE (sharding §5): the
565
+ * read's per-shard values ride the §5.2 delta lane as gate-recomputed committed
566
+ * subtotals on the coordinator, and the logical workspace answers it O(1) from the
567
+ * maintained estate total — never a scatter-gather.
568
+ *
569
+ * export const fxSiteCount = scoped(count("fxSiteCount").of(Site), EstateWs);
570
+ *
571
+ * Lives on THIS subpath (never the runtime barrel, and never a method on the
572
+ * count/sum builders) by the slice-1 hash-stability law: the builders' bytes ride
573
+ * every tenant's engine lump, and a taxonomy-free domain's package bytes must not
574
+ * move. The marker is HASH-BEARING (the canonical manifest's `scope` key,
575
+ * omitted-when-absent): scoping a read is a law change.
576
+ *
577
+ * PREDICATE-BEARING reads (slice 4): a `.where(...)`-filtered count/sum MAY be
578
+ * estate-scoped — the shard-side capture's oracle is the projection's MAINTAINED
579
+ * (predicate-aware) tally, and the suffix re-derivation evaluates the canonical
580
+ * predicate in its host fold (`engine.mjs evalCanonicalPred`). The predicate is
581
+ * hash-bearing through the canonical manifest's `where` key exactly as before.
582
+ *
583
+ * FAIL-CLOSED boundary (named): the scope must be a DECLARED workspace type (the
584
+ * compile gate additionally pins it to the coordinator type of a packed taxonomy).
585
+ */
586
+ export function scoped<T extends { readonly id: string; readonly of: string }>(
587
+ read: T,
588
+ scope: WorkspaceTypeRef,
589
+ ): T & { readonly scope: string } {
590
+ const decl = typeof scope === "function" ? scope() : scope;
591
+ if (!decl || (decl as { __isWorkspaceType?: unknown }).__isWorkspaceType !== true) {
592
+ throw new Error(
593
+ `scoped(read, scope): the scope must be a workspaceType(...) declaration (got ${typeof scope})`,
594
+ );
595
+ }
596
+ const r = read as { id?: unknown; of?: unknown; _where?: unknown };
597
+ if (typeof r.id !== "string" || typeof r.of !== "string") {
598
+ throw new Error(
599
+ `scoped(read, scope): the read must be a count/sum with .of(...) declared (a named, maintained read)`,
600
+ );
601
+ }
602
+ if ((r.id as string).includes("|")) {
603
+ throw new Error(
604
+ `estate-scoped read '${String(r.id)}' contains '|' — the estate-summary bucket separator. ` +
605
+ `Rename the read (scoped read ids may not contain '|').`,
606
+ );
607
+ }
608
+ return Object.freeze({ ...(read as object), scope: decl.id }) as T & { readonly scope: string };
609
+ }