@githolon/dsl 0.45.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
  }
@@ -275,6 +275,23 @@ class NomosCreated {
275
275
  String toString() => id;
276
276
  }
277
277
 
278
+ /// A geographic bounding box for spatial reads (`[minLng, minLat] .. [maxLng, maxLat]`,
279
+ /// RFC 7946 lng/lat order — the R*Tree probe's extent).
280
+ class NomosBbox {
281
+ final double minLng;
282
+ final double minLat;
283
+ final double maxLng;
284
+ final double maxLat;
285
+ const NomosBbox({
286
+ required this.minLng,
287
+ required this.minLat,
288
+ required this.maxLng,
289
+ required this.maxLat,
290
+ });
291
+ Map<String, Object?> toJson() =>
292
+ {'minLng': minLng, 'minLat': minLat, 'maxLng': maxLng, 'maxLat': maxLat};
293
+ }
294
+
278
295
  /// The result of [NomosBridge.offerCreates]: the new local [head] plus every aggregate the plan minted.
279
296
  class NomosOfferOutcome {
280
297
  final String head;
@@ -341,8 +358,9 @@ class NomosBridge {
341
358
  Future<void> get ready => _ready.future;
342
359
 
343
360
  void _onMessage(Map<String, Object?> m) {
344
- if (m['v'] != kWebviewBridgeVersion)
361
+ if (m['v'] != kWebviewBridgeVersion) {
345
362
  return; // a foreign-version message is ignored (forward-compatible)
363
+ }
346
364
  final event = m['event'];
347
365
  if (event == 'ready') {
348
366
  if (!_ready.isCompleted) _ready.complete();
@@ -411,8 +429,9 @@ class NomosBridge {
411
429
  if (event == 'span') {
412
430
  // LIVE per-layer span: { event:'span', span:{ phase:'begin'|'end', id, track, name, kind, t0|t1 } }.
413
431
  _spanBuffer.add(m);
414
- if (_spanBuffer.length > 400)
432
+ if (_spanBuffer.length > 400) {
415
433
  _spanBuffer.removeRange(0, _spanBuffer.length - 400);
434
+ }
416
435
  if (!_spanEvents.isClosed) _spanEvents.add(m);
417
436
  return;
418
437
  }
@@ -761,6 +780,13 @@ class NomosBridge {
761
780
  Future<List<Map<String, dynamic>>> queryById(String id) async =>
762
781
  _asRows(await _call('queryById', {'id': id}));
763
782
 
783
+ /// THE SPATIAL READ: rows of the declared spatial index's of-type whose geometry
784
+ /// intersects [bbox] — an R*Tree probe on the local projection, same row shape as [query].
785
+ Future<List<Map<String, dynamic>>> spatialWithin(
786
+ String spatialId, NomosBbox bbox) async =>
787
+ _asRows(await _call(
788
+ 'spatialWithin', {'spatialId': spatialId, 'bbox': bbox.toJson()}));
789
+
764
790
  /// A maintained O(1) count / sum.
765
791
  Future<int> count(String countId, [String group = '']) async =>
766
792
  ((_asMap(await _call('count', {'countId': countId, 'group': group}))[
@@ -914,6 +940,10 @@ class NomosBridge {
914
940
  Stream<List<dynamic>> watchById(String id) =>
915
941
  _watchStream(kind: 'byId', id: id);
916
942
 
943
+ /// Reactive SPATIAL read: re-emits the R*Tree probe's rows whenever they change locally.
944
+ Stream<List<dynamic>> watchSpatial(String spatialId, NomosBbox bbox) =>
945
+ _watchStream(kind: 'spatial', spatialId: spatialId, bbox: bbox);
946
+
917
947
  /// SHAPE COMPATIBILITY (#72): one-shot verdict for this client's structural [requirement]
918
948
  /// (its baked `…InterfaceJson`, decoded) against the workspace's ON-LEDGER offer (auto-sourced).
919
949
  /// `{ ok, missing[], incompatible[] }` — names the exact directive/aggregate/field, not a hash.
@@ -953,7 +983,9 @@ class NomosBridge {
953
983
  {required String kind,
954
984
  String? queryId,
955
985
  Map<String, Object?>? params,
956
- String? id}) {
986
+ String? id,
987
+ String? spatialId,
988
+ NomosBbox? bbox}) {
957
989
  final subId = 'w${++_seq}';
958
990
  late final StreamController<List<dynamic>> ctrl;
959
991
  ctrl = StreamController<List<dynamic>>(
@@ -962,7 +994,9 @@ class NomosBridge {
962
994
  'kind': kind,
963
995
  if (queryId != null) 'queryId': queryId,
964
996
  if (params != null) 'params': params,
965
- if (id != null) 'id': id
997
+ if (id != null) 'id': id,
998
+ if (spatialId != null) 'spatialId': spatialId,
999
+ if (bbox != null) 'bbox': bbox.toJson(),
966
1000
  }),
967
1001
  onCancel: () async {
968
1002
  _watches.remove(subId);
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.31.0
14
+ version: 0.32.0
15
15
 
16
16
  environment:
17
17
  sdk: ^3.5.0
@@ -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
+ }