@githolon/dsl 0.38.0 → 0.40.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.38.0",
3
+ "version": "0.40.0",
4
4
  "type": "module",
5
5
  "description": "Nomos 2 domain-authoring DSL: aggregates + directives in TS, executed and encoded to the Rust kernel's wire shapes.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -29,6 +29,7 @@
29
29
  * generated, so they stay reproducible from the domain definitions.
30
30
  */
31
31
  import { z } from "zod";
32
+ import { voMetaOf, collectPayloadValueObjectDescriptors, idOfMetaOf, NOMOS_UNIT_BRAND } from "./value_object.js";
32
33
  import type { AggregateHandle } from "./aggregate.js";
33
34
  import type { WorkspaceInvariantDecl } from "./framework/workspace_invariant.js";
34
35
  import type { RelationTypeDecl } from "./relation_schema.js";
@@ -60,7 +61,10 @@ export type ToJsonWrap =
60
61
  | "scalar" // String/int/bool — emit the field directly.
61
62
  | "enum" // generated enum — emit `.wire`.
62
63
  | "strList" // List<String> — emit directly.
63
- | "json"; // nested object — emit the field's `Map<String, Object?>` directly.
64
+ | "json" // nested object — emit the field's `Map<String, Object?>` directly.
65
+ | "vo" // a declared value object — emit `.toJson()` (the generated VO class).
66
+ | "voList" // a list of value objects — emit `.map((e) => e.toJson()).toList()`.
67
+ | "wrapped"; // a typed-id class or unit extension type — emit `.value` (the wire scalar).
64
68
 
65
69
  export interface PayloadFieldSpec {
66
70
  name: string;
@@ -236,10 +240,25 @@ function introspectField(
236
240
  zt: z.ZodTypeAny,
237
241
  enumDartTypes: Map<string, string>,
238
242
  ): Omit<PayloadFieldSpec, "optional"> {
243
+ // A DECLARED VO/union keeps its name regardless of its zod kind (a union(...) is a
244
+ // z.discriminatedUnion, not a ZodObject — without this it would fall to the throw lane).
245
+ {
246
+ const vo = voMetaOf(zt);
247
+ if (vo !== undefined) {
248
+ return { name, dartType: vo.name, toJson: "vo" };
249
+ }
250
+ }
239
251
  const tn = zodTypeName(zt);
240
252
  switch (tn) {
241
- case "ZodString":
253
+ case "ZodString": {
254
+ // idOf(Agg) (dart_codegen_parity.md P2): wire-identical to z.string(), but the Dart
255
+ // field is the aggregate's TYPED id class — cross-aggregate id mixups fail to compile.
256
+ const idAgg = idOfMetaOf(zt);
257
+ if (idAgg !== undefined) {
258
+ return { name, dartType: typedIdClassForAggregateType(idAgg), toJson: "wrapped" };
259
+ }
242
260
  return { name, dartType: "String", toJson: "scalar" };
261
+ }
243
262
  case "ZodNumber":
244
263
  // A `z.number()` is a FRACTIONAL double in JS (e.g. catalogues `createListing`
245
264
  // scaleX/scaleY = 0.5) UNLESS the schema adds the `.int()` check. Only an
@@ -247,6 +266,12 @@ function introspectField(
247
266
  // emits `double` so a fractional value (0.5/0.75) is NOT truncated to 0 at the
248
267
  // call site. Detect the `.int()` check on the Zod number's `_def.checks`
249
268
  // (`{kind:"int"}`), exactly the runtime constraint the engine's Zod validates.
269
+ {
270
+ const unitName = (zt as unknown as Record<symbol, unknown>)[NOMOS_UNIT_BRAND];
271
+ if (typeof unitName === "string") {
272
+ return { name, dartType: unitName, toJson: "wrapped" };
273
+ }
274
+ }
250
275
  return {
251
276
  name,
252
277
  dartType: zodNumberIsInt(zt) ? "int" : "double",
@@ -262,19 +287,31 @@ function introspectField(
262
287
  }
263
288
  case "ZodArray": {
264
289
  const elem = zodArrayElement(zt);
265
- // A `string[]` is a typed `List<String>`. ANY other element type (e.g.
266
- // `z.array(z.object(...))` — proposals' replacement `edges`) is arbitrary
267
- // JSON DATA to the engine, modelled as `List<Object?>` and shipped verbatim.
290
+ // A `string[]` is a typed `List<String>`. A list of DECLARED VALUE OBJECTS is typed
291
+ // with the generated VO class (vo_authoring_end_to_end.md slice B). ANY other element
292
+ // type is arbitrary JSON DATA to the engine, modelled as `List<Object?>`.
268
293
  if (zodTypeName(elem) === "ZodString") {
269
294
  return { name, dartType: "List<String>", toJson: "strList" };
270
295
  }
296
+ const elemVo = voMetaOf(elem);
297
+ if (elemVo !== undefined) {
298
+ return { name, dartType: `List<${elemVo.name}>`, toJson: "voList" };
299
+ }
271
300
  return { name, dartType: "List<Object?>", toJson: "json" };
272
301
  }
273
- case "ZodObject":
302
+ case "ZodObject": {
303
+ // A DECLARED VALUE OBJECT keeps its name: the payload field is the generated VO class
304
+ // (typed author path — the same class the read model decodes to), serialised via its
305
+ // own `toJson()`. An anonymous `z.object(...)` stays the raw-map lane below.
306
+ const vo = voMetaOf(zt);
307
+ if (vo !== undefined) {
308
+ return { name, dartType: vo.name, toJson: "vo" };
309
+ }
274
310
  // Nested object payload (e.g. `target: z.object({targetType, targetId})`). The
275
311
  // engine receives it as DATA verbatim, so we model it as an arbitrary JSON map
276
312
  // — the call site builds the literal, and `toPayloadJson()` ships it through.
277
313
  return { name, dartType: "Map<String, Object?>", toJson: "json" };
314
+ }
278
315
  case "ZodRecord": {
279
316
  const valueType = zodRecordValueType(zt);
280
317
  const dartType =
@@ -1246,16 +1283,28 @@ function emitVoEnum(voName: string, fieldName: string, values: readonly string[]
1246
1283
 
1247
1284
  /** Every (fieldName, values) enum pair a VO descriptor carries (arrays recursed —
1248
1285
  * an array-of-enum field's element enum is named by the FIELD). */
1249
- function voEnumFields(d: ValueObjectDescriptor): { fieldName: string; values: readonly string[] }[] {
1250
- const out: { fieldName: string; values: readonly string[] }[] = [];
1251
- const walk = (fieldName: string, f: ValueObjectFieldDescriptor): void => {
1252
- if (f.type === "enum") out.push({ fieldName, values: f.values });
1253
- else if (f.type === "array") walk(fieldName, f.items);
1286
+ function voEnumFields(d: ValueObjectDescriptor): { fieldName: string; values: readonly string[]; owner?: string }[] {
1287
+ const out: { fieldName: string; values: readonly string[]; owner?: string }[] = [];
1288
+ const walk = (fieldName: string, f: ValueObjectFieldDescriptor, owner?: string): void => {
1289
+ if (f.type === "enum") out.push({ fieldName, values: f.values, ...(owner !== undefined ? { owner } : {}) });
1290
+ else if (f.type === "array") walk(fieldName, f.items, owner);
1254
1291
  };
1255
1292
  for (const [fieldName, f] of Object.entries(d.fields ?? {})) walk(fieldName, f);
1293
+ // union arms: enums are owned by the ARM CLASS (`<Union><ArmCap>`) so two arms' same-named
1294
+ // enum fields with different value sets never collide.
1295
+ for (const [key, arm] of Object.entries(d.union ?? {})) {
1296
+ if ("fields" in arm) {
1297
+ for (const [fieldName, f] of Object.entries(arm.fields)) walk(fieldName, f, unionArmClassName(d.name, key));
1298
+ }
1299
+ }
1256
1300
  return out;
1257
1301
  }
1258
1302
 
1303
+ /** The generated arm class name of one union arm (`Shape` + `circle` → `ShapeCircle`). */
1304
+ function unionArmClassName(unionName: string, armKey: string): string {
1305
+ return `${unionName}${armKey[0]!.toUpperCase()}${armKey.slice(1)}`;
1306
+ }
1307
+
1259
1308
  /** The Dart type of one VO descriptor field. `voName`/`fieldName` name the field's
1260
1309
  * generated enum type (`<VoName><FieldName>Enum`) — threaded through arrays. */
1261
1310
  function dartTypeOfVoField(f: ValueObjectFieldDescriptor, voName: string, fieldName: string): string {
@@ -1263,12 +1312,16 @@ function dartTypeOfVoField(f: ValueObjectFieldDescriptor, voName: string, fieldN
1263
1312
  case "string":
1264
1313
  return "String";
1265
1314
  case "number":
1266
- return "double";
1315
+ // a UNIT-tagged number is the generated zero-cost extension type; `.int()` is Dart int.
1316
+ if (f.unit !== undefined) return f.unit;
1317
+ return f.int === true ? "int" : "double";
1267
1318
  case "boolean":
1268
1319
  return "bool";
1269
1320
  case "enum":
1270
1321
  // A REAL generated Dart enum (`emitVoEnum`), named by the owning VO field.
1271
1322
  return voEnumName(voName, fieldName);
1323
+ case "literal":
1324
+ return "String"; // surfaced as a constant getter, not a ctor param
1272
1325
  case "array":
1273
1326
  return `List<${dartTypeOfVoField(f.items, voName, fieldName)}>`;
1274
1327
  case "object":
@@ -1293,7 +1346,10 @@ function voFromExpr(
1293
1346
  // TOLERANT typed decode — an unknown wire value becomes the sentinel case.
1294
1347
  return `${voEnumName(voName, fieldName)}.fromWire(${expr} as String)`;
1295
1348
  case "number":
1296
- return `(${expr} as num).toDouble()`;
1349
+ if (f.unit !== undefined) return `${f.unit}((${expr} as num).toDouble())`;
1350
+ return f.int === true ? `(${expr} as num).toInt()` : `(${expr} as num).toDouble()`;
1351
+ case "literal":
1352
+ return `${expr} as String`;
1297
1353
  case "boolean":
1298
1354
  return `${expr} as bool`;
1299
1355
  case "array": {
@@ -1309,9 +1365,11 @@ function voFromExpr(
1309
1365
  /** A Dart expression serializing the typed member `expr` back to plain json. */
1310
1366
  function voToJsonExpr(f: ValueObjectFieldDescriptor, expr: string, depth = 0): string {
1311
1367
  switch (f.type) {
1312
- case "string":
1313
1368
  case "number":
1369
+ return f.unit !== undefined ? `${expr}.value` : expr;
1370
+ case "string":
1314
1371
  case "boolean":
1372
+ case "literal":
1315
1373
  return expr;
1316
1374
  case "enum":
1317
1375
  return `${expr}.wire`; // the exact wire string the law's zod enum validates
@@ -1365,7 +1423,121 @@ function emitVoHelpers(): string {
1365
1423
  * `toJson` (the authored payload shape — optionals OMITTED when null, matching the zod
1366
1424
  * `.optional()` contract), and toJson-based value equality. A `geo.*` VO emits the
1367
1425
  * fixed RFC 7946 shape (typed `coordinates`; `type` is the constant geometry kind). */
1426
+ /** Emit a `union(...)` as a SEALED Dart hierarchy: the sealed base (decode switch on the
1427
+ * blessed `type` discriminator + abstract `toJson`), one final arm class per arm (anonymous
1428
+ * arms carry their fields; NAMED-VO arms hold a single `value` of the named class), and the
1429
+ * load-bearing `<Name>Unknown` arm: a client compiled under OLD law decodes a NEWER law's arm
1430
+ * into `Unknown` (raw json, round-trips on re-write) instead of crashing — which makes adding
1431
+ * an arm ADDITIVE evolution. Exhaustive `switch` over the sealed base forces call sites to
1432
+ * decide what Unknown means for them (the forward-compat prompt we WANT the type system to give). */
1433
+ function emitUnionClasses(d: ValueObjectDescriptor): string {
1434
+ const arms = Object.entries(d.union ?? {});
1435
+ const base = [
1436
+ `/// Union \`${d.name}\` — a sum type stored as ONE json leaf (wholesale-replace; the`,
1437
+ `/// \`type\` discriminator names the arm). Adding an arm is ADDITIVE (older clients decode`,
1438
+ `/// it as [${d.name}Unknown]); removing/renaming an arm is a retype (#58 dispositions).`,
1439
+ `sealed class ${d.name} {`,
1440
+ ` const ${d.name}();`,
1441
+ ` Map<String, Object?> toJson();`,
1442
+ ` factory ${d.name}.fromProjected(Map<String, Object?> m) => switch (m['type']) {`,
1443
+ ...arms.map(([key]) => ` '${key}' => ${unionArmClassName(d.name, key)}.fromProjected(m),`),
1444
+ ` _ => ${d.name}Unknown(m),`,
1445
+ ` };`,
1446
+ `}`,
1447
+ ].join("\n");
1448
+
1449
+ const armClasses = arms.map(([key, arm]) => {
1450
+ const cls = unionArmClassName(d.name, key);
1451
+ if ("ref" in arm) {
1452
+ return [
1453
+ `/// The \`${key}\` arm of [${d.name}] — holds the named value object [${arm.ref}]`,
1454
+ `/// (nested under \`value\` on the wire; the named VO stays the one home of its shape).`,
1455
+ `final class ${cls} extends ${d.name} {`,
1456
+ ` final ${arm.ref} value;`,
1457
+ ` const ${cls}(this.value);`,
1458
+ ` factory ${cls}.fromProjected(Map<String, Object?> m) =>`,
1459
+ ` ${cls}(${arm.ref}.fromProjected((m['value']! as Map).cast<String, Object?>()));`,
1460
+ ` @override`,
1461
+ ` Map<String, Object?> toJson() => <String, Object?>{'type': '${key}', 'value': value.toJson()};`,
1462
+ ` @override`,
1463
+ ` bool operator ==(Object other) => other is ${cls} && _voJsonEq(other.toJson(), toJson());`,
1464
+ ` @override`,
1465
+ ` int get hashCode => _voJsonHash(toJson());`,
1466
+ `}`,
1467
+ ].join("\n");
1468
+ }
1469
+ const armFields = Object.entries(arm.fields).map(([wireName, f]) => ({
1470
+ wireName,
1471
+ member: dartMemberName(wireName),
1472
+ f,
1473
+ optional: f.optional === true,
1474
+ }));
1475
+ const armLiterals = armFields.filter((x) => x.f.type === "literal");
1476
+ const fields = armFields.filter((x) => x.f.type !== "literal");
1477
+ const literalGetters = armLiterals.map((x) => ` String get ${x.member} => '${(x.f as { value: string }).value}';`);
1478
+ const literalToLines = armLiterals.map((x) => ` '${x.wireName}': '${(x.f as { value: string }).value}',`);
1479
+ const decls = [
1480
+ ...armFields.filter((x) => x.f.type !== "literal").map((x) => ` final ${dartTypeOfVoField(x.f, cls, x.wireName)}${x.optional ? "?" : ""} ${x.member};`),
1481
+ ...literalGetters,
1482
+ ];
1483
+ const ctor = fields.length === 0
1484
+ ? [` const ${cls}();`]
1485
+ : [` const ${cls}({`, ...fields.map((x) => (x.optional ? ` this.${x.member},` : ` required this.${x.member},`)), ` });`];
1486
+ const fromLines = fields.map((x) =>
1487
+ x.optional
1488
+ ? ` ${x.member}: m['${x.wireName}'] == null ? null : ${voFromExpr(x.f, `m['${x.wireName}']!`, cls, x.wireName)},`
1489
+ : ` ${x.member}: ${voFromExpr(x.f, `m['${x.wireName}']!`, cls, x.wireName)},`,
1490
+ );
1491
+ const toLines = fields.map((x) =>
1492
+ x.optional
1493
+ ? ` if (${x.member} != null) '${x.wireName}': ${voToJsonExpr(x.f, `${x.member}!`)},`
1494
+ : ` '${x.wireName}': ${voToJsonExpr(x.f, x.member)},`,
1495
+ );
1496
+ return [
1497
+ `/// The \`${key}\` arm of [${d.name}].`,
1498
+ `final class ${cls} extends ${d.name} {`,
1499
+ ...decls,
1500
+ ...ctor,
1501
+ ` factory ${cls}.fromProjected(Map<String, Object?> m) => ${cls}(`,
1502
+ ...fromLines,
1503
+ ` );`,
1504
+ ` @override`,
1505
+ ` Map<String, Object?> toJson() => <String, Object?>{`,
1506
+ ` 'type': '${key}',`,
1507
+ ...literalToLines,
1508
+ ...toLines,
1509
+ ` };`,
1510
+ ` @override`,
1511
+ ` bool operator ==(Object other) => other is ${cls} && _voJsonEq(other.toJson(), toJson());`,
1512
+ ` @override`,
1513
+ ` int get hashCode => _voJsonHash(toJson());`,
1514
+ `}`,
1515
+ ].join("\n");
1516
+ });
1517
+
1518
+ const unknown = [
1519
+ `/// FORWARD-COMPAT arm of [${d.name}]: a wire value whose \`type\` this client's law`,
1520
+ `/// version does not know (a NEWER law added an arm). Carries the raw json VERBATIM —`,
1521
+ `/// re-writing it round-trips unchanged. Handle it explicitly in every switch.`,
1522
+ `final class ${d.name}Unknown extends ${d.name} {`,
1523
+ ` final Map<String, Object?> raw;`,
1524
+ ` const ${d.name}Unknown(this.raw);`,
1525
+ ` /// The unrecognised discriminator value ('' when absent/malformed).`,
1526
+ ` String get type => (raw['type'] ?? '').toString();`,
1527
+ ` @override`,
1528
+ ` Map<String, Object?> toJson() => raw;`,
1529
+ ` @override`,
1530
+ ` bool operator ==(Object other) => other is ${d.name}Unknown && _voJsonEq(other.raw, raw);`,
1531
+ ` @override`,
1532
+ ` int get hashCode => _voJsonHash(raw);`,
1533
+ `}`,
1534
+ ].join("\n");
1535
+
1536
+ return [base, ...armClasses, unknown].join("\n\n");
1537
+ }
1538
+
1368
1539
  function emitValueObjectClass(d: ValueObjectDescriptor): string {
1540
+ if (d.union !== undefined) return emitUnionClasses(d);
1369
1541
  if (d.geometry !== undefined) {
1370
1542
  const coordsType = geometryCoordsDart[d.geometry]!;
1371
1543
  const coordsFrom: Record<string, string> = {
@@ -1401,12 +1573,21 @@ function emitValueObjectClass(d: ValueObjectDescriptor): string {
1401
1573
  `}`,
1402
1574
  ].join("\n");
1403
1575
  }
1404
- const fields = Object.entries(d.fields ?? {}).map(([wireName, f]) => ({
1576
+ const allFields = Object.entries(d.fields ?? {}).map(([wireName, f]) => ({
1405
1577
  wireName,
1406
1578
  member: dartMemberName(wireName),
1407
1579
  f,
1408
1580
  optional: f.optional === true,
1409
1581
  }));
1582
+ // `z.literal(...)` members are CONSTANTS: a getter (never a ctor param), always serialized.
1583
+ const literals = allFields.filter((x) => x.f.type === "literal");
1584
+ const fields = allFields.filter((x) => x.f.type !== "literal");
1585
+ const literalGetters = literals.map(
1586
+ (x) => ` String get ${x.member} => '${(x.f as { value: string }).value}';`,
1587
+ );
1588
+ const literalToLines = literals.map(
1589
+ (x) => ` '${x.wireName}': '${(x.f as { value: string }).value}',`,
1590
+ );
1410
1591
  const decls = fields.map(
1411
1592
  (x) => ` final ${dartTypeOfVoField(x.f, d.name, x.wireName)}${x.optional ? "?" : ""} ${x.member};`,
1412
1593
  );
@@ -1428,10 +1609,9 @@ function emitValueObjectClass(d: ValueObjectDescriptor): string {
1428
1609
  `/// zod \`.optional()\` contract — absent, never JSON null).`,
1429
1610
  `class ${d.name} {`,
1430
1611
  ...decls,
1612
+ ...literalGetters,
1431
1613
  ``,
1432
- ` const ${d.name}({`,
1433
- ...ctor,
1434
- ` });`,
1614
+ ...(fields.length === 0 ? [` const ${d.name}();`] : [` const ${d.name}({`, ...ctor, ` });`]),
1435
1615
  ``,
1436
1616
  ` /// Read from the projection's decoded json object.`,
1437
1617
  ` factory ${d.name}.fromProjected(Map<String, Object?> m) {`,
@@ -1442,6 +1622,7 @@ function emitValueObjectClass(d: ValueObjectDescriptor): string {
1442
1622
  ``,
1443
1623
  ` /// The authored payload shape (embed it in a directive payload / \`set()\`).`,
1444
1624
  ` Map<String, Object?> toJson() => <String, Object?>{`,
1625
+ ...literalToLines,
1445
1626
  ...toLines,
1446
1627
  ` };`,
1447
1628
  ``,
@@ -1908,6 +2089,12 @@ export function toJsonExpr(spec: PayloadFieldSpec, accessor: string): string {
1908
2089
  return accessor;
1909
2090
  case "enum":
1910
2091
  return `${accessor}.wire`;
2092
+ case "vo":
2093
+ return `${accessor}.toJson()`;
2094
+ case "voList":
2095
+ return `${accessor}.map((e) => e.toJson()).toList()`;
2096
+ case "wrapped":
2097
+ return `${accessor}.value`;
1911
2098
  }
1912
2099
  }
1913
2100
 
@@ -2942,19 +3129,58 @@ export function generateDartDomain(mod: DomainModule, opts: GenerateDartDomainOp
2942
3129
  const skipValueObjectNames = opts.skipValueObjectNames ?? new Set<string>();
2943
3130
  // VALUE OBJECTS: one generated class per named VO the module carries (composeDomainModule
2944
3131
  // collected + sorted them), minus the package-shared ones (emitted once in the framework file).
2945
- const localValueObjects = (mod.valueObjects ?? []).filter((d) => !skipValueObjectNames.has(d.name));
3132
+ // Payload-only VOs (a VO used in a directive payload but on no aggregate field) still need
3133
+ // their generated class — collected from the branded payload schemas, deduped by name against
3134
+ // the aggregate-derived set. Codegen-only: the IR key stays aggregate-derived (era rule).
3135
+ const aggVoNames = new Set((mod.valueObjects ?? []).map((d) => d.name));
3136
+ const payloadVos = collectPayloadValueObjectDescriptors(mod.directives ?? []).filter((d) => !aggVoNames.has(d.name));
3137
+ const localValueObjects = [...(mod.valueObjects ?? []), ...payloadVos]
3138
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
3139
+ .filter((d) => !skipValueObjectNames.has(d.name));
2946
3140
  // Each VO's block = its FIELD ENUMS (real Dart enums, tolerant fromWire) then its class —
2947
3141
  // so skipping a shared VO (the framework-file dedup) skips its enums with it.
2948
- const voBlock =
2949
- localValueObjects.length > 0
3142
+ // UNIT extension types (dart_codegen_parity.md P2): every `unit("X", z.number())` tag seen
3143
+ // in local VO descriptors OR directive payloads gets ONE zero-cost wrapper. Opaque by design —
3144
+ // no generated arithmetic/conversions (a physics library is not a type system).
3145
+ const unitNames = new Set<string>();
3146
+ const walkUnitField = (f: ValueObjectFieldDescriptor): void => {
3147
+ if (f.type === "number" && f.unit !== undefined) unitNames.add(f.unit);
3148
+ else if (f.type === "array") walkUnitField(f.items);
3149
+ };
3150
+ for (const d of localValueObjects) {
3151
+ for (const f of Object.values(d.fields ?? {})) walkUnitField(f);
3152
+ for (const arm of Object.values(d.union ?? {})) {
3153
+ if ("fields" in arm) for (const f of Object.values(arm.fields)) walkUnitField(f);
3154
+ }
3155
+ }
3156
+ for (const dir of mod.directives ?? []) {
3157
+ const schema = (dir as { payloadSchema?: unknown }).payloadSchema;
3158
+ if (schema && typeof schema === "object" && zodTypeName(schema as z.ZodTypeAny) === "ZodObject") {
3159
+ try {
3160
+ for (const spec of introspectPayload(schema as z.ZodTypeAny, new Map())) {
3161
+ if (spec.toJson === "wrapped" && !spec.dartType.endsWith("Id")) unitNames.add(spec.dartType);
3162
+ }
3163
+ } catch { /* an unsupported payload shape is reported by the payload emitter, not here */ }
3164
+ }
3165
+ }
3166
+ const unitBlock = [...unitNames].sort().map((u) => [
3167
+ `/// UNIT \`${u}\` — a ZERO-COST wrapper over the wire's plain JSON number (Dart extension`,
3168
+ `/// type): passing a bare double (or another unit) where [${u}] is required is a COMPILE`,
3169
+ `/// error. Opaque tag only — no arithmetic/conversions are generated.`,
3170
+ `extension type const ${u}(double value) {}`,
3171
+ ].join("\n")).join("\n\n");
3172
+ const voBlock = [
3173
+ ...(unitBlock !== "" ? [unitBlock] : []),
3174
+ ...(localValueObjects.length > 0
2950
3175
  ? [
2951
3176
  emitVoHelpers(),
2952
3177
  ...localValueObjects.flatMap((d) => [
2953
- ...voEnumFields(d).map((e) => emitVoEnum(d.name, e.fieldName, e.values)),
3178
+ ...voEnumFields(d).map((e) => emitVoEnum(e.owner ?? d.name, e.fieldName, e.values)),
2954
3179
  emitValueObjectClass(d),
2955
3180
  ]),
2956
- ].join("\n\n")
2957
- : "";
3181
+ ]
3182
+ : []),
3183
+ ].join("\n\n");
2958
3184
  // The symbols whose Dart surface this file EMITS (framework-shared ones live in the shared file).
2959
3185
  const localAggregates = mod.aggregates.filter((a) => !skipAggregateIds.has(a.id));
2960
3186
  const localDirectives = mod.directives.filter((d) => !skipDirectiveIds.has(d.id));
@@ -44,6 +44,7 @@ import type { FieldOp } from "./ops.js";
44
44
  import type { RelationTypeDecl, Rewrite } from "./relation_schema.js";
45
45
  import type { WorkspaceInvariantDecl } from "./framework/workspace_invariant.js";
46
46
  import { finishCount } from "./count.js";
47
+ import { voMetaOf, voSample } from "./value_object.js";
47
48
  import { mintedCreateField } from "./workspace_routing.js";
48
49
  import { deterministicPorts } from "./ctx.js";
49
50
  import { __setDispatchDomain } from "./framework/relations.js";
@@ -98,6 +99,12 @@ function sampleString(zt: z.ZodTypeAny, field: string): string {
98
99
 
99
100
  /** One sample value for one zod shape. NO FALLBACK: an unhandled shape throws, naming its remedy. */
100
101
  function sampleOfZod(zt: z.ZodTypeAny, field: string, where: string): unknown {
102
+ // VALUE OBJECTS FIRST (vo_authoring_end_to_end.md slice C): a declared VO carries its own
103
+ // lawful sample (the author's validated `example`, the blessed geometry sample, or per-field
104
+ // descriptor synthesis) — the raw-zod walk below cannot see constraints like geo's min-2
105
+ // coordinates and would synthesize a value the law itself refuses.
106
+ const vo = voMetaOf(zt);
107
+ if (vo !== undefined) return voSample(vo);
101
108
  switch (zodKind(zt)) {
102
109
  case "string":
103
110
  return sampleString(zt, field);
@@ -171,6 +171,22 @@ function obligationFromJsonExpr(s: PayloadFieldSpec): string {
171
171
  // a list. The Dart type drives the cast.
172
172
  return s.optional ? `${raw} as ${s.dartType}?` : `${raw} as ${s.dartType}`;
173
173
  }
174
+ case "vo": {
175
+ // A declared value object — decode through the generated class (typed both ways).
176
+ return s.optional
177
+ ? `${raw} == null ? null : ${s.dartType}.fromProjected((${raw} as Map).cast<String, Object?>())`
178
+ : `${s.dartType}.fromProjected((${raw} as Map).cast<String, Object?>())`;
179
+ }
180
+ case "voList": {
181
+ const elem = s.dartType.replace(/^List<(.*)>$/, "$1");
182
+ const decode = `(${raw} as List).map((e) => ${elem}.fromProjected((e as Map).cast<String, Object?>())).toList()`;
183
+ return s.optional ? `${raw} == null ? null : ${decode}` : decode;
184
+ }
185
+ case "wrapped": {
186
+ // typed-id class (String wire) or unit extension type (num wire): wrap the raw scalar.
187
+ const wrap = (v: string) => `${s.dartType}${s.dartType.endsWith("Id") ? `.fromRow(${v} as String)` : `((${v} as num).toDouble())`}`;
188
+ return s.optional ? `${raw} == null ? null : ${wrap(raw)}` : wrap(raw);
189
+ }
174
190
  }
175
191
  }
176
192
 
package/src/codegen_ts.ts CHANGED
@@ -41,6 +41,7 @@ import type { Field, ValueObjectDescriptor, ValueObjectFieldDescriptor } from ".
41
41
  import type { Directive } from "./directive.js";
42
42
  import type { DomainModule } from "./codegen_dart.js";
43
43
  import type { QueryDecl } from "./query.js";
44
+ import { voMetaOf, collectPayloadValueObjectDescriptors } from "./value_object.js";
44
45
  import { finishCount } from "./count.js";
45
46
  import { finishSum } from "./sum.js";
46
47
  import type { DerivedDecl } from "./derived.js";
@@ -122,6 +123,14 @@ const paren = (t: string) => (t.includes("|") || t.includes("&") ? `(${t})` : t)
122
123
 
123
124
  /** Recursive zod → TS type text. NO FALLBACK: an unhandled shape throws. */
124
125
  function tsTypeOfZod(zt: z.ZodTypeAny, indent: string): string {
126
+ // A DECLARED VALUE OBJECT keeps its NAME (vo_authoring_end_to_end.md slice B): the payload
127
+ // references the same exported interface the read model uses — one name, both directions —
128
+ // instead of an anonymous inline literal type. Checked on the BARE object only: optional/
129
+ // default wrappers must keep flowing through the switch so `| undefined` survives.
130
+ if (zodKind(zt) === "object" || zodKind(zt) === "union") {
131
+ const vo = voMetaOf(zt);
132
+ if (vo !== undefined) return vo.name;
133
+ }
125
134
  switch (zodKind(zt)) {
126
135
  case "string":
127
136
  return "string";
@@ -282,11 +291,13 @@ function tsTypeOfVoField(f: ValueObjectFieldDescriptor): string {
282
291
  case "string":
283
292
  return "string";
284
293
  case "number":
285
- return "number";
294
+ return "number"; // `.int()`/units are Dart-side nominal products; TS keeps one number
286
295
  case "boolean":
287
296
  return "boolean";
288
297
  case "enum":
289
298
  return union(f.values.map((v) => JSON.stringify(v)));
299
+ case "literal":
300
+ return JSON.stringify(f.value);
290
301
  case "array":
291
302
  return `${paren(tsTypeOfVoField(f.items))}[]`;
292
303
  case "object":
@@ -305,6 +316,25 @@ function emitValueObjectTs(d: ValueObjectDescriptor): string[] {
305
316
  ``,
306
317
  ];
307
318
  }
319
+ if (d.union !== undefined) {
320
+ // A union(...) sum type: the named TS union of its arms. An anonymous arm's fields ride
321
+ // inline beside the `type` discriminator; a NAMED-VO arm nests under `value` (the named
322
+ // VO stays the one nominal home of its shape — dart_codegen_parity.md P1).
323
+ const arms = Object.entries(d.union).map(([key, arm]) => {
324
+ if ("ref" in arm) return `{ type: ${JSON.stringify(key)}; value: ${arm.ref} }`;
325
+ const fields = Object.entries(arm.fields)
326
+ .map(([n, f]) => `${n}${f.optional === true ? "?" : ""}: ${tsTypeOfVoField(f)}`)
327
+ .join("; ");
328
+ return fields.length > 0 ? `{ type: ${JSON.stringify(key)}; ${fields} }` : `{ type: ${JSON.stringify(key)} }`;
329
+ });
330
+ return [
331
+ `/** Union \`${d.name}\` — a sum type stored as ONE json leaf (wholesale-replace; the \`type\``,
332
+ ` * discriminator names the arm). Adding an arm is ADDITIVE evolution; removing one is a retype. */`,
333
+ `export type ${d.name} =`,
334
+ ...arms.map((a, i) => ` | ${a}${i === arms.length - 1 ? ";" : ""}`),
335
+ ``,
336
+ ];
337
+ }
308
338
  const lines = Object.entries(d.fields ?? {}).map(
309
339
  ([name, f]) => ` ${name}${f.optional === true ? "?" : ""}: ${tsTypeOfVoField(f)};`,
310
340
  );
@@ -476,7 +506,11 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
476
506
  // ── value objects: one interface per named VO, deduped across the package's domains ──
477
507
  const voByName = new Map<string, ValueObjectDescriptor>();
478
508
  for (const mod of modules) {
479
- for (const d of (mod as { valueObjects?: readonly ValueObjectDescriptor[] }).valueObjects ?? []) {
509
+ // aggregate-carried VOs ∪ payload-only VOs (branded schemas) — both need their interface.
510
+ const payloadVos = collectPayloadValueObjectDescriptors(
511
+ ((mod as { directives?: readonly { payloadSchema?: unknown }[] }).directives ?? []),
512
+ );
513
+ for (const d of [...((mod as { valueObjects?: readonly ValueObjectDescriptor[] }).valueObjects ?? []), ...payloadVos]) {
480
514
  const existing = voByName.get(d.name);
481
515
  if (existing !== undefined && JSON.stringify(existing) !== JSON.stringify(d)) {
482
516
  throw new Error(
package/src/fields.ts CHANGED
@@ -89,15 +89,28 @@ export type GeometryKind = "Point" | "LineString" | "Polygon";
89
89
  */
90
90
  export type ValueObjectFieldDescriptor =
91
91
  | { readonly type: "string"; readonly optional?: true }
92
- | { readonly type: "number"; readonly optional?: true }
92
+ /** `int: true` when the zod carried `.int()` (Dart `int`, TS `number`); `unit` when the field
93
+ * was declared through `unit(...)` — the generated Dart extension-type wrapper's name. */
94
+ | { readonly type: "number"; readonly int?: true; readonly unit?: string; readonly optional?: true }
93
95
  | { readonly type: "boolean"; readonly optional?: true }
94
96
  | { readonly type: "enum"; readonly values: readonly string[]; readonly optional?: true }
97
+ /** A `z.literal(<string>)` — a CONSTANT discriminator-style member (generated as a getter). */
98
+ | { readonly type: "literal"; readonly value: string; readonly optional?: true }
95
99
  | { readonly type: "array"; readonly items: ValueObjectFieldDescriptor; readonly optional?: true }
96
100
  /** A nested value object, referenced BY NAME (its own descriptor rides alongside). */
97
101
  | { readonly type: "object"; readonly ref: string; readonly optional?: true }
98
102
  /** A nested GEO value object (RFC 7946 geometry), referenced by name. */
99
103
  | { readonly type: "geometry"; readonly ref: string; readonly geometry: GeometryKind; readonly optional?: true };
100
104
 
105
+ /**
106
+ * One arm of a `union(...)` value object: either an ANONYMOUS shape (fields ride inline,
107
+ * flattened beside the `type` discriminator on the wire) or a NAMED value object (the arm
108
+ * nests it under `value` on the wire — the named VO stays the one nominal home of its shape).
109
+ */
110
+ export type UnionArmDescriptor =
111
+ | { readonly fields: Readonly<Record<string, ValueObjectFieldDescriptor>> }
112
+ | { readonly ref: string };
113
+
101
114
  /**
102
115
  * The canonical descriptor of ONE named value object. A structural VO carries
103
116
  * `fields` (keys SORTED — byte-determinism); a `geo.*` VO carries `geometry`
@@ -107,6 +120,9 @@ export interface ValueObjectDescriptor {
107
120
  readonly name: string;
108
121
  readonly fields?: Readonly<Record<string, ValueObjectFieldDescriptor>>;
109
122
  readonly geometry?: GeometryKind;
123
+ /** A `union(...)` sum type: arm key (the wire `type` discriminator value) → arm descriptor.
124
+ * Mutually exclusive with `fields`/`geometry`. Keys SORTED (byte-determinism). */
125
+ readonly union?: Readonly<Record<string, UnionArmDescriptor>>;
110
126
  }
111
127
 
112
128
  /**
@@ -126,6 +142,9 @@ export interface ValueObjectFieldMeta {
126
142
  readonly dependencies: readonly ValueObjectDescriptor[];
127
143
  /** Present iff this VO is a `geo.*` geometry (the spatial-index pairing check). */
128
144
  readonly geometry?: GeometryKind;
145
+ /** The author-supplied lawful example (validated against the VO's own zod at declaration).
146
+ * Proof synthesis + docs prefer it over per-field synthesis. */
147
+ readonly example?: unknown;
129
148
  }
130
149
 
131
150
  export interface Field<T = unknown, K extends FieldKind = FieldKind> {
package/src/index.ts CHANGED
@@ -47,6 +47,13 @@ export {
47
47
  // `geo.*` are the RFC 7946 geometry VOs (pair `geo.point()` with `spatial(...)`).
48
48
  export {
49
49
  valueObject,
50
+ variant,
51
+ unit,
52
+ idOf,
53
+ idOfMetaOf,
54
+ voMetaOf,
55
+ voSample,
56
+ NOMOS_VO_BRAND,
50
57
  geo,
51
58
  type ValueObject,
52
59
  type ValueObjectValue,
@@ -80,6 +80,14 @@ function payloadFieldKind(spec: PayloadFieldSpec): string {
80
80
  return "set";
81
81
  case "json":
82
82
  return "json";
83
+ case "wrapped":
84
+ // typed-id (string wire) or unit (number wire) — the interface sees the WIRE kind.
85
+ return spec.dartType.endsWith("Id") ? "string" : "double";
86
+ case "vo":
87
+ case "voList":
88
+ // Interface kinds stay at the base lane for now (slice D of vo_authoring_end_to_end.md
89
+ // upgrades these to a first-class "vo" kind) — a VO IS a json leaf structurally.
90
+ return "json";
83
91
  case "scalar":
84
92
  default:
85
93
  switch (spec.dartType) {
@@ -56,6 +56,7 @@ import {
56
56
  valueObjectField,
57
57
  type Field,
58
58
  type GeometryKind,
59
+ type UnionArmDescriptor,
59
60
  type ValueObjectDescriptor,
60
61
  type ValueObjectFieldDescriptor,
61
62
  type ValueObjectFieldMeta,
@@ -186,6 +187,74 @@ function collectDescriptors(vo: ValueObject<unknown>, into: Map<string, ValueObj
186
187
  }
187
188
  }
188
189
 
190
+ /** `.int()` detection across zod v4's check encodings (mirrors codegen_dart's payload rule). */
191
+ function zodNumberIsInt(zt: z.ZodTypeAny): boolean {
192
+ const def = zodDef(zt) as { checks?: readonly unknown[]; format?: string };
193
+ if (def.format === "safeint" || def.format === "int32" || def.format === "int64") return true;
194
+ for (const c of def.checks ?? []) {
195
+ const chk = c as { kind?: string; def?: { check?: string; format?: string } };
196
+ if (chk.kind === "int") return true;
197
+ if (chk.def?.check === "number_format" && (chk.def.format === "safeint" || chk.def.format === "int32")) return true;
198
+ }
199
+ return false;
200
+ }
201
+
202
+ /**
203
+ * THE UNIT BRAND (dart_codegen_parity.md P2) — `unit("Millimetres", z.number())` stamps the
204
+ * schema so the descriptor carries the unit name and the generated Dart wraps the field in a
205
+ * ZERO-COST extension type (`Millimetres`). Compile-time only: the wire value stays a plain
206
+ * JSON number; validation is untouched; domainHash-inert. v1 = opaque tags — NO generated
207
+ * arithmetic or conversions (a physics library is not a type system).
208
+ */
209
+ export const NOMOS_UNIT_BRAND: unique symbol = Symbol.for("nomos.unit");
210
+
211
+ const UNIT_NAME_RE = /^[A-Z][A-Za-z0-9_]*$/;
212
+
213
+ /** Tag a numeric field with a UNIT: `radiusMm: unit("Millimetres", z.number())`. */
214
+ export function unit<Z extends z.ZodTypeAny>(name: string, inner: Z): Z {
215
+ if (!UNIT_NAME_RE.test(name)) {
216
+ throw new Error(`unit: '${name}' must be a capitalized identifier — it becomes the generated extension type's name.`);
217
+ }
218
+ if (zodKind(inner) !== "number") {
219
+ throw new Error(`unit '${name}': only z.number() fields carry a unit in v1.`);
220
+ }
221
+ Object.defineProperty(inner, NOMOS_UNIT_BRAND, { value: name, enumerable: false });
222
+ return inner;
223
+ }
224
+
225
+ /**
226
+ * THE TYPED-ID BRAND (dart_codegen_parity.md P2) — `idOf(Site)` in a directive payload is
227
+ * wire-identical to `z.string()` but the generated Dart field is the aggregate's TYPED id
228
+ * class (`SiteId`), and the generated TS references the branded alias. Passing another
229
+ * aggregate's id where a SiteId belongs becomes a COMPILE error. Payload-side only — the
230
+ * kernel keeps seeing a string; validation/domainHash untouched.
231
+ */
232
+ export const NOMOS_IDOF_BRAND: unique symbol = Symbol.for("nomos.idOf");
233
+
234
+ /** A payload field holding an EXISTING aggregate's id, typed: `siteId: idOf(Site)`. */
235
+ export function idOf(aggregate: { id: string } | string): z.ZodType<string> {
236
+ const aggregateType = typeof aggregate === "string" ? aggregate : aggregate.id;
237
+ if (typeof aggregateType !== "string" || aggregateType === "") {
238
+ throw new Error("idOf: pass the aggregate handle (idOf(Site)) or its type name.");
239
+ }
240
+ const schema = z.string().min(1);
241
+ Object.defineProperty(schema, NOMOS_IDOF_BRAND, { value: aggregateType, enumerable: false });
242
+ return schema;
243
+ }
244
+
245
+ /** Resolve the `idOf(...)` aggregate type of a payload schema (through optional/default). */
246
+ export function idOfMetaOf(zt: z.ZodTypeAny): string | undefined {
247
+ let cur: z.ZodTypeAny | undefined = zt;
248
+ for (let hops = 0; cur !== undefined && hops < 16; hops++) {
249
+ const branded = (cur as unknown as Record<symbol, unknown>)[NOMOS_IDOF_BRAND];
250
+ if (typeof branded === "string") return branded;
251
+ const kind = zodKind(cur);
252
+ if (kind !== "optional" && kind !== "default" && kind !== "nullable") return undefined;
253
+ cur = zodDef(cur).innerType;
254
+ }
255
+ return undefined;
256
+ }
257
+
189
258
  /**
190
259
  * Describe one CONSTRAINED zod schema as a descriptor field (NO FALLBACK — an
191
260
  * out-of-set shape throws with the field's path so the author fixes the declaration,
@@ -195,12 +264,26 @@ function describeZod(voName: string, path: string, schema: z.ZodTypeAny): ValueO
195
264
  switch (zodKind(schema)) {
196
265
  case "string":
197
266
  return { type: "string" };
198
- case "number":
199
- return { type: "number" };
267
+ case "number": {
268
+ const unitName = (schema as unknown as Record<symbol, unknown>)[NOMOS_UNIT_BRAND];
269
+ return {
270
+ type: "number",
271
+ ...(zodNumberIsInt(schema) ? { int: true as const } : {}),
272
+ ...(typeof unitName === "string" ? { unit: unitName } : {}),
273
+ };
274
+ }
200
275
  case "boolean":
201
276
  return { type: "boolean" };
202
277
  case "enum":
203
278
  return { type: "enum", values: zodEnumValues(schema) };
279
+ case "literal": {
280
+ const def = zodDef(schema) as { value?: unknown; values?: readonly unknown[] };
281
+ const v = def.value ?? def.values?.[0];
282
+ if (typeof v !== "string") {
283
+ throw new Error(`valueObject '${voName}': field '${path}' — only STRING literals are in the v1 set.`);
284
+ }
285
+ return { type: "literal", value: v };
286
+ }
204
287
  case "array": {
205
288
  const items = describeZod(voName, `${path}[]`, zodArrayElement(schema));
206
289
  if (items.optional === true) {
@@ -226,12 +309,109 @@ function describeZod(voName: string, path: string, schema: z.ZodTypeAny): ValueO
226
309
 
227
310
  const VO_NAME_RE = /^[A-Za-z][A-Za-z0-9_]*$/;
228
311
 
312
+ /**
313
+ * THE VO BRAND (slice A of vo_authoring_end_to_end.md) — `valueObject()` stamps its zod schema
314
+ * with the VO's {@link ValueObjectFieldMeta} under this symbol, COMPILE-TIME ONLY: the brand is
315
+ * a non-enumerable property, never serialized, never read by the engine's validation path, so a
316
+ * branded schema's package bytes (and domainHash) are byte-identical to an unbranded one.
317
+ * Every generated-surface introspector (Dart/TS payload gen, interface kinds, proof synthesis)
318
+ * consults {@link voMetaOf} BEFORE its raw zod-kind switch — the VO keeps its NAME on every
319
+ * surface instead of degrading to an anonymous json object.
320
+ */
321
+ export const NOMOS_VO_BRAND: unique symbol = Symbol.for("nomos.vo");
322
+
323
+ /**
324
+ * Resolve the value-object identity of a zod schema, unwrapping `.optional()` / `.default()`
325
+ * wrappers (the brand lives on the VO's own object schema). `undefined` for anything that is
326
+ * not a declared VO — callers then fall through to their raw-zod lane, so non-VO behaviour is
327
+ * untouched.
328
+ */
329
+ export function voMetaOf(zt: z.ZodTypeAny): ValueObjectFieldMeta | undefined {
330
+ let cur: z.ZodTypeAny | undefined = zt;
331
+ for (let hops = 0; cur !== undefined && hops < 16; hops++) {
332
+ const branded = (cur as unknown as Record<symbol, unknown>)[NOMOS_VO_BRAND];
333
+ if (branded !== undefined) return branded as ValueObjectFieldMeta;
334
+ const kind = zodKind(cur);
335
+ if (kind !== "optional" && kind !== "default" && kind !== "nullable") return undefined;
336
+ cur = zodDef(cur).innerType;
337
+ }
338
+ return undefined;
339
+ }
340
+
341
+ /** The blessed lawful sample per RFC 7946 geometry kind (proof synthesis + docs). */
342
+ const GEOMETRY_SAMPLES: Record<GeometryKind, unknown> = {
343
+ Point: { type: "Point", coordinates: [0, 0] },
344
+ LineString: { type: "LineString", coordinates: [[0, 0], [1, 1]] },
345
+ Polygon: { type: "Polygon", coordinates: [[[0, 0], [0, 1], [1, 1], [0, 0]]] },
346
+ };
347
+
348
+ function sampleOfDescriptorField(
349
+ f: ValueObjectFieldDescriptor,
350
+ fieldName: string,
351
+ byName: Map<string, ValueObjectDescriptor>,
352
+ ): unknown {
353
+ switch (f.type) {
354
+ case "string": return `sample-${fieldName}`;
355
+ case "number": return 1;
356
+ case "literal": return f.value;
357
+ case "boolean": return true;
358
+ case "enum": return f.values[0];
359
+ case "array": return [sampleOfDescriptorField(f.items, fieldName, byName)];
360
+ case "geometry": return GEOMETRY_SAMPLES[f.geometry];
361
+ case "object": {
362
+ const dep = byName.get(f.ref);
363
+ if (dep === undefined) throw new Error(`voSample: nested value object '${f.ref}' has no descriptor`);
364
+ return sampleOfDescriptor(dep, byName);
365
+ }
366
+ }
367
+ }
368
+
369
+ function sampleOfDescriptor(d: ValueObjectDescriptor, byName: Map<string, ValueObjectDescriptor>): unknown {
370
+ if (d.geometry !== undefined) return GEOMETRY_SAMPLES[d.geometry];
371
+ if (d.union !== undefined) {
372
+ // FIRST declared arm (keys are sorted — deterministic); `example:` overrides upstream.
373
+ const [key, arm] = Object.entries(d.union)[0]!;
374
+ if ("ref" in arm) {
375
+ const dep = byName.get(arm.ref);
376
+ if (dep === undefined) throw new Error(`voSample: union arm VO '${arm.ref}' has no descriptor`);
377
+ return { type: key, value: sampleOfDescriptor(dep, byName) };
378
+ }
379
+ const out: Record<string, unknown> = { type: key };
380
+ for (const [k, f] of Object.entries(arm.fields)) {
381
+ if (f.optional === true) continue;
382
+ out[k] = sampleOfDescriptorField(f, k, byName);
383
+ }
384
+ return out;
385
+ }
386
+ const out: Record<string, unknown> = {};
387
+ for (const [k, f] of Object.entries(d.fields ?? {})) {
388
+ if (f.optional === true) continue; // optional → omitted (mirrors payload synthesis)
389
+ out[k] = sampleOfDescriptorField(f, k, byName);
390
+ }
391
+ return out;
392
+ }
393
+
394
+ /**
395
+ * A LAWFUL sample value for a VO (slice C): the author's validated `example` when given, the
396
+ * blessed geometry sample for `geo.*` kinds, else per-field synthesis (recursing into nested
397
+ * VOs). Proof synthesis calls this instead of guessing at the raw zod — which is exactly the
398
+ * lane that refused its own sample on constrained shapes (geo min-2 coordinates).
399
+ */
400
+ export function voSample(meta: ValueObjectFieldMeta): unknown {
401
+ if (meta.example !== undefined) return meta.example;
402
+ const byName = new Map<string, ValueObjectDescriptor>();
403
+ for (const d of meta.dependencies) byName.set(d.name, d);
404
+ byName.set(meta.descriptor.name, meta.descriptor);
405
+ return sampleOfDescriptor(meta.descriptor, byName);
406
+ }
407
+
229
408
  function makeValueObject<T>(
230
409
  name: string,
231
410
  zodSchema: z.ZodType<T>,
232
411
  descriptor: ValueObjectDescriptor,
233
412
  dependencies: readonly ValueObjectDescriptor[],
234
413
  geometry?: GeometryKind,
414
+ example?: T,
235
415
  ): ValueObject<T> {
236
416
  if (!VO_NAME_RE.test(name)) {
237
417
  throw new Error(
@@ -239,12 +419,25 @@ function makeValueObject<T>(
239
419
  `generated TS interface / Dart class name.`,
240
420
  );
241
421
  }
422
+ if (example !== undefined) {
423
+ const v = zodSchema.safeParse(example);
424
+ if (!v.success) {
425
+ throw new Error(
426
+ `valueObject '${name}': the declared example does not satisfy the VO's own schema — ` +
427
+ `a lying example would poison proof synthesis. ${v.error.message}`,
428
+ );
429
+ }
430
+ }
242
431
  const meta: ValueObjectFieldMeta = {
243
432
  name,
244
433
  descriptor,
245
434
  dependencies,
246
435
  ...(geometry !== undefined ? { geometry } : {}),
436
+ ...(example !== undefined ? { example } : {}),
247
437
  };
438
+ // THE BRAND (slice A): compile-time identity on the schema itself — non-enumerable, never
439
+ // serialized, domainHash-inert. voMetaOf() is the one resolver every introspector uses.
440
+ Object.defineProperty(zodSchema, NOMOS_VO_BRAND, { value: meta, enumerable: false });
248
441
  const vo: ValueObject<T> = {
249
442
  __isValueObject: true,
250
443
  name,
@@ -272,6 +465,13 @@ function makeValueObject<T>(
272
465
  export function valueObject<const Name extends string, S extends Record<string, ValueObjectShapeEntry>>(
273
466
  name: Name,
274
467
  shape: S,
468
+ opts?: {
469
+ /** A lawful example value, VALIDATED against the VO's own schema at declaration (a lying
470
+ * example throws here, loudly). Proof synthesis prefers it over per-field synthesis — the
471
+ * escape hatch for constraint-rich shapes (refinements, cross-field rules) a synthesizer
472
+ * cannot guess. */
473
+ example?: ShapeValue<S>;
474
+ },
275
475
  ): ValueObject<ShapeValue<S>> {
276
476
  const zodShape: Record<string, z.ZodTypeAny> = {};
277
477
  const fields: Record<string, ValueObjectFieldDescriptor> = {};
@@ -308,9 +508,98 @@ export function valueObject<const Name extends string, S extends Record<string,
308
508
  z.object(zodShape) as unknown as z.ZodType<ShapeValue<S>>,
309
509
  descriptor,
310
510
  dependencies,
511
+ undefined,
512
+ opts?.example,
311
513
  );
312
514
  }
313
515
 
516
+ // ── variant(...) — sum types as ONE json leaf (dart_codegen_parity.md P1; named `variant`
517
+ // because the ReBAC rewrite set-op already owns `union`) ──────────────
518
+
519
+ /** One arm of a `variant(...)`: an anonymous shape (the v1 VO field set) or a NAMED value object. */
520
+ export type UnionArmShape = Record<string, ValueObjectShapeEntry> | ValueObject<unknown>;
521
+
522
+ type UnionArmValue<A> = A extends ValueObject<infer V>
523
+ ? { value: V }
524
+ : A extends Record<string, ValueObjectShapeEntry>
525
+ ? ShapeValue<A>
526
+ : never;
527
+
528
+ type UnionValue<Arms extends Record<string, UnionArmShape>> = {
529
+ [K in keyof Arms & string]: { type: K } & UnionArmValue<Arms[K]>;
530
+ }[keyof Arms & string];
531
+
532
+ /**
533
+ * Declare a NAMED SUM TYPE (`variant("Shape", { circle: {...}, rect: {...} })`). The blessed,
534
+ * FIXED discriminator is `type` (the geo/RFC 7946 convention); its value is the arm key.
535
+ * On the wire an ANONYMOUS arm's fields ride flattened beside `type`; a NAMED-VO arm nests
536
+ * its value under `value` (the named VO stays the one nominal home of its shape).
537
+ *
538
+ * STORAGE/MERGE: identical to every VO — ONE Lww-class json leaf, replaced WHOLESALE
539
+ * (switching arms is just a write; no per-arm merge). EVOLUTION: adding an arm is ADDITIVE
540
+ * (the generated Dart's `Unknown` arm keeps OLD clients decoding a NEWER law's arm without
541
+ * crashing); removing/renaming an arm is a retype (the #58 dispositions lane).
542
+ */
543
+ export function variant<const Name extends string, Arms extends Record<string, UnionArmShape>>(
544
+ name: Name,
545
+ arms: Arms,
546
+ opts?: { example?: UnionValue<Arms> },
547
+ ): ValueObject<UnionValue<Arms>> {
548
+ const armKeys = Object.keys(arms).sort();
549
+ if (armKeys.length === 0) throw new Error(`variant '${name}': at least one arm is required.`);
550
+ const deps = new Map<string, ValueObjectDescriptor>();
551
+ const armDescriptors: Record<string, UnionArmDescriptor> = {};
552
+ const armZods: z.ZodTypeAny[] = [];
553
+ for (const key of armKeys) {
554
+ if (!VO_NAME_RE.test(key) || key !== key[0]!.toLowerCase() + key.slice(1)) {
555
+ throw new Error(`variant '${name}': arm key '${key}' must be a lowerCamel identifier — it is the wire discriminator AND names the generated arm class.`);
556
+ }
557
+ const arm = arms[key]!;
558
+ if (isValueObject(arm)) {
559
+ if (arm.descriptor.union !== undefined) {
560
+ throw new Error(`variant '${name}': arm '${key}' is itself a union — unions-of-unions are refused in v1.`);
561
+ }
562
+ armDescriptors[key] = { ref: arm.name };
563
+ armZods.push(z.object({ type: z.literal(key), value: arm.zod as z.ZodTypeAny }));
564
+ collectDescriptors(arm, deps);
565
+ } else {
566
+ const fields: Record<string, ValueObjectFieldDescriptor> = {};
567
+ const zodShape: Record<string, z.ZodTypeAny> = { type: z.literal(key) };
568
+ for (const fieldKey of Object.keys(arm).sort()) {
569
+ if (fieldKey === "type") throw new Error(`variant '${name}': arm '${key}' declares a 'type' field — reserved for the discriminator.`);
570
+ if (fieldKey === "value") throw new Error(`variant '${name}': arm '${key}' declares a 'value' field — reserved for named-VO arms.`);
571
+ const entry = arm[fieldKey]!;
572
+ if (isOptionalValueObject(entry)) {
573
+ const inner = entry.valueObject;
574
+ zodShape[fieldKey] = (inner.zod as z.ZodTypeAny).optional();
575
+ fields[fieldKey] = inner.geometry !== undefined
576
+ ? { type: "geometry", ref: inner.name, geometry: inner.geometry, optional: true }
577
+ : { type: "object", ref: inner.name, optional: true };
578
+ collectDescriptors(inner, deps);
579
+ } else if (isValueObject(entry)) {
580
+ zodShape[fieldKey] = entry.zod as z.ZodTypeAny;
581
+ fields[fieldKey] = entry.geometry !== undefined
582
+ ? { type: "geometry", ref: entry.name, geometry: entry.geometry }
583
+ : { type: "object", ref: entry.name };
584
+ collectDescriptors(entry, deps);
585
+ } else {
586
+ fields[fieldKey] = describeZod(name, `${key}.${fieldKey}`, entry);
587
+ zodShape[fieldKey] = entry;
588
+ }
589
+ }
590
+ armDescriptors[key] = { fields };
591
+ armZods.push(z.object(zodShape));
592
+ }
593
+ }
594
+ if (deps.has(name)) throw new Error(`variant '${name}': a union cannot nest itself (directly or via a dependency).`);
595
+ const descriptor: ValueObjectDescriptor = { name, union: armDescriptors };
596
+ const dependencies = [...deps.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
597
+ const zodSchema = (armZods.length === 1
598
+ ? armZods[0]!
599
+ : z.discriminatedUnion("type", armZods as [z.ZodObject<z.ZodRawShape>, ...z.ZodObject<z.ZodRawShape>[]])) as unknown as z.ZodType<UnionValue<Arms>>;
600
+ return makeValueObject(name, zodSchema, descriptor, dependencies, undefined, opts?.example);
601
+ }
602
+
314
603
  // ── geo.* — the RFC 7946 geometry value objects ──────────────────────────────────────
315
604
 
316
605
  /** An RFC 7946 position: `[lng, lat]` or `[lng, lat, altitude]`. */
@@ -388,6 +677,46 @@ export const geo = {
388
677
  * descriptors throw (one name, one shape). Returns `[]` when no field carries a VO —
389
678
  * the omit-when-empty input to the `nomosValueObjects` era key.
390
679
  */
680
+ /**
681
+ * Collect the value objects referenced by DIRECTIVE PAYLOADS (top-level fields + array
682
+ * elements), via the {@link NOMOS_VO_BRAND} the schemas carry. CODEGEN-ONLY (slice B of
683
+ * vo_authoring_end_to_end.md): the result feeds the generated Dart/TS surfaces so a
684
+ * payload-only VO still gets its class/interface — it does NOT enter the `nomosValueObjects`
685
+ * IR key (that stays aggregate-derived, keeping package bytes / domainHash untouched).
686
+ */
687
+ export function collectPayloadValueObjectDescriptors(
688
+ directives: readonly { payloadSchema?: unknown }[],
689
+ ): ValueObjectDescriptor[] {
690
+ const byName = new Map<string, ValueObjectDescriptor>();
691
+ const take = (meta: ValueObjectFieldMeta | undefined) => {
692
+ if (meta === undefined) return;
693
+ for (const d of [meta.descriptor, ...meta.dependencies]) {
694
+ const existing = byName.get(d.name);
695
+ if (existing !== undefined && JSON.stringify(existing) !== JSON.stringify(d)) {
696
+ throw new Error(`value object '${d.name}' has DIVERGENT shapes across payloads — one name, one shape.`);
697
+ }
698
+ byName.set(d.name, d);
699
+ }
700
+ };
701
+ for (const dir of directives) {
702
+ const schema = dir?.payloadSchema as z.ZodTypeAny | undefined;
703
+ if (!schema || typeof schema !== "object" || zodKind(schema) !== "object") continue;
704
+ const rawShape = (zodDef(schema) as { shape?: Record<string, z.ZodTypeAny> | (() => Record<string, z.ZodTypeAny>) }).shape
705
+ ?? (schema as unknown as { shape?: Record<string, z.ZodTypeAny> }).shape;
706
+ const shape = typeof rawShape === "function" ? rawShape() : rawShape ?? {};
707
+ for (const raw of Object.values(shape)) {
708
+ let zt = raw as z.ZodTypeAny;
709
+ take(voMetaOf(zt));
710
+ // unwrap optional/default to reach an array of VOs
711
+ for (let hops = 0; hops < 8 && (zodKind(zt) === "optional" || zodKind(zt) === "default"); hops++) {
712
+ zt = zodDef(zt).innerType!;
713
+ }
714
+ if (zodKind(zt) === "array") take(voMetaOf(zodArrayElement(zt)));
715
+ }
716
+ }
717
+ return [...byName.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
718
+ }
719
+
391
720
  export function collectValueObjectDescriptors(
392
721
  aggregates: readonly { id: string; fields: Record<string, Field> }[],
393
722
  ): ValueObjectDescriptor[] {