@githolon/dsl 0.32.0 → 0.34.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.
@@ -54,8 +54,15 @@ import type { ImpureCapabilityDecl } from "./codegen_provider_dart.js";
54
54
  import type { WorkspaceTypeDecl } from "./workspace_type.js";
55
55
  import type { WorkspaceInvariantDecl } from "./framework/workspace_invariant.js";
56
56
  import { synthesizePreconditionInvariants } from "./precondition.js";
57
+ import {
58
+ BIRTH_METER_AGGREGATES,
59
+ BIRTH_METER_QUERIES,
60
+ synthesizeBirthQuotaInvariants,
61
+ } from "./birth_meter.js";
62
+ import { withBirthMeterExports } from "./birth_meter_ensure.js";
57
63
  import type { RelationTypeDecl } from "./relation_schema.js";
58
64
  import { domainHash, emitManifestBytes } from "./manifest.js";
65
+ import { collectValueObjectDescriptors } from "./value_object.js";
59
66
  import { emitUsd } from "./usd.js";
60
67
  import { expandCapabilityExports } from "./capability_exports.js";
61
68
  import { RelationTuple } from "./framework/relations.js";
@@ -145,6 +152,11 @@ export function composeDomainModule(spec: DomainModuleSpec): DomainModule {
145
152
  // `capability()` bundles expand into discoverable entries (the package plane's
146
153
  // merge point) — the quartet validation below then passes without re-exports.
147
154
  merged = expandCapabilityExports(merged);
155
+ // METERED BIRTH: inject the sanctioning `nomosBirthRecordEnsure` directive + meter aggregates
156
+ // into a `.births()`-declaring domain at the SAME seam the engine merge applies it — the
157
+ // Ensure→NomosBirthRecord manifest declaration is what admits the auto-appended record through
158
+ // the id-mint gate's declared-ensures lane. Birth-free domains are byte-identical (era rule).
159
+ merged = withBirthMeterExports(merged);
148
160
 
149
161
  const byId = new Map<string, AggregateHandle>();
150
162
  for (const a of [...aggregatesOf(merged), ...(spec.extraAggregates ?? [])]) byId.set(a.id, a);
@@ -157,6 +169,18 @@ export function composeDomainModule(spec: DomainModuleSpec): DomainModule {
157
169
  if (!byId.has(RelationTuple.id) && directives.some((d) => d.aggregateId === RelationTuple.id)) {
158
170
  byId.set(RelationTuple.id, RelationTuple);
159
171
  }
172
+ // BIRTH-METER AUTO-ENSURE (metered birth, slice A — the same "discoverable without re-exports"
173
+ // courtesy as RelationTuple): a domain declaring ≥1 `.births()` directive gets the meter aggregates
174
+ // (NomosBirthRecord/BirthPolicy/BirthGrant) injected so the auto-record folds + projects, and the
175
+ // meter queries injected so the captured reads route/re-derive. Era-safe: only a birth-declaring
176
+ // domain gains them, so no existing (birth-free) manifest hash drifts — and a birth-declaring domain
177
+ // recompiled under this DSL moves hash ANYWAY (the meter IS its new era).
178
+ const declaresBirth = directives.some((d) => (d as { birthsChild?: boolean }).birthsChild === true);
179
+ if (declaresBirth) {
180
+ for (const a of BIRTH_METER_AGGREGATES) {
181
+ if (!byId.has(a.id)) byId.set(a.id, a);
182
+ }
183
+ }
160
184
  const aggregates = [...byId.values()];
161
185
  const directiveIds = new Set(directives.map((d) => d.id));
162
186
  for (const task of spec.impureCapabilities ?? []) {
@@ -187,7 +211,39 @@ export function composeDomainModule(spec: DomainModuleSpec): DomainModule {
187
211
  // hand-authored `workspaceInvariant(...)` occupies — so downstream treats them identically. Era-safe:
188
212
  // a directive with no precondition synthesizes nothing (byte-identical manifest).
189
213
  for (const d of directives) workspaceInvariants.push(...synthesizePreconditionInvariants(d));
214
+ // METERED BIRTH (slice A2): synthesize THE BIRTH-QUOTA invariant for every `.births()` directive —
215
+ // the same desugaring lane as preconditions, so the manifest (`nomosInvariantGate`) and the sealed
216
+ // engine (which synthesizes from the SAME directives in `buildDomainSlice`) can never disagree.
217
+ for (const d of directives) workspaceInvariants.push(...synthesizeBirthQuotaInvariants(d));
218
+ // The meter's declared queries route like any exported query (read manifest + captured-read re-derive).
219
+ if (declaresBirth) {
220
+ const haveQuery = new Set(queries.map((q) => q.id));
221
+ for (const q of BIRTH_METER_QUERIES) {
222
+ if (!haveQuery.has(q.id)) queries.push(q);
223
+ }
224
+ }
190
225
  const relationTypes = [...(spec.relationTypes ?? [])];
226
+ // FIRST-CLASS VALUE OBJECTS (value_object.ts): the deduped, sorted descriptors of every
227
+ // named VO the domain's aggregates carry (transitive nested VOs included) — the input to
228
+ // the additive `nomosValueObjects` USD key. Omit-when-empty (era rule): a VO-free domain's
229
+ // module — and thus its USD/package bytes — is byte-identical to before VOs existed.
230
+ const valueObjects = collectValueObjectDescriptors(aggregates);
231
+ // SPATIAL × VO PAIRING (fail at build, not at fold): a spatial index over a VO-typed field
232
+ // must index a GEO value object (an RFC 7946 geometry — the R*Tree's bbox source). A
233
+ // structural VO (e.g. a PostalAddress) has no geometry to index. Non-VO fields (a
234
+ // hand-rolled `t.jsonObject()` GeoJSON leaf) pass through unchecked, exactly as before.
235
+ for (const s of spatials) {
236
+ const agg = byId.get(s.of);
237
+ const field = agg?.fields[s.on];
238
+ const vo = field?.valueObject;
239
+ if (vo !== undefined && vo.geometry === undefined) {
240
+ throw new Error(
241
+ `build-package: spatial '${s.id}' indexes '${s.of}.${s.on}', a '${vo.name}' value object with NO ` +
242
+ `geometry — the R*Tree needs an RFC 7946 geometry leaf. Give the geometry its OWN field ` +
243
+ `(e.g. \`point: geo.point().field()\`) and index that (v1: no sub-path indexing).`,
244
+ );
245
+ }
246
+ }
191
247
  const dartImports = [...(spec.dartImports ?? [])];
192
248
  // THE PEER-SOVEREIGN ROOT-OF-TRUST: a home/child law that exports `requireParentAttestedBirth` declares
193
249
  // it requires a parent-attested-birth cert chain. Discovered by shape across the module list (the SAME
@@ -233,6 +289,9 @@ export function composeDomainModule(spec: DomainModuleSpec): DomainModule {
233
289
  // Omit-when-empty — the relation schema is hashed law (P1); a relation-free domain's
234
290
  // canonical manifest/USD stays byte-identical (the nomosRelations era rule).
235
291
  ...(relationTypes.length > 0 ? { relationTypes } : {}),
292
+ // Omit-when-empty — the `nomosValueObjects` era rule: a VO-free domain's USD/package
293
+ // bytes are byte-identical to before value objects existed.
294
+ ...(valueObjects.length > 0 ? { valueObjects } : {}),
236
295
  // THE PEER-SOVEREIGN ROOT-OF-TRUST: omit-when-false — a law that doesn't require parent-attested
237
296
  // birth is byte-identical in the canonical manifest/USD (the nomosParentAttestedBirth era rule).
238
297
  ...(parentAttestedBirth ? { parentAttestedBirth } : {}),
@@ -32,7 +32,7 @@ import { z } from "zod";
32
32
  import type { AggregateHandle } from "./aggregate.js";
33
33
  import type { WorkspaceInvariantDecl } from "./framework/workspace_invariant.js";
34
34
  import type { RelationTypeDecl } from "./relation_schema.js";
35
- import type { Field, FieldKind } from "./fields.js";
35
+ import type { Field, FieldKind, ValueObjectDescriptor, ValueObjectFieldDescriptor } from "./fields.js";
36
36
  import type { Directive } from "./directive.js";
37
37
  import { executeDirectiveToIntent } from "./wire_encode.js";
38
38
  import { deterministicPorts } from "./ctx.js";
@@ -347,6 +347,13 @@ interface AggFieldSpec {
347
347
  * `undefined` for a non-map field.
348
348
  */
349
349
  mapValueKind?: FieldKind;
350
+ /**
351
+ * For a `json`-kind field lowered from a NAMED VALUE OBJECT (`ValueObject.field()`):
352
+ * the generated Dart class name. The field decodes via `readJsonObject…` +
353
+ * `<VoClass>.fromProjected(m)` (the EvidenceRef pattern) and types as the class.
354
+ * `undefined` for a plain `t.json()`/`t.jsonObject()` field.
355
+ */
356
+ voClass?: string;
350
357
  /**
351
358
  * For a `json`-kind field: the asserted decoded SHAPE (`field.jsonShape`). `"object"`
352
359
  * (a `t.jsonObject()` leaf, e.g. `location`) means the value is ALWAYS a JSON object —
@@ -581,8 +588,21 @@ function aggFieldSpec(
581
588
  ...(valueClass !== undefined ? { valueClass } : {}),
582
589
  };
583
590
  case "json":
591
+ // A NAMED VALUE OBJECT (`ValueObject.field()`) types as its generated Dart class
592
+ // (typed decode via `.fromProjected`, the EvidenceRef pattern). Otherwise
584
593
  // `jsonShape` ("object" via `t.jsonObject()`) chooses strict-object vs
585
594
  // permissive read-model type.
595
+ if (field.valueObject !== undefined) {
596
+ return {
597
+ name,
598
+ wireName,
599
+ kind: field.kind,
600
+ dartType: field.valueObject.name,
601
+ optional,
602
+ voClass: field.valueObject.name,
603
+ ...(field.jsonShape !== undefined ? { jsonShape: field.jsonShape } : {}),
604
+ };
605
+ }
586
606
  return {
587
607
  name,
588
608
  wireName,
@@ -1166,6 +1186,275 @@ function emitEvidenceRefClass(): string {
1166
1186
  ].join("\n");
1167
1187
  }
1168
1188
 
1189
+ // ── value objects → Dart classes (`value_object.ts` descriptors) ─────────────────────
1190
+
1191
+ /** The Dart coordinates type per RFC 7946 geometry kind. */
1192
+ const geometryCoordsDart: Record<string, string> = {
1193
+ Point: "List<double>",
1194
+ LineString: "List<List<double>>",
1195
+ Polygon: "List<List<List<double>>>",
1196
+ };
1197
+
1198
+ /**
1199
+ * The generated Dart enum NAME for one VO enum field: `<VoName><FieldName>Enum`
1200
+ * (e.g. `PostalAddressCountryCodeEnum`). The VO is the field's semantic home — the
1201
+ * exact analogue of the aggregate-enum convention's domain prefix (`collectEnums`'s
1202
+ * `<Domain><Field>Enum`), so two barrel-exported domains never collide on a bare name.
1203
+ * IDENTITY = THE VO FIELD, not the value set: two identical value-sets in DIFFERENT
1204
+ * VOs stay separate types (VO names are package-unique, so so are these).
1205
+ */
1206
+ function voEnumName(voName: string, fieldName: string): string {
1207
+ return `${voName}${dartTypeNameFromField(fieldName)}Enum`;
1208
+ }
1209
+
1210
+ /** The tolerant-decode SENTINEL case name — dodge a literal enum value that would
1211
+ * collide with it (`dartIdent("unknownWire") === "unknownWire"`). */
1212
+ function voEnumSentinel(values: readonly string[]): string {
1213
+ let s = "unknownWire";
1214
+ while (values.some((v) => dartIdent(v) === s)) s = `${s}_`;
1215
+ return s;
1216
+ }
1217
+
1218
+ /** Emit ONE generated Dart enum for a VO enum field. TOLERANT DECODE (law evolves —
1219
+ * a new value added by a later law must NOT crash an old client): `fromWire` maps an
1220
+ * unrecognized wire string to the [sentinel] case instead of throwing (the deliberate
1221
+ * divergence from the strict aggregate-enum `fromWire`; VO data crosses law versions). */
1222
+ function emitVoEnum(voName: string, fieldName: string, values: readonly string[]): string {
1223
+ const name = voEnumName(voName, fieldName);
1224
+ const sentinel = voEnumSentinel(values);
1225
+ const variants = values.map((v) => ` ${dartIdent(v)}('${v}')`).join(",\n");
1226
+ return [
1227
+ `/// Generated enum for value object field '${voName}.${fieldName}': ${values.join(", ")}.`,
1228
+ `/// TOLERANT DECODE: an unrecognized wire value (law can add enum values; old`,
1229
+ `/// clients must not crash on new data) decodes to [${name}.${sentinel}] — never a throw.`,
1230
+ `/// [wire] is the exact wire string ([${sentinel}].wire is the '__unknown__' marker;`,
1231
+ `/// authoring code constructs KNOWN cases, so toJson never writes it in practice).`,
1232
+ `enum ${name} {`,
1233
+ `${variants},`,
1234
+ ` /// The tolerant-decode sentinel — an enum value this client's law snapshot does not know.`,
1235
+ ` ${sentinel}('__unknown__');`,
1236
+ ``,
1237
+ ` final String wire;`,
1238
+ ` const ${name}(this.wire);`,
1239
+ ``,
1240
+ ` static ${name} fromWire(String wire) =>`,
1241
+ ` ${name}.values.firstWhere((e) => e.wire == wire,`,
1242
+ ` orElse: () => ${name}.${sentinel});`,
1243
+ `}`,
1244
+ ].join("\n");
1245
+ }
1246
+
1247
+ /** Every (fieldName, values) enum pair a VO descriptor carries (arrays recursed —
1248
+ * 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);
1254
+ };
1255
+ for (const [fieldName, f] of Object.entries(d.fields ?? {})) walk(fieldName, f);
1256
+ return out;
1257
+ }
1258
+
1259
+ /** The Dart type of one VO descriptor field. `voName`/`fieldName` name the field's
1260
+ * generated enum type (`<VoName><FieldName>Enum`) — threaded through arrays. */
1261
+ function dartTypeOfVoField(f: ValueObjectFieldDescriptor, voName: string, fieldName: string): string {
1262
+ switch (f.type) {
1263
+ case "string":
1264
+ return "String";
1265
+ case "number":
1266
+ return "double";
1267
+ case "boolean":
1268
+ return "bool";
1269
+ case "enum":
1270
+ // A REAL generated Dart enum (`emitVoEnum`), named by the owning VO field.
1271
+ return voEnumName(voName, fieldName);
1272
+ case "array":
1273
+ return `List<${dartTypeOfVoField(f.items, voName, fieldName)}>`;
1274
+ case "object":
1275
+ case "geometry":
1276
+ return f.ref; // the named VO class (emitted alongside / imported)
1277
+ }
1278
+ }
1279
+
1280
+ /** A Dart expression converting the RAW decoded json value `expr` to the field's typed
1281
+ * value. `depth` names nested lambda vars (`e0`, `e1`, …) for arrays-of-arrays. */
1282
+ function voFromExpr(
1283
+ f: ValueObjectFieldDescriptor,
1284
+ expr: string,
1285
+ voName: string,
1286
+ fieldName: string,
1287
+ depth = 0,
1288
+ ): string {
1289
+ switch (f.type) {
1290
+ case "string":
1291
+ return `${expr} as String`;
1292
+ case "enum":
1293
+ // TOLERANT typed decode — an unknown wire value becomes the sentinel case.
1294
+ return `${voEnumName(voName, fieldName)}.fromWire(${expr} as String)`;
1295
+ case "number":
1296
+ return `(${expr} as num).toDouble()`;
1297
+ case "boolean":
1298
+ return `${expr} as bool`;
1299
+ case "array": {
1300
+ const v = `e${depth}`;
1301
+ return `(${expr} as List).map((${v}) => ${voFromExpr(f.items, v, voName, fieldName, depth + 1)}).toList()`;
1302
+ }
1303
+ case "object":
1304
+ case "geometry":
1305
+ return `${f.ref}.fromProjected((${expr} as Map).cast<String, Object?>())`;
1306
+ }
1307
+ }
1308
+
1309
+ /** A Dart expression serializing the typed member `expr` back to plain json. */
1310
+ function voToJsonExpr(f: ValueObjectFieldDescriptor, expr: string, depth = 0): string {
1311
+ switch (f.type) {
1312
+ case "string":
1313
+ case "number":
1314
+ case "boolean":
1315
+ return expr;
1316
+ case "enum":
1317
+ return `${expr}.wire`; // the exact wire string the law's zod enum validates
1318
+ case "array": {
1319
+ const inner = voToJsonExpr(f.items, `e${depth}`, depth + 1);
1320
+ return inner === `e${depth}` ? expr : `${expr}.map((e${depth}) => ${inner}).toList()`;
1321
+ }
1322
+ case "object":
1323
+ case "geometry":
1324
+ return `${expr}.toJson()`;
1325
+ }
1326
+ }
1327
+
1328
+ /** Deep json equality/hash helpers shared by every generated VO class — emitted ONCE
1329
+ * per file when the file emits ≥1 VO class (`toJson()`-based value equality). */
1330
+ function emitVoHelpers(): string {
1331
+ return [
1332
+ `/// Deep json equality for generated value-object classes (toJson-based).`,
1333
+ `bool _voJsonEq(Object? a, Object? b) {`,
1334
+ ` if (a is Map && b is Map) {`,
1335
+ ` if (a.length != b.length) return false;`,
1336
+ ` for (final k in a.keys) {`,
1337
+ ` if (!b.containsKey(k) || !_voJsonEq(a[k], b[k])) return false;`,
1338
+ ` }`,
1339
+ ` return true;`,
1340
+ ` }`,
1341
+ ` if (a is List && b is List) {`,
1342
+ ` if (a.length != b.length) return false;`,
1343
+ ` for (var i = 0; i < a.length; i++) {`,
1344
+ ` if (!_voJsonEq(a[i], b[i])) return false;`,
1345
+ ` }`,
1346
+ ` return true;`,
1347
+ ` }`,
1348
+ ` return a == b;`,
1349
+ `}`,
1350
+ ``,
1351
+ `/// Deep json hash for generated value-object classes (toJson-based).`,
1352
+ `int _voJsonHash(Object? v) {`,
1353
+ ` if (v is Map) {`,
1354
+ ` return Object.hashAllUnordered(`,
1355
+ ` v.entries.map((e) => Object.hash(e.key, _voJsonHash(e.value))));`,
1356
+ ` }`,
1357
+ ` if (v is List) return Object.hashAll(v.map(_voJsonHash));`,
1358
+ ` return v.hashCode;`,
1359
+ `}`,
1360
+ ].join("\n");
1361
+ }
1362
+
1363
+ /** Emit ONE generated Dart value-object class from its canonical descriptor: typed
1364
+ * final fields, a const ctor, `fromProjected` (the projection's decoded json object),
1365
+ * `toJson` (the authored payload shape — optionals OMITTED when null, matching the zod
1366
+ * `.optional()` contract), and toJson-based value equality. A `geo.*` VO emits the
1367
+ * fixed RFC 7946 shape (typed `coordinates`; `type` is the constant geometry kind). */
1368
+ function emitValueObjectClass(d: ValueObjectDescriptor): string {
1369
+ if (d.geometry !== undefined) {
1370
+ const coordsType = geometryCoordsDart[d.geometry]!;
1371
+ const coordsFrom: Record<string, string> = {
1372
+ Point: `(m['coordinates']! as List).map((e0) => (e0 as num).toDouble()).toList()`,
1373
+ LineString: `(m['coordinates']! as List).map((e0) => (e0 as List).map((e1) => (e1 as num).toDouble()).toList()).toList()`,
1374
+ Polygon: `(m['coordinates']! as List).map((e0) => (e0 as List).map((e1) => (e1 as List).map((e2) => (e2 as num).toDouble()).toList()).toList()).toList()`,
1375
+ };
1376
+ return [
1377
+ `/// RFC 7946 ${d.geometry} — a \`geo.*\` value object ('${d.name}'). Positions are`,
1378
+ `/// \`[lng, lat]\` or \`[lng, lat, altitude]\` (RFC 7946 order).`,
1379
+ `class ${d.name} {`,
1380
+ ` final ${coordsType} coordinates;`,
1381
+ ``,
1382
+ ` const ${d.name}({required this.coordinates});`,
1383
+ ``,
1384
+ ` /// The RFC 7946 geometry kind (constant).`,
1385
+ ` String get type => '${d.geometry}';`,
1386
+ ``,
1387
+ ` factory ${d.name}.fromProjected(Map<String, Object?> m) =>`,
1388
+ ` ${d.name}(coordinates: ${coordsFrom[d.geometry]!});`,
1389
+ ``,
1390
+ ` Map<String, Object?> toJson() => <String, Object?>{`,
1391
+ ` 'type': '${d.geometry}',`,
1392
+ ` 'coordinates': coordinates,`,
1393
+ ` };`,
1394
+ ``,
1395
+ ` @override`,
1396
+ ` bool operator ==(Object other) =>`,
1397
+ ` other is ${d.name} && _voJsonEq(other.toJson(), toJson());`,
1398
+ ``,
1399
+ ` @override`,
1400
+ ` int get hashCode => _voJsonHash(toJson());`,
1401
+ `}`,
1402
+ ].join("\n");
1403
+ }
1404
+ const fields = Object.entries(d.fields ?? {}).map(([wireName, f]) => ({
1405
+ wireName,
1406
+ member: dartMemberName(wireName),
1407
+ f,
1408
+ optional: f.optional === true,
1409
+ }));
1410
+ const decls = fields.map(
1411
+ (x) => ` final ${dartTypeOfVoField(x.f, d.name, x.wireName)}${x.optional ? "?" : ""} ${x.member};`,
1412
+ );
1413
+ const ctor = fields.map((x) => (x.optional ? ` this.${x.member},` : ` required this.${x.member},`));
1414
+ const fromLines = fields.map((x) =>
1415
+ x.optional
1416
+ ? ` ${x.member}: m['${x.wireName}'] == null ? null : ${voFromExpr(x.f, `m['${x.wireName}']!`, d.name, x.wireName)},`
1417
+ : ` ${x.member}: ${voFromExpr(x.f, `m['${x.wireName}']!`, d.name, x.wireName)},`,
1418
+ );
1419
+ const toLines = fields.map((x) =>
1420
+ x.optional
1421
+ ? ` if (${x.member} != null) '${x.wireName}': ${voToJsonExpr(x.f, `${x.member}!`)},`
1422
+ : ` '${x.wireName}': ${voToJsonExpr(x.f, x.member)},`,
1423
+ );
1424
+ return [
1425
+ `/// Generated value object '${d.name}' (value_object.ts). Stored as ONE json-object`,
1426
+ `/// value leaf on its aggregate field — replaced WHOLESALE on write (Lww-class; no`,
1427
+ `/// per-subfield merge). Optional members are OMITTED from [toJson] when null (the`,
1428
+ `/// zod \`.optional()\` contract — absent, never JSON null).`,
1429
+ `class ${d.name} {`,
1430
+ ...decls,
1431
+ ``,
1432
+ ` const ${d.name}({`,
1433
+ ...ctor,
1434
+ ` });`,
1435
+ ``,
1436
+ ` /// Read from the projection's decoded json object.`,
1437
+ ` factory ${d.name}.fromProjected(Map<String, Object?> m) {`,
1438
+ ` return ${d.name}(`,
1439
+ ...fromLines,
1440
+ ` );`,
1441
+ ` }`,
1442
+ ``,
1443
+ ` /// The authored payload shape (embed it in a directive payload / \`set()\`).`,
1444
+ ` Map<String, Object?> toJson() => <String, Object?>{`,
1445
+ ...toLines,
1446
+ ` };`,
1447
+ ``,
1448
+ ` @override`,
1449
+ ` bool operator ==(Object other) =>`,
1450
+ ` other is ${d.name} && _voJsonEq(other.toJson(), toJson());`,
1451
+ ``,
1452
+ ` @override`,
1453
+ ` int get hashCode => _voJsonHash(toJson());`,
1454
+ `}`,
1455
+ ].join("\n");
1456
+ }
1457
+
1169
1458
  /** True when ANY aggregate field in the module is `t.evidence()` — gates emitting the
1170
1459
  * shared [emitEvidenceRefClass] once. */
1171
1460
  function moduleUsesEvidence(aggregates: readonly AggregateHandle[]): boolean {
@@ -1235,8 +1524,15 @@ function parseField(s: AggFieldSpec, aggregate: string): string {
1235
1524
  ? `readStrOrNull(data, '${s.wireName}')`
1236
1525
  : `readStr(data, '${aggregate}', '${s.wireName}')`;
1237
1526
  case "json":
1238
- // A `json`-kind field is typed `String` on the aggregate; the projection may
1239
- // have parsed its stored JSON into a container — re-stringify to keep `String`.
1527
+ // A NAMED VALUE OBJECT reads the projection's decoded object and wraps it in its
1528
+ // generated class (the EvidenceRef pattern). A plain `json`-kind field is typed
1529
+ // `String` on the aggregate; the projection may have parsed its stored JSON into a
1530
+ // container — re-stringify to keep `String`.
1531
+ if (s.voClass !== undefined) {
1532
+ return opt
1533
+ ? `() { final m = readJsonObjectOrNull(data, '${aggregate}', '${s.wireName}'); return m == null ? null : ${s.voClass}.fromProjected(m); }()`
1534
+ : `${s.voClass}.fromProjected(readJsonObject(data, '${aggregate}', '${s.wireName}'))`;
1535
+ }
1240
1536
  return opt
1241
1537
  ? `readJsonStrOrNull(data, '${s.wireName}')`
1242
1538
  : `readJsonStr(data, '${aggregate}', '${s.wireName}')`;
@@ -1304,6 +1600,8 @@ function readModelDartType(s: AggFieldSpec): string {
1304
1600
  // has an UNKNOWN/non-object decoded shape (a double/bool/list authored as a JSON
1305
1601
  // leaf — the kernel `Value` is Str|Int only), so it is surfaced as the permissive
1306
1602
  // `Object?` (the read engine's decoded value as-is — never coerced/thrown).
1603
+ // A NAMED VALUE OBJECT reads as its generated class (typed decode).
1604
+ if (s.voClass !== undefined) return s.voClass;
1307
1605
  return isJsonObjectLeaf(s) ? "Map<String, Object?>" : "Object?";
1308
1606
  // `position`/`counter` are normalized to `string`/`int` in `aggFieldSpec` (read-collapsed scalars),
1309
1607
  // so these labels are unreachable at runtime — present for FieldKind exhaustiveness.
@@ -1380,6 +1678,12 @@ function parseReadModelField(s: AggFieldSpec, aggregate: string): string {
1380
1678
  // shape (a double/bool/list authored as a JSON leaf), so surface the read engine's
1381
1679
  // decoded value AS-IS via the NON-THROWING `readJsonValueOrNull` (`Object?`, null
1382
1680
  // when absent) — NEVER a strict-object reader that would throw on a legit non-object.
1681
+ if (s.voClass !== undefined) {
1682
+ // A NAMED VALUE OBJECT: the decoded object wrapped in its generated class.
1683
+ return opt
1684
+ ? `() { final m = readJsonObjectOrNull(data, '${aggregate}', '${s.wireName}'); return m == null ? null : ${s.voClass}.fromProjected(m); }()`
1685
+ : `${s.voClass}.fromProjected(readJsonObject(data, '${aggregate}', '${s.wireName}'))`;
1686
+ }
1383
1687
  if (isPermissiveJsonLeaf(s)) {
1384
1688
  return `readJsonValueOrNull(data, '${s.wireName}')`;
1385
1689
  }
@@ -2489,6 +2793,16 @@ export interface DomainModule {
2489
2793
  * AST is hashed; the recursive Check/Expand EVALUATOR is the Rust engine substrate, never authored.
2490
2794
  */
2491
2795
  relationTypes?: RelationTypeDecl[];
2796
+ /**
2797
+ * The domain's FIRST-CLASS VALUE OBJECTS (`value_object.ts`) — the deduped, sorted
2798
+ * canonical descriptors of every named VO the aggregates carry (transitive nested VOs
2799
+ * included; collected by `composeDomainModule`). ADDITIVE + OPTIONAL: a VO-free domain
2800
+ * omits this entirely (the `nomosValueObjects` era rule — package bytes unchanged).
2801
+ * NEVER in the canonical manifest/domainHash: a VO field's manifest bytes equal a plain
2802
+ * `t.jsonObject()` field's. Drives the generated TS interfaces + Dart classes and the
2803
+ * additive `nomosValueObjects` USD key (`usd.ts`).
2804
+ */
2805
+ valueObjects?: readonly ValueObjectDescriptor[];
2492
2806
  /**
2493
2807
  * The domain's FIRST-CLASS WORKSPACE TYPES (`workspace_type.ts`; sharding §10
2494
2808
  * RATIFIED, slice 1) — the law-declared workspace taxonomy
@@ -2598,6 +2912,13 @@ export interface GenerateDartDomainOptions {
2598
2912
  * `import` so skipped framework symbols resolve. Omit when nothing is skipped.
2599
2913
  */
2600
2914
  readonly frameworkImportUri?: string;
2915
+ /**
2916
+ * VALUE OBJECT names whose generated Dart class is emitted ONCE per package in the shared
2917
+ * `nomos_framework.dart` (a VO used by ≥2 domains) and MUST NOT be re-emitted here — this
2918
+ * domain references them via {@link frameworkImportUri}. Empty/absent ⇒ emit every VO the
2919
+ * module carries locally.
2920
+ */
2921
+ readonly skipValueObjectNames?: ReadonlySet<string>;
2601
2922
  }
2602
2923
 
2603
2924
  /** Generate the full Dart source for one domain module. */
@@ -2606,6 +2927,22 @@ export function generateDartDomain(mod: DomainModule, opts: GenerateDartDomainOp
2606
2927
  const skipAggregateIds = opts.skipAggregateIds ?? new Set<string>();
2607
2928
  const skipDirectiveIds = opts.skipDirectiveIds ?? new Set<string>();
2608
2929
  const skipQueryIds = opts.skipQueryIds ?? new Set<string>();
2930
+ const skipValueObjectNames = opts.skipValueObjectNames ?? new Set<string>();
2931
+ // VALUE OBJECTS: one generated class per named VO the module carries (composeDomainModule
2932
+ // collected + sorted them), minus the package-shared ones (emitted once in the framework file).
2933
+ const localValueObjects = (mod.valueObjects ?? []).filter((d) => !skipValueObjectNames.has(d.name));
2934
+ // Each VO's block = its FIELD ENUMS (real Dart enums, tolerant fromWire) then its class —
2935
+ // so skipping a shared VO (the framework-file dedup) skips its enums with it.
2936
+ const voBlock =
2937
+ localValueObjects.length > 0
2938
+ ? [
2939
+ emitVoHelpers(),
2940
+ ...localValueObjects.flatMap((d) => [
2941
+ ...voEnumFields(d).map((e) => emitVoEnum(d.name, e.fieldName, e.values)),
2942
+ emitValueObjectClass(d),
2943
+ ]),
2944
+ ].join("\n\n")
2945
+ : "";
2609
2946
  // The symbols whose Dart surface this file EMITS (framework-shared ones live in the shared file).
2610
2947
  const localAggregates = mod.aggregates.filter((a) => !skipAggregateIds.has(a.id));
2611
2948
  const localDirectives = mod.directives.filter((d) => !skipDirectiveIds.has(d.id));
@@ -2841,6 +3178,13 @@ export function generateDartDomain(mod: DomainModule, opts: GenerateDartDomainOp
2841
3178
  ``,
2842
3179
  ]
2843
3180
  : []),
3181
+ ...(voBlock !== ""
3182
+ ? [
3183
+ `// ---- value objects (named composable json-object leaves; value_object.ts) ----`,
3184
+ voBlock,
3185
+ ``,
3186
+ ]
3187
+ : []),
2844
3188
  `// ---- aggregates (projection types) ----`,
2845
3189
  aggBlock,
2846
3190
  ``,
package/src/codegen_ts.ts CHANGED
@@ -37,7 +37,7 @@
37
37
  import type { z } from "zod";
38
38
 
39
39
  import type { AggregateHandle } from "./aggregate.js";
40
- import type { Field } from "./fields.js";
40
+ import type { Field, ValueObjectDescriptor, ValueObjectFieldDescriptor } from "./fields.js";
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";
@@ -249,6 +249,9 @@ function tsTypeOfField(field: Field): string {
249
249
  case "ref":
250
250
  return "string"; // the related aggregate's id
251
251
  case "json":
252
+ // A NAMED VALUE OBJECT field reads as its generated interface (`value_object.ts`);
253
+ // a bare `t.jsonObject()` stays the anonymous object; a plain `t.json()` is unknown.
254
+ if (field.valueObject !== undefined) return field.valueObject.name;
252
255
  return field.jsonShape === "object" ? "Record<string, unknown>" : "unknown";
253
256
  case "set":
254
257
  return "string[]"; // `t.set(t.string())` is the one set shape (fields.ts)
@@ -264,6 +267,56 @@ function tsTypeOfField(field: Field): string {
264
267
  }
265
268
  }
266
269
 
270
+ // ── value objects → TS interfaces (`value_object.ts` descriptors) ─────────────────
271
+
272
+ /** The coordinates TS type per RFC 7946 geometry kind. */
273
+ const geometryCoordsTs: Record<string, string> = {
274
+ Point: "number[]",
275
+ LineString: "number[][]",
276
+ Polygon: "number[][][]",
277
+ };
278
+
279
+ /** One descriptor field → its TS type text. NO FALLBACK (the descriptor set is closed). */
280
+ function tsTypeOfVoField(f: ValueObjectFieldDescriptor): string {
281
+ switch (f.type) {
282
+ case "string":
283
+ return "string";
284
+ case "number":
285
+ return "number";
286
+ case "boolean":
287
+ return "boolean";
288
+ case "enum":
289
+ return union(f.values.map((v) => JSON.stringify(v)));
290
+ case "array":
291
+ return `${paren(tsTypeOfVoField(f.items))}[]`;
292
+ case "object":
293
+ case "geometry":
294
+ return f.ref; // the named VO interface (emitted alongside)
295
+ }
296
+ }
297
+
298
+ /** Emit one `export interface <VO> { ... }` from its canonical descriptor. A geo VO
299
+ * emits the fixed RFC 7946 shape (`{ type: "<kind>"; coordinates: ... }`). */
300
+ function emitValueObjectTs(d: ValueObjectDescriptor): string[] {
301
+ if (d.geometry !== undefined) {
302
+ return [
303
+ `/** RFC 7946 ${d.geometry} — a \`geo.${d.geometry === "Point" ? "point" : d.geometry === "LineString" ? "lineString" : "polygon"}(...)\` value object. */`,
304
+ `export interface ${d.name} { type: ${JSON.stringify(d.geometry)}; coordinates: ${geometryCoordsTs[d.geometry]}; }`,
305
+ ``,
306
+ ];
307
+ }
308
+ const lines = Object.entries(d.fields ?? {}).map(
309
+ ([name, f]) => ` ${name}${f.optional === true ? "?" : ""}: ${tsTypeOfVoField(f)};`,
310
+ );
311
+ return [
312
+ `/** Value object \`${d.name}\` — ONE json-object value leaf, replaced WHOLESALE on write (no per-subfield merge). */`,
313
+ `export interface ${d.name} {`,
314
+ ...lines,
315
+ `}`,
316
+ ``,
317
+ ];
318
+ }
319
+
267
320
  /** A loose TS type for a derived/combined field's declared zod `returns`. */
268
321
  function tsTypeOfReturns(returns: unknown): string {
269
322
  try {
@@ -420,6 +473,26 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
420
473
  );
421
474
  }
422
475
  }
476
+ // ── value objects: one interface per named VO, deduped across the package's domains ──
477
+ const voByName = new Map<string, ValueObjectDescriptor>();
478
+ for (const mod of modules) {
479
+ for (const d of (mod as { valueObjects?: readonly ValueObjectDescriptor[] }).valueObjects ?? []) {
480
+ const existing = voByName.get(d.name);
481
+ if (existing !== undefined && JSON.stringify(existing) !== JSON.stringify(d)) {
482
+ throw new Error(
483
+ `codegen-ts: value object '${d.name}' has DIVERGENT shapes across domains — one name, one shape.`,
484
+ );
485
+ }
486
+ voByName.set(d.name, d);
487
+ }
488
+ }
489
+ if (voByName.size > 0) {
490
+ out.push(`// ── value objects (named, composable json-object leaves — value_object.ts) ──`, ``);
491
+ for (const [, d] of [...voByName].sort(([a], [b]) => a.localeCompare(b))) {
492
+ out.push(...emitValueObjectTs(d));
493
+ }
494
+ }
495
+
423
496
  out.push(`// ── read models (projection \`data\` shapes; every field is partial-fold optional) ──`, ``);
424
497
  for (const [typeId, agg] of [...aggByType].sort(([a], [b]) => a.localeCompare(b))) {
425
498
  out.push(`export interface ${typeId}Data {`);
@@ -41,6 +41,7 @@
41
41
  * re-baking is a later, gated step. SYNTHETIC framework units only.
42
42
  */
43
43
  import type { Directive } from "./directive.js";
44
+ import { isNamespacedAggregate, READ_PROJECTION_DELIMITER } from "./read_projection_namespace.js";
44
45
  import type { AggregateHandle, AggregateInvariantFn } from "./aggregate.js";
45
46
  import type { ComposeOptions } from "./compose.js";
46
47
  import { flattenUsd, usdHash, type UsdDocument, type UsdPrim } from "./usd.js";
@@ -274,7 +275,17 @@ export function compileEngineModule(
274
275
  `id "${directiveId}".`,
275
276
  );
276
277
  }
277
- if (binding.directive.aggregateId !== prim.target) {
278
+ // READ-PROJECTION NAMESPACING: the USD law's target is the QUALIFIED type name for a
279
+ // non-global aggregate (`<domain>__<handleId>`); the executable binding keeps the bare
280
+ // handle id (the source literal). Accept either the bare id (global / pre-namespacing law)
281
+ // or the domain-qualified form (the prim's own layer domain — the 2nd path segment).
282
+ const aggGlobal = (binding.agg as { global?: boolean }).global;
283
+ const targetMatches = (aggId: string): boolean =>
284
+ prim.target === aggId ||
285
+ (isNamespacedAggregate(aggGlobal === undefined ? { id: aggId } : { id: aggId, global: aggGlobal }) &&
286
+ prim.target.endsWith(`${READ_PROJECTION_DELIMITER}${aggId}`) &&
287
+ prim.target.length > READ_PROJECTION_DELIMITER.length + aggId.length);
288
+ if (!targetMatches(binding.directive.aggregateId)) {
278
289
  throw new EngineCompileError(
279
290
  "binding-mismatch",
280
291
  directiveId,
@@ -282,7 +293,7 @@ export function compileEngineModule(
282
293
  `USD law's target is "${prim.target}".`,
283
294
  );
284
295
  }
285
- if (binding.agg.id !== prim.target) {
296
+ if (!targetMatches(binding.agg.id)) {
286
297
  throw new EngineCompileError(
287
298
  "binding-mismatch",
288
299
  directiveId,