@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.
- package/package.json +4 -1
- package/src/build_package.ts +124 -3
- package/src/codegen_dart.ts +15 -0
- package/src/codegen_ts.ts +235 -7
- package/src/compile_package_main.ts +342 -6
- package/src/engine_entry.ts +124 -4
- package/src/framework/workspace_invariant.ts +7 -0
- package/src/index.ts +6 -0
- package/src/manifest.ts +56 -7
- package/src/usd.ts +37 -0
- package/src/workspace_routing.ts +585 -0
- package/src/workspace_sharding.ts +1179 -0
- package/src/workspace_type.ts +609 -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
|
+
}
|