@githolon/dsl 0.37.0 → 0.39.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/dart/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## 0.29.0
2
+
3
+ - LAW-MINTED IDS RETURN TO THE CALLER: `NomosBridge.offerCreates(...)` → `NomosOfferOutcome{head, created}` — a `.creates` plan mints its aggregate id inside the sealed intent (payloads never carry one); the offer now hands the minted ids back, so generated create methods return the typed id with no query-after round-trip. New types `NomosOfferOutcome` / `NomosCreated`.
4
+ - `NomosBridge.mintId(typeTag)` — reserve a kernel-minted typed id (`<TypeTag>_<uuidv7>`) via the holon's front-door mint, for app-side references. (`.creates` payloads still carry NO id — the law mints those.)
5
+ - Generated Dart clients (`nomos-compile` ≥0.38 tooling): `.creates(...)` directive methods now return `Future<NomosOfferOutcome>` instead of `Future<String>` — `outcome.created.single.id` is the new aggregate's typed id.
6
+
1
7
  ## 0.28.0
2
8
 
3
9
  - `NomosTestHarness` (`nomos_headless.dart`): ephemeral real-law fixtures — one call births a throwaway cloud workspace, deploys compiled `*.deploy.json` law, and hands out independent client replicas (`connect(clientId:)`) over the node runner. Identity defaults to the `githolon login --agent` session (`~/.holon/credentials.json`). Live-proven: birth → deploy → write on one replica → converge + typed read on another, 14s end-to-end.
@@ -265,6 +265,23 @@ class SyncStatus {
265
265
  );
266
266
  }
267
267
 
268
+ /// One law-minted aggregate from a `.creates(...)` offer: the full typed [id] (`<domain>.<Type>_<uuidv7>`)
269
+ /// and its [type] (`<domain>.<Type>`).
270
+ class NomosCreated {
271
+ final String type;
272
+ final String id;
273
+ const NomosCreated({required this.type, required this.id});
274
+ @override
275
+ String toString() => id;
276
+ }
277
+
278
+ /// The result of [NomosBridge.offerCreates]: the new local [head] plus every aggregate the plan minted.
279
+ class NomosOfferOutcome {
280
+ final String head;
281
+ final List<NomosCreated> created;
282
+ const NomosOfferOutcome({required this.head, required this.created});
283
+ }
284
+
268
285
  /// THE BRIDGE. Construct over a [BridgeTransport], `await ready`, then `connect(...)` and drive the holon.
269
286
  class NomosBridge {
270
287
  final BridgeTransport _transport;
@@ -509,6 +526,42 @@ class NomosBridge {
509
526
  return r['head'].toString();
510
527
  }
511
528
 
529
+ /// [offerDirective], answering the LAW-MINTED ids too. A `.creates(...)` plan mints its aggregate id
530
+ /// inside the sealed intent (callers never supply one); the runner hands back what was minted, so a
531
+ /// generated create method returns the typed id with NO query-after round-trip. [created] is empty for
532
+ /// pure mutations.
533
+ Future<NomosOfferOutcome> offerCreates({
534
+ required String domain,
535
+ required String directiveId,
536
+ required Object? payload,
537
+ String? actor,
538
+ String? domainHash,
539
+ }) async {
540
+ final r = _asMap(await _call('offerDirective', {
541
+ 'domain': domain,
542
+ 'directiveId': directiveId,
543
+ 'payload': payload,
544
+ if (actor != null) 'actor': actor,
545
+ if (domainHash != null) 'domainHash': domainHash,
546
+ }));
547
+ return NomosOfferOutcome(
548
+ head: r['head'].toString(),
549
+ created: ((r['created'] as List?) ?? const [])
550
+ .whereType<Map>()
551
+ .map((m) => NomosCreated(
552
+ type: (m['type'] ?? '').toString(),
553
+ id: (m['id'] ?? '').toString()))
554
+ .toList(),
555
+ );
556
+ }
557
+
558
+ /// Reserve a KERNEL-MINTED typed id (`<TypeTag>_<uuidv7>`) via the holon's front-door mint — for app-side
559
+ /// references (routing keys, optimistic UI). `.creates` payloads still carry NO id: the LAW mints those
560
+ /// (see [offerCreates], which returns them).
561
+ Future<String> mintId(String typeTag) async =>
562
+ (_asMap(await _call('mintId', {'typeTag': typeTag}))['id'] ?? '')
563
+ .toString();
564
+
512
565
  /// UNDO the most-recent action via the kernel STRIKE primitive (the fold drops the struck intent by parity).
513
566
  /// [scope] `'all'` undoes any author's most-recent action; `'user'` only THIS replica's own. [actions] is the
514
567
  /// app's set of undoable directive ids (so undo never strikes a framework/control intent). [domain] +
package/dart/pubspec.yaml CHANGED
@@ -11,7 +11,7 @@ description: >-
11
11
  Nomos 2 Dart frontend types — the hand-written kernel wire types plus the
12
12
  code-generated per-domain aggregate + directive-payload types. (De)serializes
13
13
  the EXACT serde JSON the Rust kernel + projection produce/consume.
14
- version: 0.28.0
14
+ version: 0.29.0
15
15
 
16
16
  environment:
17
17
  sdk: ^3.5.0
@@ -44,7 +44,9 @@ void main() {
44
44
  try {
45
45
  final writer = await harness.connect(clientId: 'writer');
46
46
  final reader = await harness.connect(clientId: 'reader');
47
- await writer.offerDirective(
47
+ final minted = await writer.mintId('Fixture');
48
+ expect(minted, startsWith('Fixture_'));
49
+ final outcome = await writer.offerCreates(
48
50
  domain: 'guestbook',
49
51
  directiveId: 'signGuestbook',
50
52
  payload: {
@@ -53,10 +55,13 @@ void main() {
53
55
  'mood': 'delighted',
54
56
  'signedAt': DateTime.now().toUtc().toIso8601String(),
55
57
  });
58
+ expect(outcome.created, hasLength(1));
59
+ expect(outcome.created.single.type, 'guestbook.GuestbookEntry');
56
60
  await writer.sync(admit: true);
57
61
  await reader.sync(admit: false);
58
62
  final rows = await reader.query('entriesByAuthor', {'author': 'harness'});
59
63
  expect(rows, hasLength(1));
64
+ expect(rows.first['id'], outcome.created.single.id);
60
65
  expect(
61
66
  (rows.first['data'] as Map)['message'], 'ephemeral fixture green');
62
67
  stdout.writeln(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.37.0",
3
+ "version": "0.39.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 } 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,9 @@ 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()`.
64
67
 
65
68
  export interface PayloadFieldSpec {
66
69
  name: string;
@@ -262,19 +265,31 @@ function introspectField(
262
265
  }
263
266
  case "ZodArray": {
264
267
  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.
268
+ // A `string[]` is a typed `List<String>`. A list of DECLARED VALUE OBJECTS is typed
269
+ // with the generated VO class (vo_authoring_end_to_end.md slice B). ANY other element
270
+ // type is arbitrary JSON DATA to the engine, modelled as `List<Object?>`.
268
271
  if (zodTypeName(elem) === "ZodString") {
269
272
  return { name, dartType: "List<String>", toJson: "strList" };
270
273
  }
274
+ const elemVo = voMetaOf(elem);
275
+ if (elemVo !== undefined) {
276
+ return { name, dartType: `List<${elemVo.name}>`, toJson: "voList" };
277
+ }
271
278
  return { name, dartType: "List<Object?>", toJson: "json" };
272
279
  }
273
- case "ZodObject":
280
+ case "ZodObject": {
281
+ // A DECLARED VALUE OBJECT keeps its name: the payload field is the generated VO class
282
+ // (typed author path — the same class the read model decodes to), serialised via its
283
+ // own `toJson()`. An anonymous `z.object(...)` stays the raw-map lane below.
284
+ const vo = voMetaOf(zt);
285
+ if (vo !== undefined) {
286
+ return { name, dartType: vo.name, toJson: "vo" };
287
+ }
274
288
  // Nested object payload (e.g. `target: z.object({targetType, targetId})`). The
275
289
  // engine receives it as DATA verbatim, so we model it as an arbitrary JSON map
276
290
  // — the call site builds the literal, and `toPayloadJson()` ships it through.
277
291
  return { name, dartType: "Map<String, Object?>", toJson: "json" };
292
+ }
278
293
  case "ZodRecord": {
279
294
  const valueType = zodRecordValueType(zt);
280
295
  const dartType =
@@ -1908,6 +1923,10 @@ export function toJsonExpr(spec: PayloadFieldSpec, accessor: string): string {
1908
1923
  return accessor;
1909
1924
  case "enum":
1910
1925
  return `${accessor}.wire`;
1926
+ case "vo":
1927
+ return `${accessor}.toJson()`;
1928
+ case "voList":
1929
+ return `${accessor}.map((e) => e.toJson()).toList()`;
1911
1930
  }
1912
1931
  }
1913
1932
 
@@ -2942,7 +2961,14 @@ export function generateDartDomain(mod: DomainModule, opts: GenerateDartDomainOp
2942
2961
  const skipValueObjectNames = opts.skipValueObjectNames ?? new Set<string>();
2943
2962
  // VALUE OBJECTS: one generated class per named VO the module carries (composeDomainModule
2944
2963
  // 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));
2964
+ // Payload-only VOs (a VO used in a directive payload but on no aggregate field) still need
2965
+ // their generated class — collected from the branded payload schemas, deduped by name against
2966
+ // the aggregate-derived set. Codegen-only: the IR key stays aggregate-derived (era rule).
2967
+ const aggVoNames = new Set((mod.valueObjects ?? []).map((d) => d.name));
2968
+ const payloadVos = collectPayloadValueObjectDescriptors(mod.directives ?? []).filter((d) => !aggVoNames.has(d.name));
2969
+ const localValueObjects = [...(mod.valueObjects ?? []), ...payloadVos]
2970
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
2971
+ .filter((d) => !skipValueObjectNames.has(d.name));
2946
2972
  // Each VO's block = its FIELD ENUMS (real Dart enums, tolerant fromWire) then its class —
2947
2973
  // so skipping a shared VO (the framework-file dedup) skips its enums with it.
2948
2974
  const voBlock =
@@ -142,7 +142,29 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
142
142
  const gateDoc = gate !== undefined
143
143
  ? ` /// GATED: needs an \`actor\` (the \`${gate.relation}\` relation) — throws MissingOfferAuthority otherwise.`
144
144
  : "";
145
- if (stamps.length > 0) {
145
+ if ((d as { marker?: string }).marker === "creates") {
146
+ // LAW-MINTED ID RETURN: a `.creates` plan mints its aggregate id inside the sealed intent (the payload
147
+ // carries none — callers never supply ids). The bridge's offerCreates hands the minted ids back with the
148
+ // head, so the create method returns the typed id with NO query-after round-trip.
149
+ methods.push(
150
+ ` /// Author \`${domain}/${d.id}\` — the LAW mints the ${(d as { aggregateId?: string }).aggregateId ?? "aggregate"} id inside the sealed`,
151
+ ` /// intent; it returns here (\`outcome.created\`) with the new local head. No id in the payload, no query-after.`,
152
+ ...(stamps.length > 0 ? [` /// Omitted ${stamps.map((s) => `\`${s}\``).join(" / ")} auto-stamp(s) ISO now() at offer.`] : []),
153
+ ...(gateDoc ? [gateDoc] : []),
154
+ ` Future<NomosOfferOutcome> ${method}(${payloadClass} payload) {`,
155
+ ...(gateGuard ? [gateGuard] : []),
156
+ ` final data = payload.toPayloadJson();`,
157
+ ...(stamps.length > 0
158
+ ? [
159
+ ` final now = DateTime.now().toUtc().toIso8601String();`,
160
+ ...stamps.map((s) => ` data.putIfAbsent(${dartStr(s)}, () => now);`),
161
+ ]
162
+ : []),
163
+ ` return bridge.offerCreates(domain: ${dartStr(domain)}, directiveId: ${dartStr(d.id)}, payload: data, actor: actor, domainHash: domainHash);`,
164
+ ` }`,
165
+ ``,
166
+ );
167
+ } else if (stamps.length > 0) {
146
168
  methods.push(
147
169
  ` /// Author \`${domain}/${d.id}\` locally under the installed law; returns the new local head.`,
148
170
  ` /// Omitted ${stamps.map((s) => `\`${s}\``).join(" / ")} auto-stamp(s) ISO now() at offer.`,
@@ -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,17 @@ 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
+ }
174
185
  }
175
186
  }
176
187
 
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") {
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";
@@ -400,7 +409,7 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
400
409
  ` /** THE ONE ENCODER offer (the single v2-envelope codec lives in @githolon/client \`encodeIntentOfferBytes\`):`,
401
410
  ` * hand the holon a PLANNED directive + the verified \`actor\`; the holon encodes + offers. The generated`,
402
411
  ` * client calls THIS, never building offer bytes itself — so there is exactly ONE copy of the wire format. */`,
403
- ` offerDirective(domain: string, directiveId: string, payload: unknown, opts?: { domainHash?: string; actor?: string }): Promise<string>;`,
412
+ ` offerDirective(domain: string, directiveId: string, payload: unknown, opts?: { domainHash?: string; actor?: string; returnCreated?: boolean }): Promise<string>;`,
404
413
  ` /** Opaque-bytes offer — retained for carriers that already hold an encoded envelope (back-compat). */`,
405
414
  ` offer(intentBytes: string | Uint8Array): Promise<string>;`,
406
415
  ` query(queryId: string, params?: Record<string, unknown>): Promise<unknown>;`,
@@ -476,7 +485,11 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
476
485
  // ── value objects: one interface per named VO, deduped across the package's domains ──
477
486
  const voByName = new Map<string, ValueObjectDescriptor>();
478
487
  for (const mod of modules) {
479
- for (const d of (mod as { valueObjects?: readonly ValueObjectDescriptor[] }).valueObjects ?? []) {
488
+ // aggregate-carried VOs ∪ payload-only VOs (branded schemas) — both need their interface.
489
+ const payloadVos = collectPayloadValueObjectDescriptors(
490
+ ((mod as { directives?: readonly { payloadSchema?: unknown }[] }).directives ?? []),
491
+ );
492
+ for (const d of [...((mod as { valueObjects?: readonly ValueObjectDescriptor[] }).valueObjects ?? []), ...payloadVos]) {
480
493
  const existing = voByName.get(d.name);
481
494
  if (existing !== undefined && JSON.stringify(existing) !== JSON.stringify(d)) {
482
495
  throw new Error(
@@ -753,6 +766,22 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
753
766
  continue;
754
767
  }
755
768
  const offerPayload = stamps.length ? `stampNow(payload, ${JSON.stringify(stamps)})` : `payload`;
769
+ if (d.marker === "creates") {
770
+ // LAW-MINTED ID RETURN: a `.creates` plan mints its aggregate id inside the sealed intent (the payload
771
+ // carries none). The holon hands the minted ids back on the offer (`returnCreated`), so the create
772
+ // method returns the typed id with NO query-after. Older holons (pre-returnCreated) answer a bare head
773
+ // string — degrade to id:"" rather than refuse (structural binding, era-safe).
774
+ methods.push(
775
+ ` /** Author \`${domain}/${d.id}\`: the LAW mints the ${d.aggregateId ?? "aggregate"} id inside the sealed intent`,
776
+ ` * and it returns here with the new local head — no id in the payload, no query-after.${gate ? ` GATED: needs \`{ actor }\` (\`${gate.relation}\`).` : ""} */`,
777
+ ` ${d.id}: async (payload: ${payloadName}): Promise<{ head: string; id: string }> => {`,
778
+ ...(gateGuard ? [gateGuard.replace(/\n$/, "")] : []),
779
+ ` const r = await (holon.offerDirective(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, ${offerPayload}, { domainHash, actor, returnCreated: true }) as Promise<string | { head: string; created?: { type: string; id: string }[] }>);`,
780
+ ` return typeof r === "object" && r !== null ? { head: r.head, id: r.created?.[0]?.id ?? "" } : { head: String(r), id: "" };`,
781
+ ` },`,
782
+ );
783
+ continue;
784
+ }
756
785
  const offerCall = `holon.offerDirective(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, ${offerPayload}, { domainHash, actor })`;
757
786
  if (gateGuard) {
758
787
  // GATED directive: a block body that throws MissingOfferAuthority when no actor is bound — an
package/src/fields.ts CHANGED
@@ -126,6 +126,9 @@ export interface ValueObjectFieldMeta {
126
126
  readonly dependencies: readonly ValueObjectDescriptor[];
127
127
  /** Present iff this VO is a `geo.*` geometry (the spatial-index pairing check). */
128
128
  readonly geometry?: GeometryKind;
129
+ /** The author-supplied lawful example (validated against the VO's own zod at declaration).
130
+ * Proof synthesis + docs prefer it over per-field synthesis. */
131
+ readonly example?: unknown;
129
132
  }
130
133
 
131
134
  export interface Field<T = unknown, K extends FieldKind = FieldKind> {
package/src/index.ts CHANGED
@@ -47,6 +47,9 @@ export {
47
47
  // `geo.*` are the RFC 7946 geometry VOs (pair `geo.point()` with `spatial(...)`).
48
48
  export {
49
49
  valueObject,
50
+ voMetaOf,
51
+ voSample,
52
+ NOMOS_VO_BRAND,
50
53
  geo,
51
54
  type ValueObject,
52
55
  type ValueObjectValue,
@@ -80,6 +80,11 @@ function payloadFieldKind(spec: PayloadFieldSpec): string {
80
80
  return "set";
81
81
  case "json":
82
82
  return "json";
83
+ case "vo":
84
+ case "voList":
85
+ // Interface kinds stay at the base lane for now (slice D of vo_authoring_end_to_end.md
86
+ // upgrades these to a first-class "vo" kind) — a VO IS a json leaf structurally.
87
+ return "json";
83
88
  case "scalar":
84
89
  default:
85
90
  switch (spec.dartType) {
@@ -226,12 +226,93 @@ function describeZod(voName: string, path: string, schema: z.ZodTypeAny): ValueO
226
226
 
227
227
  const VO_NAME_RE = /^[A-Za-z][A-Za-z0-9_]*$/;
228
228
 
229
+ /**
230
+ * THE VO BRAND (slice A of vo_authoring_end_to_end.md) — `valueObject()` stamps its zod schema
231
+ * with the VO's {@link ValueObjectFieldMeta} under this symbol, COMPILE-TIME ONLY: the brand is
232
+ * a non-enumerable property, never serialized, never read by the engine's validation path, so a
233
+ * branded schema's package bytes (and domainHash) are byte-identical to an unbranded one.
234
+ * Every generated-surface introspector (Dart/TS payload gen, interface kinds, proof synthesis)
235
+ * consults {@link voMetaOf} BEFORE its raw zod-kind switch — the VO keeps its NAME on every
236
+ * surface instead of degrading to an anonymous json object.
237
+ */
238
+ export const NOMOS_VO_BRAND: unique symbol = Symbol.for("nomos.vo");
239
+
240
+ /**
241
+ * Resolve the value-object identity of a zod schema, unwrapping `.optional()` / `.default()`
242
+ * wrappers (the brand lives on the VO's own object schema). `undefined` for anything that is
243
+ * not a declared VO — callers then fall through to their raw-zod lane, so non-VO behaviour is
244
+ * untouched.
245
+ */
246
+ export function voMetaOf(zt: z.ZodTypeAny): ValueObjectFieldMeta | 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_VO_BRAND];
250
+ if (branded !== undefined) return branded as ValueObjectFieldMeta;
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
+
258
+ /** The blessed lawful sample per RFC 7946 geometry kind (proof synthesis + docs). */
259
+ const GEOMETRY_SAMPLES: Record<GeometryKind, unknown> = {
260
+ Point: { type: "Point", coordinates: [0, 0] },
261
+ LineString: { type: "LineString", coordinates: [[0, 0], [1, 1]] },
262
+ Polygon: { type: "Polygon", coordinates: [[[0, 0], [0, 1], [1, 1], [0, 0]]] },
263
+ };
264
+
265
+ function sampleOfDescriptorField(
266
+ f: ValueObjectFieldDescriptor,
267
+ fieldName: string,
268
+ byName: Map<string, ValueObjectDescriptor>,
269
+ ): unknown {
270
+ switch (f.type) {
271
+ case "string": return `sample-${fieldName}`;
272
+ case "number": return 1;
273
+ case "boolean": return true;
274
+ case "enum": return f.values[0];
275
+ case "array": return [sampleOfDescriptorField(f.items, fieldName, byName)];
276
+ case "geometry": return GEOMETRY_SAMPLES[f.geometry];
277
+ case "object": {
278
+ const dep = byName.get(f.ref);
279
+ if (dep === undefined) throw new Error(`voSample: nested value object '${f.ref}' has no descriptor`);
280
+ return sampleOfDescriptor(dep, byName);
281
+ }
282
+ }
283
+ }
284
+
285
+ function sampleOfDescriptor(d: ValueObjectDescriptor, byName: Map<string, ValueObjectDescriptor>): unknown {
286
+ if (d.geometry !== undefined) return GEOMETRY_SAMPLES[d.geometry];
287
+ const out: Record<string, unknown> = {};
288
+ for (const [k, f] of Object.entries(d.fields ?? {})) {
289
+ if (f.optional === true) continue; // optional → omitted (mirrors payload synthesis)
290
+ out[k] = sampleOfDescriptorField(f, k, byName);
291
+ }
292
+ return out;
293
+ }
294
+
295
+ /**
296
+ * A LAWFUL sample value for a VO (slice C): the author's validated `example` when given, the
297
+ * blessed geometry sample for `geo.*` kinds, else per-field synthesis (recursing into nested
298
+ * VOs). Proof synthesis calls this instead of guessing at the raw zod — which is exactly the
299
+ * lane that refused its own sample on constrained shapes (geo min-2 coordinates).
300
+ */
301
+ export function voSample(meta: ValueObjectFieldMeta): unknown {
302
+ if (meta.example !== undefined) return meta.example;
303
+ const byName = new Map<string, ValueObjectDescriptor>();
304
+ for (const d of meta.dependencies) byName.set(d.name, d);
305
+ byName.set(meta.descriptor.name, meta.descriptor);
306
+ return sampleOfDescriptor(meta.descriptor, byName);
307
+ }
308
+
229
309
  function makeValueObject<T>(
230
310
  name: string,
231
311
  zodSchema: z.ZodType<T>,
232
312
  descriptor: ValueObjectDescriptor,
233
313
  dependencies: readonly ValueObjectDescriptor[],
234
314
  geometry?: GeometryKind,
315
+ example?: T,
235
316
  ): ValueObject<T> {
236
317
  if (!VO_NAME_RE.test(name)) {
237
318
  throw new Error(
@@ -239,12 +320,25 @@ function makeValueObject<T>(
239
320
  `generated TS interface / Dart class name.`,
240
321
  );
241
322
  }
323
+ if (example !== undefined) {
324
+ const v = zodSchema.safeParse(example);
325
+ if (!v.success) {
326
+ throw new Error(
327
+ `valueObject '${name}': the declared example does not satisfy the VO's own schema — ` +
328
+ `a lying example would poison proof synthesis. ${v.error.message}`,
329
+ );
330
+ }
331
+ }
242
332
  const meta: ValueObjectFieldMeta = {
243
333
  name,
244
334
  descriptor,
245
335
  dependencies,
246
336
  ...(geometry !== undefined ? { geometry } : {}),
337
+ ...(example !== undefined ? { example } : {}),
247
338
  };
339
+ // THE BRAND (slice A): compile-time identity on the schema itself — non-enumerable, never
340
+ // serialized, domainHash-inert. voMetaOf() is the one resolver every introspector uses.
341
+ Object.defineProperty(zodSchema, NOMOS_VO_BRAND, { value: meta, enumerable: false });
248
342
  const vo: ValueObject<T> = {
249
343
  __isValueObject: true,
250
344
  name,
@@ -272,6 +366,13 @@ function makeValueObject<T>(
272
366
  export function valueObject<const Name extends string, S extends Record<string, ValueObjectShapeEntry>>(
273
367
  name: Name,
274
368
  shape: S,
369
+ opts?: {
370
+ /** A lawful example value, VALIDATED against the VO's own schema at declaration (a lying
371
+ * example throws here, loudly). Proof synthesis prefers it over per-field synthesis — the
372
+ * escape hatch for constraint-rich shapes (refinements, cross-field rules) a synthesizer
373
+ * cannot guess. */
374
+ example?: ShapeValue<S>;
375
+ },
275
376
  ): ValueObject<ShapeValue<S>> {
276
377
  const zodShape: Record<string, z.ZodTypeAny> = {};
277
378
  const fields: Record<string, ValueObjectFieldDescriptor> = {};
@@ -308,6 +409,8 @@ export function valueObject<const Name extends string, S extends Record<string,
308
409
  z.object(zodShape) as unknown as z.ZodType<ShapeValue<S>>,
309
410
  descriptor,
310
411
  dependencies,
412
+ undefined,
413
+ opts?.example,
311
414
  );
312
415
  }
313
416
 
@@ -388,6 +491,46 @@ export const geo = {
388
491
  * descriptors throw (one name, one shape). Returns `[]` when no field carries a VO —
389
492
  * the omit-when-empty input to the `nomosValueObjects` era key.
390
493
  */
494
+ /**
495
+ * Collect the value objects referenced by DIRECTIVE PAYLOADS (top-level fields + array
496
+ * elements), via the {@link NOMOS_VO_BRAND} the schemas carry. CODEGEN-ONLY (slice B of
497
+ * vo_authoring_end_to_end.md): the result feeds the generated Dart/TS surfaces so a
498
+ * payload-only VO still gets its class/interface — it does NOT enter the `nomosValueObjects`
499
+ * IR key (that stays aggregate-derived, keeping package bytes / domainHash untouched).
500
+ */
501
+ export function collectPayloadValueObjectDescriptors(
502
+ directives: readonly { payloadSchema?: unknown }[],
503
+ ): ValueObjectDescriptor[] {
504
+ const byName = new Map<string, ValueObjectDescriptor>();
505
+ const take = (meta: ValueObjectFieldMeta | undefined) => {
506
+ if (meta === undefined) return;
507
+ for (const d of [meta.descriptor, ...meta.dependencies]) {
508
+ const existing = byName.get(d.name);
509
+ if (existing !== undefined && JSON.stringify(existing) !== JSON.stringify(d)) {
510
+ throw new Error(`value object '${d.name}' has DIVERGENT shapes across payloads — one name, one shape.`);
511
+ }
512
+ byName.set(d.name, d);
513
+ }
514
+ };
515
+ for (const dir of directives) {
516
+ const schema = dir?.payloadSchema as z.ZodTypeAny | undefined;
517
+ if (!schema || typeof schema !== "object" || zodKind(schema) !== "object") continue;
518
+ const rawShape = (zodDef(schema) as { shape?: Record<string, z.ZodTypeAny> | (() => Record<string, z.ZodTypeAny>) }).shape
519
+ ?? (schema as unknown as { shape?: Record<string, z.ZodTypeAny> }).shape;
520
+ const shape = typeof rawShape === "function" ? rawShape() : rawShape ?? {};
521
+ for (const raw of Object.values(shape)) {
522
+ let zt = raw as z.ZodTypeAny;
523
+ take(voMetaOf(zt));
524
+ // unwrap optional/default to reach an array of VOs
525
+ for (let hops = 0; hops < 8 && (zodKind(zt) === "optional" || zodKind(zt) === "default"); hops++) {
526
+ zt = zodDef(zt).innerType!;
527
+ }
528
+ if (zodKind(zt) === "array") take(voMetaOf(zodArrayElement(zt)));
529
+ }
530
+ }
531
+ return [...byName.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
532
+ }
533
+
391
534
  export function collectValueObjectDescriptors(
392
535
  aggregates: readonly { id: string; fields: Record<string, Field> }[],
393
536
  ): ValueObjectDescriptor[] {