@githolon/dsl 0.35.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dart/CHANGELOG.md CHANGED
@@ -1,3 +1,8 @@
1
+ ## 0.28.0
2
+
3
+ - `NomosTestHarness` (`nomos_headless.dart`): ephemeral real-law fixtures — one call births a throwaway cloud workspace, deploys compiled `*.deploy.json` law, and hands out independent client replicas (`connect(clientId:)`) over the node runner. Identity defaults to the `githolon login --agent` session (`~/.holon/credentials.json`). Live-proven: birth → deploy → write on one replica → converge + typed read on another, 14s end-to-end.
4
+ - HEADLESS TRANSPORT (`package:nomos_client/nomos_headless.dart`): `NodeRunnerTransport` — a `BridgeTransport` over the `@githolon/client` node runner (`runner/node-entry.mjs`) as a plain child process (NDJSON stdio). `flutter test` / `dart test` / CI drive the REAL githolon (byte-identical wasm, real ledger, real gate) through `NomosBridge` + the generated typed clients — no Flutter plugins, no WebView, no assets. Requires `node` on PATH + a resolvable `@githolon/client` (auto-resolved from the app's node_modules, or pass `entry:`). Proven end-to-end: connect → offerDirective → sync(admit) → typed query, and the same test green under both `dart test` and `flutter test`.
5
+
1
6
  ## 0.27.0
2
7
 
3
8
  - TYPED SNAPSHOT RECOVERY (corrupt local custody): `NomosBridge.connect(recover: true)` recovers IN-PLACE from a corrupt restoreFrom snapshot — salvages local-only authored writes (via the fallible `intents_above`, which stops at a missing-ancestor gap) + the DLQ, refolds FRESH from cloud, re-offers the salvaged work (deduped), re-queues the DLQ. The report rides `bridge.lastRecovery` = `{salvagedWrites, requeuedDeadLetters, unsalvageable}` — a write the current law refuses lands in `unsalvageable` + the DLQ (surfaced, never dropped). Also `holon.salvage()` / `holon.absorbSalvage()`. Closes the restoreFrom-leaves-read-model-empty case.
@@ -0,0 +1,19 @@
1
+ /// The HEADLESS transport library — `dart:io` only, so it lives outside the main
2
+ /// `package:nomos_client/nomos_client.dart` barrel (which stays web-safe).
3
+ ///
4
+ /// ```dart
5
+ /// import 'package:nomos_client/nomos_client.dart';
6
+ /// import 'package:nomos_client/nomos_headless.dart';
7
+ ///
8
+ /// final transport = await NodeRunnerTransport.start(resolveFrom: 'path/to/app');
9
+ /// final bridge = NomosBridge(transport);
10
+ /// await bridge.ready;
11
+ /// await bridge.connect(cloud: ..., workspace: ..., clientId: 'test-runner');
12
+ /// ```
13
+ ///
14
+ /// The runner child process is the SAME `@githolon/client` every peer runs; the
15
+ /// generated Dart clients drive it through [NomosBridge] exactly as in the app.
16
+ library;
17
+
18
+ export 'src/node_runner_transport.dart';
19
+ export 'src/test_harness.dart';
@@ -0,0 +1,125 @@
1
+ // THE HEADLESS (NODE) TRANSPORT — a [BridgeTransport] over the `@githolon/client` node runner
2
+ // (`runner/node-entry.mjs`), spawned as a plain child process speaking the ONE bridge protocol as
3
+ // newline-delimited JSON on stdio. This is what lets `dart test` / `flutter test` / CI drive the REAL
4
+ // githolon (the byte-identical wasm, the real ledger, the real gate) through [NomosBridge] and the
5
+ // generated typed clients — no Flutter plugins, no WebView, no assets. The only environment requirement
6
+ // is `node` on PATH plus a resolvable `@githolon/client` (any app that compiles domains already has both).
7
+ //
8
+ // This file is a PURE RELAY: no orchestration, no op knowledge — the protocol implementation lives in
9
+ // createWebviewRunner (shared with the WebView path) and the orchestration in index.mjs, same as every peer.
10
+ import 'dart:async';
11
+ import 'dart:convert';
12
+ import 'dart:io';
13
+
14
+ import 'webview_bridge.dart' show BridgeTransport;
15
+
16
+ class NodeRunnerTransport implements BridgeTransport {
17
+ final Process _proc;
18
+ final StreamController<Map<String, Object?>> _incoming =
19
+ StreamController<Map<String, Object?>>.broadcast();
20
+ final void Function(String line)? _onDiagnostic;
21
+ bool _disposed = false;
22
+
23
+ NodeRunnerTransport._(this._proc, this._onDiagnostic) {
24
+ _proc.stdout
25
+ .transform(utf8.decoder)
26
+ .transform(const LineSplitter())
27
+ .listen((line) {
28
+ final s = line.trim();
29
+ if (s.isEmpty) return;
30
+ Object? decoded;
31
+ try {
32
+ decoded = jsonDecode(s);
33
+ } catch (_) {
34
+ _diag('[nomos-node-runner stdout] $line');
35
+ return;
36
+ }
37
+ if (decoded is Map && !_incoming.isClosed) {
38
+ _incoming.add(decoded.cast<String, Object?>());
39
+ }
40
+ }, onDone: () {
41
+ if (!_disposed && !_incoming.isClosed) {
42
+ _incoming.addError(
43
+ StateError('the nomos node runner exited unexpectedly'));
44
+ }
45
+ });
46
+ _proc.stderr
47
+ .transform(utf8.decoder)
48
+ .transform(const LineSplitter())
49
+ .listen((line) => _diag('[nomos-node-runner] $line'));
50
+ }
51
+
52
+ void _diag(String line) {
53
+ final f = _onDiagnostic;
54
+ if (f != null) {
55
+ f(line);
56
+ } else {
57
+ stderr.writeln(line);
58
+ }
59
+ }
60
+
61
+ /// Spawn the node runner and return the connected transport. Wrap it in a
62
+ /// [NomosBridge] and `await bridge.ready` (the runner posts `{event:'ready'}`
63
+ /// on boot), then `connect(...)` as any other transport.
64
+ ///
65
+ /// [entry] — path to `node-entry.mjs`. When omitted it is resolved with
66
+ /// `require.resolve('@githolon/client/runner/node-entry.mjs')` from
67
+ /// [resolveFrom] (default: the current directory), i.e. the consuming app's
68
+ /// own `node_modules`.
69
+ static Future<NodeRunnerTransport> start({
70
+ String? entry,
71
+ String? resolveFrom,
72
+ String node = 'node',
73
+ Map<String, String>? environment,
74
+ void Function(String line)? onDiagnostic,
75
+ }) async {
76
+ final entryPath = entry ??
77
+ await _resolveEntry(node, resolveFrom ?? Directory.current.path);
78
+ final proc = await Process.start(
79
+ node,
80
+ [entryPath],
81
+ environment: environment,
82
+ workingDirectory: resolveFrom,
83
+ );
84
+ return NodeRunnerTransport._(proc, onDiagnostic);
85
+ }
86
+
87
+ static Future<String> _resolveEntry(String node, String from) async {
88
+ final r = await Process.run(
89
+ node,
90
+ ['-p', "require.resolve('@githolon/client/runner/node-entry.mjs')"],
91
+ workingDirectory: from,
92
+ );
93
+ final out = (r.stdout as String).trim();
94
+ if (r.exitCode != 0 || out.isEmpty) {
95
+ throw StateError(
96
+ 'cannot resolve @githolon/client/runner/node-entry.mjs from $from — '
97
+ 'is @githolon/client installed (npm install)? stderr: ${r.stderr}');
98
+ }
99
+ return out;
100
+ }
101
+
102
+ @override
103
+ Stream<Map<String, Object?>> get messages => _incoming.stream;
104
+
105
+ @override
106
+ void send(Map<String, Object?> message) {
107
+ if (_disposed) throw StateError('NodeRunnerTransport is disposed');
108
+ _proc.stdin.writeln(jsonEncode(message));
109
+ }
110
+
111
+ /// Tear down: stop the runner process and close the message stream.
112
+ Future<void> dispose() async {
113
+ if (_disposed) return;
114
+ _disposed = true;
115
+ try {
116
+ await _proc.stdin.close();
117
+ } catch (_) {}
118
+ _proc.kill(ProcessSignal.sigterm);
119
+ await _proc.exitCode.timeout(const Duration(seconds: 5), onTimeout: () {
120
+ _proc.kill(ProcessSignal.sigkill);
121
+ return -9;
122
+ });
123
+ await _incoming.close();
124
+ }
125
+ }
@@ -0,0 +1,235 @@
1
+ // NOMOS TEST HARNESS — ephemeral real-law fixtures for headless app tests. One call births a throwaway
2
+ // workspace on the cloud, deploys the given compiled domain packages (`build/<name>.deploy.json`), and hands
3
+ // out [NomosBridge]s over [NodeRunnerTransport] — the REAL githolon (byte-identical wasm, real ledger, real
4
+ // gate) driven by the same generated Dart clients the app UI uses, under `flutter test` / `dart test` / CI.
5
+ //
6
+ // ```dart
7
+ // final harness = await NomosTestHarness.ephemeral(
8
+ // deployJsonPaths: ['build/guestbook.deploy.json'],
9
+ // );
10
+ // final bridge = await harness.connect(clientId: 'test-runner');
11
+ // final app = GuestbookClient(bridge); // the generated client — same one the app uses
12
+ // ...
13
+ // await harness.dispose();
14
+ // ```
15
+ //
16
+ // Identity: a birth needs a principal. Pass [principal]/[authToken] explicitly, or the harness falls back to
17
+ // the `githolon` CLI session (`~/.holon/credentials.json`, minted by `npx githolon login --agent`) — the same
18
+ // self-onboarded verified identity the compile/deploy tooling uses. The token rides `x-nomos-auth` as proof;
19
+ // its `sub` claim rides `x-nomos-principal` (the transitional bare claim the birth lane requires until kernel
20
+ // birth-verify lands).
21
+ //
22
+ // The harness is HOST-LANE plumbing only (create + deploy are the open, kernel-gated cloud endpoints — the
23
+ // same calls `githolon ws create` / `githolon deploy` make); everything after rides the one bridge protocol.
24
+ // Workspaces are throwaway: there is no open destroy lane (reclaim is law-gated), so `dispose()` tears down
25
+ // the local transports and leaves the ledger — name them uniquely (the default) and let them age out.
26
+ import 'dart:convert';
27
+ import 'dart:io';
28
+ import 'dart:math';
29
+
30
+ import 'node_runner_transport.dart';
31
+ import 'webview_bridge.dart' show NomosBridge;
32
+
33
+ class NomosTestHarnessException implements Exception {
34
+ final String message;
35
+ NomosTestHarnessException(this.message);
36
+ @override
37
+ String toString() => 'NomosTestHarnessException: $message';
38
+ }
39
+
40
+ class NomosTestHarness {
41
+ /// The cloud base URL the harness (and every bridge it connects) talks to.
42
+ final String cloud;
43
+
44
+ /// The ephemeral workspace name this harness birthed.
45
+ final String workspace;
46
+
47
+ final String? _principal;
48
+ final String? _authToken;
49
+ final String? _nodeEntry;
50
+ final String? _resolveFrom;
51
+ final void Function(String line)? _onDiagnostic;
52
+ final List<NodeRunnerTransport> _transports = [];
53
+ bool _disposed = false;
54
+
55
+ NomosTestHarness._(this.cloud, this.workspace, this._principal,
56
+ this._authToken, this._nodeEntry, this._resolveFrom, this._onDiagnostic);
57
+
58
+ /// Birth a throwaway workspace and deploy the given compiled domain packages into it.
59
+ ///
60
+ /// [deployJsonPaths] — the `build/<name>.deploy.json` files `nomos-compile` emits (deployed in order;
61
+ /// platform/framework law first when one domain's law depends on another's).
62
+ /// [workspace] — override the minted `test-<hex>` name (e.g. to reuse a workspace across a suite).
63
+ /// [principal]/[authToken] — the identity the workspace is born for; defaults to the `githolon` CLI
64
+ /// session (`~/.holon/credentials.json`).
65
+ /// [nodeEntry]/[resolveFrom] — forwarded to [NodeRunnerTransport.start].
66
+ static Future<NomosTestHarness> ephemeral({
67
+ List<String> deployJsonPaths = const [],
68
+ String cloud = 'https://nomos.captainapp.co.uk',
69
+ String? workspace,
70
+ String? principal,
71
+ String? authToken,
72
+ String? nodeEntry,
73
+ String? resolveFrom,
74
+ void Function(String line)? onDiagnostic,
75
+ }) async {
76
+ final id = _identity(principal, authToken);
77
+ final ws = workspace ?? 'test-${_hex(12)}';
78
+ final harness = NomosTestHarness._(_stripSlash(cloud), ws, id.principal,
79
+ id.authToken, nodeEntry, resolveFrom, onDiagnostic);
80
+ await harness._create();
81
+ for (final p in deployJsonPaths) {
82
+ await harness.deployLaw(p);
83
+ }
84
+ return harness;
85
+ }
86
+
87
+ static String _brief(String s) => s.length > 600 ? '${s.substring(0, 600)}…[+${s.length - 600} chars]' : s;
88
+
89
+ /// Deploy one more compiled package (`<name>.deploy.json`) into the workspace — e.g. a law evolve mid-test.
90
+ Future<void> deployLaw(String deployJsonPath) async {
91
+ final f = File(deployJsonPath);
92
+ if (!f.existsSync()) {
93
+ throw NomosTestHarnessException(
94
+ 'deploy json not found: $deployJsonPath — run `npx githolon compile` first');
95
+ }
96
+ final r = await _post('/v2/workspaces/$workspace/domains',
97
+ body: await f.readAsString(), contentType: 'application/json');
98
+ if (r.$1 >= 300 || !(r.$2['ok'] == true)) {
99
+ throw NomosTestHarnessException(
100
+ 'deploy of $deployJsonPath to $workspace refused (${r.$1}): ${_brief(r.$3)}');
101
+ }
102
+ }
103
+
104
+ /// Spawn a fresh runner + bridge connected to the harness workspace. Each call is an independent
105
+ /// client replica (own clientId → own session branch), so multi-client convergence is testable.
106
+ Future<NomosBridge> connect({
107
+ String clientId = 'test-runner',
108
+ String? authToken,
109
+ String? authorSecret,
110
+ bool allowUnborn = false,
111
+ }) async {
112
+ if (_disposed) throw StateError('harness is disposed');
113
+ final transport = await NodeRunnerTransport.start(
114
+ entry: _nodeEntry,
115
+ resolveFrom: _resolveFrom,
116
+ onDiagnostic: _onDiagnostic,
117
+ );
118
+ _transports.add(transport);
119
+ final bridge = NomosBridge(transport);
120
+ await bridge.ready.timeout(const Duration(seconds: 30));
121
+ await bridge.connect(
122
+ cloud: cloud,
123
+ workspace: workspace,
124
+ clientId: clientId,
125
+ authToken: authToken ?? _authToken,
126
+ authorSecret: authorSecret,
127
+ allowUnborn: allowUnborn,
128
+ );
129
+ return bridge;
130
+ }
131
+
132
+ /// Tear down every runner this harness spawned. The throwaway workspace's ledger stays on the cloud
133
+ /// (there is no open destroy lane — reclaim is law-gated); its unique name keeps it inert.
134
+ Future<void> dispose() async {
135
+ if (_disposed) return;
136
+ _disposed = true;
137
+ for (final t in _transports) {
138
+ await t.dispose();
139
+ }
140
+ _transports.clear();
141
+ }
142
+
143
+ // ── host-lane plumbing ──
144
+
145
+ Future<void> _create() async {
146
+ final r = await _post('/v2/workspaces/$workspace');
147
+ if (r.$1 >= 300 || !(r.$2['ok'] == true)) {
148
+ throw NomosTestHarnessException(
149
+ 'workspace birth for $workspace refused (${r.$1}): ${_brief(r.$3)} — '
150
+ 'a birth needs a principal (npx githolon login --agent, or pass principal:/authToken:)');
151
+ }
152
+ }
153
+
154
+ Future<(int, Map<String, Object?>, String)> _post(String path,
155
+ {String? body, String? contentType}) async {
156
+ final client = HttpClient();
157
+ try {
158
+ final req = await client.postUrl(Uri.parse('$cloud$path'));
159
+ if (_authToken != null) req.headers.set('x-nomos-auth', _authToken);
160
+ if (_principal != null) req.headers.set('x-nomos-principal', _principal);
161
+ if (contentType != null) {
162
+ req.headers.contentType = ContentType.parse('$contentType; charset=utf-8');
163
+ }
164
+ if (body != null) req.add(utf8.encode(body));
165
+ final res = await req.close();
166
+ final text = await res.transform(utf8.decoder).join();
167
+ Map<String, Object?> decoded = const {};
168
+ try {
169
+ final d = jsonDecode(text);
170
+ if (d is Map) decoded = d.cast<String, Object?>();
171
+ } catch (_) {}
172
+ return (res.statusCode, decoded, text);
173
+ } finally {
174
+ client.close();
175
+ }
176
+ }
177
+
178
+ static ({String? principal, String? authToken}) _identity(
179
+ String? principal, String? authToken) {
180
+ var p = principal;
181
+ var t = authToken;
182
+ if (p == null && t == null) {
183
+ final creds = _holonCreds();
184
+ p = creds.principal;
185
+ t = creds.authToken;
186
+ }
187
+ // the token's own `sub` claim is the transitional bare principal claim the birth lane requires.
188
+ p ??= t != null ? _jwtSub(t) : null;
189
+ if (p == null && t == null) {
190
+ throw NomosTestHarnessException(
191
+ 'no identity for the workspace birth — run `npx githolon login --agent` '
192
+ '(the harness reads ~/.holon/credentials.json) or pass principal:/authToken:');
193
+ }
194
+ return (principal: p, authToken: t);
195
+ }
196
+
197
+ static ({String? principal, String? authToken}) _holonCreds() {
198
+ try {
199
+ final home = Platform.environment['HOME'] ??
200
+ Platform.environment['USERPROFILE'] ??
201
+ '';
202
+ final f = File('$home/.holon/credentials.json');
203
+ if (!f.existsSync()) return (principal: null, authToken: null);
204
+ final d = jsonDecode(f.readAsStringSync()) as Map;
205
+ final session = (d['session'] as Map?)?.cast<String, Object?>();
206
+ return (
207
+ principal: session?['uid']?.toString() ?? d['principal']?.toString(),
208
+ authToken: session?['accessToken']?.toString(),
209
+ );
210
+ } catch (_) {
211
+ return (principal: null, authToken: null);
212
+ }
213
+ }
214
+
215
+ static String? _jwtSub(String token) {
216
+ final parts = token.split('.');
217
+ if (parts.length != 3) return null;
218
+ try {
219
+ final d = jsonDecode(
220
+ utf8.decode(base64Url.decode(base64Url.normalize(parts[1])))) as Map;
221
+ final sub = d['sub'];
222
+ return sub is String && sub.isNotEmpty ? sub : null;
223
+ } catch (_) {
224
+ return null;
225
+ }
226
+ }
227
+
228
+ static String _stripSlash(String s) =>
229
+ s.endsWith('/') ? s.substring(0, s.length - 1) : s;
230
+
231
+ static String _hex(int n) {
232
+ final rng = Random.secure();
233
+ return List.generate(n, (_) => rng.nextInt(16).toRadixString(16)).join();
234
+ }
235
+ }
package/dart/pubspec.yaml CHANGED
@@ -11,7 +11,7 @@ description: >-
11
11
  Nomos 2 Dart frontend types — the hand-written kernel wire types plus the
12
12
  code-generated per-domain aggregate + directive-payload types. (De)serializes
13
13
  the EXACT serde JSON the Rust kernel + projection produce/consume.
14
- version: 0.27.0
14
+ version: 0.28.0
15
15
 
16
16
  environment:
17
17
  sdk: ^3.5.0
@@ -0,0 +1,74 @@
1
+ // The headless transport against the REAL node runner (`cloud/web-client/runner/node-entry.mjs`):
2
+ // protocol round-trip through NomosBridge without touching the cloud — ready handshake, typed refusal
3
+ // on a pre-connect op, unknown-op error. The live connect path is covered by the env-gated smoke below.
4
+ @TestOn('vm')
5
+ library;
6
+
7
+ import 'dart:io';
8
+
9
+ import 'package:nomos_client/nomos_client.dart';
10
+ import 'package:nomos_client/nomos_headless.dart';
11
+ import 'package:test/test.dart';
12
+
13
+ String _repoEntry() {
14
+ // In-repo the runner lives beside us; consumers resolve it from node_modules instead.
15
+ final here = Directory.current.path;
16
+ final p = '$here/../../cloud/web-client/runner/node-entry.mjs';
17
+ if (!File(p).existsSync()) {
18
+ throw StateError('node-entry.mjs not found at $p');
19
+ }
20
+ return p;
21
+ }
22
+
23
+ void main() {
24
+ late NodeRunnerTransport transport;
25
+ late NomosBridge bridge;
26
+
27
+ setUp(() async {
28
+ transport = await NodeRunnerTransport.start(entry: _repoEntry());
29
+ bridge = NomosBridge(transport);
30
+ });
31
+
32
+ tearDown(() async {
33
+ await transport.dispose();
34
+ });
35
+
36
+ test('posts the ready handshake', () async {
37
+ await bridge.ready.timeout(const Duration(seconds: 15));
38
+ });
39
+
40
+ test('a pre-connect op answers a typed bridge error', () async {
41
+ await bridge.ready.timeout(const Duration(seconds: 15));
42
+ await expectLater(
43
+ bridge.head(),
44
+ throwsA(isA<NomosBridgeError>().having((e) => e.message, 'message',
45
+ contains('not connected'))),
46
+ );
47
+ });
48
+
49
+ test('requests are id-correlated (two in flight)', () async {
50
+ await bridge.ready.timeout(const Duration(seconds: 15));
51
+ final a = bridge.head().then((_) => 'ok', onError: (_) => 'err');
52
+ final b = bridge.metrics().then((_) => 'ok', onError: (_) => 'err');
53
+ expect(await a, 'err');
54
+ expect(await b, 'err');
55
+ });
56
+
57
+ test(
58
+ 'LIVE smoke: connect + query against a real workspace',
59
+ skip: Platform.environment['NOMOS_HEADLESS_E2E'] == null
60
+ ? 'set NOMOS_HEADLESS_E2E=<workspace> (and optionally NOMOS_CLOUD) to run'
61
+ : null,
62
+ () async {
63
+ final ws = Platform.environment['NOMOS_HEADLESS_E2E']!;
64
+ final cloud = Platform.environment['NOMOS_CLOUD'] ??
65
+ 'https://nomos.captainapp.co.uk';
66
+ await bridge.ready.timeout(const Duration(seconds: 15));
67
+ await bridge.connect(
68
+ cloud: cloud, workspace: ws, clientId: 'headless-test');
69
+ final head = await bridge.head();
70
+ expect(head, isNotEmpty);
71
+ },
72
+ timeout: const Timeout(Duration(minutes: 3)),
73
+ );
74
+ }
@@ -0,0 +1,70 @@
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.
4
+ @TestOn('vm')
5
+ library;
6
+
7
+ import 'dart:convert';
8
+ import 'dart:io';
9
+
10
+ import 'package:nomos_client/nomos_headless.dart';
11
+ import 'package:test/test.dart';
12
+
13
+ String _repoEntry() =>
14
+ '${Directory.current.path}/../../cloud/web-client/runner/node-entry.mjs';
15
+
16
+ String _guestbookDeployJson() =>
17
+ '${Directory.current.path}/../../examples/guestbook/build/guestbook.deploy.json';
18
+
19
+ 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
+ }
28
+ await expectLater(
29
+ NomosTestHarness.ephemeral(),
30
+ throwsA(isA<NomosTestHarnessException>()),
31
+ );
32
+ });
33
+
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
+ await writer.offerDirective(
48
+ domain: 'guestbook',
49
+ directiveId: 'signGuestbook',
50
+ payload: {
51
+ 'author': 'harness',
52
+ 'message': 'ephemeral fixture green',
53
+ 'mood': 'delighted',
54
+ 'signedAt': DateTime.now().toUtc().toIso8601String(),
55
+ });
56
+ await writer.sync(admit: true);
57
+ await reader.sync(admit: false);
58
+ final rows = await reader.query('entriesByAuthor', {'author': 'harness'});
59
+ expect(rows, hasLength(1));
60
+ expect(
61
+ (rows.first['data'] as Map)['message'], 'ephemeral fixture green');
62
+ stdout.writeln(
63
+ 'live harness green: ws=${harness.workspace} rows=${jsonEncode(rows.first['data'])}');
64
+ } finally {
65
+ await harness.dispose();
66
+ }
67
+ },
68
+ timeout: const Timeout(Duration(minutes: 5)),
69
+ );
70
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.35.0",
3
+ "version": "0.37.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",
package/src/attested.ts CHANGED
@@ -28,6 +28,8 @@ import { NomosSourceFrontier } from "./attested_core.js";
28
28
 
29
29
  export {
30
30
  trustSource,
31
+ trustParent,
32
+ PARENT_TRUST_SENTINEL,
31
33
  foreignQuery,
32
34
  isTrustSourceDecl,
33
35
  NomosSourceFrontier,
@@ -94,6 +94,27 @@ export function trustSource(workspace: string, opts: TrustSourceOptions): TrustS
94
94
  return decl;
95
95
  }
96
96
 
97
+ /** K2's genesis sentinel — `trustParent()` declares `genesis: "@parent"`; the kernel derives
98
+ * identity from the reader's OWN birth certificate (kernel mirror:
99
+ * `nomos_executor::PARENT_TRUST_SENTINEL`). */
100
+ export const PARENT_TRUST_SENTINEL = "@parent";
101
+
102
+ /**
103
+ * DECLARE trust in the reader's PARENT (K2, stage 4 — §1.3): the accepted attestor set is
104
+ * DERIVED from the reader's own birth certificate (`BirthAttestation.parentKey`, seeded at
105
+ * birth by the parent's offer-effect) plus any delegation cert the envelope carries that the
106
+ * parent key signed — zero hand-pinned keys, one-hop vouching by construction. `workspace`
107
+ * remains the ROUTABLE name (transport must know whom to ask); identity is the derived chain,
108
+ * never the name. Rotation = the source serves a fresh parent-signed delegation — no reader
109
+ * law change ever.
110
+ */
111
+ export function trustParent(
112
+ workspace: string,
113
+ opts: Omit<TrustSourceOptions, "genesis" | "keyHashes" | "roster"> = {},
114
+ ): TrustSourceDecl {
115
+ return trustSource(workspace, { ...opts, genesis: PARENT_TRUST_SENTINEL, roster: "derived" });
116
+ }
117
+
97
118
  /** Shape predicate for compile discovery (the `isQueryDecl` pattern). */
98
119
  export function isTrustSourceDecl(v: unknown): v is TrustSourceDecl {
99
120
  return (
@@ -0,0 +1,185 @@
1
+ // NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
2
+ // remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
3
+ // wasm32-wasip1 artifact {kernel · projection · embedded QuickJS engine}.
4
+ // If a file isn't this / hosting this / authoring for this / proving this — it's gone.
5
+
6
+ /**
7
+ * framework/attested_receipts.ts — ATTESTATION RECEIPTS + EQUIVOCATION ACCOUNTABILITY
8
+ * (attested reads slice B, stage 3 — `architecture/attested_reads_design.md` §5).
9
+ *
10
+ * The SOURCE's ledger must REMEMBER what it vouched: every attested-read signature the
11
+ * read-attestor emits is leaf-accumulated (`sha256hex(attested_read_signed_bytes)` — derived,
12
+ * never carried) and SEALED into the source's own chain as one {@link NomosAttestationReceipt}
13
+ * per batch — one write covers up to 1024 reads. The seal is an ORDINARY OFFER
14
+ * ({@link recordAttestationBatch}, authored AS the enrolled `workspace:read-attestor` through
15
+ * the one gate); the kernel's `attestation_ledger_violation` arm DEMANDS the receipt's
16
+ * `batchRoot` be the Merkle root of its carried leaves on every door, so a chain-committed
17
+ * receipt is always internally consistent. The adapter only SCHEDULES the seal (the debounced-
18
+ * admission-alarm precedent: scheduling affects WHEN, never WHAT or WHETHER).
19
+ *
20
+ * The READER's half: {@link reportSourceEquivocation} folds a {@link NomosSourceDishonor} fact
21
+ * from two mutually-incriminating signed envelopes (same source key, same fold_root, same
22
+ * (queryId, args), DIVERGENT canonical results). The kernel verifies the evidence — pure
23
+ * carried bytes, the xwi posture — at author, edge admission, AND verify_chain; a forged or
24
+ * non-contradictory report is a typed refusal (`EquivocationEvidenceInvalid`). Once folded,
25
+ * a trust edge declaring `onDishonor: "refuse"` refuses every LATER attestation from that
26
+ * `(genesis, keyHash)` — position-aware like revocation, never retroactive.
27
+ */
28
+ import { z } from "zod";
29
+ import { aggregate, instance } from "../aggregate.js";
30
+ import { t } from "../fields.js";
31
+ import { Lww } from "../drivers.js";
32
+ import { directive } from "../directive.js";
33
+ import { set } from "../ops.js";
34
+ import { query } from "../query.js";
35
+
36
+ /** The framework-reserved receipt instance-id prefix (kernel mirror:
37
+ * `nomos_executor::ATTESTATION_RECEIPT_PREFIX`). */
38
+ export const ATTESTATION_RECEIPT_PREFIX = "attest-receipt:";
39
+
40
+ /** The receipt instance id for a batch. */
41
+ export function attestationReceiptId(batchId: string): string {
42
+ return ATTESTATION_RECEIPT_PREFIX + batchId;
43
+ }
44
+
45
+ /** The framework-reserved dishonor instance-id prefix (kernel mirror:
46
+ * `nomos_executor::SOURCE_DISHONOR_PREFIX`). */
47
+ export const SOURCE_DISHONOR_PREFIX = "source-dishonor:";
48
+
49
+ /** The dishonor instance id for a proven `(sourceGenesis, keyHash)`. */
50
+ export function sourceDishonorId(sourceGenesis: string, keyHash: string): string {
51
+ return `${SOURCE_DISHONOR_PREFIX}${sourceGenesis}:${keyHash}`;
52
+ }
53
+
54
+ /**
55
+ * NomosAttestationReceipt (§5.1) — ONE sealed batch of attested-read receipt leaves in the
56
+ * SOURCE's own chain. Each leaf = `sha256hex(attested_read_signed_bytes)` — it commits to the
57
+ * FULL attestation (query, args, canonical result, head, fold_root, epoch, time) with zero
58
+ * extra signed bytes and NO reader identity (least-disclosure). `receiptPath` (the kernel
59
+ * query op) serves Merkle inclusion `{batchRoot, index, path}` from the folded `leaves`.
60
+ */
61
+ export const NomosAttestationReceipt = aggregate("NomosAttestationReceipt", {
62
+ /** `"<epoch>-<nSealedReceiptsInFold>-<keyHash8>"` — chain-derived mint (collision-free across remounts); promised on every envelope's `receipt_hint`. */
63
+ batchId: t.string().merge(Lww),
64
+ /** sha256 Merkle root over the batch's leaf hashes (kernel-verified == root(leaves) at the gate). */
65
+ batchRoot: t.string().merge(Lww),
66
+ /** The leaf count (kernel-verified == leaves.length at the gate). */
67
+ leafCount: t.int().merge(Lww),
68
+ /** The leaf hashes, in serve order (JSON array) — what `receiptPath` serves from. */
69
+ leaves: t.json().merge(Lww),
70
+ /** The `attested_at_ms` range the batch covers. */
71
+ firstAt: t.int().merge(Lww),
72
+ lastAt: t.int().merge(Lww),
73
+ /** The source chain position when the batch sealed. */
74
+ headAtSeal: t.string().merge(Lww),
75
+ foldRootAtSeal: t.string().merge(Lww),
76
+ /** Which read-attestor key signed every member of this batch (`sha256hex(pubkey)`). */
77
+ signerKeyHash: t.string().merge(Lww),
78
+ });
79
+
80
+ /** Audit read: the source's sealed receipt stream, by batch id. */
81
+ export const attestationReceiptByBatch = query("attestationReceiptByBatch")
82
+ .key("batchId")
83
+ .returns(NomosAttestationReceipt);
84
+
85
+ /**
86
+ * `recordAttestationBatch` (§5.2) — THE SEAL. One authored intent per batch, authored AS the
87
+ * enrolled `workspace:read-attestor` through the NORMAL offer gate (the key is an enrolled
88
+ * signer precisely so it can do this — §1.2's "one key, two duties"). The payload is the
89
+ * kernel's own `attestationOpenBatch` snapshot, carried verbatim by the adapter; the gate
90
+ * re-derives the Merkle root and refuses any drift (`AttestationReceiptInvalid`) — the
91
+ * adapter carries, never computes. Seal triggers (leafCount ≥ 1024 / age ≥ 60s / epoch change
92
+ * / unmount) are the kernel's verdict on the same snapshot; a failed seal retries, never
93
+ * drops silently.
94
+ */
95
+ export const recordAttestationBatch = directive("recordAttestationBatch")
96
+ .ensures(NomosAttestationReceipt)
97
+ .payload(
98
+ z.object({
99
+ batchId: z.string().min(1),
100
+ batchRoot: z.string().min(1),
101
+ leafCount: z.number().int().positive(),
102
+ leaves: z.array(z.string().min(1)).min(1),
103
+ firstAt: z.number().int().nonnegative(),
104
+ lastAt: z.number().int().nonnegative(),
105
+ headAtSeal: z.string(),
106
+ foldRootAtSeal: z.string(),
107
+ signerKeyHash: z.string().min(1),
108
+ }),
109
+ )
110
+ .plan((p) => {
111
+ const r = instance(NomosAttestationReceipt, attestationReceiptId(p.batchId));
112
+ return [
113
+ set(r, "batchId", p.batchId),
114
+ set(r, "batchRoot", p.batchRoot),
115
+ set(r, "leafCount", p.leafCount),
116
+ set(r, "leaves", JSON.stringify(p.leaves)),
117
+ set(r, "firstAt", p.firstAt),
118
+ set(r, "lastAt", p.lastAt),
119
+ set(r, "headAtSeal", p.headAtSeal),
120
+ set(r, "foldRootAtSeal", p.foldRootAtSeal),
121
+ set(r, "signerKeyHash", p.signerKeyHash),
122
+ ];
123
+ });
124
+
125
+ /**
126
+ * NomosSourceDishonor (§5.3) — a PROVEN equivocation, folded in the READER's (or a
127
+ * governor's) chain at `source-dishonor:<sourceGenesis>:<keyHash>`. The full evidence (the
128
+ * two signed envelopes) rides the committed intent's payload — the fact aggregate carries
129
+ * the identity tuple + when it was proven. Once folded, `onDishonor: "refuse"` trust edges
130
+ * refuse every LATER attestation from this `(genesis, keyHash)` (position-aware).
131
+ */
132
+ export const NomosSourceDishonor = aggregate("NomosSourceDishonor", {
133
+ /** The equivocating source chain's genesis oid (its true identity). */
134
+ sourceGenesis: t.string().merge(Lww),
135
+ /** The equivocating read-attestor key (`sha256hex(pubkey)`). */
136
+ keyHash: t.string().merge(Lww),
137
+ /** The fold-root BOTH envelopes pinned — the state the source spoke of with two tongues. */
138
+ foldRoot: t.string().merge(Lww),
139
+ /** When the reporter proved it (ms — the reporter's clock, informational). */
140
+ provenAtMs: t.int().merge(Lww),
141
+ });
142
+
143
+ /** Audit read: the folded dishonor facts, by source genesis. */
144
+ export const sourceDishonorByGenesis = query("sourceDishonorByGenesis")
145
+ .key("sourceGenesis")
146
+ .returns(NomosSourceDishonor);
147
+
148
+ /** One carried evidence envelope (an attested read exactly as captured — §5.3 walk 1). */
149
+ const evidenceEnvelope = z.object({
150
+ queryId: z.string().min(1),
151
+ args: z.record(z.string(), z.string()).default({}),
152
+ result: z.unknown(),
153
+ attestation: z.record(z.string(), z.unknown()),
154
+ });
155
+
156
+ /**
157
+ * `reportSourceEquivocation` (§5.3) — carry TWO signed attested-read envelopes; the KERNEL
158
+ * gate verifies (pure carried bytes): both ed25519 signatures, same source key, same
159
+ * `(source_genesis, fold_root, queryId, canonical args)`, DIVERGENT canonical results — and
160
+ * that the folded fact's id names exactly the proven `(genesis, keyHash)`. Forged or
161
+ * non-contradictory evidence is `EquivocationEvidenceInvalid` on every door. The plan trusts
162
+ * the payload's identity mirrors ONLY because the gate re-derives them from the evidence.
163
+ */
164
+ export const reportSourceEquivocation = directive("reportSourceEquivocation")
165
+ .ensures(NomosSourceDishonor)
166
+ .payload(
167
+ z.object({
168
+ a: evidenceEnvelope,
169
+ b: evidenceEnvelope,
170
+ /** Mirror of the proven identity (kernel-checked against the verified evidence). */
171
+ sourceGenesis: z.string().min(1),
172
+ keyHash: z.string().min(1),
173
+ foldRoot: z.string().min(1),
174
+ provenAtMs: z.number().int().nonnegative(),
175
+ }),
176
+ )
177
+ .plan((p) => {
178
+ const d = instance(NomosSourceDishonor, sourceDishonorId(p.sourceGenesis, p.keyHash));
179
+ return [
180
+ set(d, "sourceGenesis", p.sourceGenesis),
181
+ set(d, "keyHash", p.keyHash),
182
+ set(d, "foldRoot", p.foldRoot),
183
+ set(d, "provenAtMs", p.provenAtMs),
184
+ ];
185
+ });
@@ -0,0 +1,96 @@
1
+ // NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
2
+ // remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
3
+ // wasm32-wasip1 artifact {kernel · projection · embedded QuickJS engine}.
4
+ // If a file isn't this / hosting this / authoring for this / proving this — it's gone.
5
+
6
+ /**
7
+ * framework/source_roster.ts — K3 TRACKED ROSTERS (attested reads slice B, stage 5 —
8
+ * `architecture/attested_reads_design.md` §1.5).
9
+ *
10
+ * The reader's chain maintains a {@link NomosSourceRoster} per trusted source: a reader-side
11
+ * mirror of the SOURCE's own read-attestor signer roster, delivered as ATTESTED READS of the
12
+ * source's signer chain itself (the kernel-native `attestorRoster` query — the roster rides
13
+ * the SAME attested-read machinery as any foreign premise). `roster: "tracked"` on the trust
14
+ * edge activates the folded roster inside `accepted_keys`; rotation then needs ZERO reader
15
+ * law deploys, and a source-side revocation propagates on the reader's next sync.
16
+ *
17
+ * THE GROUNDED RECURSION (§1.5.2): a roster read's envelope is verified against the accepted
18
+ * set AS ALREADY FOLDED in the reader's prefix — the FIRST sync therefore verifies against
19
+ * the bootstrap trust (the K1 pins or the K2 birth-cert-derived key), which exists by
20
+ * construction before any roster does; every later sync verifies against the roster its own
21
+ * prefix folded. Deterministic at replay: the roster is reader-chain state.
22
+ */
23
+ import { z } from "zod";
24
+ import { aggregate, instance } from "../aggregate.js";
25
+ import { t } from "../fields.js";
26
+ import { Lww } from "../drivers.js";
27
+ import { directive, type Directive } from "../directive.js";
28
+ import { set } from "../ops.js";
29
+ import { read } from "../read.js";
30
+ import { foreignQuery } from "../attested_core.js";
31
+
32
+ /** The framework-reserved roster instance-id prefix (kernel mirror:
33
+ * `nomos_executor::SOURCE_ROSTER_PREFIX`). */
34
+ export const SOURCE_ROSTER_PREFIX = "source-roster:";
35
+
36
+ /** The roster instance id for a source genesis. */
37
+ export function sourceRosterId(sourceGenesis: string): string {
38
+ return SOURCE_ROSTER_PREFIX + sourceGenesis;
39
+ }
40
+
41
+ /** The kernel-native source-side roster query id (`nomos_executor::ATTESTOR_ROSTER_QUERY`). */
42
+ export const ATTESTOR_ROSTER_QUERY_ID = "attestorRoster";
43
+
44
+ /**
45
+ * NomosSourceRoster (§1.5) — the reader's folded mirror of one source's read-attestor
46
+ * roster, keyed by the source's TRUE identity (`source-roster:<genesis>`). `rosterJson` is
47
+ * the `keyHash → leaf` map exactly as the source's signer chain folds it (a key is ACCEPTED
48
+ * iff its leaf's `status == "active"` — the position-aware revocation mirror); the kernel's
49
+ * `accepted_keys` reads THIS under `roster: "tracked"`.
50
+ */
51
+ export const NomosSourceRoster = aggregate("NomosSourceRoster", {
52
+ /** The routable source name the edge was declared under (diagnostics). */
53
+ source: t.string().merge(Lww),
54
+ /** The `keyHash → enrollment leaf` map, JSON — as attested by the source's own chain. */
55
+ rosterJson: t.json().merge(Lww),
56
+ /** The source's `SignerEpoch` at the attested sync. */
57
+ currentEpoch: t.int().merge(Lww),
58
+ });
59
+
60
+ /** One roster row as the source's `attestorRoster` query answers it. */
61
+ interface AttestorRosterRow {
62
+ id: string;
63
+ data: {
64
+ genesis: string;
65
+ currentEpoch: number;
66
+ keys: Record<string, unknown>;
67
+ };
68
+ }
69
+
70
+ /**
71
+ * BUILD the reader-side `syncSourceRoster` framework directive for ONE tracked source
72
+ * (§1.5.1) — export the result from the law that declares `trustSource(sourceWs, { roster:
73
+ * "tracked", … })`. Its plan performs `read(attestorRoster, {}, { from: sourceWs })` (an
74
+ * ordinary attested read — verified by the five-step arm against the accepted set the reader's
75
+ * PREFIX already folds: pins/birth-cert first, then the roster itself) and folds the answer
76
+ * into {@link NomosSourceRoster}. One directive per tracked source per law (the id embeds
77
+ * nothing — a law tracking several sources exports several via distinct ids).
78
+ */
79
+ export function sourceRosterSync(sourceWs: string, directiveId = "syncSourceRoster"): Directive<Record<string, never>> {
80
+ const rosterQuery = foreignQuery(ATTESTOR_ROSTER_QUERY_ID);
81
+ return directive(directiveId)
82
+ .ensures(NomosSourceRoster)
83
+ .payload(z.object({}).strict())
84
+ .plan(() => {
85
+ const rows = read<AttestorRosterRow[]>(rosterQuery, {}, { from: sourceWs });
86
+ if (rows.length === 0) throw new Error("attestor-roster-empty");
87
+ const d = rows[0]!.data;
88
+ const r = instance(NomosSourceRoster, sourceRosterId(d.genesis));
89
+ return [
90
+ set(r, "source", sourceWs),
91
+ set(r, "rosterJson", JSON.stringify(d.keys ?? {})),
92
+ set(r, "currentEpoch", d.currentEpoch ?? 0),
93
+ ];
94
+ })
95
+ .readsFrom(sourceWs, rosterQuery) as Directive<Record<string, never>>;
96
+ }
package/src/index.ts CHANGED
@@ -124,6 +124,8 @@ export { read } from "./read.js";
124
124
  // surface (`trustSource`), the foreign query reference, and the reader-side SourceFrontier.
125
125
  export {
126
126
  trustSource,
127
+ trustParent,
128
+ PARENT_TRUST_SENTINEL,
127
129
  foreignQuery,
128
130
  NomosSourceFrontier,
129
131
  sourceFrontierId,
@@ -133,6 +135,31 @@ export {
133
135
  type TrustSourceOptions,
134
136
  type ForeignQueryRef,
135
137
  } from "./attested.js";
138
+ // ATTESTATION RECEIPTS + EQUIVOCATION ACCOUNTABILITY (slice B stage 3 — §5): the source-side
139
+ // sealed receipt batch + the reader-side proven-dishonor fact, both ordinary law the kernel's
140
+ // `attestation_ledger_violation` arm guards on every door.
141
+ export {
142
+ NomosAttestationReceipt,
143
+ recordAttestationBatch,
144
+ attestationReceiptByBatch,
145
+ attestationReceiptId,
146
+ ATTESTATION_RECEIPT_PREFIX,
147
+ NomosSourceDishonor,
148
+ reportSourceEquivocation,
149
+ sourceDishonorByGenesis,
150
+ sourceDishonorId,
151
+ SOURCE_DISHONOR_PREFIX,
152
+ } from "./framework/attested_receipts.js";
153
+ // K3 TRACKED ROSTERS (slice B stage 5 — §1.5): the reader-side folded source roster + the
154
+ // per-source sync directive factory (roster updates ride the attested-read machinery itself,
155
+ // grounded on the pins/birth-cert bootstrap).
156
+ export {
157
+ NomosSourceRoster,
158
+ sourceRosterSync,
159
+ sourceRosterId,
160
+ SOURCE_ROSTER_PREFIX,
161
+ ATTESTOR_ROSTER_QUERY_ID,
162
+ } from "./framework/source_roster.js";
136
163
  // THE BIRTH EFFECT (G3 — birth is a declared kernel primitive): a `.births()` directive's plan records the
137
164
  // child's genesis recipe via `birth(recipe)` (reuse `buildGenesisChain`); it rides the committed intent as a
138
165
  // declared output, and the kernel — reading the `.births` declaration off the law manifest (the