@githolon/dsl 0.46.0 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,31 +1,37 @@
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.
1
+ // NOMOS TEST HARNESS — an EPHEMERAL, FULLY-OFFLINE real-law fixture for headless app tests. One call
2
+ // installs a tenant's compiled law into a FRESH LOCAL workspace (no cloud contact, ever), boots the REAL
3
+ // githolon (the byte-identical wasm, the real ledger, the real gate) over [NodeRunnerTransport], and hands
4
+ // out a [NomosBridge] the generated Dart clients drive — the SAME surface the app UI uses — under
5
+ // `flutter test` / `dart test` / CI.
5
6
  //
6
7
  // ```dart
7
8
  // final harness = await NomosTestHarness.ephemeral(
8
9
  // deployJsonPaths: ['build/guestbook.deploy.json'],
10
+ // registerTearDown: addTearDown, // auto-disposes when the test ends (see below)
9
11
  // );
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();
12
+ // final app = guestbookClient(harness.bridge); // the generated client — same one the app uses
13
+ // await app.signGuestbook(author: 'ada', message: 'hi', mood: 'happy', signedAt: ...);
14
+ // final rows = await app.entriesByAuthor(author: 'ada');
15
+ // await harness.dispose(); // DETERMINISTIC teardown (also auto-fired via registerTearDown)
14
16
  // ```
15
17
  //
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).
18
+ // HOW IT STAYS OFFLINE. Two node children, both talking only to the local disk:
19
+ // 1. `offline-install.mjs` runs the PROVEN offline install lane (`githolon proof`'s adapter engine) —
20
+ // genesis → framework controller → each tenant package's `installDomain`, folded through the ONE gate —
21
+ // and serializes the workspace tree to a snapshot in a fresh temp dir.
22
+ // 2. the node runner (`node-entry.mjs`) boots in `NOMOS_OFFLINE=1` mode: it serves the runtime wasm from the
23
+ // local cache (`~/.holon/runtime`, filled by any warm `githolon` run) and boots the holon from that
24
+ // snapshot (`restoreFrom`). Every other URL is REFUSED — a stray fetch fails loudly, never reaches the net.
21
25
  //
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
+ // PREREQUISITES (documented so a failure is legible): `node` on PATH; a resolvable
27
+ // `@githolon/client/runner/node-entry.mjs`; and a warm `~/.holon/runtime` cache (holon.wasm + packages.json —
28
+ // one `githolon` run fills it). All local; no network at test time.
29
+ //
30
+ // PACKAGING NOTE: `offline-install.mjs` imports the adapter engine, which today lives beside the in-repo
31
+ // runner. For a PUBLISHED harness this install belongs behind a `githolon` subcommand (the CLI already
32
+ // bundles the engine + this exact sequence in `proof_offline`); [installHelper] is the one seam to repoint.
26
33
  import 'dart:convert';
27
34
  import 'dart:io';
28
- import 'dart:math';
29
35
 
30
36
  import 'node_runner_transport.dart';
31
37
  import 'webview_bridge.dart' show NomosBridge;
@@ -38,198 +44,199 @@ class NomosTestHarnessException implements Exception {
38
44
  }
39
45
 
40
46
  class NomosTestHarness {
41
- /// The cloud base URL the harness (and every bridge it connects) talks to.
42
- final String cloud;
47
+ /// The connected bridge — hand it to the generated typed clients (the same surface the app UI uses).
48
+ final NomosBridge bridge;
43
49
 
44
- /// The ephemeral workspace name this harness birthed.
50
+ /// The local workspace name the law was installed into.
45
51
  final String workspace;
46
52
 
47
- final String? _principal;
48
- final String? _authToken;
49
- final String? _nodeEntry;
50
- final String? _resolveFrom;
53
+ final NodeRunnerTransport _transport;
54
+ final Directory _workdir;
51
55
  final void Function(String line)? _onDiagnostic;
52
- final List<NodeRunnerTransport> _transports = [];
53
56
  bool _disposed = false;
54
57
 
55
- NomosTestHarness._(this.cloud, this.workspace, this._principal,
56
- this._authToken, this._nodeEntry, this._resolveFrom, this._onDiagnostic);
58
+ NomosTestHarness._(this.bridge, this.workspace, this._transport, this._workdir,
59
+ this._onDiagnostic);
57
60
 
58
- /// Birth a throwaway workspace and deploy the given compiled domain packages into it.
61
+ /// Install [deployJsonPaths] into a fresh LOCAL workspace and boot a bridge over it — NO cloud contact.
59
62
  ///
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].
63
+ /// [deployJsonPaths] — the `build/<name>.deploy.json` files `nomos-compile` emits (installed in order;
64
+ /// framework/platform law before a domain that depends on it).
65
+ /// [workspace] — the local workspace name (default `test-ws`; law-only, never leaves the machine).
66
+ /// [runtimeDir] — the runtime cache (default `~/.holon/runtime`); must hold `holon.wasm` + `packages.json`.
67
+ /// [nodeEntry] — path to `node-entry.mjs`; when omitted it is resolved from [resolveFrom] via
68
+ /// `require.resolve('@githolon/client/runner/node-entry.mjs')`.
69
+ /// [installHelper] — path to `offline-install.mjs`; defaults to the file beside [nodeEntry].
70
+ /// [registerTearDown] — pass the test framework's `addTearDown` to auto-[dispose] when the test ends
71
+ /// (feature-detecting `package:test` from a library is not clean in Dart, so this is explicit and safe).
72
+ /// [autoTearDown] — when true (default) and [registerTearDown] is given, registers [dispose] as a teardown.
66
73
  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,
74
+ required List<String> deployJsonPaths,
75
+ String workspace = 'test-ws',
76
+ String clientId = 'test-runner',
77
+ String? runtimeDir,
72
78
  String? nodeEntry,
79
+ String? installHelper,
73
80
  String? resolveFrom,
81
+ String node = 'node',
82
+ bool autoTearDown = true,
83
+ void Function(Future<void> Function())? registerTearDown,
74
84
  void Function(String line)? onDiagnostic,
85
+ Duration readyTimeout = const Duration(seconds: 30),
75
86
  }) 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()) {
87
+ if (deployJsonPaths.isEmpty) {
93
88
  throw NomosTestHarnessException(
94
- 'deploy json not found: $deployJsonPath — run `npx githolon compile` first');
89
+ 'no deployJsonPaths — pass the build/<name>.deploy.json `npx githolon compile` emits');
95
90
  }
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)}');
91
+ final entry = nodeEntry ??
92
+ await _resolveEntry(node, resolveFrom ?? Directory.current.path);
93
+ final helper = installHelper ??
94
+ '${File(entry).parent.path}/offline-install.mjs';
95
+
96
+ // A FRESH temp dir per harness — the snapshot + any transient runner state live here and nowhere else,
97
+ // so dispose() leaves nothing behind.
98
+ final workdir = await Directory.systemTemp.createTemp('nomos-harness-');
99
+ // Total-ordered teardown even on a mid-setup failure: build the transport lazily and clean up on throw.
100
+ NodeRunnerTransport? transport;
101
+ try {
102
+ final snapshot = '${workdir.path}/snapshot.bin';
103
+ await _runInstall(
104
+ node: node,
105
+ helper: helper,
106
+ deployJsonPaths: deployJsonPaths,
107
+ workspace: workspace,
108
+ snapshot: snapshot,
109
+ runtimeDir: runtimeDir,
110
+ onDiagnostic: onDiagnostic);
111
+
112
+ transport = await NodeRunnerTransport.start(
113
+ entry: entry,
114
+ resolveFrom: resolveFrom,
115
+ node: node,
116
+ environment: {
117
+ 'NOMOS_OFFLINE': '1',
118
+ 'NOMOS_OFFLINE_RESTORE': snapshot,
119
+ if (runtimeDir != null) 'NOMOS_OFFLINE_RUNTIME': runtimeDir,
120
+ },
121
+ onDiagnostic: onDiagnostic,
122
+ );
123
+ final bridge = NomosBridge(transport);
124
+ await bridge.ready.timeout(readyTimeout,
125
+ onTimeout: () => throw NomosTestHarnessException(
126
+ 'the offline runner never signalled ready within $readyTimeout '
127
+ '(is `node` on PATH and ~/.holon/runtime warm?)'));
128
+ await bridge.connect(
129
+ cloud: 'https://offline.invalid', // ignored: the offline runner serves everything locally
130
+ workspace: workspace,
131
+ clientId: clientId,
132
+ allowUnborn: true,
133
+ );
134
+ final harness =
135
+ NomosTestHarness._(bridge, workspace, transport, workdir, onDiagnostic);
136
+ if (autoTearDown && registerTearDown != null) {
137
+ registerTearDown(harness.dispose);
138
+ }
139
+ return harness;
140
+ } catch (_) {
141
+ // Setup failed — tear down deterministically so nothing is left running or on disk.
142
+ if (transport != null) {
143
+ try {
144
+ await transport.dispose();
145
+ } catch (_) {}
146
+ }
147
+ try {
148
+ await workdir.delete(recursive: true);
149
+ } catch (_) {}
150
+ rethrow;
101
151
  }
102
152
  }
103
153
 
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.
154
+ /// Deterministic, idempotent, total-ordered teardown: stop the runner (SIGTERM → 5s deadline → SIGKILL,
155
+ /// via [NodeRunnerTransport.dispose]), then delete the temp dir. Safe to call twice; safe to call after a
156
+ /// failed setup. Nothing left running, nothing left on disk.
134
157
  Future<void> dispose() async {
135
158
  if (_disposed) return;
136
159
  _disposed = true;
137
- for (final t in _transports) {
138
- await t.dispose();
160
+ try {
161
+ await _transport.dispose();
162
+ } catch (e) {
163
+ _diag('[nomos-test-harness] transport dispose error (continuing to temp-dir cleanup): $e');
139
164
  }
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:)');
165
+ try {
166
+ if (await _workdir.exists()) await _workdir.delete(recursive: true);
167
+ } catch (e) {
168
+ _diag('[nomos-test-harness] temp-dir cleanup error: $e');
151
169
  }
152
170
  }
153
171
 
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();
172
+ void _diag(String line) {
173
+ final f = _onDiagnostic;
174
+ if (f != null) {
175
+ f(line);
176
+ } else {
177
+ stderr.writeln(line);
175
178
  }
176
179
  }
177
180
 
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;
181
+ // ── offline install (a short-lived node child; produces the restore snapshot) ──
182
+
183
+ static Future<void> _runInstall({
184
+ required String node,
185
+ required String helper,
186
+ required List<String> deployJsonPaths,
187
+ required String workspace,
188
+ required String snapshot,
189
+ String? runtimeDir,
190
+ void Function(String line)? onDiagnostic,
191
+ }) async {
192
+ if (!File(helper).existsSync()) {
193
+ throw NomosTestHarnessException(
194
+ 'offline-install helper not found at $helper — pass installHelper: explicitly '
195
+ '(it ships beside node-entry.mjs)');
196
+ }
197
+ final r = await Process.run(node, [
198
+ helper,
199
+ '--deploy', deployJsonPaths.join(','),
200
+ '--workspace', workspace,
201
+ '--out', snapshot,
202
+ if (runtimeDir != null) ...['--runtime', runtimeDir],
203
+ ]);
204
+ final err = (r.stderr as String).trim();
205
+ if (err.isNotEmpty) {
206
+ for (final line in err.split('\n')) {
207
+ (onDiagnostic ?? stderr.writeln)('[offline-install] $line');
208
+ }
186
209
  }
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) {
210
+ if (r.exitCode != 0) {
190
211
  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:');
212
+ 'offline law install failed (exit ${r.exitCode}): ${err.isEmpty ? "(no stderr)" : err}');
193
213
  }
194
- return (principal: p, authToken: t);
195
- }
196
-
197
- static ({String? principal, String? authToken}) _holonCreds() {
214
+ // The helper prints one JSON line on success; verify it before spawning the runner.
215
+ final out = (r.stdout as String).trim();
216
+ final last = out.isEmpty ? '' : out.split('\n').last;
217
+ Map<String, Object?> decoded = const {};
198
218
  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);
219
+ final d = jsonDecode(last);
220
+ if (d is Map) decoded = d.cast<String, Object?>();
221
+ } catch (_) {}
222
+ if (decoded['ok'] != true || !File(snapshot).existsSync()) {
223
+ throw NomosTestHarnessException(
224
+ 'offline law install produced no snapshot (stdout: ${out.isEmpty ? "(empty)" : out})');
212
225
  }
213
226
  }
214
227
 
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;
228
+ static Future<String> _resolveEntry(String node, String from) async {
229
+ final r = await Process.run(
230
+ node,
231
+ ['-p', "require.resolve('@githolon/client/runner/node-entry.mjs')"],
232
+ workingDirectory: from,
233
+ );
234
+ final out = (r.stdout as String).trim();
235
+ if (r.exitCode != 0 || out.isEmpty) {
236
+ throw NomosTestHarnessException(
237
+ 'cannot resolve @githolon/client/runner/node-entry.mjs from $from — '
238
+ 'is @githolon/client installed (npm install)? Pass nodeEntry: to override. stderr: ${r.stderr}');
225
239
  }
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();
240
+ return out;
234
241
  }
235
242
  }
@@ -0,0 +1,77 @@
1
+ // NOMOS — TYPED ERROR MINTING (§7 structured runtime error DX). `nomosErrorFromInfo` routes a structured
2
+ // `NomosErrorInfo` to the MOST SPECIFIC typed class by stable code prefix, so an app's `errorBuilder` branches
3
+ // on `is` and reads structured fields (not scraped prose). Every typed error still extends `NomosBridgeError`.
4
+ import 'package:nomos_client/nomos_client.dart';
5
+ import 'package:test/test.dart';
6
+
7
+ NomosErrorInfo info(String code, {Map<String, Object?> attrs = const {}, String? workspace, String? operation}) =>
8
+ NomosErrorInfo(code: code, message: 'refused', workspace: workspace, operation: operation, attributes: attrs);
9
+
10
+ void main() {
11
+ test('law.* mints LawMismatchError with both sides + compat verdict + the exact message', () {
12
+ final e = nomosErrorFromInfo(info(
13
+ 'nomos.law.mismatch',
14
+ workspace: 'ws-1',
15
+ operation: 'signGuestbook',
16
+ attrs: {
17
+ 'nomos.law.generatedFor': 'aaaa',
18
+ 'nomos.law.workspaceRuns': 'bbbb',
19
+ 'nomos.law.structurallyCompatible': false,
20
+ 'nomos.law.incompatibleMember': 'mood',
21
+ },
22
+ ));
23
+ expect(e, isA<NomosBridgeError>()); // still catchable generically
24
+ expect(e, isA<LawMismatchError>());
25
+ final m = e as LawMismatchError;
26
+ expect(m.code, 'nomos.law.mismatch');
27
+ expect(m.workspace, 'ws-1');
28
+ expect(m.generatedFor, 'aaaa');
29
+ expect(m.workspaceRuns, 'bbbb');
30
+ expect(m.operation, 'signGuestbook');
31
+ expect(m.structurallyCompatible, isFalse);
32
+ expect(m.incompatibleMember, 'mood');
33
+ expect(m.recommendedAction, NomosRecommendedAction.recompileClient);
34
+ expect(
35
+ m.mismatchMessage,
36
+ 'client generated for law aaaa, workspace runs law bbbb; structurally incompatible on mood — '
37
+ 'recompile the client (githolon compile / npx nomos-compile) against the deployed law',
38
+ );
39
+ });
40
+
41
+ test('law.* compatible verdict says "proceeding"', () {
42
+ final m = nomosErrorFromInfo(info('nomos.law.drift', attrs: {
43
+ 'nomos.law.generatedFor': 'aaaa',
44
+ 'nomos.law.workspaceRuns': 'bbbb',
45
+ 'nomos.law.structurallyCompatible': true,
46
+ })) as LawMismatchError;
47
+ expect(m.mismatchMessage, startsWith('client generated for law aaaa, workspace runs law bbbb; structurally compatible — proceeding —'));
48
+ });
49
+
50
+ test('auth.* mints AuthError with reauth as the remedy', () {
51
+ final e = nomosErrorFromInfo(info('nomos.auth.issuer_mismatch', workspace: 'ws-1', attrs: {'nomos.auth.issuer': 'https://evil'}));
52
+ expect(e, isA<AuthError>());
53
+ final a = e as AuthError;
54
+ expect(a.code, 'nomos.auth.issuer_mismatch');
55
+ expect(a.issuer, 'https://evil');
56
+ expect(a.recommendedAction, NomosRecommendedAction.reauth);
57
+ });
58
+
59
+ test('load.* mints LoadError carrying the artifact + source URL', () {
60
+ final e = nomosErrorFromInfo(info('nomos.load.wasm_fetch_failed', attrs: {
61
+ 'nomos.load.artifact': 'holon.wasm',
62
+ 'nomos.load.sourceUrl': 'https://nomos/runtime/holon.wasm',
63
+ }));
64
+ expect(e, isA<LoadError>());
65
+ final l = e as LoadError;
66
+ expect(l.artifact, 'holon.wasm');
67
+ expect(l.sourceUrl, 'https://nomos/runtime/holon.wasm');
68
+ expect(l.recommendedAction, NomosRecommendedAction.retry);
69
+ });
70
+
71
+ test('custody.* still mints CustodyError (unchanged), unknown codes stay generic', () {
72
+ expect(nomosErrorFromInfo(info('nomos.custody.missing_object')), isA<CustodyError>());
73
+ final g = nomosErrorFromInfo(info('nomos.bridge.error'));
74
+ expect(g, isA<NomosBridgeError>());
75
+ expect(g, isNot(isA<LawMismatchError>()));
76
+ });
77
+ }