@githolon/dsl 0.54.1 → 0.55.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.
@@ -60,8 +60,33 @@ import {
60
60
  zodEnumValues,
61
61
  zodKind,
62
62
  zodObjectShape,
63
+ zodUnwrapLazy,
63
64
  } from "./codegen_ts.js";
64
65
 
66
+ /** Shared empty visited-set for the lazy-recursion guard (no allocation on the common path). */
67
+ const EMPTY_LAZY_VISITED: ReadonlySet<z.ZodTypeAny> = new Set();
68
+
69
+ /**
70
+ * The top-level payload fields that ADMIT a domain `null` (their zod is `.nullable()`, a
71
+ * `t.nullable(...)`/`t.clearable(...)` sink — null_semantics.md). The proof drives the NULL ARM for
72
+ * each (the value arm is the normal synthesized sample) so the law is provably total over `null`.
73
+ * A nullable wrapped in optional/default still counts (unwrapped in ANY order). Empty for a
74
+ * null-free payload — no extra leg, byte-identical proof. */
75
+ export function nullablePayloadFields(schema: z.ZodTypeAny): string[] {
76
+ if (zodKind(schema) !== "object") return [];
77
+ const out: string[] = [];
78
+ for (const [name, raw] of Object.entries(zodObjectShape(schema))) {
79
+ let f = raw as z.ZodTypeAny;
80
+ let admits = false;
81
+ while (zodKind(f) === "optional" || zodKind(f) === "default" || zodKind(f) === "nullable") {
82
+ if (zodKind(f) === "nullable") admits = true;
83
+ f = zodDef(f).innerType!;
84
+ }
85
+ if (admits) out.push(name);
86
+ }
87
+ return out;
88
+ }
89
+
65
90
  const camel = (s: string) => s.replace(/[_\s-]+(\w)/g, (_m, c: string) => c.toUpperCase());
66
91
  const lcFirst = (s: string) => (s.length ? s[0]!.toLowerCase() + s.slice(1) : s);
67
92
 
@@ -124,7 +149,28 @@ function joinIssuePath(base: string, segs: readonly PropertyKey[]): string {
124
149
  * names the exact field (`controlPoints[0].geoPoint.latitude`, never a bare leaf or "field ?"). NO
125
150
  * FALLBACK: an unhandled shape throws, naming the path and the remedy.
126
151
  */
127
- function sampleOfZod(zt: z.ZodTypeAny, path: string, where: string, voIncludeOptional = false): unknown {
152
+ function sampleOfZod(
153
+ zt: z.ZodTypeAny,
154
+ path: string,
155
+ where: string,
156
+ voIncludeOptional = false,
157
+ visited: ReadonlySet<z.ZodTypeAny> = EMPTY_LAZY_VISITED,
158
+ ): unknown {
159
+ // RECURSIVE `z.lazy` (a self-referencing tree/JSON payload): unwrap one level, tracking the
160
+ // lazy object's IDENTITY on this descent. Re-entering the SAME lazy is a knot: sampling can go
161
+ // no deeper. The common case terminates NATURALLY one level up (the recursive branch is an
162
+ // OPTIONAL field, which the object case omits, or an ARRAY, which emits `[]`); only a REQUIRED
163
+ // non-array self-reference is genuinely unsampleable, and THAT throws with the exact path.
164
+ if (zodKind(zt) === "lazy") {
165
+ if (visited.has(zt)) {
166
+ throw new Error(
167
+ `codegen-proof: ${where} — field '${path}': recursive z.lazy schema — a REQUIRED self-reference makes a finite sample impossible; make the recursive field \`.optional()\` (proof omits it) or wrap it in an array (proof emits \`[]\`), or prove this directive by hand in test/e2e.mts`,
168
+ );
169
+ }
170
+ const next = new Set(visited);
171
+ next.add(zt);
172
+ return sampleOfZod(zodUnwrapLazy(zt), path, where, voIncludeOptional, next);
173
+ }
128
174
  // VALUE OBJECTS FIRST (vo_authoring_end_to_end.md slice C): a declared VO carries its own lawful
129
175
  // sample (the author's validated `example`, the blessed geometry sample, or per-field descriptor
130
176
  // synthesis, recursing through nested VOs / arrays-of-VOs / unions). The raw-zod walk below cannot
@@ -172,14 +218,17 @@ function sampleOfZod(zt: z.ZodTypeAny, path: string, where: string, voIncludeOpt
172
218
  // A one-element array satisfies `.min(1)` coverage; a string element keeps the readable
173
219
  // `sample-a` (constraint-probed) so golden outputs stay stable.
174
220
  if (zodKind(el) === "string") return [sampleString(el, "a")];
175
- return [sampleOfZod(el, `${path}[0]`, where, voIncludeOptional)];
221
+ // A recursive array element (its lazy is already on this descent) bottoms out as `[]` —
222
+ // an empty array is a valid finite sample and breaks the recursion (depth-bound at 1).
223
+ if (zodKind(el) === "lazy" && visited.has(el)) return [];
224
+ return [sampleOfZod(el, `${path}[0]`, where, voIncludeOptional, visited)];
176
225
  }
177
226
  case "object": {
178
227
  const out: Record<string, unknown> = {};
179
228
  for (const [name, raw] of Object.entries(zodObjectShape(zt))) {
180
229
  const k = zodKind(raw as z.ZodTypeAny);
181
230
  if (k === "optional" || k === "default") continue; // optional → omit
182
- out[name] = sampleOfZod(raw as z.ZodTypeAny, joinIssuePath(path, [name]), where, voIncludeOptional);
231
+ out[name] = sampleOfZod(raw as z.ZodTypeAny, joinIssuePath(path, [name]), where, voIncludeOptional, visited);
183
232
  }
184
233
  return out;
185
234
  }
@@ -188,10 +237,10 @@ function sampleOfZod(zt: z.ZodTypeAny, path: string, where: string, voIncludeOpt
188
237
  case "nullable":
189
238
  case "optional":
190
239
  case "default":
191
- return sampleOfZod(zodDef(zt).innerType!, path, where, voIncludeOptional);
240
+ return sampleOfZod(zodDef(zt).innerType!, path, where, voIncludeOptional, visited);
192
241
  case "union": {
193
242
  const first = zodDef(zt).options?.[0];
194
- if (first !== undefined) return sampleOfZod(first, path, where, voIncludeOptional);
243
+ if (first !== undefined) return sampleOfZod(first, path, where, voIncludeOptional, visited);
195
244
  throw new Error(`codegen-proof: ${where} — field '${path}': an empty union has no arm to sample`);
196
245
  }
197
246
  default:
@@ -2592,6 +2641,25 @@ export function generateTsProof(modules: readonly DomainModule[], opts: TsProofO
2592
2641
  );
2593
2642
  }
2594
2643
 
2644
+ // NULL SEMANTICS BOTH-ARM PROOF (null_semantics.md): the primary dispatch above already proved the
2645
+ // VALUE arm (a nullable field synthesizes to a sampled value); here we prove the NULL arm — the law
2646
+ // ADMITS an explicit `null` for each nullable field, so it is provably total over null. Each create
2647
+ // arm mints a fresh id. Empty for a null-free payload (byte-identical proof).
2648
+ const nullArms = nullablePayloadFields(createDir.payloadSchema as z.ZodTypeAny);
2649
+ for (const field of nullArms) {
2650
+ const armMint = mintField !== undefined ? `, ${JSON.stringify(mintField)}: await holon.mint(${JSON.stringify(agg.id)})` : "";
2651
+ out.push(
2652
+ `// ${n()}. NULL-ARM PROOF — \`${field}\` is nullable: the law must ADMIT an explicit null (both arms proven).`,
2653
+ `try {`,
2654
+ ` await app.${createDir.id}({ ...${JSON.stringify(baseValue)}${armMint}, ${JSON.stringify(field)}: null });`,
2655
+ ` ok(${JSON.stringify(`null-arm — ${domain}/${createDir.id} admitted with \`${field}\`=null (the law handles the explicit-null arm)`)});`,
2656
+ `} catch (e) {`,
2657
+ ` fail(\`null-arm ${field}=null was REFUSED — a nullable field's null arm must be admitted: \${(e as Error).message}\`);`,
2658
+ `}`,
2659
+ ``,
2660
+ );
2661
+ }
2662
+
2595
2663
  out.push(
2596
2664
  `globalThis.fetch = realFetch;`,
2597
2665
  `ok("offline proof ENFORCED — fetch was trapped: local write/read touched no network");`,
package/src/codegen_ts.ts CHANGED
@@ -43,12 +43,14 @@ import type { DomainModule } from "./codegen_dart.js";
43
43
  import type { NomosCompat } from "./compat_manifest.js";
44
44
  import type { QueryDecl } from "./query.js";
45
45
  import { voMetaOf, collectPayloadValueObjectDescriptors } from "./value_object.js";
46
+ import { recursiveTypeBaseName, zodUnwrapLazy } from "./zod_lazy.js";
46
47
  import { finishCount } from "./count.js";
47
48
  import { finishSum } from "./sum.js";
48
49
  import type { DerivedDecl } from "./derived.js";
49
50
  import type { CombinedDecl } from "./combined.js";
50
51
  import { resolveWorkspaceTypes, type ResolvedWorkspaceType, type WorkspaceTypeDecl } from "./workspace_type.js";
51
52
  import { deriveClientMintPlan, type ClientMintPlanEntry } from "./workspace_routing.js";
53
+ import { admitsNull, nullDocLine } from "./null_meta.js";
52
54
 
53
55
  const cap = (s: string) => (s.length ? s[0]!.toUpperCase() + s.slice(1) : s);
54
56
  const camel = (s: string) => s.replace(/[_\s-]+(\w)/g, (_m, c: string) => c.toUpperCase());
@@ -206,8 +208,13 @@ type ZodInternalDef = {
206
208
  value?: unknown;
207
209
  values?: readonly unknown[];
208
210
  valueType?: z.ZodTypeAny;
211
+ getter?: () => z.ZodTypeAny; // `z.lazy(() => …)` — the deferred inner schema (zod v4)
209
212
  };
210
213
 
214
+ // The lazy-unwrap + recursive-name helpers live in the dependency-free `zod_lazy.ts` (shared with
215
+ // codegen_dart WITHOUT an import cycle). Re-exported so codegen_proof's existing import holds.
216
+ export { zodUnwrapLazy };
217
+
211
218
  export function zodDef(zt: z.ZodTypeAny): ZodInternalDef {
212
219
  const raw = zt as unknown as { _def?: ZodInternalDef; def?: ZodInternalDef };
213
220
  return raw._def ?? raw.def ?? {};
@@ -245,8 +252,44 @@ export function zodArrayElement(zt: z.ZodTypeAny): z.ZodTypeAny {
245
252
  const union = (parts: string[]) => [...new Set(parts)].join(" | ");
246
253
  const paren = (t: string) => (t.includes("|") || t.includes("&") ? `(${t})` : t);
247
254
 
248
- /** Recursive zod → TS type text. NO FALLBACK: an unhandled shape throws. */
249
- function tsTypeOfZod(zt: z.ZodTypeAny, indent: string): string {
255
+ /**
256
+ * THE RECURSIVE-TYPE REGISTRY (`z.lazy` full support). A recursive payload schema (a `TreeNode`
257
+ * that references itself) can't be a finite inline literal — so at each lazy KNOT we MINT a stable
258
+ * named type alias (from the payload path — deterministic across compiles), emit its body ONCE, and
259
+ * reference the name everywhere it recurs. TS resolves named recursion natively for every shape, so
260
+ * NOTHING degrades: `type SaveTreeRoot = { label: string; children?: SaveTreeRoot[] }`. The `names`
261
+ * map (keyed on the lazy's object IDENTITY) doubles as the cycle guard — a re-encountered lazy
262
+ * returns its already-minted name. Mutual recursion A↔B mints two names by first-encounter path. */
263
+ interface LazyTypeRegistry {
264
+ readonly names: Map<z.ZodTypeAny, string>; // lazy identity → minted type name (also the guard)
265
+ readonly decls: Map<string, string>; // name → `export type <Name> = <body>;` (dedup, deterministic)
266
+ readonly used: Set<string>; // all minted names — collision-avoidance across knots
267
+ }
268
+
269
+ function newLazyRegistry(): LazyTypeRegistry {
270
+ return { names: new Map(), decls: new Map(), used: new Set() };
271
+ }
272
+
273
+ /** Mint a UNIQUE PascalCase type name from a payload path; collisions get a numeric suffix. */
274
+ function mintLazyTypeName(reg: LazyTypeRegistry, path: string): string {
275
+ const base = recursiveTypeBaseName(path);
276
+ let name = base;
277
+ for (let i = 2; reg.used.has(name); i++) name = `${base}${i}`;
278
+ reg.used.add(name);
279
+ return name;
280
+ }
281
+
282
+ /** Recursive zod → TS type text. NO FALLBACK for an unknown shape (it throws). A RECURSIVE
283
+ * `z.lazy` knot is FULLY SUPPORTED: it mints a named type alias into `reg` and references it
284
+ * (never `JsonValue`) — see {@link LazyTypeRegistry}. `reg` is absent only for standalone callers
285
+ * (e.g. a query return) that can't host a named decl; a recursive lazy there is the one place the
286
+ * `JsonValue` safety valve applies (query returns are read models, never recursive in practice). */
287
+ function tsTypeOfZod(
288
+ zt: z.ZodTypeAny,
289
+ indent: string,
290
+ reg?: LazyTypeRegistry,
291
+ path = "",
292
+ ): string {
250
293
  // A DECLARED VALUE OBJECT keeps its NAME (vo_authoring_end_to_end.md slice B): the payload
251
294
  // references the same exported interface the read model uses — one name, both directions —
252
295
  // instead of an anonymous inline literal type. Checked on the BARE object only: optional/
@@ -256,6 +299,16 @@ function tsTypeOfZod(zt: z.ZodTypeAny, indent: string): string {
256
299
  if (vo !== undefined) return vo.name;
257
300
  }
258
301
  switch (zodKind(zt)) {
302
+ case "lazy": {
303
+ if (reg === undefined) return "JsonValue"; // safety valve (no host for a named decl)
304
+ const existing = reg.names.get(zt);
305
+ if (existing !== undefined) return existing; // already minted (the cycle guard)
306
+ const name = mintLazyTypeName(reg, path);
307
+ reg.names.set(zt, name); // register BEFORE recursing so self-references resolve to the name
308
+ const body = tsTypeOfZod(zodUnwrapLazy(zt), "", reg, path);
309
+ reg.decls.set(name, `export type ${name} = ${body};`);
310
+ return name;
311
+ }
259
312
  case "string":
260
313
  return "string";
261
314
  case "number":
@@ -270,7 +323,7 @@ function tsTypeOfZod(zt: z.ZodTypeAny, indent: string): string {
270
323
  return JSON.stringify(v);
271
324
  }
272
325
  case "array":
273
- return `${paren(tsTypeOfZod(zodArrayElement(zt), indent))}[]`;
326
+ return `${paren(tsTypeOfZod(zodArrayElement(zt), indent, reg, `${path}[]`))}[]`;
274
327
  case "object": {
275
328
  const shape = zodObjectShape(zt);
276
329
  const inner = indent + " ";
@@ -281,21 +334,21 @@ function tsTypeOfZod(zt: z.ZodTypeAny, indent: string): string {
281
334
  optional = true;
282
335
  f = zodDef(f).innerType!;
283
336
  }
284
- return `${inner}${name}${optional ? "?" : ""}: ${tsTypeOfZod(f, inner)};`;
337
+ return `${inner}${name}${optional ? "?" : ""}: ${tsTypeOfZod(f, inner, reg, path === "" ? name : `${path}.${name}`)};`;
285
338
  });
286
339
  return fields.length ? `{\n${fields.join("\n")}\n${indent}}` : "Record<string, never>";
287
340
  }
288
341
  case "record": {
289
342
  const vt = zodDef(zt).valueType;
290
- return `Record<string, ${vt ? tsTypeOfZod(vt, indent) : "unknown"}>`;
343
+ return `Record<string, ${vt ? tsTypeOfZod(vt, indent, reg, `${path}.*`) : "unknown"}>`;
291
344
  }
292
345
  case "optional":
293
346
  case "default":
294
- return `${tsTypeOfZod(zodDef(zt).innerType!, indent)} | undefined`;
347
+ return `${tsTypeOfZod(zodDef(zt).innerType!, indent, reg, path)} | undefined`;
295
348
  case "nullable":
296
- return `${tsTypeOfZod(zodDef(zt).innerType!, indent)} | null`;
349
+ return `${tsTypeOfZod(zodDef(zt).innerType!, indent, reg, path)} | null`;
297
350
  case "union":
298
- return union((zodDef(zt).options ?? []).map((o) => tsTypeOfZod(o, indent)));
351
+ return union((zodDef(zt).options ?? []).map((o) => tsTypeOfZod(o, indent, reg, path)));
299
352
  case "unknown":
300
353
  case "any":
301
354
  return "unknown";
@@ -304,6 +357,57 @@ function tsTypeOfZod(zt: z.ZodTypeAny, indent: string): string {
304
357
  }
305
358
  }
306
359
 
360
+ /**
361
+ * The RECURSIVE `z.lazy` knots in one directive payload, each reported as `{ path, name }` where
362
+ * `name` is the stable generated type minted for it (the SAME name {@link tsTypeOfZod} emits — the
363
+ * mint is deterministic from the path). Surfaced as a compile-summary NOTE so the tenant sees which
364
+ * fields became named recursive types (NOT a degrade — recursion is fully supported). `pathPrefix`
365
+ * (the directive id) scopes the minted names, matching the client's package-level registry. Empty
366
+ * for a lazy-free payload. */
367
+ export function recursiveLazyTypes(schema: z.ZodTypeAny, pathPrefix = ""): { path: string; name: string }[] {
368
+ const reg = newLazyRegistry();
369
+ const out: { path: string; name: string }[] = [];
370
+ const walk = (zt: z.ZodTypeAny, path: string): void => {
371
+ switch (zodKind(zt)) {
372
+ case "lazy": {
373
+ if (reg.names.has(zt)) return; // already recorded (the cycle guard)
374
+ const name = mintLazyTypeName(reg, path);
375
+ reg.names.set(zt, name);
376
+ out.push({ path, name });
377
+ walk(zodUnwrapLazy(zt), path);
378
+ return;
379
+ }
380
+ case "array":
381
+ walk(zodArrayElement(zt), `${path}[]`);
382
+ return;
383
+ case "object": {
384
+ if (voMetaOf(zt) !== undefined) return; // a VO is an opaque leaf — don't descend
385
+ for (const [name, raw] of Object.entries(zodObjectShape(zt))) {
386
+ let f = raw as z.ZodTypeAny;
387
+ while (zodKind(f) === "optional" || zodKind(f) === "default") f = zodDef(f).innerType!;
388
+ walk(f, path === "" ? name : `${path}.${name}`);
389
+ }
390
+ return;
391
+ }
392
+ case "optional":
393
+ case "default":
394
+ case "nullable":
395
+ walk(zodDef(zt).innerType!, path);
396
+ return;
397
+ case "record":
398
+ if (zodDef(zt).valueType) walk(zodDef(zt).valueType!, `${path}.*`);
399
+ return;
400
+ case "union":
401
+ for (const o of zodDef(zt).options ?? []) walk(o, path);
402
+ return;
403
+ default:
404
+ return;
405
+ }
406
+ };
407
+ walk(schema, pathPrefix);
408
+ return out;
409
+ }
410
+
307
411
  // ── caller-side clock sugar (the `…At` payload convention) ────────────────────────
308
412
 
309
413
  /**
@@ -325,15 +429,19 @@ export function autoStampFields(schema: z.ZodTypeAny): string[] {
325
429
  return out;
326
430
  }
327
431
 
328
- /** The payload type text, with auto-stamped / front-door-minted / auto-placed fields optional (`?`). */
432
+ /** The payload type text, with auto-stamped / front-door-minted / auto-placed fields optional (`?`).
433
+ * `reg` collects any RECURSIVE `z.lazy` named types minted while walking (emitted once by the
434
+ * caller); `pathPrefix` (the directive id) scopes their names so two directives can't collide. */
329
435
  function tsTypeOfPayload(
330
436
  schema: z.ZodTypeAny,
331
437
  autoStamped: readonly string[],
332
438
  minted: readonly string[] = [],
333
439
  autoPlaced: readonly string[] = [],
440
+ reg?: LazyTypeRegistry,
441
+ pathPrefix = "",
334
442
  ): string {
335
443
  if (zodKind(schema) !== "object" || (autoStamped.length === 0 && minted.length === 0 && autoPlaced.length === 0)) {
336
- return tsTypeOfZod(schema, "");
444
+ return tsTypeOfZod(schema, "", reg, pathPrefix);
337
445
  }
338
446
  const stamped = new Set(autoStamped);
339
447
  const mintedSet = new Set(minted);
@@ -352,7 +460,8 @@ function tsTypeOfPayload(
352
460
  : placedSet.has(name)
353
461
  ? " // picked LEAST-LOADED over the coordinator's law-state at offer when omitted (sharding §3)"
354
462
  : "";
355
- return ` ${name}${optional ? "?" : ""}: ${tsTypeOfZod(f, " ")};${note}`;
463
+ const fieldPath = pathPrefix === "" ? name : `${pathPrefix}.${name}`;
464
+ return ` ${name}${optional ? "?" : ""}: ${tsTypeOfZod(f, " ", reg, fieldPath)};${note}`;
356
465
  });
357
466
  return fields.length ? `{\n${fields.join("\n")}\n}` : "Record<string, never>";
358
467
  }
@@ -360,6 +469,14 @@ function tsTypeOfPayload(
360
469
  // ── DSL Field → read-model TS type (the projection's decode surface) ──────────────
361
470
 
362
471
  function tsTypeOfField(field: Field): string {
472
+ const base = tsBaseTypeOfField(field);
473
+ // NULL SEMANTICS (null_semantics.md): a `nullable`/`explicit-empty` field ADMITS a domain `null`,
474
+ // so its read-model type carries the `| null` arm (distinct from the `?` partial-fold absence a
475
+ // non-null-admitting field gets). A `forbid` json/VO field NEVER gets the null arm.
476
+ return admitsNull(field) ? `${base} | null` : base;
477
+ }
478
+
479
+ function tsBaseTypeOfField(field: Field): string {
363
480
  switch (field.kind) {
364
481
  case "string":
365
482
  return "string";
@@ -564,6 +681,10 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
564
681
  const encPresent = encMap !== null;
565
682
  const encConst = encryptionConstName(opts.packageName);
566
683
  const out: string[] = [];
684
+ // The PACKAGE-LEVEL recursive-type registry: every directive payload's `z.lazy` knots mint
685
+ // named recursive type aliases into it (deduped by lazy identity across directives); the decls
686
+ // are emitted once, after the payload types (TS hoists type aliases, so order is free).
687
+ const lazyReg = newLazyRegistry();
567
688
  out.push(
568
689
  `// AUTO-GENERATED by nomos-compile — typed TS client for package "${opts.packageName}".`,
569
690
  `// Regenerated on every compile; do not edit. Zero imports: bind it to any holon`,
@@ -600,6 +721,11 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
600
721
  `/** One projection row: the aggregate's wire type, its id, and the folded fields. */`,
601
722
  `export interface Row<T> { type: string; id: string; data: T }`,
602
723
  ``,
724
+ `/** Arbitrary JSON — the type a RECURSIVE \`z.lazy\` payload subtree degrades to (a self-`,
725
+ ` * referencing tree/JSON schema can't be a finite named literal, so the engine validates it`,
726
+ ` * at runtime and the recursive edge is typed here). Named recursion TS resolves fine. */`,
727
+ `export type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };`,
728
+ ``,
603
729
  `/** The holon surface this client binds to (structurally satisfied by @githolon/client). */`,
604
730
  `export interface NomosHolon {`,
605
731
  ` /** THE ONE ENCODER offer (the single v2-envelope codec lives in @githolon/client \`encodeIntentOfferBytes\`):`,
@@ -1023,6 +1149,8 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
1023
1149
  out.push(`export interface ${typeId}Data {`);
1024
1150
  for (const [name, field] of Object.entries(agg.fields)) {
1025
1151
  const required = (field as Field).isFromBirth === true;
1152
+ const doc = nullDocLine(field);
1153
+ if (doc !== undefined) out.push(` /** ${doc} */`);
1026
1154
  out.push(` ${name}${required ? "" : "?"}: ${tsTypeOfField(field as Field)};`);
1027
1155
  }
1028
1156
  for (const extra of extrasByType.get(typeId) ?? []) out.push(extra);
@@ -1280,7 +1408,7 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
1280
1408
  const pv = wantSeal ? "_sealed" : "payload";
1281
1409
  out.push(
1282
1410
  `/** \`${domain}/${d.id}\` payload (mirrors the engine's zod schema).${stamps.length ? ` Omitted ${stamps.map((s) => `\`${s}\``).join("/")} auto-stamp(s) ISO now() at offer.` : ""}${mint ? ` Omitted \`${mint.field}\` is KERNEL-MINTED at offer (returned on the result).` : ""}${placed.length ? " Omitted `shard` is picked LEAST-LOADED at offer (the routed holon)." : ""} */`,
1283
- `export type ${payloadName} = ${tsTypeOfPayload(schema, stamps, mint ? [mint.field] : [], placed)};`,
1411
+ `export type ${payloadName} = ${tsTypeOfPayload(schema, stamps, mint ? [mint.field] : [], placed, lazyReg, d.id)};`,
1284
1412
  ``,
1285
1413
  );
1286
1414
  if (mint !== undefined) {
@@ -1467,5 +1595,15 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
1467
1595
  );
1468
1596
  }
1469
1597
 
1598
+ // RECURSIVE PAYLOAD TYPES (`z.lazy`): emit each minted named alias once. Sorted by name for
1599
+ // byte-determinism; TS hoists type aliases so referencing them above is fine.
1600
+ if (lazyReg.decls.size > 0) {
1601
+ out.push(
1602
+ `// ── recursive payload types (z.lazy) — named so a self-referencing schema is a real TS type ──`,
1603
+ );
1604
+ for (const name of [...lazyReg.decls.keys()].sort()) out.push(lazyReg.decls.get(name)!);
1605
+ out.push(``);
1606
+ }
1607
+
1470
1608
  return out.join("\n");
1471
1609
  }
@@ -117,7 +117,7 @@ import {
117
117
  } from "./stable_ids.js";
118
118
  import { emitUsd, type UsdLayer } from "./usd.js";
119
119
  import { emitLayeredRelationUsda, type RelationLayerInput } from "./relation_composition.js";
120
- import { generateTsClient, tsClientFactoryName } from "./codegen_ts.js";
120
+ import { generateTsClient, tsClientFactoryName, recursiveLazyTypes } from "./codegen_ts.js";
121
121
  import { generateOpenApiFragment, mergeOpenApiFragments } from "./codegen_openapi.js";
122
122
  import { domainInterface, type DomainInterface } from "./shape_compat.js";
123
123
  import { generateTsProof, proofLegsIndex } from "./codegen_proof.js";
@@ -379,6 +379,10 @@ const DART_SUPPORT_SRC = [
379
379
  "offer.dart",
380
380
  "subscriptions.dart",
381
381
  "provider.dart",
382
+ // The tri-state slot (NomosSlot<T>) for `nullable`/`clearable` fields (null_semantics.md) — the
383
+ // barrel re-exports it and generated read-model/payload classes reference it, so it must vendor.
384
+ // Zero external pub deps (pure Dart).
385
+ "null_slot.dart",
382
386
  // Runtime aggregate-meta support types (NomosAggregateMeta/NomosFieldMeta) — the generated
383
387
  // per-domain registry consts reference them through the barrel.
384
388
  "aggregate_meta.dart",
@@ -1235,6 +1239,20 @@ async function main(): Promise<void> {
1235
1239
  "utf8",
1236
1240
  );
1237
1241
 
1242
+ // ── RECURSIVE-PAYLOAD NOTES (z.lazy) — a self-referencing tree/JSON payload field is FULLY
1243
+ // supported: each codegen layer mints a named recursive type for it (TS `type <Name>`, Dart
1244
+ // recursive class). Surfaced here so the tenant sees which fields became which named types.
1245
+ const recursiveNotes: string[] = [];
1246
+ for (const mod of domainModules) {
1247
+ for (const d of (mod.directives ?? []) as Array<{ id?: unknown; payloadSchema?: unknown }>) {
1248
+ if (!d || typeof d.id !== "string" || !d.payloadSchema || typeof d.payloadSchema !== "object") continue;
1249
+ for (const { path, name } of recursiveLazyTypes(d.payloadSchema as Parameters<typeof recursiveLazyTypes>[0], d.id)) {
1250
+ const field = path.startsWith(`${d.id}.`) ? path.slice(d.id.length + 1) : path;
1251
+ recursiveNotes.push(`${mod.domain ?? mod.name}.${d.id} payload '${field}': recursive z.lazy schema → generated named type '${name}' (TS type + Dart class)`);
1252
+ }
1253
+ }
1254
+ }
1255
+
1238
1256
  // ── reports ──
1239
1257
  const entryReports: string[] = [];
1240
1258
  for (const [reportId, ref] of Object.entries(cfg.reports ?? {})) {
@@ -1815,6 +1833,7 @@ async function main(): Promise<void> {
1815
1833
  }
1816
1834
  }
1817
1835
  console.log(` client ${rel(clientPath)} (typed TS client — payloads, read models, query accessors)`);
1836
+ for (const note of recursiveNotes) console.log(` note ${note}`);
1818
1837
  console.log(` openapi ${rel(openapiPath)} (${Object.keys(openapi.paths).length} operation(s) — the law's self-describing API; the host's runtime routing table)`);
1819
1838
  if (proofPath !== undefined) {
1820
1839
  console.log(` proof ${rel(proofPath)} (a runnable e2e GENERATED from your law — run: githolon proof)`);