@githolon/dsl 0.46.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.
@@ -1,75 +1,103 @@
1
- // NomosTestHarness: unit coverage for the identity/jwt plumbing, plus the LIVE ephemeral fixture loop
2
- // (birth a throwaway ws → deploy real law → two client replicas → write on one, converge on the other).
3
- // The live test needs a `githolon login --agent` session (or NOMOS_PRINCIPAL) and is opt-in via NOMOS_LIVE=1.
1
+ // NomosTestHarness — the EPHEMERAL, FULLY-OFFLINE fixture loop: install real tenant law into a fresh local
2
+ // workspace (no cloud), drive one directive + one read through the REAL githolon over the node runner, and
3
+ // tear down deterministically. This is the headless app-test lane: the same bridge + generated-client surface
4
+ // the app UI uses, under `dart test` / `flutter test`, with no network.
5
+ //
6
+ // Prerequisites (skipped-with-reason when absent, so CI stays green either way): `node` on PATH; the
7
+ // in-repo runner; a warm `~/.holon/runtime` cache (holon.wasm + packages.json — one `githolon` run fills it);
8
+ // and the compiled `examples/guestbook/build/guestbook.deploy.json` (`cd examples/guestbook && npx nomos-compile`).
4
9
  @TestOn('vm')
5
10
  library;
6
11
 
7
- import 'dart:convert';
8
12
  import 'dart:io';
9
13
 
10
14
  import 'package:nomos_client/nomos_headless.dart';
11
15
  import 'package:test/test.dart';
12
16
 
13
- String _repoEntry() =>
17
+ String get _repoEntry =>
14
18
  '${Directory.current.path}/../../cloud/web-client/runner/node-entry.mjs';
15
-
16
- String _guestbookDeployJson() =>
19
+ String get _guestbookDeployJson =>
17
20
  '${Directory.current.path}/../../examples/guestbook/build/guestbook.deploy.json';
21
+ String get _runtimeDir =>
22
+ '${Platform.environment['HOME'] ?? ''}/.holon/runtime';
23
+
24
+ /// The prerequisite that isn't installable in-test (a warm runtime cache + the compiled fixture). Returns a
25
+ /// skip reason, or null when everything the harness needs is present.
26
+ String? _missingPrereq() {
27
+ if (!File(_repoEntry).existsSync()) return 'in-repo runner not found at $_repoEntry';
28
+ if (!File(_guestbookDeployJson).existsSync()) {
29
+ return 'compile the fixture first: cd examples/guestbook && npx nomos-compile';
30
+ }
31
+ if (!File('$_runtimeDir/holon.wasm').existsSync() ||
32
+ !File('$_runtimeDir/packages.json').existsSync()) {
33
+ return 'warm ~/.holon/runtime first (run any `githolon` command once to cache the runtime)';
34
+ }
35
+ return null;
36
+ }
18
37
 
19
38
  void main() {
20
- test('ephemeral without any identity refuses with a named remedy', () async {
21
- // isolate from a real ~/.holon by pointing HOME at an empty dir
22
- // (can't re-env the current isolate — so only run when no creds file exists).
23
- final home = Platform.environment['HOME'] ?? '';
24
- if (File('$home/.holon/credentials.json').existsSync()) {
25
- markTestSkipped('a githolon session exists — the fallback would find it');
26
- return;
27
- }
39
+ test('ephemeral rejects an empty deploy list with a named remedy', () async {
28
40
  await expectLater(
29
- NomosTestHarness.ephemeral(),
41
+ NomosTestHarness.ephemeral(deployJsonPaths: const []),
30
42
  throwsA(isA<NomosTestHarnessException>()),
31
43
  );
32
44
  });
33
45
 
34
- test(
35
- 'LIVE: ephemeral fixture — birth, deploy law, write on one replica, read it on another',
36
- skip: Platform.environment['NOMOS_LIVE'] != '1'
37
- ? 'set NOMOS_LIVE=1 (needs a githolon login --agent session)'
38
- : null,
39
- () async {
40
- final harness = await NomosTestHarness.ephemeral(
41
- deployJsonPaths: [_guestbookDeployJson()],
42
- nodeEntry: _repoEntry(),
43
- );
44
- try {
45
- final writer = await harness.connect(clientId: 'writer');
46
- final reader = await harness.connect(clientId: 'reader');
47
- final minted = await writer.mintId('Fixture');
48
- expect(minted, startsWith('Fixture_'));
49
- final outcome = await writer.offerCreates(
50
- domain: 'guestbook',
51
- directiveId: 'signGuestbook',
52
- payload: {
53
- 'author': 'harness',
54
- 'message': 'ephemeral fixture green',
55
- 'mood': 'delighted',
56
- 'signedAt': DateTime.now().toUtc().toIso8601String(),
57
- });
58
- expect(outcome.created, hasLength(1));
59
- expect(outcome.created.single.type, 'guestbook.GuestbookEntry');
60
- await writer.sync(admit: true);
61
- await reader.sync(admit: false);
62
- final rows = await reader.query('entriesByAuthor', {'author': 'harness'});
63
- expect(rows, hasLength(1));
64
- expect(rows.first['id'], outcome.created.single.id);
65
- expect(
66
- (rows.first['data'] as Map)['message'], 'ephemeral fixture green');
67
- stdout.writeln(
68
- 'live harness green: ws=${harness.workspace} rows=${jsonEncode(rows.first['data'])}');
69
- } finally {
70
- await harness.dispose();
71
- }
72
- },
73
- timeout: const Timeout(Duration(minutes: 5)),
74
- );
46
+ test('OFFLINE: install real law, write one directive, read it back, dispose', () async {
47
+ final skip = _missingPrereq();
48
+ if (skip != null) {
49
+ markTestSkipped(skip);
50
+ return;
51
+ }
52
+ final harness = await NomosTestHarness.ephemeral(
53
+ deployJsonPaths: [_guestbookDeployJson],
54
+ nodeEntry: _repoEntry,
55
+ runtimeDir: _runtimeDir,
56
+ registerTearDown: addTearDown, // deterministic auto-dispose even if an expect() throws
57
+ );
58
+
59
+ // Drive the REAL law: a create directive + a declared query — the same ops a generated client emits.
60
+ final outcome = await harness.bridge.offerCreates(
61
+ domain: 'guestbook',
62
+ directiveId: 'signGuestbook',
63
+ payload: {
64
+ 'author': 'harness',
65
+ 'message': 'offline fixture green',
66
+ 'mood': 'happy',
67
+ 'signedAt': DateTime.now().toUtc().toIso8601String(),
68
+ },
69
+ );
70
+ expect(outcome.created, hasLength(1));
71
+ expect(outcome.created.single.type, 'guestbook.GuestbookEntry');
72
+
73
+ final rows = await harness.bridge.query('entriesByAuthor', {'author': 'harness'});
74
+ expect(rows, hasLength(1));
75
+ expect(rows.first['id'], outcome.created.single.id);
76
+ expect((rows.first['data'] as Map)['message'], 'offline fixture green');
77
+ expect((rows.first['data'] as Map)['moodEmoji'], '😊'); // proves the law's plan ran, not a stub
78
+
79
+ stdout.writeln('offline harness green: ws=${harness.workspace}');
80
+ }, timeout: const Timeout(Duration(minutes: 2)));
81
+
82
+ test('OFFLINE: dispose is idempotent and removes the temp dir', () async {
83
+ final skip = _missingPrereq();
84
+ if (skip != null) {
85
+ markTestSkipped(skip);
86
+ return;
87
+ }
88
+ final harness = await NomosTestHarness.ephemeral(
89
+ deployJsonPaths: [_guestbookDeployJson],
90
+ nodeEntry: _repoEntry,
91
+ runtimeDir: _runtimeDir,
92
+ autoTearDown: false, // drive dispose explicitly to assert its determinism
93
+ );
94
+ // A read proves the holon is live before teardown.
95
+ expect(await harness.bridge.query('entriesByAuthor', {'author': 'nobody'}), isEmpty);
96
+
97
+ await harness.dispose();
98
+ await harness.dispose(); // idempotent: the second call is a no-op, never throws
99
+ // The bridge is dead after dispose — a call fails rather than silently reaching a leaked runner.
100
+ await expectLater(
101
+ harness.bridge.query('entriesByAuthor', {'author': 'x'}), throwsA(anything));
102
+ }, timeout: const Timeout(Duration(minutes: 2)));
75
103
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.46.0",
3
+ "version": "0.47.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",
@@ -60,6 +60,9 @@ export const dartHashConstName = (fileBase: string): string => `${lcFirst(camel(
60
60
  /** `todo-nomos` → `todoNomosInterfaceJson` — the baked structural-interface JSON const (#72). */
61
61
  export const interfaceConstNameDart = (fileBase: string): string => `${lcFirst(camel(fileBase))}InterfaceJson`;
62
62
 
63
+ /** `todo-nomos` → `todoNomosCompatJson` — the baked client/law compat manifest JSON const (§5). */
64
+ export const compatConstNameDart = (fileBase: string): string => `${lcFirst(camel(fileBase))}CompatJson`;
65
+
63
66
  /** Emit a Dart RAW triple-quoted string literal of an object's JSON (the interface is JSON-only
64
67
  * data — no `'`/`$`/backslash escaping concerns under `r'''…'''`; JSON never contains `'''`). */
65
68
  function dartRawJson(value: unknown): string {
@@ -97,6 +100,9 @@ export interface DartBridgeClientOptions {
97
100
  * (read shapes, sid-keyed). Baked in as JSON so the app can negotiate shape-compatibility
98
101
  * against the workspace's installed offer, NOT by law hash. */
99
102
  readonly interface?: unknown;
103
+ /** The CLIENT/LAW COMPAT MANIFEST (§5) — the law hash + interface hash + generator provenance
104
+ * this client was generated against. Baked in as JSON (DIAGNOSIS only, never a gate). */
105
+ readonly compat?: unknown;
100
106
  /** Where the bridge/runtime support types are imported from. */
101
107
  readonly supportImport?: "vendored" | "package";
102
108
  /**
@@ -314,6 +320,15 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
314
320
  ``,
315
321
  ]
316
322
  : []),
323
+ ...(opts.compat !== undefined
324
+ ? [
325
+ `/// The CLIENT/LAW COMPAT MANIFEST (§5) as JSON — the law hash, structural interface hash, and`,
326
+ `/// generator provenance this client was generated against. A DIAGNOSIS surface for connect-time`,
327
+ `/// classification (silent / shape-compatible / incompatible); it NEVER gates.`,
328
+ `const String ${compatConstNameDart(mod.name)} = ${dartRawJson(opts.compat)};`,
329
+ ``,
330
+ ]
331
+ : []),
317
332
  `/// Typed async client for the \`${domain}\` domain, bound to a connected [NomosBridge].`,
318
333
  `///`,
319
334
  `/// Pass \`actor\` (the verified principal uid) to author AS that user — the bridge's \`offerDirective\` op`,
@@ -1286,6 +1286,8 @@ export function diagnoseRelationBootstrapGap(mod: DomainModule): string | undefi
1286
1286
  interface DiscoveredGenesisStep {
1287
1287
  readonly domain: string;
1288
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;
1289
1291
  }
1290
1292
 
1291
1293
  /** A discovered CHILD-BIRTH leg: a parent `.births()` directive proven end-to-end into the child it births
@@ -1333,6 +1335,12 @@ export interface ChildBirthLeg {
1333
1335
  * parent workspace (home → estate is the canonical case), the proof runner drives this setup birth first
1334
1336
  * and then authors the child birth inside the born parent. */
1335
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[];
1336
1344
  }
1337
1345
 
1338
1346
  export interface ChildBirthParentSetup {
@@ -1391,13 +1399,94 @@ export function discoverChildBirthLeg(
1391
1399
  parentDomain: string,
1392
1400
  modules: readonly DomainModule[],
1393
1401
  ): ChildBirthLeg | undefined {
1394
- 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}`;
1395
1484
  let recipe: { workspace?: unknown; genesisChain?: unknown } | undefined;
1396
1485
  let synth: SynthesizedPayload;
1397
1486
  try {
1398
1487
  synth = synthesizePayload(birthDir.payloadSchema as z.ZodTypeAny, `${parentDomain}/${birthDir.id}`);
1399
- } catch {
1400
- 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)` };
1401
1490
  }
1402
1491
  // The plan needs concrete payload VALUES for every field it reads to build the recipe (frameworkHash /
1403
1492
  // lawHash / owner …). The synthesizer omits `…At` (auto-stamped) + optional fields; fill the auto-stamped
@@ -1418,10 +1507,15 @@ export function discoverChildBirthLeg(
1418
1507
  const drained = __drainBirths();
1419
1508
  recipe = drained[0] as typeof recipe;
1420
1509
  });
1421
- } catch {
1422
- 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 ] }` };
1423
1518
  }
1424
- if (recipe === undefined || !Array.isArray(recipe.genesisChain)) return undefined;
1425
1519
  const chain = recipe.genesisChain as DiscoveredGenesisStep[];
1426
1520
  // The child seed = the FIRST genesis step that is neither bootstrap nor nomos installDomain (the leaf's
1427
1521
  // own seed). A platform-shaped recipe (seedGenesis → grantAdmin → birthCloud tail) is out of scope here —
@@ -1430,7 +1524,10 @@ export function discoverChildBirthLeg(
1430
1524
  (s) => s !== null && typeof s === "object" && s.directiveId !== "installDomain"
1431
1525
  && s.domain !== "bootstrap" && s.domain !== "nomos",
1432
1526
  );
1433
- 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);
1434
1531
  const childDomain = seedStep.domain;
1435
1532
  const childSeedDirectiveId = seedStep.directiveId;
1436
1533
 
@@ -1481,7 +1578,7 @@ export function discoverChildBirthLeg(
1481
1578
  if (frameworkHashField !== undefined) delete birthPayload[frameworkHashField];
1482
1579
  if (lawHashField !== undefined) delete birthPayload[lawHashField];
1483
1580
 
1484
- return {
1581
+ const leg: ChildBirthLeg = {
1485
1582
  parentDomain,
1486
1583
  birthDirectiveId: birthDir.id,
1487
1584
  birthPayload,
@@ -1495,7 +1592,9 @@ export function discoverChildBirthLeg(
1495
1592
  ...(childSeedAggregateId !== undefined ? { childSeedAggregateId } : {}),
1496
1593
  ...(childSeedInstanceId !== undefined ? { childSeedInstanceId } : {}),
1497
1594
  ...(childQuery !== undefined ? { childQuery } : {}),
1595
+ ...(recipeIssues.length > 0 ? { recipeIssues } : {}),
1498
1596
  };
1597
+ return { leg };
1499
1598
  }
1500
1599
 
1501
1600
  function childBirthsForModule(mod: DomainModule, modules: readonly DomainModule[]): ChildBirthLeg[] {
@@ -1604,6 +1703,11 @@ export interface ProofLegs {
1604
1703
  readonly mintField?: string;
1605
1704
  readonly query?: { readonly id: string; readonly params: Record<string, unknown> };
1606
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[];
1607
1711
  /** SPATIAL MEMBERSHIP (lane-2 read-serving): present when a declared spatial index covers the created
1608
1712
  * aggregate AND the plan-probe proved the synthesized payload lands a geometry on the indexed path —
1609
1713
  * the offline runner asserts `spatialWithin(id, world)` contains the created row. */
@@ -1786,9 +1890,20 @@ export function legsForDomain(mod: DomainModule, modules: readonly DomainModule[
1786
1890
  if (childBirthWithSetup !== undefined) legs.push("child-birth-provable");
1787
1891
  if (relationBootstrap !== undefined) legs.push("relation-bootstrap-provable");
1788
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
+
1789
1903
  return {
1790
1904
  domain,
1791
1905
  legs,
1906
+ ...(birthDiagnostics.length > 0 ? { birthDiagnostics } : {}),
1792
1907
  ...(standalone !== null
1793
1908
  ? {
1794
1909
  directiveId: standalone.directiveId,
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,8 +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>;`,
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>;`,
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>;`,
443
461
  ` sum(sumId: string, groupKey?: string): Promise<number>;`,
444
462
  ...(anyPackedTaxonomy(modules)
445
463
  ? [
@@ -452,6 +470,10 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
452
470
  ` deadLetters(): Promise<{ id?: string; oid: string; source: string; error: string; domain?: string | null; directiveId?: string | null; at: string }[]>;`,
453
471
  ` retryDeadLetter(id: string): Promise<{ ok: boolean; head?: string; alreadyOnMain?: boolean; error?: string }>;`,
454
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;`,
455
477
  `}`,
456
478
  ``,
457
479
  `/** AUTHORITY IS UNFORGEABLE-BY-CONSTRUCTION: a GATED directive (one carrying a \`.requires(relation)\` write`,
@@ -829,7 +851,7 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
829
851
  for (const sp of (mod.spatials ?? []) as { id: string; of: string; on: string }[]) {
830
852
  methods.push(
831
853
  ` /** 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>[]>,`,
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>[]>,`,
833
855
  );
834
856
  }
835
857
  for (const q of (mod.queries ?? []) as QueryDecl[]) {
@@ -898,6 +920,9 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
898
920
  `export function ${tsClientFactoryName(domain)}(holon: NomosHolon, opts: { domainHash?: string; actor?: string } = {}) {`,
899
921
  ` const domainHash = opts.domainHash ?? ${hashConst};`,
900
922
  ` const actor = opts.actor;`,
923
+ ...(opts.compat !== undefined
924
+ ? [` holon.checkCompat?.(${compatConstName(opts.packageName)});`]
925
+ : []),
901
926
  ` return {`,
902
927
  ` domain: ${JSON.stringify(domain)} as const,`,
903
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
+ }