@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.
@@ -30,10 +30,12 @@
30
30
  */
31
31
  import { z } from "zod";
32
32
  import { voMetaOf, collectPayloadValueObjectDescriptors, idOfMetaOf, brandedIdMetaOf, NOMOS_UNIT_BRAND } from "./value_object.js";
33
+ import { recursiveTypeBaseName, zodUnwrapLazy } from "./zod_lazy.js";
33
34
  import type { AggregateHandle } from "./aggregate.js";
34
35
  import type { WorkspaceInvariantDecl } from "./framework/workspace_invariant.js";
35
36
  import type { RelationTypeDecl } from "./relation_schema.js";
36
37
  import type { Field, FieldKind, ValueObjectDescriptor, ValueObjectFieldDescriptor } from "./fields.js";
38
+ import { admitsNull, nullMetaOf, nullDocLine } from "./null_meta.js";
37
39
  import type { Directive } from "./directive.js";
38
40
  import { executeDirectiveToIntent } from "./wire_encode.js";
39
41
  import { deterministicPorts } from "./ctx.js";
@@ -80,6 +82,13 @@ export interface PayloadFieldSpec {
80
82
  * only emits ops for present fields). Required fields are always emitted.
81
83
  */
82
84
  optional: boolean;
85
+ /**
86
+ * NULL SEMANTICS (null_semantics.md): the payload field ADMITS a domain `null` (its zod is
87
+ * `.nullable()`, a `t.nullable(...)`/`t.clearable(...)` sink). It generates as the sealed
88
+ * tri-state [NomosSlot] so the call site says OMITTED / EXPLICIT-NULL / PRESENT explicitly —
89
+ * `toPayloadJson` then omits, writes a real `null`, or writes the value accordingly.
90
+ * `false`/absent for a plain field (byte-identical to a pre-feature payload). */
91
+ nullAdmits?: boolean;
83
92
  }
84
93
 
85
94
  type ZodInternalDef = {
@@ -93,8 +102,23 @@ type ZodInternalDef = {
93
102
  value?: unknown;
94
103
  values?: readonly unknown[];
95
104
  valueType?: z.ZodTypeAny;
105
+ getter?: () => z.ZodTypeAny; // `z.lazy(() => …)` — the deferred inner schema (zod v4)
96
106
  };
97
107
 
108
+ /** Resolve `z.lazy(() => …)` chains to the first non-lazy schema, with an identity-keyed
109
+ * visited guard so a directly self-referencing lazy (`z.lazy(() => TheLazy)`) can't loop.
110
+ * A non-lazy schema returns unchanged. */
111
+ function resolveLazy(zt: z.ZodTypeAny): z.ZodTypeAny {
112
+ const seen = new Set<z.ZodTypeAny>();
113
+ let cur = zt;
114
+ while (zodDef(cur).type === "lazy" && typeof zodDef(cur).getter === "function") {
115
+ if (seen.has(cur)) return cur; // pathological lazy→lazy cycle — stop (caller degrades)
116
+ seen.add(cur);
117
+ cur = zodDef(cur).getter!();
118
+ }
119
+ return cur;
120
+ }
121
+
98
122
  function zodDef(zt: z.ZodTypeAny): ZodInternalDef {
99
123
  const raw = zt as unknown as { _def?: ZodInternalDef; def?: ZodInternalDef };
100
124
  return raw._def ?? raw.def ?? {};
@@ -210,6 +234,7 @@ function zodNumberIsInt(zt: z.ZodTypeAny): boolean {
210
234
  export function introspectPayload(
211
235
  schema: z.ZodTypeAny,
212
236
  enumDartTypes: Map<string, string>,
237
+ lazyNames?: Map<z.ZodTypeAny, string>,
213
238
  ): PayloadFieldSpec[] {
214
239
  if (zodTypeName(schema) !== "ZodObject") {
215
240
  throw new Error("directive payload must be a z.object");
@@ -222,11 +247,19 @@ export function introspectPayload(
222
247
  // when null — mirroring the engine's Zod, which only sees present fields).
223
248
  let zt = ztRaw as z.ZodTypeAny;
224
249
  let optional = false;
225
- while (isOptionalOrDefault(zt)) {
226
- optional = true;
250
+ let nullAdmits = false;
251
+ // Unwrap optional/default (omission) AND nullable (a domain `null`) in ANY order — a payload
252
+ // field may be `.nullable()`, `.optional()`, or both. A nullable field carries the tri-state slot.
253
+ while (isOptionalOrDefault(zt) || zodTypeName(zt) === "ZodNullable") {
254
+ if (zodTypeName(zt) === "ZodNullable") nullAdmits = true;
255
+ else optional = true;
227
256
  zt = zodInnerType(zt);
228
257
  }
229
- out.push({ ...introspectField(name, zt, enumDartTypes), optional });
258
+ out.push({
259
+ ...introspectField(name, zt, enumDartTypes, lazyNames),
260
+ optional,
261
+ ...(nullAdmits ? { nullAdmits: true } : {}),
262
+ });
230
263
  }
231
264
  return out;
232
265
  }
@@ -239,7 +272,26 @@ function introspectField(
239
272
  name: string,
240
273
  zt: z.ZodTypeAny,
241
274
  enumDartTypes: Map<string, string>,
275
+ lazyNames?: Map<z.ZodTypeAny, string>,
242
276
  ): Omit<PayloadFieldSpec, "optional"> {
277
+ // RECURSIVE `z.lazy` (a self-referencing tree/JSON payload): if the compiler synthesized a
278
+ // typed recursive class for this knot (`lazyNames`), the field IS that class (toJson via its
279
+ // `.toJson()`). Without a synthesized class (no map, or a degraded knot) it resolves one level
280
+ // and flows into the opaque-JSON lane below (`Map<String,Object?>`) — the last-resort fallback.
281
+ if (zodTypeName(zt) === "ZodLazy") {
282
+ const cls = lazyNames?.get(zt);
283
+ if (cls !== undefined) return { name, dartType: cls, toJson: "vo" };
284
+ // No synthesized class (no map, or a DEGRADED knot — e.g. a lazy of a union): last-resort
285
+ // opaque JSON. Resolve one level and reuse the normal lane, but any shape the lane can't
286
+ // express (union, undeclared nested object) falls back to `Object?` rather than throwing.
287
+ const inner = resolveLazy(zt);
288
+ if (zodTypeName(inner) === "ZodLazy") return { name, dartType: "Object?", toJson: "json" };
289
+ try {
290
+ return introspectField(name, inner, enumDartTypes, lazyNames);
291
+ } catch {
292
+ return { name, dartType: "Object?", toJson: "json" };
293
+ }
294
+ }
243
295
  // A DECLARED VO/union keeps its name regardless of its zod kind (a union(...) is a
244
296
  // z.discriminatedUnion, not a ZodObject — without this it would fall to the throw lane).
245
297
  {
@@ -298,6 +350,11 @@ function introspectField(
298
350
  if (zodTypeName(elem) === "ZodString") {
299
351
  return { name, dartType: "List<String>", toJson: "strList" };
300
352
  }
353
+ // A list of a RECURSIVE `z.lazy` knot (a synthesized class) is a typed `List<Class>`.
354
+ const elemCls = zodTypeName(elem) === "ZodLazy" ? lazyNames?.get(elem) : undefined;
355
+ if (elemCls !== undefined) {
356
+ return { name, dartType: `List<${elemCls}>`, toJson: "voList" };
357
+ }
301
358
  const elemVo = voMetaOf(elem);
302
359
  if (elemVo !== undefined) {
303
360
  return { name, dartType: `List<${elemVo.name}>`, toJson: "voList" };
@@ -412,6 +469,15 @@ interface AggFieldSpec {
412
469
  * only the generated read-model type. `undefined` for a non-json field.
413
470
  */
414
471
  jsonShape?: "object";
472
+ /**
473
+ * NULL SEMANTICS (null_semantics.md): the field ADMITS a domain `null` (declared
474
+ * `t.nullable(...)` / `t.clearable(...)`), so its read-model + payload surface is the
475
+ * sealed tri-state [NomosSlot] (Omitted / ExplicitNull / Present) instead of a plain
476
+ * `T?` — so the app edge can't collapse omission and explicit-null. `false`/absent for a
477
+ * `forbid` field (byte-identical to a pre-feature field). */
478
+ nullAdmits?: boolean;
479
+ /** The law-documentation `reason` for a null-admitting field (surfaces in the Dart doc comment). */
480
+ nullReason?: string;
415
481
  }
416
482
 
417
483
  export interface DartImport {
@@ -764,11 +830,16 @@ function aggregateFieldSpecs(
764
830
  // BIRTH-PRESENCE LAW (`.fromBirth()`): the gate refuses any event that leaves the field
765
831
  // absent, so the read model may type it NON-NULLABLE — the one lawful exception to the
766
832
  // partial-fold nullability doctrine (absence is unrepresentable by law, not convention).
767
- const spec: AggFieldSpec = isCollectionKind(base.kind)
833
+ const baseSpec: AggFieldSpec = isCollectionKind(base.kind)
768
834
  ? base
769
835
  : f.isFromBirth === true
770
836
  ? { ...base, optional: false }
771
837
  : { ...base, optional: true };
838
+ // NULL SEMANTICS (null_semantics.md): a null-admitting scalar carries the tri-state slot.
839
+ const nmeta = nullMetaOf(f);
840
+ const spec: AggFieldSpec = admitsNull(f)
841
+ ? { ...baseSpec, nullAdmits: true, ...(nmeta.nullReason !== undefined ? { nullReason: nmeta.nullReason } : {}) }
842
+ : baseSpec;
772
843
  if (used.has(spec.name)) {
773
844
  throw new Error(
774
845
  `aggregate '${agg.id}' field '${spec.wireName}' maps to duplicate Dart field '${spec.name}'`,
@@ -1093,19 +1164,27 @@ function emitAggregate(
1093
1164
  ` /// indexed/by-id accessor matched on (e.g. the siteId). Empty string only when`,
1094
1165
  ` /// the aggregate is built from a bare \`data\` map with no row id (e.g. a unit test).`,
1095
1166
  ` final ${idType} aggregateId;`,
1096
- ...specs.map(
1097
- (s) => ` final ${s.dartType}${s.optional ? "?" : ""} ${s.name};`,
1098
- ),
1167
+ ...specs.flatMap((s) => {
1168
+ const doc = s.nullReason !== undefined ? [` /// null semantic: ${s.nullReason}`] : [];
1169
+ // NULL SEMANTICS: a null-admitting field is the sealed tri-state slot (Omitted / ExplicitNull /
1170
+ // Present) — omission and explicit-null never collapse. Others keep the plain `T?`.
1171
+ return s.nullAdmits === true
1172
+ ? [...doc, ` final NomosSlot<${stripNullable(s.dartType)}> ${s.name};`]
1173
+ : [` final ${s.dartType}${s.optional ? "?" : ""} ${s.name};`];
1174
+ }),
1099
1175
  ].join("\n");
1100
1176
  const ctorParams = [
1101
1177
  ` this.aggregateId = const ${idType}.fromRow(''),`,
1102
1178
  ...specs.map(
1103
1179
  // A required scalar stays `required`; an optional scalar OR a collection is not
1104
1180
  // `required` (an absent optional scalar is null; an absent collection is empty).
1181
+ // A null-admitting slot defaults to Omitted.
1105
1182
  (s) =>
1106
- s.optional || isCollectionKind(s.kind)
1107
- ? ` ${collectionDefaultParam(s)}`
1108
- : ` required this.${s.name},`,
1183
+ s.nullAdmits === true
1184
+ ? ` this.${s.name} = const NomosSlot.omitted(),`
1185
+ : s.optional || isCollectionKind(s.kind)
1186
+ ? ` ${collectionDefaultParam(s)}`
1187
+ : ` required this.${s.name},`,
1109
1188
  ),
1110
1189
  ].join("\n");
1111
1190
 
@@ -1117,7 +1196,11 @@ function emitAggregate(
1117
1196
  // threaded from the row id (GAP 2) — defaulting to '' for a bare-map call.
1118
1197
  const parseLines = [
1119
1198
  ` aggregateId: ${aggregateIdFromRowExpr(agg.id, "id")},`,
1120
- ...specs.map((s) => ` ${s.name}: ${parseField(s, className)},`),
1199
+ ...specs.map((s) =>
1200
+ s.nullAdmits === true
1201
+ ? ` ${s.name}: NomosSlot.decode<${stripNullable(s.dartType)}>(data, '${s.wireName}', ${slotDecodeCallback(s, "aggregate")}),`
1202
+ : ` ${s.name}: ${parseField(s, className)},`,
1203
+ ),
1121
1204
  ].join("\n");
1122
1205
 
1123
1206
  // `==`/`hashCode` include the id (two rows with the same data but different ids are
@@ -1912,6 +1995,53 @@ function parseField(s: AggFieldSpec, aggregate: string): string {
1912
1995
  }
1913
1996
  }
1914
1997
 
1998
+ /**
1999
+ * The `(v) => …` DECODE CALLBACK for a null-admitting field's [NomosSlot] (null_semantics.md).
2000
+ * [NomosSlot.decode] pre-handles the OMITTED (key absent) + EXPLICIT-NULL (value `null`) cases,
2001
+ * so this callback ONLY sees a real PRESENT value `v` (never null) and turns it into the typed
2002
+ * Dart value — mirroring [parseField]'s present-branch per kind, but over a raw leaf `v`. */
2003
+ function slotDecodeCallback(s: AggFieldSpec, mode: "aggregate" | "readModel"): string {
2004
+ switch (s.kind) {
2005
+ case "ref":
2006
+ return s.refIdClass !== undefined
2007
+ ? `(v) => ${s.refIdClass}.fromRow(v as String)`
2008
+ : `(v) => v as String`;
2009
+ case "position":
2010
+ case "string":
2011
+ return s.valueClass !== undefined
2012
+ ? `(v) => ${s.valueClass}.fromRow(v as String)`
2013
+ : `(v) => v as String`;
2014
+ case "json":
2015
+ if (s.voClass !== undefined) {
2016
+ return `(v) => ${s.voClass}.fromProjected((v as Map).cast<String, Object?>())`;
2017
+ }
2018
+ // The AGGREGATE class types every `json` leaf as `String` (re-stringify a parsed container via
2019
+ // `jsonEncode`); the READ MODEL types a `t.jsonObject()` leaf as `Map<String, Object?>` (strict
2020
+ // object) and a plain `t.json()` leaf as the permissive `Object?` (kept as-is).
2021
+ if (mode === "aggregate") return `(v) => v is String ? v : jsonEncode(v)`;
2022
+ return s.jsonShape === "object" ? `(v) => (v as Map).cast<String, Object?>()` : `(v) => v`;
2023
+ case "bool":
2024
+ return `(v) => v as bool`;
2025
+ case "counter":
2026
+ case "int":
2027
+ return `(v) => (v as num).toInt()`;
2028
+ case "enum":
2029
+ return `(v) => ${s.dartType}.fromWire(v as String)`;
2030
+ case "evidence":
2031
+ return `(v) => EvidenceRef.fromProjected((v as Map).cast<String, Object?>())`;
2032
+ // Collections never admit null (the law bans nullable on a set/map) — unreachable.
2033
+ case "set":
2034
+ case "map":
2035
+ return `(v) => v`;
2036
+ }
2037
+ }
2038
+
2039
+ /** Strip a trailing `?` so a nullable-typed base (`Object?`) becomes the slot's inner
2040
+ * non-null type (`NomosSlot<Object>` — the slot owns nullability via ExplicitNull). */
2041
+ function stripNullable(t: string): string {
2042
+ return t.endsWith("?") ? t.slice(0, -1) : t;
2043
+ }
2044
+
1915
2045
  // ---- generated read model class --------------------------------------------
1916
2046
  //
1917
2047
  // A typed read model class generated from aggregate DSL field declarations. It
@@ -2104,9 +2234,12 @@ function emitReadModel(
2104
2234
  ` /// The kernel aggregate id (the \`{type,id,data}\` row's \`id\`, e.g. the siteId).`,
2105
2235
  ` /// Empty string only when built from a bare \`data\` map with no row id (a unit test).`,
2106
2236
  ` final ${idType} aggregateId;`,
2107
- ...specs.map(
2108
- (s) => ` final ${readModelDartType(s)}${readModelNullableSuffix(s)} ${s.name};`,
2109
- ),
2237
+ ...specs.flatMap((s) => {
2238
+ const doc = s.nullReason !== undefined ? [` /// null semantic: ${s.nullReason}`] : [];
2239
+ return s.nullAdmits === true
2240
+ ? [...doc, ` final NomosSlot<${stripNullable(readModelDartType(s))}> ${s.name};`]
2241
+ : [` final ${readModelDartType(s)}${readModelNullableSuffix(s)} ${s.name};`];
2242
+ }),
2110
2243
  ...deriveds.map(
2111
2244
  (d) =>
2112
2245
  ` /// Engine-projected DERIVED read field (pure fn of the folded fields; NOT kernel state).\n final ${projectionReturnDartType(d.returns)} ${d.id};`,
@@ -2121,9 +2254,12 @@ function emitReadModel(
2121
2254
  ...specs.map((s) =>
2122
2255
  // A required field is `required`; an optional scalar, a collection, OR a
2123
2256
  // permissive json leaf (already nullable `Object?`) takes its default/null.
2124
- s.optional || isCollectionKind(s.kind) || isPermissiveJsonLeaf(s)
2125
- ? ` ${readModelDefaultParam(s)}`
2126
- : ` required this.${s.name},`,
2257
+ // A null-admitting slot defaults to Omitted.
2258
+ s.nullAdmits === true
2259
+ ? ` this.${s.name} = const NomosSlot.omitted(),`
2260
+ : s.optional || isCollectionKind(s.kind) || isPermissiveJsonLeaf(s)
2261
+ ? ` ${readModelDefaultParam(s)}`
2262
+ : ` required this.${s.name},`,
2127
2263
  ),
2128
2264
  // Derived/combined fields are always nullable (null until the engine pass runs).
2129
2265
  ...deriveds.map((d) => ` this.${d.id},`),
@@ -2132,7 +2268,11 @@ function emitReadModel(
2132
2268
 
2133
2269
  const parseLines = [
2134
2270
  ` aggregateId: ${aggregateIdFromRowExpr(agg.id, "id")},`,
2135
- ...specs.map((s) => ` ${s.name}: ${parseReadModelField(s, wireType)},`),
2271
+ ...specs.map((s) =>
2272
+ s.nullAdmits === true
2273
+ ? ` ${s.name}: NomosSlot.decode<${stripNullable(readModelDartType(s))}>(data, '${s.wireName}', ${slotDecodeCallback(s, "readModel")}),`
2274
+ : ` ${s.name}: ${parseReadModelField(s, wireType)},`,
2275
+ ),
2136
2276
  // Derived/combined fields decode from the engine-produced bare JSON leaf.
2137
2277
  ...deriveds.map((d) => ` ${d.id}: ${parseProjectionReturnField(d.id, d.returns)},`),
2138
2278
  ...combineds.map((c) => ` ${c.id}: ${parseProjectionReturnField(c.id, c.returns)},`),
@@ -2176,17 +2316,28 @@ function emitDirective(
2176
2316
  d: AnyDirective,
2177
2317
  domainName: string,
2178
2318
  enumDartTypes: Map<string, string>,
2319
+ lazyNames?: Map<z.ZodTypeAny, string>,
2179
2320
  ): string {
2180
2321
  const className = `${cap(d.id)}Payload`;
2181
- const specs = introspectPayload(d.payloadSchema as z.ZodTypeAny, enumDartTypes);
2322
+ const specs = introspectPayload(d.payloadSchema as z.ZodTypeAny, enumDartTypes, lazyNames);
2182
2323
 
2183
2324
  // Field decls: optional payload fields are nullable Dart fields (omitted from the
2184
2325
  // payload JSON when null); required fields are non-null + `required` in the ctor.
2185
2326
  const fieldDecls = specs
2186
- .map((s) => ` final ${s.dartType}${s.optional ? "?" : ""} ${s.name};`)
2327
+ .map((s) =>
2328
+ s.nullAdmits === true
2329
+ ? ` final NomosSlot<${stripNullable(s.dartType)}> ${s.name};`
2330
+ : ` final ${s.dartType}${s.optional ? "?" : ""} ${s.name};`,
2331
+ )
2187
2332
  .join("\n");
2188
2333
  const ctorParams = specs
2189
- .map((s) => ` ${s.optional ? "" : "required "}this.${s.name},`)
2334
+ .map((s) =>
2335
+ // A null-admitting slot defaults to Omitted (the call site chooses Present/ExplicitNull);
2336
+ // an optional field defaults to null; a required field is `required`.
2337
+ s.nullAdmits === true
2338
+ ? ` this.${s.name} = const NomosSlot.omitted(),`
2339
+ : ` ${s.optional ? "" : "required "}this.${s.name},`,
2340
+ )
2190
2341
  .join("\n");
2191
2342
  // Zero-field payload (e.g. a `.births()` sanctioning directive with an empty
2192
2343
  // object schema): `const X({});` / a dangling blank field block is INVALID Dart —
@@ -2201,14 +2352,21 @@ function emitDirective(
2201
2352
  // fields are written only when present (mirrors the engine's Zod: ops are emitted
2202
2353
  // only for present fields).
2203
2354
  const requiredEntries = specs
2204
- .filter((s) => !s.optional)
2355
+ .filter((s) => !s.optional && s.nullAdmits !== true)
2205
2356
  .map((s) => ` '${s.name}': ${toJsonExpr(s, s.name)},`);
2206
2357
  const optionalLines = specs
2207
- .filter((s) => s.optional)
2358
+ .filter((s) => s.optional && s.nullAdmits !== true)
2208
2359
  .map(
2209
2360
  (s) =>
2210
2361
  ` if (${s.name} != null) '${s.name}': ${toJsonExpr(s, `${s.name}!`)},`,
2211
2362
  );
2363
+ // NULL SEMANTICS: a null-admitting field writes through its slot — Omitted writes nothing,
2364
+ // ExplicitNull writes a real `null`, Present writes the encoded value (null_semantics.md).
2365
+ const slotSpecs = specs.filter((s) => s.nullAdmits === true);
2366
+ const slotWriteLines = slotSpecs.map(
2367
+ (s) => ` ${s.name}.writeTo(data, '${s.name}', (v) => ${toJsonExpr(s, "v")});`,
2368
+ );
2369
+ const hasSlots = slotSpecs.length > 0;
2212
2370
 
2213
2371
  const docFirst = directiveDoc(d);
2214
2372
 
@@ -2242,12 +2400,25 @@ function emitDirective(
2242
2400
  ``,
2243
2401
  ` /// Serialise this payload to the intent DATA JSON (the shape the`,
2244
2402
  ` /// domain Zod schema validates inside the engine). Optional fields are`,
2245
- ` /// omitted when null.`,
2403
+ ` /// omitted when null; a null-admitting field writes through its tri-state slot.`,
2246
2404
  ` @override`,
2247
- ` Map<String, Object?> toPayloadJson() => <String, Object?>{`,
2248
- ...requiredEntries,
2249
- ...optionalLines,
2250
- ` };`,
2405
+ ...(hasSlots
2406
+ ? [
2407
+ ` Map<String, Object?> toPayloadJson() {`,
2408
+ ` final data = <String, Object?>{`,
2409
+ ...requiredEntries,
2410
+ ...optionalLines,
2411
+ ` };`,
2412
+ ...slotWriteLines,
2413
+ ` return data;`,
2414
+ ` }`,
2415
+ ]
2416
+ : [
2417
+ ` Map<String, Object?> toPayloadJson() => <String, Object?>{`,
2418
+ ...requiredEntries,
2419
+ ...optionalLines,
2420
+ ` };`,
2421
+ ]),
2251
2422
  `}`,
2252
2423
  ].join("\n");
2253
2424
  }
@@ -2270,6 +2441,159 @@ export function toJsonExpr(spec: PayloadFieldSpec, accessor: string): string {
2270
2441
  }
2271
2442
  }
2272
2443
 
2444
+ // ── RECURSIVE `z.lazy` payloads → synthesized Dart classes (full support) ─────────
2445
+ //
2446
+ // A self-referencing payload schema (`TreeNode = z.lazy(() => z.object({ label, children:
2447
+ // z.array(TreeNode).optional() }))`) is FULLY typed in Dart, not degraded: each lazy KNOT
2448
+ // becomes a real recursive `class` reusing the value-object class emitter — its `children`
2449
+ // field types as `List<SaveTreeRoot>?`, i.e. itself. We synthesize a `ValueObjectDescriptor`
2450
+ // per knot (a `{type:"object", ref:<self>}` field is the recursion, which the VO emitter
2451
+ // renders by NAME, so emission never loops) and route the payload field to it.
2452
+ //
2453
+ // A knot whose shape is NOT expressible as a VO field set (e.g. a lazy of a union, or an
2454
+ // interior undeclared nested object) is the ONE last-resort degrade: it drops to `Object?`
2455
+ // (opaque JSON) and its path is reported. The common tree/JSON recursion never degrades.
2456
+
2457
+ export interface SynthesizedRecursiveTypes {
2458
+ /** lazy zod identity → generated Dart class name (SUCCESSFULLY synthesized knots only). */
2459
+ readonly names: Map<z.ZodTypeAny, string>;
2460
+ /** generated recursive-class descriptors, keyed by class name (fed to [emitValueObjectClass]). */
2461
+ readonly descriptors: Map<string, ValueObjectDescriptor>;
2462
+ /** knot paths that could NOT be represented as a class (degraded to `Object?`) — reported. */
2463
+ readonly degraded: string[];
2464
+ }
2465
+
2466
+ /** Synthesize the recursive-payload Dart classes for a module's directives (see the block header). */
2467
+ export function synthesizeRecursivePayloadTypes(
2468
+ directives: readonly { payloadSchema?: unknown; id?: string }[],
2469
+ ): SynthesizedRecursiveTypes {
2470
+ const names = new Map<z.ZodTypeAny, string>();
2471
+ const descriptors = new Map<string, ValueObjectDescriptor>();
2472
+ const used = new Set<string>();
2473
+ const degraded: string[] = [];
2474
+
2475
+ const mint = (path: string): string => {
2476
+ const base = recursiveTypeBaseName(path);
2477
+ let n = base;
2478
+ for (let i = 2; used.has(n); i++) n = `${base}${i}`;
2479
+ used.add(n);
2480
+ return n;
2481
+ };
2482
+
2483
+ // ONE zod field inside a knot → its VO field descriptor. THROWS for a shape the VO field set
2484
+ // can't express (→ the enclosing knot degrades). A nested lazy recurses into [buildKnot].
2485
+ const fieldOf = (zt: z.ZodTypeAny, path: string): ValueObjectFieldDescriptor => {
2486
+ if (zodTypeName(zt) === "ZodOptional" || zodTypeName(zt) === "ZodDefault") {
2487
+ return { ...fieldOf(zodInnerType(zt), path), optional: true };
2488
+ }
2489
+ if (zodTypeName(zt) === "ZodLazy") {
2490
+ const ref = buildKnot(zt, path);
2491
+ if (ref === undefined) throw new Error("recursive knot degraded");
2492
+ return { type: "object", ref };
2493
+ }
2494
+ const vo = voMetaOf(zt);
2495
+ if (vo !== undefined) {
2496
+ return vo.geometry !== undefined
2497
+ ? { type: "geometry", ref: vo.name, geometry: vo.geometry }
2498
+ : { type: "object", ref: vo.name };
2499
+ }
2500
+ switch (zodTypeName(zt)) {
2501
+ case "ZodString": {
2502
+ const b = brandedIdMetaOf(zt);
2503
+ if (b !== undefined) return { type: "string", brand: b };
2504
+ const idAgg = idOfMetaOf(zt);
2505
+ if (idAgg !== undefined) return { type: "string", brand: `${idAgg}Id` };
2506
+ return { type: "string" };
2507
+ }
2508
+ case "ZodNumber": {
2509
+ const unit = (zt as unknown as Record<symbol, unknown>)[NOMOS_UNIT_BRAND];
2510
+ return {
2511
+ type: "number",
2512
+ ...(zodNumberIsInt(zt) ? { int: true as const } : {}),
2513
+ ...(typeof unit === "string" ? { unit } : {}),
2514
+ };
2515
+ }
2516
+ case "ZodBoolean":
2517
+ return { type: "boolean" };
2518
+ case "ZodEnum":
2519
+ return { type: "enum", values: zodEnumValues(zt) };
2520
+ case "ZodLiteral": {
2521
+ const v = zodLiteralValue(zt);
2522
+ if (typeof v !== "string") throw new Error("non-string literal");
2523
+ return { type: "literal", value: v };
2524
+ }
2525
+ case "ZodArray": {
2526
+ const items = fieldOf(zodArrayElement(zt), `${path}[]`);
2527
+ if (items.optional === true) throw new Error("array of optionals");
2528
+ return { type: "array", items };
2529
+ }
2530
+ case "ZodRecord": {
2531
+ const vt = zodRecordValueType(zt);
2532
+ if (vt === undefined) throw new Error("record without value type");
2533
+ const values = fieldOf(vt, `${path}.*`);
2534
+ if (values.optional === true) throw new Error("record of optionals");
2535
+ return { type: "map", values };
2536
+ }
2537
+ default:
2538
+ throw new Error(`unsupported field shape '${zodTypeName(zt)}'`);
2539
+ }
2540
+ };
2541
+
2542
+ // Build (once) the class descriptor for a lazy knot. Returns its class name, or undefined if
2543
+ // the knot degrades. Registers the name BEFORE recursing so a self-reference resolves cleanly.
2544
+ const buildKnot = (lazyZt: z.ZodTypeAny, path: string): string | undefined => {
2545
+ const existing = names.get(lazyZt);
2546
+ if (existing !== undefined) return existing;
2547
+ const name = mint(path);
2548
+ names.set(lazyZt, name);
2549
+ const obj = zodUnwrapLazy(lazyZt);
2550
+ if (zodTypeName(obj) !== "ZodObject") {
2551
+ names.delete(lazyZt);
2552
+ degraded.push(path);
2553
+ return undefined;
2554
+ }
2555
+ const fields: Record<string, ValueObjectFieldDescriptor> = {};
2556
+ try {
2557
+ const shape = zodObjectShape(obj);
2558
+ for (const key of Object.keys(shape).sort()) fields[key] = fieldOf(shape[key]!, `${path}.${key}`);
2559
+ } catch {
2560
+ names.delete(lazyZt);
2561
+ degraded.push(path);
2562
+ return undefined;
2563
+ }
2564
+ descriptors.set(name, { name, fields });
2565
+ return name;
2566
+ };
2567
+
2568
+ // Find the outermost lazy knots reachable from each payload field (through optional/array).
2569
+ const findKnots = (zt: z.ZodTypeAny, path: string): void => {
2570
+ switch (zodTypeName(zt)) {
2571
+ case "ZodLazy":
2572
+ buildKnot(zt, path);
2573
+ return;
2574
+ case "ZodOptional":
2575
+ case "ZodDefault":
2576
+ findKnots(zodInnerType(zt), path);
2577
+ return;
2578
+ case "ZodArray":
2579
+ findKnots(zodArrayElement(zt), `${path}[]`);
2580
+ return;
2581
+ default:
2582
+ return;
2583
+ }
2584
+ };
2585
+
2586
+ for (const d of directives) {
2587
+ const schema = d?.payloadSchema as z.ZodTypeAny | undefined;
2588
+ if (!schema || typeof schema !== "object" || zodTypeName(schema) !== "ZodObject") continue;
2589
+ const dirId = d.id ?? "";
2590
+ for (const [key, raw] of Object.entries(zodObjectShape(schema))) {
2591
+ findKnots(raw as z.ZodTypeAny, `${dirId}.${key}`);
2592
+ }
2593
+ }
2594
+ return { names, descriptors, degraded };
2595
+ }
2596
+
2273
2597
  // ---- read accessors (typed subscriptions) ----------------------------------
2274
2598
  //
2275
2599
  // The READ half of the peer query surface (Jack 2026-06-18): the app reads ONLY via
@@ -3314,7 +3638,14 @@ export function generateDartDomain(mod: DomainModule, opts: GenerateDartDomainOp
3314
3638
  // the aggregate-derived set. Codegen-only: the IR key stays aggregate-derived (era rule).
3315
3639
  const aggVoNames = new Set((mod.valueObjects ?? []).map((d) => d.name));
3316
3640
  const payloadVos = collectPayloadValueObjectDescriptors(mod.directives ?? []).filter((d) => !aggVoNames.has(d.name));
3317
- const localValueObjects = [...(mod.valueObjects ?? []), ...payloadVos]
3641
+ // RECURSIVE `z.lazy` payloads → synthesized recursive-class descriptors (full support). They
3642
+ // are codegen-only (like payloadVos): the law IR / domainHash never sees them (era rule). The
3643
+ // name map routes each recursive payload field to its class in [emitDirective].
3644
+ const recursiveTypes = synthesizeRecursivePayloadTypes(mod.directives ?? []);
3645
+ const recursiveDescriptors = [...recursiveTypes.descriptors.values()].filter(
3646
+ (d) => !aggVoNames.has(d.name) && !payloadVos.some((p) => p.name === d.name),
3647
+ );
3648
+ const localValueObjects = [...(mod.valueObjects ?? []), ...payloadVos, ...recursiveDescriptors]
3318
3649
  .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
3319
3650
  .filter((d) => !skipValueObjectNames.has(d.name));
3320
3651
  // Each VO's block = its FIELD ENUMS (real Dart enums, tolerant fromWire) then its class —
@@ -3389,6 +3720,13 @@ export function generateDartDomain(mod: DomainModule, opts: GenerateDartDomainOp
3389
3720
  ].join("\n\n");
3390
3721
  // The symbols whose Dart surface this file EMITS (framework-shared ones live in the shared file).
3391
3722
  const localAggregates = mod.aggregates.filter((a) => !skipAggregateIds.has(a.id));
3723
+ // A nullable plain-`t.json()` read-model field's slot decode re-stringifies a parsed container via
3724
+ // `jsonEncode` — the ONE case the generated file needs `dart:convert` (null_semantics.md).
3725
+ const needsJsonConvert = localAggregates.some((a) =>
3726
+ Object.values(a.fields as Record<string, Field>).some(
3727
+ (f) => admitsNull(f) && f.kind === "json" && f.valueObject === undefined,
3728
+ ),
3729
+ );
3392
3730
  const localDirectives = mod.directives.filter((d) => !skipDirectiveIds.has(d.id));
3393
3731
  const localQueries = (mod.queries ?? []).filter((q) => !skipQueryIds.has(q.id));
3394
3732
  // Enums are collected from the LOCALLY-emitted symbols only — a framework directive/aggregate's
@@ -3421,7 +3759,7 @@ export function generateDartDomain(mod: DomainModule, opts: GenerateDartDomainOp
3421
3759
  // the runtime aggregate-meta registry (lookup by op.aggregateType — generic surfaces).
3422
3760
  const aggregateMetaBlock = emitAggregateMetaRegistry(domainKey, localAggregates);
3423
3761
  const dirBlock = localDirectives
3424
- .map((d) => emitDirective(d, domainKey, dartTypes))
3762
+ .map((d) => emitDirective(d, domainKey, dartTypes, recursiveTypes.names))
3425
3763
  .join("\n\n");
3426
3764
 
3427
3765
  // TYPED IDS + MINTED CREATES (the id-mint keystone, GENERALIZED): a DISTINCT typed
@@ -3596,6 +3934,8 @@ export function generateDartDomain(mod: DomainModule, opts: GenerateDartDomainOp
3596
3934
  `// ignore_for_file: unnecessary_this, prefer_const_constructors, constant_identifier_names`,
3597
3935
  `// ignore_for_file: unused_import`,
3598
3936
  ``,
3937
+ // A nullable plain-`t.json()` read-model slot decode re-stringifies via `jsonEncode`.
3938
+ ...(needsJsonConvert ? [`import 'dart:convert';`] : []),
3599
3939
  // The generated tenant domains live in the `co2_nomos_domains` package and depend
3600
3940
  // on the framework `nomos_client` package by its PUBLIC package: URI — NOT a relative
3601
3941
  // `../wire.dart` path into the framework (which broke when the generated dir moved
@@ -38,6 +38,8 @@ import { hasManyIndexes } from "./query.js";
38
38
  import { finishCount } from "./count.js";
39
39
  import { finishSum } from "./sum.js";
40
40
  import { finishExtremum } from "./extremum.js";
41
+ import { nullDocLine } from "./null_meta.js";
42
+ import type { Field } from "./fields.js";
41
43
 
42
44
  type AnyDirective = Directive<unknown>;
43
45
 
@@ -56,13 +58,30 @@ export interface OpenApiFragmentOptions {
56
58
  }
57
59
 
58
60
  /** Lower a directive's payload zod to JSON Schema; a non-representable schema falls back to a
59
- * permissive object rather than failing the whole compile (the engine still validates the real zod). */
60
- function payloadJsonSchema(d: AnyDirective): unknown {
61
+ * permissive object rather than failing the whole compile (the engine still validates the real zod).
62
+ * NULL SEMANTICS (null_semantics.md): a payload property that maps by name to a null-admitting field
63
+ * on the directive's target aggregate carries that field's documented `reason` as its `description`,
64
+ * so every consumer of the spec knows what a `null` MEANS here. */
65
+ function payloadJsonSchema(d: AnyDirective, module: DomainModule): unknown {
66
+ let schema: unknown;
61
67
  try {
62
- return z.toJSONSchema((d as { payloadSchema: z.ZodTypeAny }).payloadSchema, { io: "input" });
68
+ schema = z.toJSONSchema((d as { payloadSchema: z.ZodTypeAny }).payloadSchema, { io: "input" });
63
69
  } catch {
64
70
  return { type: "object", additionalProperties: true };
65
71
  }
72
+ const aggId = (d as { aggregateId?: string }).aggregateId;
73
+ const agg = aggId !== undefined ? module.aggregates.find((a) => a.id === aggId) : undefined;
74
+ const props = (schema as { properties?: Record<string, Record<string, unknown>> })?.properties;
75
+ if (agg !== undefined && props !== undefined && typeof props === "object") {
76
+ for (const [name, prop] of Object.entries(props)) {
77
+ const field = (agg.fields as Record<string, Field> | undefined)?.[name];
78
+ const doc = field !== undefined ? nullDocLine(field) : undefined;
79
+ if (doc !== undefined && prop !== null && typeof prop === "object") {
80
+ prop.description = prop.description !== undefined ? `${String(prop.description)} — ${doc}` : doc;
81
+ }
82
+ }
83
+ }
84
+ return schema;
66
85
  }
67
86
 
68
87
  /** The domain key the engine resolves intents against (the `author` registry key). */
@@ -111,7 +130,7 @@ export function generateOpenApiFragment(
111
130
  },
112
131
  requestBody: {
113
132
  required: true,
114
- content: { "application/json": { schema: payloadJsonSchema(dir) } },
133
+ content: { "application/json": { schema: payloadJsonSchema(dir, module) } },
115
134
  },
116
135
  responses: {
117
136
  "200": { description: "admitted — the sealed intent head" },