@githolon/dsl 0.45.0 → 0.47.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.
@@ -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,
@@ -1257,6 +1286,8 @@ export function diagnoseRelationBootstrapGap(mod: DomainModule): string | undefi
1257
1286
  interface DiscoveredGenesisStep {
1258
1287
  readonly domain: string;
1259
1288
  readonly directiveId: string;
1289
+ /** The step's dispatch payload — validated at compile against the target directive's schema (births DX). */
1290
+ readonly payload?: unknown;
1260
1291
  }
1261
1292
 
1262
1293
  /** A discovered CHILD-BIRTH leg: a parent `.births()` directive proven end-to-end into the child it births
@@ -1304,6 +1335,12 @@ export interface ChildBirthLeg {
1304
1335
  * parent workspace (home → estate is the canonical case), the proof runner drives this setup birth first
1305
1336
  * and then authors the child birth inside the born parent. */
1306
1337
  readonly parentSetup?: ChildBirthParentSetup;
1338
+ /** COMPILE-TIME RECIPE DIAGNOSTICS (births DX): problems found by statically validating each genesis
1339
+ * step against the composed package (missing child module, unknown seed directive, payload-schema
1340
+ * mismatch, a plan that returns a non-array — the exact things that otherwise surface at proof time as
1341
+ * an opaque `born=[]`). WARNINGS, not refusals: the kernel's own gate stays the arbiter; the compile
1342
+ * summary prints these and the proof runner fails fast on them with the same words. */
1343
+ readonly recipeIssues?: readonly string[];
1307
1344
  }
1308
1345
 
1309
1346
  export interface ChildBirthParentSetup {
@@ -1362,13 +1399,94 @@ export function discoverChildBirthLeg(
1362
1399
  parentDomain: string,
1363
1400
  modules: readonly DomainModule[],
1364
1401
  ): ChildBirthLeg | undefined {
1365
- if (birthDir.birthsChild !== true) return undefined;
1402
+ const r = discoverChildBirthLegOrReason(birthDir, parentDomain, modules);
1403
+ return r.leg;
1404
+ }
1405
+
1406
+ /** The engine iterates a plan's return value unconditionally (`wire_encode.ts`: `[...returned]`), so a
1407
+ * plan returning a builder / undefined ALWAYS halts at the gate with an opaque "value is not iterable".
1408
+ * This hint turns that into the fix. */
1409
+ const PLAN_RETURN_HINT =
1410
+ "a plan must RETURN AN ARRAY of ops — fluent builders (ensure()/create().set(...)) record themselves, so end the plan with `return [];`, or wrap standalone ops in `[ ... ]`";
1411
+
1412
+ /** STATIC RECIPE VALIDATION (births DX): check each genesis step against the composed package — the same
1413
+ * checks the child's own gate will make at birth, surfaced at COMPILE with a fix in the message. The
1414
+ * bootstrap/nomos `installDomain` steps are kernel-resolved (bytes-by-hash) and skipped. */
1415
+ function validateGenesisSteps(
1416
+ chain: readonly DiscoveredGenesisStep[],
1417
+ modules: readonly DomainModule[],
1418
+ ): string[] {
1419
+ const issues: string[] = [];
1420
+ chain.forEach((s, i) => {
1421
+ const at = `genesis step ${i + 1}`;
1422
+ if (s === null || typeof s !== "object" || typeof s.domain !== "string" || typeof s.directiveId !== "string") {
1423
+ issues.push(`${at} is not a {domain, directiveId, actor, payload} step object`);
1424
+ return;
1425
+ }
1426
+ if (s.directiveId === "installDomain" && (s.domain === "bootstrap" || s.domain === "nomos")) return;
1427
+ const mod = modules.find((m) => (m.domain ?? m.name) === s.domain);
1428
+ if (mod === undefined) {
1429
+ issues.push(
1430
+ `${at} (${s.domain}/${s.directiveId}) targets domain '${s.domain}', which is NOT composed in this package — add its module to nomos.package.mjs \`domains\` (the one-package pattern: the parent must fold the child law so the kernel can resolve its bytes by hash; see bench/scale/fixtures/estate-birth)`,
1431
+ );
1432
+ return;
1433
+ }
1434
+ const dir = (mod.directives as Directive<unknown>[]).find((d) => d?.id === s.directiveId);
1435
+ if (dir === undefined) {
1436
+ issues.push(
1437
+ `${at} (${s.domain}/${s.directiveId}): domain '${s.domain}' has no directive '${s.directiveId}' — it has: ${mod.directives.map((d) => d.id).join(", ")}`,
1438
+ );
1439
+ return;
1440
+ }
1441
+ const parsed = (dir.payloadSchema as z.ZodTypeAny).safeParse(s.payload);
1442
+ if (!parsed.success) {
1443
+ const why = parsed.error.issues.map((iss) => `${iss.path.join(".") || "(payload)"}: ${iss.message}`).join("; ");
1444
+ issues.push(`${at} (${s.domain}/${s.directiveId}): the recipe payload does not satisfy the directive's payload schema — ${why}`);
1445
+ return;
1446
+ }
1447
+ // Run the step's plan on the recipe's own payload (the exact dispatch the child gate replays) and
1448
+ // enforce the ENGINE's return contract: the sealed engine spreads the return value, so a non-array
1449
+ // return is ALWAYS a runtime halt — catch it here, with the fix.
1450
+ try {
1451
+ withBirthProbeGlobals(() => {
1452
+ __resetAuthoring();
1453
+ __resetReads();
1454
+ __resetBirths();
1455
+ try {
1456
+ const returned = dir.plan(s.payload as never, deterministicPorts({ physical: 1, replica: 1, principal: "principal:probe" }));
1457
+ if (!Array.isArray(returned)) {
1458
+ issues.push(
1459
+ `${at} (${s.domain}/${s.directiveId}): the plan returned ${returned === undefined ? "undefined" : `a non-array (${typeof returned})`} — ${PLAN_RETURN_HINT}`,
1460
+ );
1461
+ }
1462
+ } finally {
1463
+ try { __drainAuthoring(); __drainReads(); __drainBirths(); } catch { /* drained */ }
1464
+ }
1465
+ });
1466
+ } catch (e) {
1467
+ issues.push(
1468
+ `${at} (${s.domain}/${s.directiveId}): the plan THREW at the compile probe (${(e as Error)?.message ?? e}) — if it only fails on the sample payload this is a false alarm, but the child gate will replay this exact step at birth`,
1469
+ );
1470
+ }
1471
+ });
1472
+ return issues;
1473
+ }
1474
+
1475
+ /** {@link discoverChildBirthLeg} with a REASON on the undiscoverable path — so `.births()` without a
1476
+ * provable leg prints WHY at compile instead of a silent \`proof SKIPPED\`. */
1477
+ export function discoverChildBirthLegOrReason(
1478
+ birthDir: Directive<unknown>,
1479
+ parentDomain: string,
1480
+ modules: readonly DomainModule[],
1481
+ ): { leg?: ChildBirthLeg; reason?: string } {
1482
+ if (birthDir.birthsChild !== true) return {};
1483
+ const who = `${parentDomain}/${birthDir.id}`;
1366
1484
  let recipe: { workspace?: unknown; genesisChain?: unknown } | undefined;
1367
1485
  let synth: SynthesizedPayload;
1368
1486
  try {
1369
1487
  synth = synthesizePayload(birthDir.payloadSchema as z.ZodTypeAny, `${parentDomain}/${birthDir.id}`);
1370
- } catch {
1371
- return undefined;
1488
+ } catch (e) {
1489
+ return { reason: `${who}: the payload could not be synthesized for the compile probe (${(e as Error)?.message ?? e}) — the offline proof needs a sampleable payload (strings/numbers/enums; exotic shapes drop the leg)` };
1372
1490
  }
1373
1491
  // The plan needs concrete payload VALUES for every field it reads to build the recipe (frameworkHash /
1374
1492
  // lawHash / owner …). The synthesizer omits `…At` (auto-stamped) + optional fields; fill the auto-stamped
@@ -1389,10 +1507,15 @@ export function discoverChildBirthLeg(
1389
1507
  const drained = __drainBirths();
1390
1508
  recipe = drained[0] as typeof recipe;
1391
1509
  });
1392
- } catch {
1393
- return undefined;
1510
+ } catch (e) {
1511
+ return { reason: `${who}: the plan THREW at the compile probe (${(e as Error)?.message ?? e}) — the probe runs it on a synthesized payload to read the birth recipe` };
1512
+ }
1513
+ if (recipe === undefined) {
1514
+ return { reason: `${who}: the plan declared .births() but recorded NO birth — its plan must call birth({ workspace, genesisChain }) (see dsl/src/framework/home.ts birthHome, or bench/scale/fixtures/estate-birth)` };
1515
+ }
1516
+ if (!Array.isArray(recipe.genesisChain)) {
1517
+ return { reason: `${who}: birth() was called without a genesisChain ARRAY — the recipe is { workspace, genesisChain: [ ...steps ] }` };
1394
1518
  }
1395
- if (recipe === undefined || !Array.isArray(recipe.genesisChain)) return undefined;
1396
1519
  const chain = recipe.genesisChain as DiscoveredGenesisStep[];
1397
1520
  // The child seed = the FIRST genesis step that is neither bootstrap nor nomos installDomain (the leaf's
1398
1521
  // own seed). A platform-shaped recipe (seedGenesis → grantAdmin → birthCloud tail) is out of scope here —
@@ -1401,7 +1524,10 @@ export function discoverChildBirthLeg(
1401
1524
  (s) => s !== null && typeof s === "object" && s.directiveId !== "installDomain"
1402
1525
  && s.domain !== "bootstrap" && s.domain !== "nomos",
1403
1526
  );
1404
- if (seedStep === undefined) return undefined;
1527
+ if (seedStep === undefined) {
1528
+ return { reason: `${who}: the genesis chain has ONLY install steps (no child seed) — the offline leg proves the LEAF shape [bootstrap/installDomain, nomos/installDomain, <childDomain>/<seedDirective>]; add the child's own seed step (a platform-shaped seedGenesis→grantAdmin→birthCloud tail is proven live, not offline)` };
1529
+ }
1530
+ const recipeIssues = validateGenesisSteps(chain, modules);
1405
1531
  const childDomain = seedStep.domain;
1406
1532
  const childSeedDirectiveId = seedStep.directiveId;
1407
1533
 
@@ -1452,7 +1578,7 @@ export function discoverChildBirthLeg(
1452
1578
  if (frameworkHashField !== undefined) delete birthPayload[frameworkHashField];
1453
1579
  if (lawHashField !== undefined) delete birthPayload[lawHashField];
1454
1580
 
1455
- return {
1581
+ const leg: ChildBirthLeg = {
1456
1582
  parentDomain,
1457
1583
  birthDirectiveId: birthDir.id,
1458
1584
  birthPayload,
@@ -1466,7 +1592,9 @@ export function discoverChildBirthLeg(
1466
1592
  ...(childSeedAggregateId !== undefined ? { childSeedAggregateId } : {}),
1467
1593
  ...(childSeedInstanceId !== undefined ? { childSeedInstanceId } : {}),
1468
1594
  ...(childQuery !== undefined ? { childQuery } : {}),
1595
+ ...(recipeIssues.length > 0 ? { recipeIssues } : {}),
1469
1596
  };
1597
+ return { leg };
1470
1598
  }
1471
1599
 
1472
1600
  function childBirthsForModule(mod: DomainModule, modules: readonly DomainModule[]): ChildBirthLeg[] {
@@ -1575,6 +1703,27 @@ export interface ProofLegs {
1575
1703
  readonly mintField?: string;
1576
1704
  readonly query?: { readonly id: string; readonly params: Record<string, unknown> };
1577
1705
  readonly count?: { readonly id: string; readonly group: string };
1706
+ /** BIRTHS DX DIAGNOSTICS: present when this domain declares ≥1 `.births()` directive whose child-birth
1707
+ * leg is either UNDISCOVERABLE (the reason, so the compile summary never silently skips a birth) or
1708
+ * discoverable-but-suspect (the leg's `recipeIssues`). Printed by the compile summary; the offline
1709
+ * runner fails fast on recipe issues with the same words. Additive: absent on older files. */
1710
+ readonly birthDiagnostics?: readonly string[];
1711
+ /** SPATIAL MEMBERSHIP (lane-2 read-serving): present when a declared spatial index covers the created
1712
+ * aggregate AND the plan-probe proved the synthesized payload lands a geometry on the indexed path —
1713
+ * the offline runner asserts `spatialWithin(id, world)` contains the created row. */
1714
+ readonly spatial?: { readonly id: string };
1715
+ /** EVERY OTHER standalone create (co2 feedback: the proof exercises ALL VO-bearing creates, not just
1716
+ * the first) — each dispatched + asserted exactly like the primary. Additive: absent on older files. */
1717
+ readonly extraCreates?: readonly {
1718
+ readonly directiveId: string;
1719
+ readonly aggregateId: string;
1720
+ readonly payload: Record<string, unknown>;
1721
+ readonly autoStamped: readonly string[];
1722
+ readonly mintField?: string;
1723
+ readonly query?: { readonly id: string; readonly params: Record<string, unknown> };
1724
+ readonly count?: { readonly id: string; readonly group: string };
1725
+ readonly spatial?: { readonly id: string };
1726
+ }[];
1578
1727
  /** THE CHILD-BIRTH LEG — present iff this domain has a `.births()` directive (it is `child-birth-provable`).
1579
1728
  * When this domain is itself the BORN child's law (no standalone create), the index also registers the
1580
1729
  * child domain key pointing at the SAME leg, so `proof --domain <child>` resolves through the parent birth. */
@@ -1603,14 +1752,69 @@ function isStandaloneCreate(d: Directive<unknown> | undefined): boolean {
1603
1752
  && d.requiresRelationGate === undefined;
1604
1753
  }
1605
1754
 
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;
1755
+ /** Does `v` carry an RFC 7946 geometry object at `segs` (walked into the composite)? */
1756
+ function geometryAtPath(v: unknown, segs: readonly string[]): boolean {
1757
+ let cur: unknown = v;
1758
+ for (const s of segs) {
1759
+ if (cur === null || typeof cur !== "object") return false;
1760
+ cur = (cur as Record<string, unknown>)[s];
1761
+ }
1762
+ return (
1763
+ cur !== null && typeof cur === "object" &&
1764
+ typeof (cur as { type?: unknown }).type === "string" &&
1765
+ (cur as { coordinates?: unknown }).coordinates !== undefined
1766
+ );
1767
+ }
1768
+
1769
+ /** Build ONE standalone-create leg entry (payload + matched query/count + the spatial-membership
1770
+ * assertion when a declared spatial index covers the type and the plan-probe lands a geometry on
1771
+ * the indexed path — trying the optionals-included VO sample when the default omits it). */
1772
+ function createLegFor(
1773
+ mod: DomainModule,
1774
+ createDir: Directive<unknown>,
1775
+ domain: string,
1776
+ ): {
1777
+ directiveId: string; aggregateId: string; payload: Record<string, unknown>;
1778
+ autoStamped: readonly string[]; mintField?: string;
1779
+ query?: { id: string; params: Record<string, unknown> };
1780
+ count?: { id: string; group: string };
1781
+ spatial?: { id: string };
1782
+ } | null {
1610
1783
  const agg = (mod.aggregates as AggregateHandle[]).find((a) => a.id === createDir.aggregateId);
1611
1784
  if (agg === undefined) return null;
1612
- const synth = synthesizePayload(createDir.payloadSchema as z.ZodTypeAny, `${domain}/${createDir.id}`);
1785
+ const where = `${domain}/${createDir.id}`;
1786
+ let synth = synthesizePayload(createDir.payloadSchema as z.ZodTypeAny, where);
1613
1787
  const mintField = mintedCreateField(createDir, agg);
1788
+
1789
+ // SPATIAL MEMBERSHIP: probe the plan — does the create set the indexed head field with a geometry
1790
+ // at the declared path? If the default sample omits an optional geometry, retry with the
1791
+ // optionals-included VO sample and adopt IT as the leg's payload (so the proof exercises the
1792
+ // geometry the index needs).
1793
+ let spatial: { id: string } | undefined;
1794
+ const spatials = ((mod.spatials ?? []) as { id: string; of: string; on: string }[]).filter((sp) => sp.of === agg.id);
1795
+ if (spatials.length > 0) {
1796
+ const probeWith = (sy: SynthesizedPayload): FieldOp[] | undefined => {
1797
+ const probePayload: Record<string, unknown> = { ...sy.value };
1798
+ for (const f of sy.autoStamped) probePayload[f] = "2026-01-01T00:00:00Z";
1799
+ const probed = probePlanOpsWithPayload(createDir, probePayload, where);
1800
+ return probed;
1801
+ };
1802
+ const hits = (ops: FieldOp[] | undefined, sp: { on: string }): boolean => {
1803
+ if (ops === undefined) return false;
1804
+ const [head, ...segs] = sp.on.split(".");
1805
+ return ops.some((o) => (o as { kind?: string }).kind === "set" && (o as { field?: string }).field === head && geometryAtPath((o as { value?: unknown }).value, segs));
1806
+ };
1807
+ let ops = probeWith(synth);
1808
+ let matched = spatials.find((sp) => hits(ops, sp));
1809
+ if (matched === undefined) {
1810
+ const richer = synthesizePayload(createDir.payloadSchema as z.ZodTypeAny, where, { voIncludeOptional: true });
1811
+ ops = probeWith(richer);
1812
+ matched = spatials.find((sp) => hits(ops, sp));
1813
+ if (matched !== undefined) synth = richer; // the geometry-bearing sample becomes the leg's payload
1814
+ }
1815
+ if (matched !== undefined) spatial = { id: matched.id };
1816
+ }
1817
+
1614
1818
  const baseValue: Record<string, unknown> = { ...synth.value };
1615
1819
  if (mintField !== undefined) delete baseValue[mintField];
1616
1820
  const query = ((mod.queries ?? []) as QueryDecl[]).find(
@@ -1620,8 +1824,6 @@ export function proofLegsForModule(mod: DomainModule): ProofLegs | null {
1620
1824
  .map((raw) => finishCount(raw))
1621
1825
  .find((c) => c.of === agg.id && (c as { where?: unknown }).where == null && (c.by == null || baseValue[c.by] !== undefined));
1622
1826
  return {
1623
- domain,
1624
- legs: ["compile-valid", "standalone-provable"],
1625
1827
  directiveId: createDir.id,
1626
1828
  aggregateId: agg.id,
1627
1829
  payload: baseValue,
@@ -1629,6 +1831,23 @@ export function proofLegsForModule(mod: DomainModule): ProofLegs | null {
1629
1831
  ...(mintField !== undefined ? { mintField } : {}),
1630
1832
  ...(query ? { query: { id: query.id, params: Object.fromEntries(query.key.map((k) => [k, baseValue[k]])) } } : {}),
1631
1833
  ...(count ? { count: { id: count.id, group: count.by != null ? String(baseValue[count.by]) : "" } } : {}),
1834
+ ...(spatial !== undefined ? { spatial } : {}),
1835
+ };
1836
+ }
1837
+
1838
+ export function proofLegsForModule(mod: DomainModule): ProofLegs | null {
1839
+ const domain = mod.domain ?? mod.name;
1840
+ const creates = (mod.directives as Directive<unknown>[]).filter((d) => isStandaloneCreate(d));
1841
+ const legsBuilt = creates
1842
+ .map((d) => { try { return createLegFor(mod, d, domain); } catch { return null; } })
1843
+ .filter((l): l is NonNullable<typeof l> => l !== null);
1844
+ if (legsBuilt.length === 0) return null;
1845
+ const [primary, ...extras] = legsBuilt;
1846
+ return {
1847
+ domain,
1848
+ legs: ["compile-valid", "standalone-provable"],
1849
+ ...primary!,
1850
+ ...(extras.length > 0 ? { extraCreates: extras } : {}),
1632
1851
  };
1633
1852
  }
1634
1853
 
@@ -1671,9 +1890,20 @@ export function legsForDomain(mod: DomainModule, modules: readonly DomainModule[
1671
1890
  if (childBirthWithSetup !== undefined) legs.push("child-birth-provable");
1672
1891
  if (relationBootstrap !== undefined) legs.push("relation-bootstrap-provable");
1673
1892
 
1893
+ // BIRTHS DX: every `.births()` directive either carries a discoverable leg (whose recipeIssues we
1894
+ // surface) or a REASON it has none — a birth is never silently unprovable.
1895
+ const birthDiagnostics: string[] = [];
1896
+ for (const d of mod.directives as Directive<unknown>[]) {
1897
+ if (d?.birthsChild !== true || typeof d.payloadSchema !== "object") continue;
1898
+ const r = discoverChildBirthLegOrReason(d, domain, modules);
1899
+ if (r.reason !== undefined) birthDiagnostics.push(r.reason);
1900
+ for (const issue of r.leg?.recipeIssues ?? []) birthDiagnostics.push(`${domain}/${d.id}: ${issue}`);
1901
+ }
1902
+
1674
1903
  return {
1675
1904
  domain,
1676
1905
  legs,
1906
+ ...(birthDiagnostics.length > 0 ? { birthDiagnostics } : {}),
1677
1907
  ...(standalone !== null
1678
1908
  ? {
1679
1909
  directiveId: standalone.directiveId,
@@ -1683,6 +1913,8 @@ export function legsForDomain(mod: DomainModule, modules: readonly DomainModule[
1683
1913
  ...(standalone.mintField !== undefined ? { mintField: standalone.mintField } : {}),
1684
1914
  ...(standalone.query !== undefined ? { query: standalone.query } : {}),
1685
1915
  ...(standalone.count !== undefined ? { count: standalone.count } : {}),
1916
+ ...(standalone.spatial !== undefined ? { spatial: standalone.spatial } : {}),
1917
+ ...(standalone.extraCreates !== undefined ? { extraCreates: standalone.extraCreates } : {}),
1686
1918
  }
1687
1919
  : {}),
1688
1920
  ...(childBirthWithSetup !== undefined ? { childBirth: childBirthWithSetup } : {}),
package/src/codegen_ts.ts CHANGED
@@ -40,6 +40,7 @@ import type { AggregateHandle } from "./aggregate.js";
40
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
+ import type { NomosCompat } from "./compat_manifest.js";
43
44
  import type { QueryDecl } from "./query.js";
44
45
  import { voMetaOf, collectPayloadValueObjectDescriptors } from "./value_object.js";
45
46
  import { finishCount } from "./count.js";
@@ -68,6 +69,9 @@ export const tsHashConstName = (packageName: string): string => `${upperSnake(pa
68
69
  /** `tool-library` → `TOOL_LIBRARY_INTERFACE` — the baked structural-interface const (#72). */
69
70
  export const interfaceConstName = (packageName: string): string => `${upperSnake(packageName)}_INTERFACE`;
70
71
 
72
+ /** `tool-library` → `TOOL_LIBRARY_COMPAT` — the baked client/law compat manifest (§5). */
73
+ export const compatConstName = (packageName: string): string => `${upperSnake(packageName)}_COMPAT`;
74
+
71
75
  // ── zod → TS (the same `_def ?? def` convention as codegen_dart's walker) ─────────
72
76
  // The walkers are exported: codegen_proof.ts synthesizes SAMPLE payloads from the
73
77
  // same zod shapes this file derives TYPES from — one introspection, two emitters.
@@ -379,6 +383,9 @@ export interface TsClientOptions {
379
383
  * (read shapes, sid-keyed), keyed by domain. Baked in so `connect()` can negotiate
380
384
  * shape-compatibility against the workspace's installed offer — NOT by hash. */
381
385
  readonly interface?: Record<string, unknown>;
386
+ /** The CLIENT/LAW COMPAT MANIFEST (§5) — baked in so the factory can hand it to the
387
+ * holon's connect-time classifier (DIAGNOSIS only, never a gate). */
388
+ readonly compat?: NomosCompat;
382
389
  }
383
390
 
384
391
  /** TRUE when any module declares a PACKED workspace taxonomy (the routed-holon shape).
@@ -422,6 +429,16 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
422
429
  ``,
423
430
  ]
424
431
  : []),
432
+ ...(opts.compat !== undefined
433
+ ? [
434
+ `/** The CLIENT/LAW COMPAT MANIFEST (§5): the law hash + structural interface hash + generator`,
435
+ ` * provenance this client was generated against. A DIAGNOSIS surface — the factory hands it to`,
436
+ ` * the holon's connect-time classifier, which logs a warning on a law-hash mismatch. It NEVER`,
437
+ ` * gates: a hash mismatch with a compatible SHAPE is normal during law evolution (#72). */`,
438
+ `export const ${compatConstName(opts.packageName)} = ${JSON.stringify(opts.compat)} as const;`,
439
+ ``,
440
+ ]
441
+ : []),
425
442
  `/** One projection row: the aggregate's wire type, its id, and the folded fields. */`,
426
443
  `export interface Row<T> { type: string; id: string; data: T }`,
427
444
  ``,
@@ -438,6 +455,9 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
438
455
  ` watchById(aggregateId: string, cb: (rows: unknown) => void, opts?: { intervalMs?: number }): () => void;`,
439
456
  ` /** Declared-count / declared-sum O(1) reads (maintained by the read engine). */`,
440
457
  ` count(countId: string, groupKey?: string): Promise<number>;`,
458
+ ` /** THE SPATIAL READ (R*Tree probe): rows intersecting the bbox — same row shape as query().`,
459
+ ` * OPTIONAL member: structural binding tolerates a pre-spatial holon (the accessor then throws at call). */`,
460
+ ` spatialWithin?(spatialId: string, bbox: { minLng: number; minLat: number; maxLng: number; maxLat: number }): Promise<unknown>;`,
441
461
  ` sum(sumId: string, groupKey?: string): Promise<number>;`,
442
462
  ...(anyPackedTaxonomy(modules)
443
463
  ? [
@@ -450,6 +470,10 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
450
470
  ` deadLetters(): Promise<{ id?: string; oid: string; source: string; error: string; domain?: string | null; directiveId?: string | null; at: string }[]>;`,
451
471
  ` retryDeadLetter(id: string): Promise<{ ok: boolean; head?: string; alreadyOnMain?: boolean; error?: string }>;`,
452
472
  ` discardDeadLetter(id: string): Promise<{ ok: boolean; removed: number }>;`,
473
+ ` /** THE CONNECT-TIME COMPAT CLASSIFIER (§5) — OPTIONAL: a holon from @githolon/client's connect()`,
474
+ ` * attaches it (implemented by \`checkCompat\`); the generated factory hands over this client's baked`,
475
+ ` * compat manifest so a law-hash mismatch logs a warning. DIAGNOSIS only — never throws, never gates. */`,
476
+ ` checkCompat?(compat: { lawHash: string; interfaceHash: string; packageName: string }): void;`,
453
477
  `}`,
454
478
  ``,
455
479
  `/** AUTHORITY IS UNFORGEABLE-BY-CONSTRUCTION: a GATED directive (one carrying a \`.requires(relation)\` write`,
@@ -824,6 +848,12 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
824
848
  }
825
849
  }
826
850
 
851
+ for (const sp of (mod.spatials ?? []) as { id: string; of: string; on: string }[]) {
852
+ methods.push(
853
+ ` /** Declared spatial index \`${sp.id}\` — rows of \`${sp.of}\` whose \`${sp.on}\` geometry intersects the bbox (R*Tree probe). */`,
854
+ ` ${lcFirst(camel(sp.id))}: (bbox: { minLng: number; minLat: number; maxLng: number; maxLat: number }) => (holon.spatialWithin ?? (() => { throw new Error("this holon predates spatial reads — upgrade @githolon/client"); }))(${JSON.stringify(sp.id)}, bbox) as Promise<Row<${sp.of}Data>[]>,`,
855
+ );
856
+ }
827
857
  for (const q of (mod.queries ?? []) as QueryDecl[]) {
828
858
  const agg = aggByType.get(q.returns);
829
859
  const params = q.key
@@ -890,6 +920,9 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
890
920
  `export function ${tsClientFactoryName(domain)}(holon: NomosHolon, opts: { domainHash?: string; actor?: string } = {}) {`,
891
921
  ` const domainHash = opts.domainHash ?? ${hashConst};`,
892
922
  ` const actor = opts.actor;`,
923
+ ...(opts.compat !== undefined
924
+ ? [` holon.checkCompat?.(${compatConstName(opts.packageName)});`]
925
+ : []),
893
926
  ` return {`,
894
927
  ` domain: ${JSON.stringify(domain)} as const,`,
895
928
  ` domainHash,`,
@@ -0,0 +1,20 @@
1
+ // The CLIENT/LAW COMPATIBILITY MANIFEST (§5, architecture/plug_and_play_dx.md).
2
+ //
3
+ // A DIAGNOSIS surface, NEVER a gate. Clients fit workspaces by STRUCTURAL INTERFACE
4
+ // (stable-id subtyping, #72), not by law-hash pin — the hash pin was a bug we removed.
5
+ // This block is baked into the generated TS + Dart clients and mirrored into the deploy
6
+ // JSON so a connect() can classify: lawHash-equal (silent) vs lawHash-differs-shape-compat
7
+ // (proceed, log) vs structurally-incompatible (warn). It is FRONTEND-ONLY — emitted like
8
+ // the dart codegen hints, it does NOT enter the USD IR / package bytes / domainHash.
9
+ export interface NomosCompat {
10
+ /** The package name (`cfg.name`). */
11
+ readonly packageName: string;
12
+ /** The deploy domainHash — sha256 of the package bytes (`policy:{hash}`). */
13
+ readonly lawHash: string;
14
+ /** sha256 over the CANONICAL serialization of the package structural interface. */
15
+ readonly interfaceHash: string;
16
+ /** Per domain key → its admission-identity hash (absent for identity-excluded domains). */
17
+ readonly domains: Record<string, { readonly identityHash?: string }>;
18
+ /** The generator provenance — the `@githolon/dsl` version + this manifest's schema. */
19
+ readonly generator: { readonly dsl: string; readonly schema: number };
20
+ }