@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.
@@ -0,0 +1,585 @@
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
+ * PER-DIRECTIVE HOME-KEY DERIVATION + the SELF-ROUTING MINT TAG
10
+ * (`architecture/workspace_types_and_sharding.md` §2/§4 — slice 2).
11
+ *
12
+ * COMPILE-LANE ONLY (subpath `@githolon/dsl/workspace-routing`, never the runtime
13
+ * barrel — the barrel is bundled into every engine lump and a taxonomy-free
14
+ * domain's package bytes must not move; the slice-1 hash-stability law).
15
+ *
16
+ * WHAT THIS DERIVES — for every directive whose target aggregate homes on a PACKED
17
+ * axis, the ROUTE: which top-level payload field carries the write's home, and how:
18
+ *
19
+ * * `via: "axis"` — the field carries the AXIS ROOT's id itself
20
+ * (`createBuilding(p.siteId, …)` routes by `p.siteId`; `renameSite(p.siteId)`
21
+ * routes by its own target id);
22
+ * * `via: "id"` — the field carries ANOTHER HOMED AGGREGATE's kernel-minted id
23
+ * (`moveAsset(p.assetId)`, `createRoom(p.buildingId)`): the id is SELF-ROUTING —
24
+ * its home rides in the minted UUIDv7's 48-bit timestamp slot as
25
+ * {@link routeTagHexOfHomeKey} (see below), so any holder of the id resolves the
26
+ * shard from the shard map alone, no lookup hop.
27
+ *
28
+ * MECHANISM — the SAME marker-driven plan trace as the kernel-mint front-door
29
+ * (#260/#262, `codegen_dart.ts`): run the directive's REAL `.plan()` with a unique
30
+ * sentinel per top-level string payload field and read which sentinels land where in
31
+ * the produced wire events (`__type`/`__id` provenance + ref-field Set ops). Ground
32
+ * truth, never a field-name guess. Plans that mint internally (`create(Agg)`) are
33
+ * traced under a sentinel `nomos.mint` shim (compile-lane only; the sealed engine's
34
+ * real mint is untouched).
35
+ *
36
+ * FAIL-CLOSED, named remedies:
37
+ * * a packed-homed directive with NO traceable home field refuses to compile
38
+ * (carry the home in the payload: the axis-root ref, or a homed aggregate's id);
39
+ * * a plan whose traced events SPAN TWO DIFFERENT PACKED HOMES refuses to compile
40
+ * (model it as the Order/Receipt PR pair — `cross_workspace.md`);
41
+ * * a home field that is OPTIONAL in the payload schema refuses to compile
42
+ * (an absent home is an unroutable write).
43
+ *
44
+ * THE ROUTE TAG (the self-routing mint, §4). The kernel mint is gate-pinned to
45
+ * `<TypeTag>_<uuidv7>` (`id-mint/src/lib.rs` — the gate parses the UUID body), so the
46
+ * id's BYTE SHAPE cannot change without a wasm rebuild. The lane that IS host-shaped
47
+ * today: the v7 timestamp field is HOST-SUPPLIED (`rpc_mint` takes `nowMillis`) and
48
+ * constitutionally demoted to metadata ("not the ledger clock; do NOT use it for
49
+ * causality"). The front-door therefore mints HOMED ids with
50
+ * `nowMillis = ROUTE_TAG(homeKey)` — the first 48 bits of
51
+ * sha256("nomos-route:" + homeKey) — and any peer recovers the tag as the UUID's
52
+ * leading 12 hex chars. The tag is a ROUTING HINT, never authority: the shard gate's
53
+ * wrong-home refusal (the edge bailiff) remains the law; a colliding/forged tag
54
+ * misroutes at worst into a typed, self-healing refusal.
55
+ */
56
+ import { createHash } from "node:crypto";
57
+ import type { z } from "zod";
58
+
59
+ import type { AggregateHandle } from "./aggregate.js";
60
+ import type { Directive } from "./directive.js";
61
+ import type { Field } from "./fields.js";
62
+ import { deterministicPorts } from "./ctx.js";
63
+ import { executeDirectiveToIntent } from "./wire_encode.js";
64
+ import {
65
+ canonicalWorkspaceTypesFragment,
66
+ resolveWorkspaceTypes,
67
+ type CanonicalWorkspaceTypesFragment,
68
+ type ResolvedWorkspaceType,
69
+ type WorkspaceTypeDecl,
70
+ } from "./workspace_type.js";
71
+
72
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
73
+ type AnyDirective = Directive<any>;
74
+
75
+ // ── zod introspection (the `_def ?? def` convention — local copies, no codegen import:
76
+ // codegen_ts imports THIS module, so sharing its walkers would be a cycle) ─────────
77
+
78
+ interface ZodInternalDef {
79
+ type?: string;
80
+ innerType?: z.ZodTypeAny;
81
+ shape?: Record<string, z.ZodTypeAny> | (() => Record<string, z.ZodTypeAny>);
82
+ options?: z.ZodTypeAny[];
83
+ value?: unknown;
84
+ values?: readonly unknown[];
85
+ entries?: Record<string, string | number>;
86
+ }
87
+
88
+ function zodDef(zt: z.ZodTypeAny): ZodInternalDef {
89
+ const raw = zt as unknown as { _def?: ZodInternalDef; def?: ZodInternalDef };
90
+ return raw._def ?? raw.def ?? {};
91
+ }
92
+
93
+ function zodKind(zt: z.ZodTypeAny): string {
94
+ const t = zodDef(zt).type;
95
+ return typeof t === "string" ? t : "unknown";
96
+ }
97
+
98
+ function zodObjectShape(zt: z.ZodTypeAny): Record<string, z.ZodTypeAny> {
99
+ const shape = zodDef(zt).shape;
100
+ if (typeof shape === "function") return shape();
101
+ if (shape !== undefined) return shape;
102
+ const raw = zt as unknown as { shape?: Record<string, z.ZodTypeAny> };
103
+ if (raw.shape !== undefined) return raw.shape;
104
+ throw new Error("workspace-routing: ZodObject has no shape");
105
+ }
106
+
107
+ function zodEnumValues(zt: z.ZodTypeAny): string[] {
108
+ const raw = zt as unknown as { options?: readonly unknown[] };
109
+ if (raw.options !== undefined) return raw.options.map(String);
110
+ const entries = zodDef(zt).entries;
111
+ if (entries !== undefined) return Object.values(entries).map(String);
112
+ throw new Error("workspace-routing: ZodEnum has no values");
113
+ }
114
+
115
+ /** How a routed directive's home key rides its payload. */
116
+ export type RouteVia = "axis" | "id";
117
+
118
+ /** One derived directive route — canonical-manifest law (hash-bearing). */
119
+ export interface CanonicalDirectiveRoute {
120
+ /** The directive id. */
121
+ readonly directive: string;
122
+ /** The PACKED workspace-type id the write homes on. */
123
+ readonly home: string;
124
+ /** The top-level payload field carrying the home. */
125
+ readonly key: string;
126
+ /** `axis` = the field IS the axis-root id; `id` = a homed aggregate's route-tagged minted id. */
127
+ readonly via: RouteVia;
128
+ }
129
+
130
+ // ── the route tag ───────────────────────────────────────────────────────────────────
131
+
132
+ /** The route-tag domain-separation prefix (shared verbatim by client + edge bailiff). */
133
+ export const ROUTE_TAG_PREFIX = "nomos-route:";
134
+
135
+ /**
136
+ * The 48-bit route tag of a home key, as 12 lowercase hex chars — the value the
137
+ * front-door mints into a homed id's UUIDv7 timestamp slot, and the value a router
138
+ * compares against `routeTagHexOfMintedId`. sha256-derived (one-way): the id never
139
+ * DISCLOSES its home, it only ROUTES against a map the holder already has.
140
+ */
141
+ export function routeTagHexOfHomeKey(homeKey: string): string {
142
+ return createHash("sha256").update(ROUTE_TAG_PREFIX + homeKey, "utf8").digest("hex").slice(0, 12);
143
+ }
144
+
145
+ /** The route tag as the `nowMillis` integer the mint RPC takes (48 bits < 2^53 — exact). */
146
+ export function routeTagMillisOfHomeKey(homeKey: string): number {
147
+ return parseInt(routeTagHexOfHomeKey(homeKey), 16);
148
+ }
149
+
150
+ /**
151
+ * Recover the 48-bit tag slot from a kernel-minted id (`<Type>_<uuidv7>`): the UUID's
152
+ * leading 12 hex chars (the v7 `unix_ts_ms` field). Returns undefined for a non-minted
153
+ * id. NOTE: on an UN-tagged id this is real mint time — match it against the map, and
154
+ * on a miss fall back to the gate (the tag is a hint, never authority).
155
+ */
156
+ export function routeTagHexOfMintedId(id: string): string | undefined {
157
+ const seg = id.indexOf("_");
158
+ if (seg <= 0) return undefined;
159
+ const body = id.slice(seg + 1).replace(/-/g, "").toLowerCase();
160
+ if (!/^[0-9a-f]{32}$/.test(body)) return undefined;
161
+ return body.slice(0, 12);
162
+ }
163
+
164
+ // ── the plan trace (the mint-front-door family, aimed at HOMES) ──────────────────────
165
+
166
+ /** Build a schema-valid probe payload: every top-level string field gets its sentinel. */
167
+ function buildProbePayload(
168
+ schema: z.ZodTypeAny,
169
+ sentinelOf: Map<string, string>,
170
+ jsonSafe = false,
171
+ ): Record<string, unknown> | undefined {
172
+ if (zodKind(schema) !== "object") return undefined;
173
+ const out: Record<string, unknown> = {};
174
+ for (const [name, raw] of Object.entries(zodObjectShape(schema))) {
175
+ let f = raw as z.ZodTypeAny;
176
+ while (zodKind(f) === "optional" || zodKind(f) === "default") f = zodDef(f).innerType!;
177
+ const sentinel = sentinelOf.get(name);
178
+ out[name] = sentinel !== undefined && zodKind(f) === "string" ? sentinel : sampleZod(f, jsonSafe);
179
+ }
180
+ return out;
181
+ }
182
+
183
+ function sampleZod(f: z.ZodTypeAny, jsonSafe = false): unknown {
184
+ // `jsonSafe` is the SECOND-PASS probe (see `deriveDirectiveRoutes`): plans may
185
+ // `JSON.parse` string payload fields (co2's map-tile geometry leaves), which throws
186
+ // on the plain "x" sample. The retry samples every non-sentinel string as "{}" —
187
+ // parseable, still a string — so the trace can reach the home field.
188
+ switch (zodKind(f)) {
189
+ case "string": return jsonSafe ? "{}" : "x";
190
+ case "number": return 1;
191
+ case "boolean": return false;
192
+ case "enum": return zodEnumValues(f)[0];
193
+ case "array": return [];
194
+ case "object": {
195
+ const o: Record<string, unknown> = {};
196
+ for (const [k, v] of Object.entries(zodObjectShape(f))) o[k] = sampleZod(v as z.ZodTypeAny, jsonSafe);
197
+ return o;
198
+ }
199
+ case "record": return {};
200
+ case "literal": {
201
+ const def = zodDef(f);
202
+ return def.value !== undefined ? def.value : def.values?.[0];
203
+ }
204
+ case "union": {
205
+ const first = (zodDef(f).options ?? [])[0];
206
+ if (first === undefined) throw new Error("union has no options");
207
+ return sampleZod(first, jsonSafe);
208
+ }
209
+ case "nullable": return null;
210
+ case "unknown":
211
+ case "any": return {};
212
+ case "optional":
213
+ case "default": return sampleZod(zodDef(f).innerType!, jsonSafe);
214
+ default:
215
+ throw new Error(`cannot sample zod kind '${zodKind(f)}'`);
216
+ }
217
+ }
218
+
219
+ interface TracedEvent {
220
+ /** The event's aggregate INSTANCE id (a sentinel when payload-supplied). */
221
+ readonly aggregate: string;
222
+ /** The stamped `__type` (the wire provenance op), when present. */
223
+ readonly type: string | undefined;
224
+ /** field → Set string value (sentinel detection on ref fields). */
225
+ readonly sets: ReadonlyMap<string, string>;
226
+ }
227
+
228
+ /**
229
+ * Run the directive's REAL plan over sentinel payload values, under a compile-lane
230
+ * `nomos.mint` shim (so internally-minting plans trace too), and return the traced
231
+ * events. Returns undefined when the payload is not an object or the plan throws on
232
+ * the probe (then no route is derivable from the plan — the caller decides the verdict).
233
+ */
234
+ function tracePlan(d: AnyDirective, agg: AggregateHandle, sentinelOf: Map<string, string>, jsonSafe = false): TracedEvent[] | undefined {
235
+ const schema = (d as unknown as { payloadSchema: z.ZodTypeAny }).payloadSchema;
236
+ let payload: Record<string, unknown> | undefined;
237
+ try {
238
+ payload = buildProbePayload(schema, sentinelOf, jsonSafe);
239
+ } catch {
240
+ return undefined;
241
+ }
242
+ if (payload === undefined) return undefined;
243
+ // The compile-lane mint shim: deterministic, obviously-synthetic ids so an
244
+ // internally-minted aggregate's events still trace (restored in `finally`).
245
+ const g = globalThis as { nomos?: { mint(t: string): string } };
246
+ const prior = g.nomos;
247
+ let mintSeq = 0;
248
+ g.nomos = { mint: (t: string) => `${t}_00000000-0000-7000-8000-${String(mintSeq++).padStart(12, "0")}` };
249
+ try {
250
+ const intent = executeDirectiveToIntent(d, agg, payload as never, deterministicPorts({ physical: 1, replica: 1 }));
251
+ return intent.events.map((ev) => {
252
+ const sets = new Map<string, string>();
253
+ let type: string | undefined;
254
+ for (const op of ev.ops) {
255
+ const v = "Set" in op.op ? (op.op.Set as { Str?: string }).Str : undefined;
256
+ if (typeof v !== "string") continue;
257
+ if (op.field === "__type") type = v;
258
+ else if (op.field !== "__id") sets.set(op.field, v);
259
+ }
260
+ return { aggregate: ev.aggregate, type, sets };
261
+ });
262
+ } catch {
263
+ return undefined;
264
+ } finally {
265
+ if (prior === undefined) delete g.nomos;
266
+ else g.nomos = prior;
267
+ }
268
+ }
269
+
270
+ // ── the derivation ────────────────────────────────────────────────────────────────────
271
+
272
+ const NO_ROUTE_REMEDY =
273
+ "Fix: carry the write's home in the payload — a field set onto the target's homing " +
274
+ "t.ref chain (e.g. the axis-root id), the target's own id, or another homed " +
275
+ "aggregate's minted id; or model a genuinely cross-home effect as the Order/Receipt " +
276
+ "PR pair (cross_workspace.md).";
277
+
278
+ /**
279
+ * Derive the routing table for a domain: every directive homed on a PACKED axis gets
280
+ * its `{home, key, via}` route. TOTAL over the homed directives, FAIL-CLOSED with
281
+ * named remedies (an unroutable or cross-home law never compiles). Returns the routes
282
+ * SORTED by directive id (canonical-manifest order).
283
+ */
284
+ export function deriveDirectiveRoutes(
285
+ mod: {
286
+ readonly name: string;
287
+ readonly aggregates: readonly AggregateHandle[];
288
+ readonly directives: readonly AnyDirective[];
289
+ },
290
+ types: ReadonlyMap<string, ResolvedWorkspaceType>,
291
+ homes: Readonly<Record<string, string>>,
292
+ ): CanonicalDirectiveRoute[] {
293
+ const byId = new Map<string, AggregateHandle>();
294
+ for (const a of mod.aggregates) byId.set(a.id, a);
295
+ const packed = new Set([...types.values()].filter((t) => t.mode === "packed").map((t) => t.id));
296
+ const axisRootOf = new Map<string, string>(); // packed type id → its root aggregate id
297
+ for (const t of types.values()) if (t.mode === "packed") axisRootOf.set(t.id, t.rootId);
298
+
299
+ /** Does `fromAggId`'s ref field `field` point (directly) at an aggregate homed on `homeType`? */
300
+ const refTargetHome = (fromAggId: string, field: string): { target: string; home: string | undefined } | undefined => {
301
+ const handle = byId.get(fromAggId);
302
+ if (handle === undefined) return undefined;
303
+ const f = handle.fields[field] as Field | undefined;
304
+ if (f === undefined || f.kind !== "ref" || f.refAggregateId === undefined) return undefined;
305
+ if (f.refWorkspace !== undefined) return undefined; // PR-tier edge — never a routing edge
306
+ return { target: f.refAggregateId, home: homes[f.refAggregateId] };
307
+ };
308
+
309
+ const routes: CanonicalDirectiveRoute[] = [];
310
+ for (const d of mod.directives) {
311
+ const targetHome = homes[d.aggregateId];
312
+ if (targetHome === undefined || !packed.has(targetHome)) continue; // coordinator-homed — never routed
313
+ const axisRoot = axisRootOf.get(targetHome)!;
314
+
315
+ const schema = (d as unknown as { payloadSchema?: z.ZodTypeAny }).payloadSchema;
316
+ if (schema === undefined || zodKind(schema) !== "object") {
317
+ throw new Error(
318
+ `domain '${mod.name}': directive '${d.id}' targets '${d.aggregateId}' (packed home ` +
319
+ `'${targetHome}') but its payload is not an object — its home cannot be derived. ${NO_ROUTE_REMEDY}`,
320
+ );
321
+ }
322
+ const shape = zodObjectShape(schema);
323
+ const required = new Set<string>();
324
+ const sentinelOf = new Map<string, string>();
325
+ const fieldOfSentinel = new Map<string, string>();
326
+ for (const [name, raw] of Object.entries(shape)) {
327
+ let f = raw as z.ZodTypeAny;
328
+ let optional = false;
329
+ while (zodKind(f) === "optional" || zodKind(f) === "default") {
330
+ optional = true;
331
+ f = zodDef(f).innerType!;
332
+ }
333
+ if (zodKind(f) !== "string") continue;
334
+ if (!optional) required.add(name);
335
+ const sentinel = `__NOMOS_ROUTE_PROBE_${name}__`;
336
+ sentinelOf.set(name, sentinel);
337
+ fieldOfSentinel.set(sentinel, name);
338
+ }
339
+
340
+ const targetAgg = byId.get(d.aggregateId) ?? mod.aggregates[0]!;
341
+ let traced = tracePlan(d, targetAgg, sentinelOf);
342
+ if (traced === undefined) {
343
+ // SECOND-PASS PROBE (slice 6 — the co2 adoption finding): a plan that
344
+ // `JSON.parse`s string payload fields throws on the all-sentinels probe.
345
+ // Retry ONE SENTINEL AT A TIME with every other string sampled JSON-safe
346
+ // ("{}"), and pool the per-field traces. This pass only runs where pass 1
347
+ // is a hard compile error today, so every previously-derived route is
348
+ // byte-identical (hash stability).
349
+ const pooled: TracedEvent[] = [];
350
+ for (const [name, sentinel] of sentinelOf) {
351
+ const one = tracePlan(d, targetAgg, new Map([[name, sentinel]]), true);
352
+ if (one !== undefined) pooled.push(...one);
353
+ }
354
+ if (pooled.length === 0) {
355
+ throw new Error(
356
+ `domain '${mod.name}': directive '${d.id}' targets '${d.aggregateId}' (packed home ` +
357
+ `'${targetHome}') but its plan could not be traced for a home key (probe failed). ${NO_ROUTE_REMEDY}`,
358
+ );
359
+ }
360
+ traced = pooled;
361
+ }
362
+
363
+ // CROSS-HOME (plan-walk leg): the traced events' SET of packed homes must be one.
364
+ const touchedHomes = new Set<string>();
365
+ for (const ev of traced) {
366
+ const t = ev.type ?? (byId.has(ev.aggregate) ? ev.aggregate : undefined);
367
+ const h = t !== undefined ? homes[t] : undefined;
368
+ if (h !== undefined && packed.has(h)) touchedHomes.add(h);
369
+ }
370
+ if (touchedHomes.size > 1) {
371
+ throw new Error(
372
+ `domain '${mod.name}': directive '${d.id}' plans effects across packed homes ` +
373
+ `${[...touchedHomes].sort().map((h) => `'${h}'`).join(" and ")} — one intent cannot ` +
374
+ `span two homes. Fix: model the cross-home effect as the Order/Receipt PR pair ` +
375
+ `(cross_workspace.md).`,
376
+ );
377
+ }
378
+
379
+ // Candidate route keys, strongest first:
380
+ // 1. a field traced as the AXIS ROOT's own instance id (via "axis")
381
+ // 2. a field traced onto a ref field that points AT the axis (via "axis")
382
+ // 3. a field traced as a PACKED-HOMED aggregate's instance id (via "id")
383
+ // 4. a field traced onto a ref field that points at a packed-homed aggregate (via "id")
384
+ let axisSelf: string | undefined;
385
+ let axisRef: string | undefined;
386
+ let homedSelf: string | undefined;
387
+ let homedRef: string | undefined;
388
+ for (const ev of traced) {
389
+ const evType = ev.type ?? (byId.has(ev.aggregate) ? ev.aggregate : undefined);
390
+ const idField = fieldOfSentinel.get(ev.aggregate);
391
+ if (idField !== undefined && evType !== undefined) {
392
+ if (evType === axisRoot) axisSelf = axisSelf ?? idField;
393
+ else if (homes[evType] === targetHome) homedSelf = homedSelf ?? idField;
394
+ }
395
+ if (evType === undefined) continue;
396
+ for (const [field, value] of ev.sets) {
397
+ const viaField = fieldOfSentinel.get(value);
398
+ if (viaField === undefined) continue;
399
+ const ref = refTargetHome(evType, field);
400
+ if (ref === undefined) continue;
401
+ if (ref.target === axisRoot) axisRef = axisRef ?? viaField;
402
+ else if (ref.home === targetHome) homedRef = homedRef ?? viaField;
403
+ }
404
+ }
405
+ const key = axisSelf ?? axisRef ?? homedSelf ?? homedRef;
406
+ if (key === undefined) {
407
+ throw new Error(
408
+ `domain '${mod.name}': directive '${d.id}' targets '${d.aggregateId}' (packed home ` +
409
+ `'${targetHome}') but NO payload field traces to its home (neither the axis-root ` +
410
+ `id, a ref onto the homing chain, nor a homed aggregate's id). ${NO_ROUTE_REMEDY}`,
411
+ );
412
+ }
413
+ if (!required.has(key)) {
414
+ throw new Error(
415
+ `domain '${mod.name}': directive '${d.id}' routes by payload field '${key}', but that ` +
416
+ `field is optional — an absent home is an unroutable write. Fix: make '${key}' ` +
417
+ `required in the payload schema.`,
418
+ );
419
+ }
420
+ const via: RouteVia = key === axisSelf || key === axisRef ? "axis" : "id";
421
+ routes.push({ directive: d.id, home: targetHome, key, via });
422
+ }
423
+ return routes.sort((a, b) => (a.directive < b.directive ? -1 : a.directive > b.directive ? 1 : 0));
424
+ }
425
+
426
+ // ── the canonical-manifest fragment (taxonomy + homes + ROUTES) ───────────────────────
427
+
428
+ /** The slice-2 taxonomy fragment: slice 1's `workspaceTypes`+`homes` plus `routes`. */
429
+ export interface CanonicalTaxonomyFragment extends CanonicalWorkspaceTypesFragment {
430
+ /**
431
+ * THE DERIVED ROUTING TABLE — directive → `{home, key, via}` for every directive
432
+ * homed on a packed axis, SORTED by directive id. HASH-BEARING law (a route move is
433
+ * a law change — the client routes by it, the shard gate's wrong-home refusal pins
434
+ * to it), and OMITTED ENTIRELY when no directive is packed-homed — so slice-1
435
+ * (route-free) taxonomies and taxonomy-free domains hash exactly as before.
436
+ */
437
+ routes?: CanonicalDirectiveRoute[];
438
+ }
439
+
440
+ /**
441
+ * Lower a module's taxonomy into its FULL canonical fragment: slice 1's
442
+ * `workspaceTypes` + `homes` (with all its fail-closed validation), then the slice-2
443
+ * `routes`. `{}` (no keys at all) for a taxonomy-free module — the hash-stability law.
444
+ */
445
+ export function canonicalTaxonomyFragment(mod: {
446
+ readonly name: string;
447
+ readonly aggregates: readonly AggregateHandle[];
448
+ readonly directives: readonly AnyDirective[];
449
+ readonly workspaceTypes?: readonly WorkspaceTypeDecl[];
450
+ }): CanonicalTaxonomyFragment {
451
+ const base = canonicalWorkspaceTypesFragment(mod);
452
+ if (base.workspaceTypes === undefined || base.homes === undefined) return base;
453
+ const types = resolveWorkspaceTypes(mod.workspaceTypes ?? [], mod.name);
454
+ const routes = deriveDirectiveRoutes(mod, types, base.homes);
455
+ return routes.length > 0 ? { ...base, routes } : base;
456
+ }
457
+
458
+ /**
459
+ * MARKER-DRIVEN front-door mint field (the #260/#262 lane, aimed at the TS client):
460
+ * which top-level payload string field is the `.creates` TARGET's own id — traced by
461
+ * running the real plan, never guessed from a name. `undefined` when the plan mints
462
+ * internally (then there is nothing for the front-door to mint).
463
+ */
464
+ export function mintedCreateField(d: AnyDirective, agg: AggregateHandle): string | undefined {
465
+ if (d.marker !== "creates" || d.aggregateId !== agg.id) return undefined;
466
+ const schema = (d as unknown as { payloadSchema?: z.ZodTypeAny }).payloadSchema;
467
+ if (schema === undefined || zodKind(schema) !== "object") return undefined;
468
+ const sentinelOf = new Map<string, string>();
469
+ const fieldOfSentinel = new Map<string, string>();
470
+ for (const [name, raw] of Object.entries(zodObjectShape(schema))) {
471
+ let f = raw as z.ZodTypeAny;
472
+ while (zodKind(f) === "optional" || zodKind(f) === "default") f = zodDef(f).innerType!;
473
+ if (zodKind(f) !== "string") continue;
474
+ const sentinel = `__NOMOS_MINT_PROBE_${name}__`;
475
+ sentinelOf.set(name, sentinel);
476
+ fieldOfSentinel.set(sentinel, name);
477
+ }
478
+ if (sentinelOf.size === 0) return undefined;
479
+ const traced = tracePlan(d, agg, sentinelOf);
480
+ if (traced === undefined) return undefined;
481
+ for (const ev of traced) {
482
+ if (ev.type !== agg.id) continue;
483
+ const field = fieldOfSentinel.get(ev.aggregate);
484
+ if (field !== undefined) return field;
485
+ }
486
+ return undefined;
487
+ }
488
+
489
+ /** One generated-client mint instruction (the TS front-door, taxonomy packages only). */
490
+ export interface ClientMintPlanEntry {
491
+ /** The directive id. */
492
+ readonly directive: string;
493
+ /** The payload field the front-door mints when omitted. */
494
+ readonly field: string;
495
+ /** The aggregate TYPE TAG to mint. */
496
+ readonly mintType: string;
497
+ /**
498
+ * How the minted id gets its ROUTE TAG:
499
+ * * `{ homeKeyField }` — tag = ROUTE_TAG(payload[homeKeyField]) (an axis-root ref);
500
+ * * `{ tagFromIdField }` — copy the tag of another homed id in the payload;
501
+ * * neither — plain mint (the id IS its own home: an axis root, or a placement).
502
+ */
503
+ readonly homeKeyField?: string;
504
+ readonly tagFromIdField?: string;
505
+ }
506
+
507
+ /**
508
+ * Derive the generated TS client's front-door mint plan for a taxonomy-bearing
509
+ * module: routed `.creates` directives whose target id rides the payload (minted
510
+ * with the home's route tag), plus the derived placement directives (the axis-root
511
+ * id minted plain — the home key IS the new identity). Sorted by directive id.
512
+ */
513
+ export function deriveClientMintPlan(mod: {
514
+ readonly name: string;
515
+ readonly aggregates: readonly AggregateHandle[];
516
+ readonly directives: readonly AnyDirective[];
517
+ readonly workspaceTypes?: readonly WorkspaceTypeDecl[];
518
+ }): ClientMintPlanEntry[] {
519
+ const fragment = canonicalTaxonomyFragment(mod);
520
+ if (fragment.workspaceTypes === undefined || fragment.homes === undefined) return [];
521
+ const routes = new Map((fragment.routes ?? []).map((r) => [r.directive, r]));
522
+ const byId = new Map(mod.aggregates.map((a) => [a.id, a]));
523
+ const types = resolveWorkspaceTypes(mod.workspaceTypes ?? [], mod.name);
524
+ const out: ClientMintPlanEntry[] = [];
525
+
526
+ // 1. the placement directives: mint the axis ROOT's id, plain (it is its own home).
527
+ const placementByDirective = new Map<string, { field: string; rootId: string }>();
528
+ for (const t of types.values()) {
529
+ if (t.mode !== "packed") continue;
530
+ placementByDirective.set(placementDirId(t.id), {
531
+ field: placementKeyField(t.id),
532
+ rootId: t.rootId,
533
+ });
534
+ }
535
+
536
+ for (const d of mod.directives) {
537
+ const placement = placementByDirective.get(d.id);
538
+ if (placement !== undefined && d.aggregateId === "NomosShardAssignment") {
539
+ out.push({ directive: d.id, field: placement.field, mintType: placement.rootId });
540
+ continue;
541
+ }
542
+ const route = routes.get(d.id);
543
+ if (route === undefined) continue;
544
+ const agg = byId.get(d.aggregateId);
545
+ if (agg === undefined) continue;
546
+ const minted = mintedCreateField(d, agg);
547
+ if (minted === undefined) continue;
548
+ if (minted === route.key) {
549
+ // The minted id IS the home key (an axis-root create): plain mint.
550
+ out.push({ directive: d.id, field: minted, mintType: d.aggregateId });
551
+ } else if (route.via === "axis") {
552
+ out.push({ directive: d.id, field: minted, mintType: d.aggregateId, homeKeyField: route.key });
553
+ } else {
554
+ out.push({ directive: d.id, field: minted, mintType: d.aggregateId, tagFromIdField: route.key });
555
+ }
556
+ }
557
+ return out.sort((a, b) => (a.directive < b.directive ? -1 : a.directive > b.directive ? 1 : 0));
558
+ }
559
+
560
+ // Local copies of the placement-lane naming (workspace_sharding.ts is the canonical
561
+ // site; duplicated here because importing it would drag zod-object construction into
562
+ // every fragment call — the two are sealed together by `workspace_routing.test.ts`).
563
+ const pascalOf = (s: string) =>
564
+ s.replace(/[_\s-]+(\w)/g, (_m, c: string) => c.toUpperCase()).replace(/^./, (c) => c.toUpperCase());
565
+ function placementDirId(axisType: string): string {
566
+ return `birth${pascalOf(axisType)}`;
567
+ }
568
+ function placementKeyField(axisType: string): string {
569
+ const p = pascalOf(axisType);
570
+ return `${p.length ? p[0]!.toLowerCase() + p.slice(1) : p}Id`;
571
+ }
572
+
573
+ /**
574
+ * The slice-2 compile gate: slice 1's taxonomy validation PLUS the route derivation
575
+ * (unroutable / cross-home / optional-home-field laws refuse to COMPILE with named
576
+ * remedies). Throwing is the verdict.
577
+ */
578
+ export function validateWorkspaceTaxonomyAndRoutes(mod: {
579
+ readonly name: string;
580
+ readonly aggregates: readonly AggregateHandle[];
581
+ readonly directives: readonly AnyDirective[];
582
+ readonly workspaceTypes?: readonly WorkspaceTypeDecl[];
583
+ }): void {
584
+ canonicalTaxonomyFragment(mod);
585
+ }