@githolon/dsl 0.54.2 → 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.
@@ -35,6 +35,7 @@ import type { AggregateHandle } from "./aggregate.js";
35
35
  import type { WorkspaceInvariantDecl } from "./framework/workspace_invariant.js";
36
36
  import type { RelationTypeDecl } from "./relation_schema.js";
37
37
  import type { Field, FieldKind, ValueObjectDescriptor, ValueObjectFieldDescriptor } from "./fields.js";
38
+ import { admitsNull, nullMetaOf, nullDocLine } from "./null_meta.js";
38
39
  import type { Directive } from "./directive.js";
39
40
  import { executeDirectiveToIntent } from "./wire_encode.js";
40
41
  import { deterministicPorts } from "./ctx.js";
@@ -81,6 +82,13 @@ export interface PayloadFieldSpec {
81
82
  * only emits ops for present fields). Required fields are always emitted.
82
83
  */
83
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;
84
92
  }
85
93
 
86
94
  type ZodInternalDef = {
@@ -239,11 +247,19 @@ export function introspectPayload(
239
247
  // when null — mirroring the engine's Zod, which only sees present fields).
240
248
  let zt = ztRaw as z.ZodTypeAny;
241
249
  let optional = false;
242
- while (isOptionalOrDefault(zt)) {
243
- 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;
244
256
  zt = zodInnerType(zt);
245
257
  }
246
- out.push({ ...introspectField(name, zt, enumDartTypes, lazyNames), optional });
258
+ out.push({
259
+ ...introspectField(name, zt, enumDartTypes, lazyNames),
260
+ optional,
261
+ ...(nullAdmits ? { nullAdmits: true } : {}),
262
+ });
247
263
  }
248
264
  return out;
249
265
  }
@@ -453,6 +469,15 @@ interface AggFieldSpec {
453
469
  * only the generated read-model type. `undefined` for a non-json field.
454
470
  */
455
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;
456
481
  }
457
482
 
458
483
  export interface DartImport {
@@ -805,11 +830,16 @@ function aggregateFieldSpecs(
805
830
  // BIRTH-PRESENCE LAW (`.fromBirth()`): the gate refuses any event that leaves the field
806
831
  // absent, so the read model may type it NON-NULLABLE — the one lawful exception to the
807
832
  // partial-fold nullability doctrine (absence is unrepresentable by law, not convention).
808
- const spec: AggFieldSpec = isCollectionKind(base.kind)
833
+ const baseSpec: AggFieldSpec = isCollectionKind(base.kind)
809
834
  ? base
810
835
  : f.isFromBirth === true
811
836
  ? { ...base, optional: false }
812
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;
813
843
  if (used.has(spec.name)) {
814
844
  throw new Error(
815
845
  `aggregate '${agg.id}' field '${spec.wireName}' maps to duplicate Dart field '${spec.name}'`,
@@ -1134,19 +1164,27 @@ function emitAggregate(
1134
1164
  ` /// indexed/by-id accessor matched on (e.g. the siteId). Empty string only when`,
1135
1165
  ` /// the aggregate is built from a bare \`data\` map with no row id (e.g. a unit test).`,
1136
1166
  ` final ${idType} aggregateId;`,
1137
- ...specs.map(
1138
- (s) => ` final ${s.dartType}${s.optional ? "?" : ""} ${s.name};`,
1139
- ),
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
+ }),
1140
1175
  ].join("\n");
1141
1176
  const ctorParams = [
1142
1177
  ` this.aggregateId = const ${idType}.fromRow(''),`,
1143
1178
  ...specs.map(
1144
1179
  // A required scalar stays `required`; an optional scalar OR a collection is not
1145
1180
  // `required` (an absent optional scalar is null; an absent collection is empty).
1181
+ // A null-admitting slot defaults to Omitted.
1146
1182
  (s) =>
1147
- s.optional || isCollectionKind(s.kind)
1148
- ? ` ${collectionDefaultParam(s)}`
1149
- : ` 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},`,
1150
1188
  ),
1151
1189
  ].join("\n");
1152
1190
 
@@ -1158,7 +1196,11 @@ function emitAggregate(
1158
1196
  // threaded from the row id (GAP 2) — defaulting to '' for a bare-map call.
1159
1197
  const parseLines = [
1160
1198
  ` aggregateId: ${aggregateIdFromRowExpr(agg.id, "id")},`,
1161
- ...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
+ ),
1162
1204
  ].join("\n");
1163
1205
 
1164
1206
  // `==`/`hashCode` include the id (two rows with the same data but different ids are
@@ -1953,6 +1995,53 @@ function parseField(s: AggFieldSpec, aggregate: string): string {
1953
1995
  }
1954
1996
  }
1955
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
+
1956
2045
  // ---- generated read model class --------------------------------------------
1957
2046
  //
1958
2047
  // A typed read model class generated from aggregate DSL field declarations. It
@@ -2145,9 +2234,12 @@ function emitReadModel(
2145
2234
  ` /// The kernel aggregate id (the \`{type,id,data}\` row's \`id\`, e.g. the siteId).`,
2146
2235
  ` /// Empty string only when built from a bare \`data\` map with no row id (a unit test).`,
2147
2236
  ` final ${idType} aggregateId;`,
2148
- ...specs.map(
2149
- (s) => ` final ${readModelDartType(s)}${readModelNullableSuffix(s)} ${s.name};`,
2150
- ),
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
+ }),
2151
2243
  ...deriveds.map(
2152
2244
  (d) =>
2153
2245
  ` /// Engine-projected DERIVED read field (pure fn of the folded fields; NOT kernel state).\n final ${projectionReturnDartType(d.returns)} ${d.id};`,
@@ -2162,9 +2254,12 @@ function emitReadModel(
2162
2254
  ...specs.map((s) =>
2163
2255
  // A required field is `required`; an optional scalar, a collection, OR a
2164
2256
  // permissive json leaf (already nullable `Object?`) takes its default/null.
2165
- s.optional || isCollectionKind(s.kind) || isPermissiveJsonLeaf(s)
2166
- ? ` ${readModelDefaultParam(s)}`
2167
- : ` 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},`,
2168
2263
  ),
2169
2264
  // Derived/combined fields are always nullable (null until the engine pass runs).
2170
2265
  ...deriveds.map((d) => ` this.${d.id},`),
@@ -2173,7 +2268,11 @@ function emitReadModel(
2173
2268
 
2174
2269
  const parseLines = [
2175
2270
  ` aggregateId: ${aggregateIdFromRowExpr(agg.id, "id")},`,
2176
- ...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
+ ),
2177
2276
  // Derived/combined fields decode from the engine-produced bare JSON leaf.
2178
2277
  ...deriveds.map((d) => ` ${d.id}: ${parseProjectionReturnField(d.id, d.returns)},`),
2179
2278
  ...combineds.map((c) => ` ${c.id}: ${parseProjectionReturnField(c.id, c.returns)},`),
@@ -2225,10 +2324,20 @@ function emitDirective(
2225
2324
  // Field decls: optional payload fields are nullable Dart fields (omitted from the
2226
2325
  // payload JSON when null); required fields are non-null + `required` in the ctor.
2227
2326
  const fieldDecls = specs
2228
- .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
+ )
2229
2332
  .join("\n");
2230
2333
  const ctorParams = specs
2231
- .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
+ )
2232
2341
  .join("\n");
2233
2342
  // Zero-field payload (e.g. a `.births()` sanctioning directive with an empty
2234
2343
  // object schema): `const X({});` / a dangling blank field block is INVALID Dart —
@@ -2243,14 +2352,21 @@ function emitDirective(
2243
2352
  // fields are written only when present (mirrors the engine's Zod: ops are emitted
2244
2353
  // only for present fields).
2245
2354
  const requiredEntries = specs
2246
- .filter((s) => !s.optional)
2355
+ .filter((s) => !s.optional && s.nullAdmits !== true)
2247
2356
  .map((s) => ` '${s.name}': ${toJsonExpr(s, s.name)},`);
2248
2357
  const optionalLines = specs
2249
- .filter((s) => s.optional)
2358
+ .filter((s) => s.optional && s.nullAdmits !== true)
2250
2359
  .map(
2251
2360
  (s) =>
2252
2361
  ` if (${s.name} != null) '${s.name}': ${toJsonExpr(s, `${s.name}!`)},`,
2253
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;
2254
2370
 
2255
2371
  const docFirst = directiveDoc(d);
2256
2372
 
@@ -2284,12 +2400,25 @@ function emitDirective(
2284
2400
  ``,
2285
2401
  ` /// Serialise this payload to the intent DATA JSON (the shape the`,
2286
2402
  ` /// domain Zod schema validates inside the engine). Optional fields are`,
2287
- ` /// omitted when null.`,
2403
+ ` /// omitted when null; a null-admitting field writes through its tri-state slot.`,
2288
2404
  ` @override`,
2289
- ` Map<String, Object?> toPayloadJson() => <String, Object?>{`,
2290
- ...requiredEntries,
2291
- ...optionalLines,
2292
- ` };`,
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
+ ]),
2293
2422
  `}`,
2294
2423
  ].join("\n");
2295
2424
  }
@@ -3591,6 +3720,13 @@ export function generateDartDomain(mod: DomainModule, opts: GenerateDartDomainOp
3591
3720
  ].join("\n\n");
3592
3721
  // The symbols whose Dart surface this file EMITS (framework-shared ones live in the shared file).
3593
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
+ );
3594
3730
  const localDirectives = mod.directives.filter((d) => !skipDirectiveIds.has(d.id));
3595
3731
  const localQueries = (mod.queries ?? []).filter((q) => !skipQueryIds.has(q.id));
3596
3732
  // Enums are collected from the LOCALLY-emitted symbols only — a framework directive/aggregate's
@@ -3798,6 +3934,8 @@ export function generateDartDomain(mod: DomainModule, opts: GenerateDartDomainOp
3798
3934
  `// ignore_for_file: unnecessary_this, prefer_const_constructors, constant_identifier_names`,
3799
3935
  `// ignore_for_file: unused_import`,
3800
3936
  ``,
3937
+ // A nullable plain-`t.json()` read-model slot decode re-stringifies via `jsonEncode`.
3938
+ ...(needsJsonConvert ? [`import 'dart:convert';`] : []),
3801
3939
  // The generated tenant domains live in the `co2_nomos_domains` package and depend
3802
3940
  // on the framework `nomos_client` package by its PUBLIC package: URI — NOT a relative
3803
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" },
@@ -66,6 +66,27 @@ import {
66
66
  /** Shared empty visited-set for the lazy-recursion guard (no allocation on the common path). */
67
67
  const EMPTY_LAZY_VISITED: ReadonlySet<z.ZodTypeAny> = new Set();
68
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
+
69
90
  const camel = (s: string) => s.replace(/[_\s-]+(\w)/g, (_m, c: string) => c.toUpperCase());
70
91
  const lcFirst = (s: string) => (s.length ? s[0]!.toLowerCase() + s.slice(1) : s);
71
92
 
@@ -2620,6 +2641,25 @@ export function generateTsProof(modules: readonly DomainModule[], opts: TsProofO
2620
2641
  );
2621
2642
  }
2622
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
+
2623
2663
  out.push(
2624
2664
  `globalThis.fetch = realFetch;`,
2625
2665
  `ok("offline proof ENFORCED — fetch was trapped: local write/read touched no network");`,
package/src/codegen_ts.ts CHANGED
@@ -50,6 +50,7 @@ import type { DerivedDecl } from "./derived.js";
50
50
  import type { CombinedDecl } from "./combined.js";
51
51
  import { resolveWorkspaceTypes, type ResolvedWorkspaceType, type WorkspaceTypeDecl } from "./workspace_type.js";
52
52
  import { deriveClientMintPlan, type ClientMintPlanEntry } from "./workspace_routing.js";
53
+ import { admitsNull, nullDocLine } from "./null_meta.js";
53
54
 
54
55
  const cap = (s: string) => (s.length ? s[0]!.toUpperCase() + s.slice(1) : s);
55
56
  const camel = (s: string) => s.replace(/[_\s-]+(\w)/g, (_m, c: string) => c.toUpperCase());
@@ -468,6 +469,14 @@ function tsTypeOfPayload(
468
469
  // ── DSL Field → read-model TS type (the projection's decode surface) ──────────────
469
470
 
470
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 {
471
480
  switch (field.kind) {
472
481
  case "string":
473
482
  return "string";
@@ -1140,6 +1149,8 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
1140
1149
  out.push(`export interface ${typeId}Data {`);
1141
1150
  for (const [name, field] of Object.entries(agg.fields)) {
1142
1151
  const required = (field as Field).isFromBirth === true;
1152
+ const doc = nullDocLine(field);
1153
+ if (doc !== undefined) out.push(` /** ${doc} */`);
1143
1154
  out.push(` ${name}${required ? "" : "?"}: ${tsTypeOfField(field as Field)};`);
1144
1155
  }
1145
1156
  for (const extra of extrasByType.get(typeId) ?? []) out.push(extra);
@@ -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",