@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.
package/src/usd.ts CHANGED
@@ -170,6 +170,24 @@ export interface UsdLayer {
170
170
  * same discipline as `queries`/`references`/`reads`/`emits`).
171
171
  */
172
172
  readonly variantSets?: Record<string, Record<string, UsdPrim[]>>;
173
+ /**
174
+ * THE INVARIANT-GATE DECLARATION (#41 — "the law is the era"). Present IFF the layer's
175
+ * module declares ≥1 invariant (aggregate or workspace): the law's EXPLICIT opt-in to
176
+ * invariant EXECUTION at the one gate. The wasm gate runs the engine-backed invariant
177
+ * oracles for an intent only when the law resolved for that intent carries this key —
178
+ * every bundle compiled before #41 (whose declared invariants the gate held as hashed-
179
+ * but-inert law) lacks it, so chains sealed under the inert gate replay green forever.
180
+ * OMITTED (`undefined`) for invariant-free law — byte-identical hash, same discipline
181
+ * as `queries`/`references`. Hand-adding it to a foreign bundle only opts IN to
182
+ * stricter checking; removing it makes new law, for that law's own chains (status quo).
183
+ */
184
+ readonly nomosInvariantGate?: {
185
+ readonly v: 1;
186
+ /** Aggregate types whose declared `invariant` bodies the gate executes, sorted. */
187
+ readonly aggregates?: string[];
188
+ /** Declared workspace invariants `{id, on}` the gate executes, sorted by id. */
189
+ readonly workspaceInvariants?: { id: string; on: string }[];
190
+ };
173
191
  }
174
192
 
175
193
  /** The reserved variant name declaring a set's DEFAULT (USD has a default variant). */
@@ -239,12 +257,31 @@ export function emitUsd(
239
257
  // which already applies omit-when-empty: `queries` is present here ONLY when the
240
258
  // module declared them, so a query-free layer is byte-identical to before.
241
259
  const manifest = domainManifest(l.module);
260
+ // THE INVARIANT-GATE DECLARATION (#41): presence-only, derived from the SAME module
261
+ // the lump's executable bodies come from — sorted, omit-when-empty (hash-stable for
262
+ // invariant-free law; see the `UsdLayer.nomosInvariantGate` banner for the era rule).
263
+ const invariantAggregates = l.module.aggregates
264
+ .filter((a) => a.hasInvariant === true)
265
+ .map((a) => a.id)
266
+ .sort();
267
+ const wsInvariants = [...(l.module.workspaceInvariants ?? [])]
268
+ .map((w) => ({ id: w.id, on: w.on }))
269
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
242
270
  return {
243
271
  path: l.path,
244
272
  prims: encodeModuleToPrims(l.path, l.module),
245
273
  ...(manifest.queries !== undefined ? { queries: manifest.queries } : {}),
246
274
  ...(manifest.deriveds !== undefined ? { deriveds: manifest.deriveds } : {}),
247
275
  ...(manifest.combineds !== undefined ? { combineds: manifest.combineds } : {}),
276
+ ...(invariantAggregates.length > 0 || wsInvariants.length > 0
277
+ ? {
278
+ nomosInvariantGate: {
279
+ v: 1 as const,
280
+ ...(invariantAggregates.length > 0 ? { aggregates: invariantAggregates } : {}),
281
+ ...(wsInvariants.length > 0 ? { workspaceInvariants: wsInvariants } : {}),
282
+ },
283
+ }
284
+ : {}),
248
285
  };
249
286
  })
250
287
  .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
@@ -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
+ }