@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.
- package/dart/lib/src/errors.dart +250 -0
- package/dart/lib/src/test_harness.dart +182 -175
- package/dart/test/errors_test.dart +77 -0
- package/dart/test/test_harness_test.dart +85 -57
- package/package.json +1 -1
- package/src/codegen_dart_client.ts +15 -0
- package/src/codegen_proof.ts +123 -8
- package/src/codegen_ts.ts +28 -3
- package/src/compat_manifest.ts +20 -0
- package/src/compile_package_main.ts +174 -16
- package/src/framework/framework_aggregates.ts +18 -0
- package/src/read_projection_composition.ts +9 -9
- package/src/read_projection_namespace.ts +4 -5
- package/src/usd.ts +3 -3
|
@@ -1,75 +1,103 @@
|
|
|
1
|
-
// NomosTestHarness
|
|
2
|
-
// (
|
|
3
|
-
//
|
|
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
|
|
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
|
-
|
|
36
|
-
skip
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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.
|
|
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`,
|
package/src/codegen_proof.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
+
}
|