@githolon/dsl 0.45.0 → 0.46.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,8 @@
1
+ ## 0.32.0
2
+
3
+ - SPATIAL READS: `NomosBbox` + `NomosBridge.spatialWithin(spatialId, bbox)` + `watchSpatial(...)` — the R*Tree probe surfaces on the bridge; generated clients (tooling ≥0.46) emit typed `sitesByBounds({required NomosBbox bbox})` + watch accessors.
4
+ - Analyzer-clean under strict lints (curly-braces rule) — the two remaining unbraced ifs in the bridge are gone.
5
+
1
6
  ## 0.31.0
2
7
 
3
8
  - `NomosFieldMeta.fromBirth` — the registry surfaces BIRTH-PRESENCE LAW (`.fromBirth()` fields, tooling ≥0.42): a gate invariant guarantees the field present on every row, and the generated read models type it non-nullable.
@@ -275,6 +275,23 @@ class NomosCreated {
275
275
  String toString() => id;
276
276
  }
277
277
 
278
+ /// A geographic bounding box for spatial reads (`[minLng, minLat] .. [maxLng, maxLat]`,
279
+ /// RFC 7946 lng/lat order — the R*Tree probe's extent).
280
+ class NomosBbox {
281
+ final double minLng;
282
+ final double minLat;
283
+ final double maxLng;
284
+ final double maxLat;
285
+ const NomosBbox({
286
+ required this.minLng,
287
+ required this.minLat,
288
+ required this.maxLng,
289
+ required this.maxLat,
290
+ });
291
+ Map<String, Object?> toJson() =>
292
+ {'minLng': minLng, 'minLat': minLat, 'maxLng': maxLng, 'maxLat': maxLat};
293
+ }
294
+
278
295
  /// The result of [NomosBridge.offerCreates]: the new local [head] plus every aggregate the plan minted.
279
296
  class NomosOfferOutcome {
280
297
  final String head;
@@ -341,8 +358,9 @@ class NomosBridge {
341
358
  Future<void> get ready => _ready.future;
342
359
 
343
360
  void _onMessage(Map<String, Object?> m) {
344
- if (m['v'] != kWebviewBridgeVersion)
361
+ if (m['v'] != kWebviewBridgeVersion) {
345
362
  return; // a foreign-version message is ignored (forward-compatible)
363
+ }
346
364
  final event = m['event'];
347
365
  if (event == 'ready') {
348
366
  if (!_ready.isCompleted) _ready.complete();
@@ -411,8 +429,9 @@ class NomosBridge {
411
429
  if (event == 'span') {
412
430
  // LIVE per-layer span: { event:'span', span:{ phase:'begin'|'end', id, track, name, kind, t0|t1 } }.
413
431
  _spanBuffer.add(m);
414
- if (_spanBuffer.length > 400)
432
+ if (_spanBuffer.length > 400) {
415
433
  _spanBuffer.removeRange(0, _spanBuffer.length - 400);
434
+ }
416
435
  if (!_spanEvents.isClosed) _spanEvents.add(m);
417
436
  return;
418
437
  }
@@ -761,6 +780,13 @@ class NomosBridge {
761
780
  Future<List<Map<String, dynamic>>> queryById(String id) async =>
762
781
  _asRows(await _call('queryById', {'id': id}));
763
782
 
783
+ /// THE SPATIAL READ: rows of the declared spatial index's of-type whose geometry
784
+ /// intersects [bbox] — an R*Tree probe on the local projection, same row shape as [query].
785
+ Future<List<Map<String, dynamic>>> spatialWithin(
786
+ String spatialId, NomosBbox bbox) async =>
787
+ _asRows(await _call(
788
+ 'spatialWithin', {'spatialId': spatialId, 'bbox': bbox.toJson()}));
789
+
764
790
  /// A maintained O(1) count / sum.
765
791
  Future<int> count(String countId, [String group = '']) async =>
766
792
  ((_asMap(await _call('count', {'countId': countId, 'group': group}))[
@@ -914,6 +940,10 @@ class NomosBridge {
914
940
  Stream<List<dynamic>> watchById(String id) =>
915
941
  _watchStream(kind: 'byId', id: id);
916
942
 
943
+ /// Reactive SPATIAL read: re-emits the R*Tree probe's rows whenever they change locally.
944
+ Stream<List<dynamic>> watchSpatial(String spatialId, NomosBbox bbox) =>
945
+ _watchStream(kind: 'spatial', spatialId: spatialId, bbox: bbox);
946
+
917
947
  /// SHAPE COMPATIBILITY (#72): one-shot verdict for this client's structural [requirement]
918
948
  /// (its baked `…InterfaceJson`, decoded) against the workspace's ON-LEDGER offer (auto-sourced).
919
949
  /// `{ ok, missing[], incompatible[] }` — names the exact directive/aggregate/field, not a hash.
@@ -953,7 +983,9 @@ class NomosBridge {
953
983
  {required String kind,
954
984
  String? queryId,
955
985
  Map<String, Object?>? params,
956
- String? id}) {
986
+ String? id,
987
+ String? spatialId,
988
+ NomosBbox? bbox}) {
957
989
  final subId = 'w${++_seq}';
958
990
  late final StreamController<List<dynamic>> ctrl;
959
991
  ctrl = StreamController<List<dynamic>>(
@@ -962,7 +994,9 @@ class NomosBridge {
962
994
  'kind': kind,
963
995
  if (queryId != null) 'queryId': queryId,
964
996
  if (params != null) 'params': params,
965
- if (id != null) 'id': id
997
+ if (id != null) 'id': id,
998
+ if (spatialId != null) 'spatialId': spatialId,
999
+ if (bbox != null) 'bbox': bbox.toJson(),
966
1000
  }),
967
1001
  onCancel: () async {
968
1002
  _watches.remove(subId);
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.31.0
14
+ version: 0.32.0
15
15
 
16
16
  environment:
17
17
  sdk: ^3.5.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.45.0",
3
+ "version": "0.46.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",
@@ -810,19 +810,21 @@ function collectEnums(
810
810
  // hand-maintained `hide` list on the package barrel. The prefix is the field's
811
811
  // semantic home, not an accident of import order.
812
812
  const domainPrefix = dartTypeNameFromField(domainKey);
813
- const register = (preferredName: string, values: readonly string[]) => {
813
+ // SEMANTIC ENUM NAMES (co2 feedback): the type is named by its SEMANTIC HOME —
814
+ // `<Domain><Owner><Field>` (`EstateSiteStatus`, not `EstateStatus5Enum`). The owner is the
815
+ // aggregate (or the payload's target aggregate / directive) the field lives on. A value-set
816
+ // shared across owners keeps the FIRST owner's name (one identical type, aggregates register
817
+ // before payloads so the aggregate's name wins). Numeric suffixes survive only as the
818
+ // last-resort collision guard — with owner-qualified bases they should never fire.
819
+ const register = (preferredName: string, values: readonly string[], owner = "") => {
814
820
  const key = values.join("|");
815
821
  if (dartTypes.has(key)) return;
816
- // Two DIFFERENT value-sets WITHIN one domain can prefer the SAME name (e.g.
817
- // several `status` fields with distinct literal lists all want `StatusEnum`).
818
- // Suffix on collision so each distinct value-set gets a UNIQUE Dart enum
819
- // (`EstateStatusEnum`, `EstateStatus2Enum`).
820
- const base = `${domainPrefix}${dartTypeNameFromField(preferredName)}Enum`;
822
+ const base = `${domainPrefix}${dartTypeNameFromField(owner)}${dartTypeNameFromField(preferredName)}`;
821
823
  let name = base;
822
824
  let n = 1;
823
825
  while (usedNames.has(name)) {
824
826
  n += 1;
825
- name = `${domainPrefix}${dartTypeNameFromField(preferredName)}${n}Enum`;
827
+ name = `${base}${n}`;
826
828
  }
827
829
  usedNames.add(name);
828
830
  dartTypes.set(key, name);
@@ -846,7 +848,7 @@ function collectEnums(
846
848
  )) {
847
849
  if (field.kind === "enum") {
848
850
  // Unwrap `.optional()` so an optional enum field still registers its options.
849
- register(name, zodEnumValues(unwrapZod(field.zod)));
851
+ register(name, zodEnumValues(unwrapZod(field.zod)), agg.id);
850
852
  }
851
853
  }
852
854
  }
@@ -856,7 +858,7 @@ function collectEnums(
856
858
  for (const [name, zt] of Object.entries(shape)) {
857
859
  const inner = unwrapZod(zt as z.ZodTypeAny);
858
860
  if (zodTypeName(inner) === "ZodEnum") {
859
- register(name, zodEnumValues(inner));
861
+ register(name, zodEnumValues(inner), (d as { aggregateId?: string }).aggregateId ?? d.id);
860
862
  }
861
863
  }
862
864
  }
@@ -1246,7 +1248,8 @@ const geometryCoordsDart: Record<string, string> = {
1246
1248
  * VOs stay separate types (VO names are package-unique, so so are these).
1247
1249
  */
1248
1250
  function voEnumName(voName: string, fieldName: string): string {
1249
- return `${voName}${dartTypeNameFromField(fieldName)}Enum`;
1251
+ // SEMANTIC NAME (co2 feedback): `<VoName><Field>` — `SiteLocationSource`, not `…SourceEnum`.
1252
+ return `${voName}${dartTypeNameFromField(fieldName)}`;
1250
1253
  }
1251
1254
 
1252
1255
  /** The tolerant-decode SENTINEL case name — dodge a literal enum value that would
@@ -1399,16 +1402,24 @@ function emitVoHelpers(): string {
1399
1402
  `/// Deep json equality for generated value-object classes (toJson-based).`,
1400
1403
  `bool _voJsonEq(Object? a, Object? b) {`,
1401
1404
  ` if (a is Map && b is Map) {`,
1402
- ` if (a.length != b.length) return false;`,
1405
+ ` if (a.length != b.length) {`,
1406
+ ` return false;`,
1407
+ ` }`,
1403
1408
  ` for (final k in a.keys) {`,
1404
- ` if (!b.containsKey(k) || !_voJsonEq(a[k], b[k])) return false;`,
1409
+ ` if (!b.containsKey(k) || !_voJsonEq(a[k], b[k])) {`,
1410
+ ` return false;`,
1411
+ ` }`,
1405
1412
  ` }`,
1406
1413
  ` return true;`,
1407
1414
  ` }`,
1408
1415
  ` if (a is List && b is List) {`,
1409
- ` if (a.length != b.length) return false;`,
1416
+ ` if (a.length != b.length) {`,
1417
+ ` return false;`,
1418
+ ` }`,
1410
1419
  ` for (var i = 0; i < a.length; i++) {`,
1411
- ` if (!_voJsonEq(a[i], b[i])) return false;`,
1420
+ ` if (!_voJsonEq(a[i], b[i])) {`,
1421
+ ` return false;`,
1422
+ ` }`,
1412
1423
  ` }`,
1413
1424
  ` return true;`,
1414
1425
  ` }`,
@@ -137,7 +137,7 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
137
137
  // appear — only the actor identity (the bridge owns signing).
138
138
  const gate = (d as { requiresRelationGate?: { relation: string } }).requiresRelationGate;
139
139
  const gateGuard = gate !== undefined
140
- ? ` if (actor == null || actor!.trim().isEmpty) throw MissingOfferAuthority(domain: ${dartStr(domain)}, directiveId: ${dartStr(d.id)}, requires: ${dartStr(gate.relation)});`
140
+ ? ` if (actor == null || actor!.trim().isEmpty) {\n throw MissingOfferAuthority(domain: ${dartStr(domain)}, directiveId: ${dartStr(d.id)}, requires: ${dartStr(gate.relation)});\n }`
141
141
  : "";
142
142
  const gateDoc = gate !== undefined
143
143
  ? ` /// GATED: needs an \`actor\` (the \`${gate.relation}\` relation) — throws MissingOfferAuthority otherwise.`
@@ -218,6 +218,23 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
218
218
  );
219
219
  }
220
220
 
221
+ // ── declared spatial indexes → typed bbox reads (lane-2 read-serving) ──
222
+ for (const sp of (mod.spatials ?? []) as { id: string; of: string; on: string }[]) {
223
+ const model = readModelClassName(sp.of);
224
+ const m = safeMethod(lcFirst(camel(sp.id)));
225
+ methods.push(
226
+ ` /// Declared spatial index \`${sp.id}\` — rows of \`${sp.of}\` whose \`${sp.on}\` geometry`,
227
+ ` /// intersects [bbox] (an R*Tree probe on the local projection, never a scan).`,
228
+ ` Future<List<${model}>> ${m}({required NomosBbox bbox}) async =>`,
229
+ ` (await bridge.spatialWithin(${dartStr(sp.id)}, bbox)).map((r) => ${rowToModel(model, "r")}).toList();`,
230
+ ``,
231
+ ` /// Reactive \`${sp.id}\` — re-emits when the probe's rows change locally.`,
232
+ ` Stream<List<${model}>> watch${cap(camel(sp.id))}({required NomosBbox bbox}) =>`,
233
+ ` bridge.watchSpatial(${dartStr(sp.id)}, bbox).map((rows) => rows.map((r) => ${rowToModel(model, "r")}).toList());`,
234
+ ``,
235
+ );
236
+ }
237
+
221
238
  // ── counts / sums → O(1) typed reads ──
222
239
  for (const c of (mod.counts ?? []).map((raw) => finishCount(raw))) {
223
240
  const m = safeMethod(lcFirst(camel(c.id)));
@@ -98,13 +98,13 @@ function sampleString(zt: z.ZodTypeAny, field: string): string {
98
98
  }
99
99
 
100
100
  /** One sample value for one zod shape. NO FALLBACK: an unhandled shape throws, naming its remedy. */
101
- function sampleOfZod(zt: z.ZodTypeAny, field: string, where: string): unknown {
101
+ function sampleOfZod(zt: z.ZodTypeAny, field: string, where: string, voIncludeOptional = false): unknown {
102
102
  // VALUE OBJECTS FIRST (vo_authoring_end_to_end.md slice C): a declared VO carries its own
103
103
  // lawful sample (the author's validated `example`, the blessed geometry sample, or per-field
104
104
  // descriptor synthesis) — the raw-zod walk below cannot see constraints like geo's min-2
105
105
  // coordinates and would synthesize a value the law itself refuses.
106
106
  const vo = voMetaOf(zt);
107
- if (vo !== undefined) return voSample(vo);
107
+ if (vo !== undefined) return voSample(vo, { includeOptional: voIncludeOptional });
108
108
  switch (zodKind(zt)) {
109
109
  case "string":
110
110
  return sampleString(zt, field);
@@ -120,14 +120,14 @@ function sampleOfZod(zt: z.ZodTypeAny, field: string, where: string): unknown {
120
120
  }
121
121
  case "array": {
122
122
  const el = zodArrayElement(zt);
123
- return zodKind(el) === "string" ? ["sample-a"] : [sampleOfZod(el, field, where)];
123
+ return zodKind(el) === "string" ? ["sample-a"] : [sampleOfZod(el, field, where, voIncludeOptional)];
124
124
  }
125
125
  case "object": {
126
126
  const out: Record<string, unknown> = {};
127
127
  for (const [name, raw] of Object.entries(zodObjectShape(zt))) {
128
128
  const k = zodKind(raw as z.ZodTypeAny);
129
129
  if (k === "optional" || k === "default") continue; // optional → omit
130
- out[name] = sampleOfZod(raw as z.ZodTypeAny, name, where);
130
+ out[name] = sampleOfZod(raw as z.ZodTypeAny, name, where, voIncludeOptional);
131
131
  }
132
132
  return out;
133
133
  }
@@ -136,10 +136,10 @@ function sampleOfZod(zt: z.ZodTypeAny, field: string, where: string): unknown {
136
136
  case "nullable":
137
137
  case "optional":
138
138
  case "default":
139
- return sampleOfZod(zodDef(zt).innerType!, field, where);
139
+ return sampleOfZod(zodDef(zt).innerType!, field, where, voIncludeOptional);
140
140
  case "union": {
141
141
  const first = zodDef(zt).options?.[0];
142
- if (first !== undefined) return sampleOfZod(first, field, where);
142
+ if (first !== undefined) return sampleOfZod(first, field, where, voIncludeOptional);
143
143
  throw new Error(`codegen-proof: empty union at field '${field}' of ${where}`);
144
144
  }
145
145
  default:
@@ -154,7 +154,7 @@ function sampleOfZod(zt: z.ZodTypeAny, field: string, where: string): unknown {
154
154
  * optional/defaulted fields omitted, required `…At` strings omitted (auto-stamped
155
155
  * by the generated client), everything else sampled by {@link sampleOfZod}.
156
156
  */
157
- export function synthesizePayload(schema: z.ZodTypeAny, where: string): SynthesizedPayload {
157
+ export function synthesizePayload(schema: z.ZodTypeAny, where: string, opts?: { voIncludeOptional?: boolean }): SynthesizedPayload {
158
158
  if (zodKind(schema) !== "object") {
159
159
  throw new Error(`codegen-proof: ${where} payload is not a zod object — the proof can only synthesize object payloads`);
160
160
  }
@@ -166,7 +166,7 @@ export function synthesizePayload(schema: z.ZodTypeAny, where: string): Synthesi
166
166
  const k = zodKind(f);
167
167
  if (k === "optional" || k === "default") continue; // optional → omit
168
168
  if (stamped.has(name)) continue; // `…At` → omit; the client stamps it
169
- value[name] = sampleOfZod(f, name, where);
169
+ value[name] = sampleOfZod(f, name, where, opts?.voIncludeOptional === true);
170
170
  }
171
171
  return { value, autoStamped };
172
172
  }
@@ -598,6 +598,35 @@ export interface BornContextAuthority {
598
598
 
599
599
  /** Run a directive's plan with a synthesized+probe payload under birth-probe globals and return the drained
600
600
  * field ops (authoring sink + returned ops). `undefined` ⇒ the plan couldn't be probed. */
601
+ /** Run a directive's plan on an EXPLICIT payload under the deterministic probe ports, returning the
602
+ * emitted ops (undefined when the plan throws) — the payload-parameterized sibling of {@link probePlanOps}. */
603
+ function probePlanOpsWithPayload(
604
+ dir: Directive<unknown>,
605
+ probePayload: Record<string, unknown>,
606
+ _where: string,
607
+ ): FieldOp[] | undefined {
608
+ let ops: FieldOp[] | undefined;
609
+ try {
610
+ withBirthProbeGlobals(() => {
611
+ __resetAuthoring();
612
+ __resetReads();
613
+ __resetBirths();
614
+ try {
615
+ const returned = dir.plan(probePayload as never, deterministicPorts({ physical: 1, replica: 1, principal: "principal:probe" }));
616
+ const drained = [...__drainAuthoring(), ...(Array.isArray(returned) ? returned : [])];
617
+ ops = drained.filter((o): o is FieldOp => (o as { kind?: string }).kind !== "strike");
618
+ } finally {
619
+ __resetAuthoring();
620
+ __resetReads();
621
+ __resetBirths();
622
+ }
623
+ });
624
+ } catch {
625
+ return undefined;
626
+ }
627
+ return ops;
628
+ }
629
+
601
630
  function probePlanOps(
602
631
  dir: Directive<unknown>,
603
632
  where: string,
@@ -1575,6 +1604,22 @@ export interface ProofLegs {
1575
1604
  readonly mintField?: string;
1576
1605
  readonly query?: { readonly id: string; readonly params: Record<string, unknown> };
1577
1606
  readonly count?: { readonly id: string; readonly group: string };
1607
+ /** SPATIAL MEMBERSHIP (lane-2 read-serving): present when a declared spatial index covers the created
1608
+ * aggregate AND the plan-probe proved the synthesized payload lands a geometry on the indexed path —
1609
+ * the offline runner asserts `spatialWithin(id, world)` contains the created row. */
1610
+ readonly spatial?: { readonly id: string };
1611
+ /** EVERY OTHER standalone create (co2 feedback: the proof exercises ALL VO-bearing creates, not just
1612
+ * the first) — each dispatched + asserted exactly like the primary. Additive: absent on older files. */
1613
+ readonly extraCreates?: readonly {
1614
+ readonly directiveId: string;
1615
+ readonly aggregateId: string;
1616
+ readonly payload: Record<string, unknown>;
1617
+ readonly autoStamped: readonly string[];
1618
+ readonly mintField?: string;
1619
+ readonly query?: { readonly id: string; readonly params: Record<string, unknown> };
1620
+ readonly count?: { readonly id: string; readonly group: string };
1621
+ readonly spatial?: { readonly id: string };
1622
+ }[];
1578
1623
  /** THE CHILD-BIRTH LEG — present iff this domain has a `.births()` directive (it is `child-birth-provable`).
1579
1624
  * When this domain is itself the BORN child's law (no standalone create), the index also registers the
1580
1625
  * child domain key pointing at the SAME leg, so `proof --domain <child>` resolves through the parent birth. */
@@ -1603,14 +1648,69 @@ function isStandaloneCreate(d: Directive<unknown> | undefined): boolean {
1603
1648
  && d.requiresRelationGate === undefined;
1604
1649
  }
1605
1650
 
1606
- export function proofLegsForModule(mod: DomainModule): ProofLegs | null {
1607
- const createDir = (mod.directives as Directive<unknown>[]).find((d) => isStandaloneCreate(d));
1608
- if (createDir === undefined) return null;
1609
- const domain = mod.domain ?? mod.name;
1651
+ /** Does `v` carry an RFC 7946 geometry object at `segs` (walked into the composite)? */
1652
+ function geometryAtPath(v: unknown, segs: readonly string[]): boolean {
1653
+ let cur: unknown = v;
1654
+ for (const s of segs) {
1655
+ if (cur === null || typeof cur !== "object") return false;
1656
+ cur = (cur as Record<string, unknown>)[s];
1657
+ }
1658
+ return (
1659
+ cur !== null && typeof cur === "object" &&
1660
+ typeof (cur as { type?: unknown }).type === "string" &&
1661
+ (cur as { coordinates?: unknown }).coordinates !== undefined
1662
+ );
1663
+ }
1664
+
1665
+ /** Build ONE standalone-create leg entry (payload + matched query/count + the spatial-membership
1666
+ * assertion when a declared spatial index covers the type and the plan-probe lands a geometry on
1667
+ * the indexed path — trying the optionals-included VO sample when the default omits it). */
1668
+ function createLegFor(
1669
+ mod: DomainModule,
1670
+ createDir: Directive<unknown>,
1671
+ domain: string,
1672
+ ): {
1673
+ directiveId: string; aggregateId: string; payload: Record<string, unknown>;
1674
+ autoStamped: readonly string[]; mintField?: string;
1675
+ query?: { id: string; params: Record<string, unknown> };
1676
+ count?: { id: string; group: string };
1677
+ spatial?: { id: string };
1678
+ } | null {
1610
1679
  const agg = (mod.aggregates as AggregateHandle[]).find((a) => a.id === createDir.aggregateId);
1611
1680
  if (agg === undefined) return null;
1612
- const synth = synthesizePayload(createDir.payloadSchema as z.ZodTypeAny, `${domain}/${createDir.id}`);
1681
+ const where = `${domain}/${createDir.id}`;
1682
+ let synth = synthesizePayload(createDir.payloadSchema as z.ZodTypeAny, where);
1613
1683
  const mintField = mintedCreateField(createDir, agg);
1684
+
1685
+ // SPATIAL MEMBERSHIP: probe the plan — does the create set the indexed head field with a geometry
1686
+ // at the declared path? If the default sample omits an optional geometry, retry with the
1687
+ // optionals-included VO sample and adopt IT as the leg's payload (so the proof exercises the
1688
+ // geometry the index needs).
1689
+ let spatial: { id: string } | undefined;
1690
+ const spatials = ((mod.spatials ?? []) as { id: string; of: string; on: string }[]).filter((sp) => sp.of === agg.id);
1691
+ if (spatials.length > 0) {
1692
+ const probeWith = (sy: SynthesizedPayload): FieldOp[] | undefined => {
1693
+ const probePayload: Record<string, unknown> = { ...sy.value };
1694
+ for (const f of sy.autoStamped) probePayload[f] = "2026-01-01T00:00:00Z";
1695
+ const probed = probePlanOpsWithPayload(createDir, probePayload, where);
1696
+ return probed;
1697
+ };
1698
+ const hits = (ops: FieldOp[] | undefined, sp: { on: string }): boolean => {
1699
+ if (ops === undefined) return false;
1700
+ const [head, ...segs] = sp.on.split(".");
1701
+ return ops.some((o) => (o as { kind?: string }).kind === "set" && (o as { field?: string }).field === head && geometryAtPath((o as { value?: unknown }).value, segs));
1702
+ };
1703
+ let ops = probeWith(synth);
1704
+ let matched = spatials.find((sp) => hits(ops, sp));
1705
+ if (matched === undefined) {
1706
+ const richer = synthesizePayload(createDir.payloadSchema as z.ZodTypeAny, where, { voIncludeOptional: true });
1707
+ ops = probeWith(richer);
1708
+ matched = spatials.find((sp) => hits(ops, sp));
1709
+ if (matched !== undefined) synth = richer; // the geometry-bearing sample becomes the leg's payload
1710
+ }
1711
+ if (matched !== undefined) spatial = { id: matched.id };
1712
+ }
1713
+
1614
1714
  const baseValue: Record<string, unknown> = { ...synth.value };
1615
1715
  if (mintField !== undefined) delete baseValue[mintField];
1616
1716
  const query = ((mod.queries ?? []) as QueryDecl[]).find(
@@ -1620,8 +1720,6 @@ export function proofLegsForModule(mod: DomainModule): ProofLegs | null {
1620
1720
  .map((raw) => finishCount(raw))
1621
1721
  .find((c) => c.of === agg.id && (c as { where?: unknown }).where == null && (c.by == null || baseValue[c.by] !== undefined));
1622
1722
  return {
1623
- domain,
1624
- legs: ["compile-valid", "standalone-provable"],
1625
1723
  directiveId: createDir.id,
1626
1724
  aggregateId: agg.id,
1627
1725
  payload: baseValue,
@@ -1629,6 +1727,23 @@ export function proofLegsForModule(mod: DomainModule): ProofLegs | null {
1629
1727
  ...(mintField !== undefined ? { mintField } : {}),
1630
1728
  ...(query ? { query: { id: query.id, params: Object.fromEntries(query.key.map((k) => [k, baseValue[k]])) } } : {}),
1631
1729
  ...(count ? { count: { id: count.id, group: count.by != null ? String(baseValue[count.by]) : "" } } : {}),
1730
+ ...(spatial !== undefined ? { spatial } : {}),
1731
+ };
1732
+ }
1733
+
1734
+ export function proofLegsForModule(mod: DomainModule): ProofLegs | null {
1735
+ const domain = mod.domain ?? mod.name;
1736
+ const creates = (mod.directives as Directive<unknown>[]).filter((d) => isStandaloneCreate(d));
1737
+ const legsBuilt = creates
1738
+ .map((d) => { try { return createLegFor(mod, d, domain); } catch { return null; } })
1739
+ .filter((l): l is NonNullable<typeof l> => l !== null);
1740
+ if (legsBuilt.length === 0) return null;
1741
+ const [primary, ...extras] = legsBuilt;
1742
+ return {
1743
+ domain,
1744
+ legs: ["compile-valid", "standalone-provable"],
1745
+ ...primary!,
1746
+ ...(extras.length > 0 ? { extraCreates: extras } : {}),
1632
1747
  };
1633
1748
  }
1634
1749
 
@@ -1683,6 +1798,8 @@ export function legsForDomain(mod: DomainModule, modules: readonly DomainModule[
1683
1798
  ...(standalone.mintField !== undefined ? { mintField: standalone.mintField } : {}),
1684
1799
  ...(standalone.query !== undefined ? { query: standalone.query } : {}),
1685
1800
  ...(standalone.count !== undefined ? { count: standalone.count } : {}),
1801
+ ...(standalone.spatial !== undefined ? { spatial: standalone.spatial } : {}),
1802
+ ...(standalone.extraCreates !== undefined ? { extraCreates: standalone.extraCreates } : {}),
1686
1803
  }
1687
1804
  : {}),
1688
1805
  ...(childBirthWithSetup !== undefined ? { childBirth: childBirthWithSetup } : {}),
package/src/codegen_ts.ts CHANGED
@@ -438,6 +438,8 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
438
438
  ` watchById(aggregateId: string, cb: (rows: unknown) => void, opts?: { intervalMs?: number }): () => void;`,
439
439
  ` /** Declared-count / declared-sum O(1) reads (maintained by the read engine). */`,
440
440
  ` count(countId: string, groupKey?: string): Promise<number>;`,
441
+ ` /** THE SPATIAL READ (R*Tree probe): rows intersecting the bbox — same row shape as query(). */`,
442
+ ` spatialWithin(spatialId: string, bbox: { minLng: number; minLat: number; maxLng: number; maxLat: number }): Promise<unknown>;`,
441
443
  ` sum(sumId: string, groupKey?: string): Promise<number>;`,
442
444
  ...(anyPackedTaxonomy(modules)
443
445
  ? [
@@ -824,6 +826,12 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
824
826
  }
825
827
  }
826
828
 
829
+ for (const sp of (mod.spatials ?? []) as { id: string; of: string; on: string }[]) {
830
+ methods.push(
831
+ ` /** Declared spatial index \`${sp.id}\` — rows of \`${sp.of}\` whose \`${sp.on}\` geometry intersects the bbox (R*Tree probe). */`,
832
+ ` ${lcFirst(camel(sp.id))}: (bbox: { minLng: number; minLat: number; maxLng: number; maxLat: number }) => holon.spatialWithin(${JSON.stringify(sp.id)}, bbox) as Promise<Row<${sp.of}Data>[]>,`,
833
+ );
834
+ }
827
835
  for (const q of (mod.queries ?? []) as QueryDecl[]) {
828
836
  const agg = aggByType.get(q.returns);
829
837
  const params = q.key
@@ -349,6 +349,7 @@ function sampleOfDescriptorField(
349
349
  f: ValueObjectFieldDescriptor,
350
350
  fieldName: string,
351
351
  byName: Map<string, ValueObjectDescriptor>,
352
+ includeOptional = false,
352
353
  ): unknown {
353
354
  switch (f.type) {
354
355
  case "string": return `sample-${fieldName}`;
@@ -356,17 +357,17 @@ function sampleOfDescriptorField(
356
357
  case "literal": return f.value;
357
358
  case "boolean": return true;
358
359
  case "enum": return f.values[0];
359
- case "array": return [sampleOfDescriptorField(f.items, fieldName, byName)];
360
+ case "array": return [sampleOfDescriptorField(f.items, fieldName, byName, includeOptional)];
360
361
  case "geometry": return GEOMETRY_SAMPLES[f.geometry];
361
362
  case "object": {
362
363
  const dep = byName.get(f.ref);
363
364
  if (dep === undefined) throw new Error(`voSample: nested value object '${f.ref}' has no descriptor`);
364
- return sampleOfDescriptor(dep, byName);
365
+ return sampleOfDescriptor(dep, byName, includeOptional);
365
366
  }
366
367
  }
367
368
  }
368
369
 
369
- function sampleOfDescriptor(d: ValueObjectDescriptor, byName: Map<string, ValueObjectDescriptor>): unknown {
370
+ function sampleOfDescriptor(d: ValueObjectDescriptor, byName: Map<string, ValueObjectDescriptor>, includeOptional = false): unknown {
370
371
  if (d.geometry !== undefined) return GEOMETRY_SAMPLES[d.geometry];
371
372
  if (d.union !== undefined) {
372
373
  // FIRST declared arm (keys are sorted — deterministic); `example:` overrides upstream.
@@ -378,15 +379,15 @@ function sampleOfDescriptor(d: ValueObjectDescriptor, byName: Map<string, ValueO
378
379
  }
379
380
  const out: Record<string, unknown> = { type: key };
380
381
  for (const [k, f] of Object.entries(arm.fields)) {
381
- if (f.optional === true) continue;
382
- out[k] = sampleOfDescriptorField(f, k, byName);
382
+ if (f.optional === true && !includeOptional) continue;
383
+ out[k] = sampleOfDescriptorField(f, k, byName, includeOptional);
383
384
  }
384
385
  return out;
385
386
  }
386
387
  const out: Record<string, unknown> = {};
387
388
  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);
389
+ if (f.optional === true && !includeOptional) continue; // optional → omitted (mirrors payload synthesis)
390
+ out[k] = sampleOfDescriptorField(f, k, byName, includeOptional);
390
391
  }
391
392
  return out;
392
393
  }
@@ -397,12 +398,12 @@ function sampleOfDescriptor(d: ValueObjectDescriptor, byName: Map<string, ValueO
397
398
  * VOs). Proof synthesis calls this instead of guessing at the raw zod — which is exactly the
398
399
  * lane that refused its own sample on constrained shapes (geo min-2 coordinates).
399
400
  */
400
- export function voSample(meta: ValueObjectFieldMeta): unknown {
401
+ export function voSample(meta: ValueObjectFieldMeta, opts?: { includeOptional?: boolean }): unknown {
401
402
  if (meta.example !== undefined) return meta.example;
402
403
  const byName = new Map<string, ValueObjectDescriptor>();
403
404
  for (const d of meta.dependencies) byName.set(d.name, d);
404
405
  byName.set(meta.descriptor.name, meta.descriptor);
405
- return sampleOfDescriptor(meta.descriptor, byName);
406
+ return sampleOfDescriptor(meta.descriptor, byName, opts?.includeOptional === true);
406
407
  }
407
408
 
408
409
  function makeValueObject<T>(