@githolon/dsl 0.73.0 → 0.75.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.
@@ -0,0 +1,301 @@
1
+ // THE FIRST-FIVE-MINUTES GAUNTLET — one sequential journey through EVERYTHING the test harness +
2
+ // session-bound client surface promises a NEW app developer, using ONLY the public API (the way a
3
+ // tenant would). Every lifecycle bug co2 found in their first minutes (session watch delegation,
4
+ // local autoSync deadlock, the keyed-reconnect lane, …) has a leg here, so the NEXT one is found
5
+ // by CI, not by a tenant.
6
+ //
7
+ // Structure: ONE test, numbered legs, fail-fast — a failure names its leg, so CI output reads as
8
+ // a checklist. Reuses the existing fixtures (estate-birth, surfaced, catalogue-src/-reader); no
9
+ // new law.
10
+ //
11
+ // Prereqs (fixtures compiled + a warm ~/.holon/runtime): locally the test SKIPS with the recipe
12
+ // when they are missing; in CI the job sets NOMOS_GAUNTLET_REQUIRED=1 so a missing prereq is a
13
+ // LOUD FAILURE, never a silently-green skip.
14
+ @TestOn('vm')
15
+ library;
16
+
17
+ import 'dart:io';
18
+
19
+ import 'package:nomos_client/nomos_headless.dart';
20
+ import 'package:test/test.dart';
21
+
22
+ import '../../../bench/scale/fixtures/estate-birth/build/dart/src/generated/home.client.dart';
23
+ import '../../../bench/scale/fixtures/estate-birth/build/dart/src/generated/estate.client.dart'
24
+ show EstateClient, SeedEstateWorkspacePayload;
25
+
26
+ String get _root => '${Directory.current.path}/../..';
27
+ String get _repoEntry => '$_root/cloud/web-client/runner/node-entry.mjs';
28
+ String get _homeDeploy =>
29
+ '$_root/bench/scale/fixtures/estate-birth/build/estate-birth.deploy.json';
30
+ String get _docsDeploy => '$_root/bench/scale/fixtures/surfaced/build/surfaced.deploy.json';
31
+ String get _catDeploy => '$_root/bench/scale/fixtures/catalogue-src/build/cat.deploy.json';
32
+ String get _readerDeploy =>
33
+ '$_root/bench/scale/fixtures/catalogue-reader/build/estate.deploy.json';
34
+ String get _runtimeDir =>
35
+ Platform.environment['NOMOS_DART_RUNTIME'] ??
36
+ '${Platform.environment['HOME'] ?? ''}/.holon/runtime';
37
+
38
+ const _owner = 'user:gauntlet-owner';
39
+ const _keyed = 'catalogue';
40
+
41
+ String? _missingPrereq() {
42
+ if (!File(_repoEntry).existsSync()) return 'in-repo runner not found at $_repoEntry';
43
+ final fixtures = {
44
+ _homeDeploy: 'bench/scale/fixtures/estate-birth',
45
+ _docsDeploy: 'bench/scale/fixtures/surfaced',
46
+ _catDeploy: 'bench/scale/fixtures/catalogue-src',
47
+ _readerDeploy: 'bench/scale/fixtures/catalogue-reader',
48
+ };
49
+ for (final e in fixtures.entries) {
50
+ if (!File(e.key).existsSync()) {
51
+ return 'missing fixture ${e.key} — compile it: (cd ${e.value} && node ../../../../dsl/compile_package.mjs)';
52
+ }
53
+ }
54
+ if (!File('$_runtimeDir/holon.wasm').existsSync() ||
55
+ !File('$_runtimeDir/packages.json').existsSync()) {
56
+ return 'warm ~/.holon/runtime first (run any `githolon` command once, or curl '
57
+ '/v1/runtime/holon.wasm + /v1/runtime/packages from the cloud)';
58
+ }
59
+ return null;
60
+ }
61
+
62
+ /// Run one numbered leg fail-fast: log enter/exit so CI output reads as a checklist, and wrap any
63
+ /// failure so the failing LEG is named at the top of the report.
64
+ Future<T> _leg<T>(String name, Future<T> Function() body) async {
65
+ final sw = Stopwatch()..start();
66
+ // ignore: avoid_print
67
+ print('▶ GAUNTLET $name …');
68
+ try {
69
+ final r = await body();
70
+ // ignore: avoid_print
71
+ print('✔ GAUNTLET $name (${sw.elapsedMilliseconds}ms)');
72
+ return r;
73
+ } catch (e, st) {
74
+ throw TestFailure('GAUNTLET $name FAILED after ${sw.elapsedMilliseconds}ms:\n$e\n$st');
75
+ }
76
+ }
77
+
78
+ void main() {
79
+ test('THE FIRST-FIVE-MINUTES GAUNTLET: harness → blessed shape → local posture → '
80
+ 'acquire → reactive reads → attested read → diagnostics → disposal', () async {
81
+ final required = Platform.environment['NOMOS_GAUNTLET_REQUIRED'] == '1';
82
+ final missing = _missingPrereq();
83
+ if (missing != null) {
84
+ if (required) {
85
+ fail('NOMOS_GAUNTLET_REQUIRED=1 but a prereq is missing — the CI job must provide it: '
86
+ '$missing');
87
+ }
88
+ markTestSkipped(missing);
89
+ return;
90
+ }
91
+
92
+ final diagnostics = <String>[];
93
+
94
+ // ── LEG 1: harness up — a 4-workspace cluster, one deviceKeyed, phase diagnostics on. ──
95
+ final harness = await _leg('leg 1: NomosTestHarness.cluster (4 ws, one deviceKeyed)', () async {
96
+ final h = await NomosTestHarness.cluster(
97
+ workspaces: {
98
+ 'home': [_homeDeploy],
99
+ 'docs-ws': [_docsDeploy],
100
+ _keyed: [_catDeploy],
101
+ 'reader': [_readerDeploy],
102
+ },
103
+ deviceKeyed: {_keyed},
104
+ nodeEntry: _repoEntry,
105
+ runtimeDir: _runtimeDir,
106
+ onDiagnostic: diagnostics.add,
107
+ phaseTimeout: const Duration(seconds: 120),
108
+ );
109
+ // Phase diagnostics (the "no silent hangs" promise): install, ready, connect, and the
110
+ // keyed 3b legs all log enter AND exit lines.
111
+ expect(diagnostics.any((l) => l.contains('offline law install')), isTrue,
112
+ reason: 'phase 1 install diagnostics must appear');
113
+ expect(diagnostics.any((l) => l.contains('runner ready') && l.contains('done')), isTrue,
114
+ reason: 'the runner-ready phase must log its exit');
115
+ expect(diagnostics.any((l) => l.contains('3b: device keygen')), isTrue,
116
+ reason: 'the deviceKeyed keygen phase must log');
117
+ expect(
118
+ diagnostics.any(
119
+ (l) => l.contains("3b: keyed reconnect of '$_keyed'") && l.contains('done')),
120
+ isTrue,
121
+ reason: 'the keyed reconnect phase must log its exit (the reported wedge)');
122
+ expect(h.sessions.toSet(), {'home', 'docs-ws', _keyed, 'reader'});
123
+ return h;
124
+ });
125
+
126
+ try {
127
+ // ── LEG 2: the blessed session-bound shape — mount → bind (preflight) → PARENTLESS birth
128
+ // → follow(ref) → second client → typed read + a directive. Zero routing strings. ──
129
+ final home = harness.session('home');
130
+ final estate = await _leg(
131
+ 'leg 2: blessed shape (bind → parentless birth → follow → 2nd client → read+directive)',
132
+ () async {
133
+ final homeClient = await HomeClient.bind(home, actor: _owner);
134
+ expect(homeClient.session!.workspace, home.workspace,
135
+ reason: 'inspectable session context');
136
+ final birth = await homeClient.birthEstateWorkspace(
137
+ payload: BirthEstateWorkspacePayload(
138
+ owner: _owner,
139
+ frameworkHash: harness.frameworkHash!,
140
+ lawHash: harness.lawHashes(home.workspace).first,
141
+ bornAt: DateTime.now().toUtc().toIso8601String(),
142
+ ),
143
+ );
144
+ expect(birth.workspace, home.workspace,
145
+ reason: 'the parentless birth lands in the SESSION workspace');
146
+ expect(birth.born, hasLength(1));
147
+ expect(birth.birthOutcomes.first['status'], 'born');
148
+
149
+ final estateSession = await harness.bridge.follow(birth.bornRefs.single);
150
+ expect(estateSession.workspace, birth.born.single);
151
+
152
+ final estateClient = await EstateClient.bind(estateSession, actor: _owner);
153
+ final rows = await estateClient.estatesByOwner(owner: _owner);
154
+ expect(rows, hasLength(1), reason: 'the child genesis seed reads back typed');
155
+ final seeded = rows.single;
156
+ final reseed = await estateClient.seedEstateWorkspace(SeedEstateWorkspacePayload(
157
+ owner: seeded.owner!,
158
+ estateName: seeded.estateName!,
159
+ bornAt: seeded.bornAt!,
160
+ ));
161
+ expect(reseed.head, isNotEmpty, reason: 'a directive on the followed second client');
162
+ return estateSession;
163
+ });
164
+
165
+ // ── LEG 3: local posture — autoSync on a local-only session returns promptly and never
166
+ // monopolizes the plane; zero network fetch attempts (asserted again at the end). ──
167
+ await _leg('leg 3: local-only autoSync completes promptly, plane stays responsive',
168
+ () async {
169
+ await home.autoSync().timeout(const Duration(seconds: 10),
170
+ onTimeout: () => throw StateError(
171
+ 'autoSync() on a local-only session did not return within 10s (the deadlock class)'));
172
+ // The plane is NOT blocked: a sibling session still answers.
173
+ final listed = await harness.bridge.listWorkspaces().timeout(const Duration(seconds: 10),
174
+ onTimeout: () =>
175
+ throw StateError('listWorkspaces() hung after a local-only autoSync'));
176
+ expect(listed.map((w) => w['session'].toString()).toSet(),
177
+ containsAll({'home', 'docs-ws', _keyed, 'reader'}));
178
+ await home.stopAutoSync().timeout(const Duration(seconds: 10),
179
+ onTimeout: () => throw StateError('stopAutoSync() hung on a local-only session'));
180
+ });
181
+
182
+ // ── LEG 4: acquireSession — concurrent double-acquire single-flights to ONE handle; the
183
+ // credentials callback is NEVER invoked for a plane-known workspace. ──
184
+ await _leg('leg 4: acquireSession (concurrent, credential-free for a known workspace)',
185
+ () async {
186
+ final acquired = await Future.wait([
187
+ harness.bridge.acquireSession('reader',
188
+ ifAbsentCredentials: () async =>
189
+ fail('reader is on the plane — credentials must not be requested')),
190
+ harness.bridge.acquireSession('reader',
191
+ ifAbsentCredentials: () async =>
192
+ fail('reader is on the plane — credentials must not be requested')),
193
+ ]);
194
+ expect(identical(acquired[0], acquired[1]), isTrue,
195
+ reason: 'a concurrent double-acquire yields ONE handle');
196
+ expect(identical(acquired[0], harness.session('reader')), isTrue,
197
+ reason: 'the acquired handle IS the mounted session');
198
+ });
199
+
200
+ // ── LEG 5: reactive reads on a SESSION — watchById / watch / watchPendingConflicts must
201
+ // all deliver a FIRST SNAPSHOT (the 0.48.x dead-delegation class). ──
202
+ await _leg('leg 5: session reactive reads emit their first snapshot', () async {
203
+ final raw = await estate.query('estatesByOwner', {'owner': _owner});
204
+ final aggId = ((raw.single as Map)['id'] ?? '').toString();
205
+ expect(aggId, isNotEmpty);
206
+ final byId = await estate
207
+ .watchById(aggId)
208
+ .first
209
+ .timeout(const Duration(seconds: 30),
210
+ onTimeout: () => throw StateError('watchById never emitted its first snapshot'));
211
+ expect(byId, hasLength(1));
212
+ final byQuery = await estate
213
+ .watch('estatesByOwner', {'owner': _owner})
214
+ .first
215
+ .timeout(const Duration(seconds: 30),
216
+ onTimeout: () => throw StateError('watch(query) never emitted its first snapshot'));
217
+ expect(byQuery, hasLength(1));
218
+ // The docs-ws session carries the Surfaced law — its pending-conflicts stream must emit a
219
+ // first snapshot (empty is correct; EMITTING at all is the regression under test).
220
+ final conflicts = await harness
221
+ .session('docs-ws')
222
+ .watchPendingConflicts()
223
+ .first
224
+ .timeout(const Duration(seconds: 30),
225
+ onTimeout: () => throw StateError(
226
+ 'watchPendingConflicts never emitted (the session-delegation regression)'));
227
+ expect(conflicts, isEmpty);
228
+ });
229
+
230
+ // ── LEG 6: the keyed lane end-to-end — an attested read served + SIGNED with the device
231
+ // key the 3b re-open installed. ──
232
+ await _leg('leg 6: attested read from the deviceKeyed workspace', () async {
233
+ final public = harness.devicePublicKey(_keyed);
234
+ expect(public, isNotNull);
235
+ expect(public, isNotEmpty, reason: 'the 3b keygen public half rides the harness');
236
+ final cat = harness.session(_keyed);
237
+ final seeded = await cat.offerCreates(
238
+ domain: 'cat',
239
+ directiveId: 'listCatalogue',
240
+ payload: {
241
+ 'listingId': 'L-1',
242
+ 'title': 'Walnut desk',
243
+ 'priceGbp': '900',
244
+ 'listedAt': DateTime.now().toUtc().toIso8601String(),
245
+ },
246
+ actor: 'user:seller',
247
+ );
248
+ expect(seeded.head, isNotEmpty);
249
+ final served = await harness.session('reader').attestedRead(
250
+ from: _keyed,
251
+ queryId: 'listingById',
252
+ args: {'listingId': 'L-1'},
253
+ );
254
+ final entry = (served['entry'] as Map).cast<String, Object?>();
255
+ final attestation = (entry['attestation'] as Map).cast<String, Object?>();
256
+ expect((attestation['public_key'] ?? '').toString(), public,
257
+ reason: 'the read is signed by the re-opened session device key');
258
+ });
259
+
260
+ // ── LEG 7: harness diagnostics — assertDomainInstalled, resetWorkspaceCustody, and a
261
+ // mid-test seedWorkspaceFromDeployJson round-trip. ──
262
+ await _leg('leg 7: harness diagnostics (assertDomainInstalled / reset / seed)', () async {
263
+ await harness.assertDomainInstalled(
264
+ workspace: estate.workspace, domain: EstateClient.interface.domain);
265
+ // The negative arm names the active domains.
266
+ await expectLater(
267
+ harness.assertDomainInstalled(workspace: 'docs-ws', domain: 'definitely-not-here'),
268
+ throwsA(isA<NomosTestHarnessException>()));
269
+ // Reset a workspace's local custody to its seed snapshot, then prove it still serves.
270
+ await harness.resetWorkspaceCustody('docs-ws');
271
+ final postReset = await harness
272
+ .session('docs-ws')
273
+ .watchPendingConflicts()
274
+ .first
275
+ .timeout(const Duration(seconds: 30),
276
+ onTimeout: () =>
277
+ throw StateError('the reset docs-ws session never served a read'));
278
+ expect(postReset, isEmpty, reason: 'the reset workspace is back at its seed');
279
+ // Seed a brand-new workspace mid-test from a deploy.json and use it immediately.
280
+ final late = await harness.seedWorkspaceFromDeployJson(
281
+ workspace: 'late-ws', deployJsonPaths: [_docsDeploy]);
282
+ expect(late.workspace, 'late-ws');
283
+ await harness.assertDomainInstalled(workspace: 'late-ws', domain: 'docs');
284
+ });
285
+
286
+ // Local posture postscript (leg 3's zero-network promise, checked over the WHOLE run): no
287
+ // session on this plane ever attempted a network fetch.
288
+ final offending = diagnostics
289
+ .where((l) => l.contains('intent-offers') || l.contains('refusing network fetch'))
290
+ .toList();
291
+ expect(offending, isEmpty,
292
+ reason: 'local-only sessions must perform NO network work — saw:\n${offending.join('\n')}');
293
+ } finally {
294
+ // ── LEG 8: clean disposal within the deadline (a hung teardown is a failure, not a wedge). ──
295
+ await _leg('leg 8: harness disposal within the deadline', () async {
296
+ await harness.dispose().timeout(const Duration(seconds: 30),
297
+ onTimeout: () => throw StateError('harness disposal hung'));
298
+ });
299
+ }
300
+ }, timeout: const Timeout(Duration(minutes: 8)));
301
+ }
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ // A FAKE offline-install helper for the hermetic HARNESS PHASE-TIMEOUT tests: honours the real helper's
3
+ // flag + one-line-JSON contract (--deploy/--workspace/--out) but installs nothing — it just writes a tiny
4
+ // dummy snapshot so the harness proceeds to the runner phases.
5
+ import { writeFileSync } from "node:fs";
6
+ const args = process.argv.slice(2);
7
+ const val = (f) => { const i = args.indexOf(f); return i >= 0 ? args[i + 1] : undefined; };
8
+ const out = val("--out");
9
+ const workspace = val("--workspace");
10
+ writeFileSync(out, Buffer.from("fake-snapshot"));
11
+ process.stdout.write(JSON.stringify({ ok: true, snapshot: out, workspace, lawHashes: ["deadbeef"], frameworkHash: "cafebabe" }) + "\n");
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+ // A WEDGING FAKE RUNNER for the hermetic HARNESS PHASE-TIMEOUT tests: speaks the NDJSON bridge protocol,
3
+ // answers everything — EXCEPT the op named in NOMOS_WEDGE_OP (optionally only when the args carry an
4
+ // authorSecret, i.e. the 3b keyed reconnect), which it swallows FOREVER. This forges the exact "silent
5
+ // hang" co2 reported so the harness's typed HarnessPhaseTimeout can be asserted without a real wait.
6
+ import { createInterface } from "node:readline";
7
+ const post = (msg) => process.stdout.write(JSON.stringify(msg) + "\n");
8
+ const wedgeOp = process.env.NOMOS_WEDGE_OP || "connect"; // default: wedge the KEYED reconnect (co2's shape)
9
+ const wedgeOnlyKeyed = process.env.NOMOS_WEDGE_ONLY_KEYED !== "0";
10
+ post({ v: 1, event: "ready" });
11
+ const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
12
+ rl.on("line", (line) => {
13
+ const s = line.trim();
14
+ if (!s) return;
15
+ let msg; try { msg = JSON.parse(s); } catch { return; }
16
+ const a = msg.args || {};
17
+ if (msg.op === wedgeOp && (!wedgeOnlyKeyed || a.authorSecret)) return; // THE WEDGE: never answer
18
+ if (msg.op === "authorKeygen") return post({ v: 1, id: msg.id, ok: true, result: { secret: "c2VjcmV0", public: "cHVibGlj" } });
19
+ if (msg.op === "connect") return post({ v: 1, id: msg.id, ok: true, result: { workspace: a.workspace, session: a.session } });
20
+ post({ v: 1, id: msg.id, ok: true, result: {} });
21
+ });
22
+ rl.on("close", () => process.exit(0));
@@ -0,0 +1,72 @@
1
+ // NO SILENT HANGS — the harness's per-phase deadline machinery, proven HERMETICALLY (no real waits, no
2
+ // wasm, no network): a fake runner (test/fixtures/wedge/wedge-entry.mjs) speaks the bridge protocol but
3
+ // SWALLOWS the 3b keyed reconnect forever — the exact wedge co2 reported. With a short `phaseTimeout` the
4
+ // harness must throw the TYPED [HarnessPhaseTimeout] naming the phase/workspace/op, never hang; and the
5
+ // failed construction must tear the runner down by itself (no leaked process holding the test runner).
6
+ @TestOn('vm')
7
+ @Timeout(Duration(seconds: 60))
8
+ library;
9
+
10
+ import 'dart:io';
11
+
12
+ import 'package:nomos_client/nomos_headless.dart';
13
+ import 'package:test/test.dart';
14
+
15
+ String get _fixtures => '${Directory.current.path}/test/fixtures/wedge';
16
+ String get _wedgeEntry => '$_fixtures/wedge-entry.mjs';
17
+ String get _fakeInstall => '$_fixtures/fake-install.mjs';
18
+
19
+ void main() {
20
+ test('a wedged keyed reconnect fires HarnessPhaseTimeout naming phase 3b (typed, no hang)', () async {
21
+ final log = <String>[];
22
+ Object? err;
23
+ try {
24
+ await NomosTestHarness.cluster(
25
+ workspaces: {
26
+ 'catalogue': ['unused.deploy.json'], // the fake install helper ignores the paths
27
+ },
28
+ deviceKeyed: {'catalogue'},
29
+ nodeEntry: _wedgeEntry,
30
+ installHelper: _fakeInstall,
31
+ phaseTimeout: const Duration(seconds: 2), // hermetic: a short deadline, not a real 120s wait
32
+ onDiagnostic: log.add,
33
+ );
34
+ } catch (e) {
35
+ err = e;
36
+ }
37
+ expect(err, isA<HarnessPhaseTimeout>(),
38
+ reason: 'the wedge must surface as the typed phase timeout, never an eternal await');
39
+ final t = err as HarnessPhaseTimeout;
40
+ expect(t.phase, "3b: keyed reconnect of 'catalogue'");
41
+ expect(t.workspace, 'catalogue');
42
+ expect(t.awaiting, 'connect');
43
+ expect(t.elapsed, const Duration(seconds: 2));
44
+ expect(t.likelyCauses, isNotEmpty, reason: 'known wedges carry their likely causes');
45
+ expect(t, isA<NomosTestHarnessException>(),
46
+ reason: 'existing on-NomosTestHarnessException handling keeps working');
47
+ expect(t.toString(), contains("3b: keyed reconnect of 'catalogue'"));
48
+ // ask 5 — the phases before the wedge logged enter AND exit; the wedged phase logged its enter.
49
+ expect(log.any((l) => l.contains('3: connect session') && l.contains('done')), isTrue);
50
+ expect(log.any((l) => l.contains('3b: device keygen') && l.contains('done')), isTrue);
51
+ expect(log.any((l) => l.contains("3b: keyed reconnect of 'catalogue'")), isTrue);
52
+ });
53
+
54
+ test('deviceKeyed naming an unknown workspace refuses typed (never a wedge)', () async {
55
+ Object? err;
56
+ try {
57
+ await NomosTestHarness.cluster(
58
+ workspaces: {
59
+ 'catalogue': ['unused.deploy.json'],
60
+ },
61
+ deviceKeyed: {'not-a-workspace'},
62
+ nodeEntry: _wedgeEntry,
63
+ installHelper: _fakeInstall,
64
+ phaseTimeout: const Duration(seconds: 5),
65
+ );
66
+ } catch (e) {
67
+ err = e;
68
+ }
69
+ expect(err, isA<NomosTestHarnessException>());
70
+ expect(err.toString(), contains('not-a-workspace'));
71
+ });
72
+ }
@@ -0,0 +1,179 @@
1
+ // E2E SEALING FROM DART (task #86 gap 3): the operator-blind encryption handshake driven end-to-end
2
+ // through the Dart sealing-client surface (NomosEncryption — the owner ceremony; WrappedKeyStore —
3
+ // the reader's key custody; the kernel crypto ops over the bridge) against the REAL encrypted-notes
4
+ // law on a local holon. Mirrors bench/scale/e2e_encryption.smoke.mjs leg for leg:
5
+ // reader device key → grantAndWrap (grant + epoch fan-out in ONE ceremony call) → author-seal →
6
+ // write the envelope → the reader unwraps its scope key from the on-chain WrappedKey and reads
7
+ // DECRYPTED → without the key the body stays the opaque envelope → a non-member read is refused.
8
+ @TestOn('vm')
9
+ library;
10
+
11
+ import 'dart:async';
12
+ import 'dart:convert';
13
+ import 'dart:io';
14
+
15
+ import 'package:nomos_client/nomos_headless.dart';
16
+ import 'package:test/test.dart';
17
+
18
+ String get _root => '${Directory.current.path}/../..';
19
+ String get _repoEntry => '$_root/cloud/web-client/runner/node-entry.mjs';
20
+ String get _fixture => '$_root/cloud/web-client/test/fixtures/encrypted-notes';
21
+ String get _deploy => '$_fixture/build/encrypted-notes.deploy.json';
22
+ String get _lockfile => '$_fixture/nomos.stable-ids.json';
23
+ String get _runtimeDir =>
24
+ Platform.environment['NOMOS_DART_RUNTIME'] ??
25
+ '${Platform.environment['HOME'] ?? ''}/.holon/runtime';
26
+
27
+ const _domain = 'encrypted_notes';
28
+ const _scope = 'readNote';
29
+ const _reader = 'reader-ada';
30
+ const _plaintext = 'the secret body';
31
+
32
+ String? _missingPrereq() {
33
+ if (!File(_repoEntry).existsSync()) return 'in-repo runner not found at $_repoEntry';
34
+ if (!File(_deploy).existsSync() || !File(_lockfile).existsSync()) {
35
+ return 'missing encrypted-notes fixture — compile it: (cd cloud/web-client/test/fixtures/encrypted-notes && node ../../../../../dsl/compile_package.mjs)';
36
+ }
37
+ if (!File('$_runtimeDir/holon.wasm').existsSync() ||
38
+ !File('$_runtimeDir/packages.json').existsSync()) {
39
+ return 'warm ~/.holon/runtime first (run any `githolon` command once)';
40
+ }
41
+ return null;
42
+ }
43
+
44
+ Future<T> _leg<T>(String name, Future<T> Function() body) async {
45
+ final sw = Stopwatch()..start();
46
+ // ignore: avoid_print
47
+ print('▶ SEAL $name …');
48
+ try {
49
+ final r = await body();
50
+ // ignore: avoid_print
51
+ print('✔ SEAL $name (${sw.elapsedMilliseconds}ms)');
52
+ return r;
53
+ } catch (e, st) {
54
+ throw TestFailure('SEAL $name FAILED after ${sw.elapsedMilliseconds}ms:\n$e\n$st');
55
+ }
56
+ }
57
+
58
+ void main() {
59
+ test('E2E sealing via the Dart harness: keygen → grantAndWrap → seal → write → decrypted read',
60
+ () async {
61
+ final missing = _missingPrereq();
62
+ if (missing != null) {
63
+ markTestSkipped(missing);
64
+ return;
65
+ }
66
+ final harness = await NomosTestHarness.ephemeral(
67
+ deployJsonPaths: [_deploy],
68
+ workspace: 'sealed-notes',
69
+ nodeEntry: _repoEntry,
70
+ runtimeDir: _runtimeDir,
71
+ phaseTimeout: const Duration(seconds: 120),
72
+ );
73
+ try {
74
+ final s = harness.bridge;
75
+ final lawHash = harness.lawHashes('sealed-notes').first;
76
+
77
+ // The #58 stable sids for the (Note, body) slot — from the committed lockfile, exactly as the
78
+ // generated sealing client bakes them.
79
+ final lock = (jsonDecode(File(_lockfile).readAsStringSync()) as Map);
80
+ final noteAgg = ((((lock['domains'] as Map)[_domain] as Map)['stableIds'] as Map)['aggregates']
81
+ as Map)['Note'] as Map;
82
+ final aggSid = noteAgg['sid'].toString();
83
+ final fieldSid = ((noteAgg['fields'] as Map)['body'] as Map)['sid'].toString();
84
+
85
+ // ── LEG 1: the reader's X25519 device key (secret stays client-side). ──
86
+ final reader = await _leg('leg 1: reader device keygen (X25519, in-kernel)', () async {
87
+ final kp = await s.cryptoKeygen();
88
+ expect((kp['secret'] ?? '').toString(), isNotEmpty);
89
+ expect((kp['public'] ?? '').toString(), isNotEmpty);
90
+ return kp;
91
+ });
92
+
93
+ // ── LEG 2: THE OWNER CEREMONY in ONE call — grant the cap + open epoch 0 wrapping the scope
94
+ // key to the resolved roster (the Dart NomosEncryption surface, gap 3's real target). ──
95
+ final scopeKey = base64Encode(
96
+ base64Decode((await s.cryptoKeygen())['secret'].toString()).sublist(0, 32));
97
+ final ceremony = NomosEncryption(
98
+ bridge: s, domain: _domain, domainHash: lawHash, scopes: const [_scope]);
99
+ final epoch0 = await _leg('leg 2: grantAndWrap (grant + epoch fan-out, one ceremony call)',
100
+ () async {
101
+ final r = await ceremony.grantAndWrap(
102
+ principal: _reader,
103
+ publicKey: reader['public'].toString(),
104
+ grantDirective: 'grantReader',
105
+ grantPayload: {
106
+ 'principal': _reader,
107
+ 'grantedBy': 'owner',
108
+ 'grantedAt': DateTime.now().toUtc().toIso8601String(),
109
+ },
110
+ scopeKey: scopeKey,
111
+ );
112
+ expect(r.epoch, 0, reason: 'the first ceremony opens epoch 0');
113
+ expect(r.roster, [_reader], reason: 'the fan-out roster is exactly the granted member');
114
+ expect(r.head, isNotEmpty);
115
+ return r;
116
+ });
117
+
118
+ // ── LEG 3: author-seal the body (the wasm seal, sid-bound) and WRITE the envelope. ──
119
+ final envelope = await _leg('leg 3: seal the body → write the envelope', () async {
120
+ final env = await s.cryptoSealField(
121
+ scopeKey: scopeKey,
122
+ scope: _scope,
123
+ epoch: epoch0.epoch,
124
+ aggSid: aggSid,
125
+ fieldSid: fieldSid,
126
+ plaintext: _plaintext,
127
+ );
128
+ expect(env, contains(fieldSid), reason: 'the envelope binds the stable field sid');
129
+ expect(env, contains(_scope));
130
+ await s.offerOutcome(
131
+ domain: _domain,
132
+ directiveId: 'writeNote',
133
+ payload: {'owner': _reader, 'body': env},
134
+ domainHash: lawHash,
135
+ );
136
+ return env;
137
+ });
138
+
139
+ // ── LEG 4: the reader's WrappedKeyStore unwraps its scope key from the on-chain WrappedKey
140
+ // with its device secret — the shipped key-custody default. ──
141
+ final keys = await _leg('leg 4: WrappedKeyStore unwraps the on-chain wrapped key', () async {
142
+ final store = WrappedKeyStore(
143
+ bridge: s, principal: _reader, deviceSecret: reader['secret'].toString());
144
+ final current = await store.scopeKey(_scope);
145
+ expect(current, isNotNull, reason: 'the reader holds a wrap for its scope');
146
+ expect(current!.epoch, 0);
147
+ expect(current.key, scopeKey,
148
+ reason: 'the unwrapped scope key IS the ceremony key (the real handoff)');
149
+ return store.readKeys(const [_scope]);
150
+ });
151
+
152
+ // ── LEG 5: read WITH the key → DECRYPTED; read WITHOUT → the opaque envelope. ──
153
+ await _leg('leg 5: keyed read decrypts; keyless read stays opaque', () async {
154
+ final withKey = await s.query('notesByOwner', {'owner': _reader}, keys, _reader);
155
+ expect(withKey, hasLength(1));
156
+ expect((withKey.single['data'] as Map)['body'], _plaintext,
157
+ reason: 'the key-holder reads plaintext');
158
+ final noKey = await s.query('notesByOwner', {'owner': _reader}, null, _reader);
159
+ final opaque = (noKey.single['data'] as Map)['body'];
160
+ expect(opaque, isNot(_plaintext), reason: 'the operator view never decrypts');
161
+ expect(opaque.toString(), contains(_scope),
162
+ reason: 'the value is the scope-bound ciphertext envelope, sizes/scope visible only');
163
+ expect(opaque.toString(), envelope, reason: 'byte-for-byte the on-chain envelope');
164
+ });
165
+
166
+ // ── LEG 6: a NON-MEMBER is refused by the read gate itself (before decryption matters). ──
167
+ await _leg('leg 6: non-member read refused by the read gate', () async {
168
+ await expectLater(
169
+ s.query('notesByOwner', {'owner': _reader},
170
+ [NomosScopeKey(scope: _scope, epoch: 0, key: scopeKey)], 'intruder-mallory'),
171
+ throwsA(predicate((e) => e.toString().contains('read-forbidden'),
172
+ 'a read-forbidden refusal')));
173
+ });
174
+ } finally {
175
+ await harness.dispose().timeout(const Duration(seconds: 30),
176
+ onTimeout: () => throw StateError('harness disposal hung'));
177
+ }
178
+ }, timeout: const Timeout(Duration(minutes: 5)));
179
+ }
@@ -11,7 +11,6 @@ library;
11
11
 
12
12
  import 'dart:io';
13
13
 
14
- import 'package:nomos_client/nomos_client.dart';
15
14
  import 'package:nomos_client/nomos_headless.dart';
16
15
  import 'package:test/test.dart';
17
16
 
@@ -123,10 +122,12 @@ void main() {
123
122
  );
124
123
  final joined = lines.join('\n');
125
124
  // the four named phases are surfaced so an AppSim can tell install vs runner-wait vs connect-block.
126
- expect(joined, contains('phase 1/4 installing'));
127
- expect(joined, contains('phase 2/4 spawning the node runner'));
128
- expect(joined, contains('phase 3/4 awaiting runner ready'));
129
- expect(joined, contains('phase 4/4 connecting session'));
125
+ expect(joined, contains('phase 1/4: offline law install'));
126
+ expect(joined, contains('phase 2/4: spawn the node runner'));
127
+ expect(joined, contains('phase 2/4: spawn the node runner done'));
128
+ expect(joined, contains('phase 3/4: runner ready'));
129
+ expect(joined, contains('phase 4/4: connect session'));
130
+ expect(joined, contains('phase 4/4: connect session "test-ws" done'));
130
131
  expect(joined, contains('ready — session "test-ws" connected'));
131
132
  expect(harness.bridge, isNotNull);
132
133
  }, timeout: const Timeout(Duration(minutes: 2)));