@githolon/dsl 0.71.0 → 0.72.1

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,31 @@
1
+ ## 0.48.1
2
+
3
+ - **Fix: `NomosSession.watchPendingConflicts()` never emitted.** The session overrode every other
4
+ reactive read to register on the parent plane (which owns the message loop) but omitted this one —
5
+ a session-bound generated client's conflict stream registered on the session's own dead controller
6
+ registry and never delivered an event. Now delegates like `watch`/`watchById`/`watchActivity`.
7
+ Regression test: a session-level subscription must emit its first snapshot. (Reported by co2 with
8
+ the exact fix — thank you.)
9
+
10
+ ## 0.48.0
11
+
12
+ - **Session-bound clients (architecture/session_bound_clients.md)** — generated clients now bind to
13
+ exactly ONE `NomosSession`: `XClient.bind(session)` async preflight factory (structural interface
14
+ compat, typed `DomainClientSessionMismatch` at construction), all dispatch through the session,
15
+ births infer the parent from the session (no loose parent strings), `engine.follow(NomosRef)`
16
+ mounts by ref, `NomosOfferOutcome.bornRefs`. The kernel's generic "no active installed law declares
17
+ domain 'x'" is wrapped into the context-rich `DomainNotInstalledForSession` (session identity, both
18
+ workspaces, active domain keys/hashes, the four possible causes).
19
+ - Root-bridge generic writes (`offer`/`offerDirective`/`offerIntent`/`offerCreates`/`createWorkspace`)
20
+ are `@Deprecated` in app code — kept for CLI/admin tooling; the session-bound generated surface is
21
+ the path.
22
+ - `NomosSession.pendingConflicts()` / `watchPendingConflicts()` — the maintained Surfaced-conflict
23
+ "decisions needed" read (derived, not drained).
24
+ - Test harness: `resetWorkspaceCustody`, `seedWorkspaceFromDeployJson`, `assertDomainInstalled`,
25
+ `session.assertCompatible(XClient.interface)`.
26
+ - Requires Nomos Cloud ≥ the 2026-07-11 deploy (wasm 72604ff7 — the Surfaced/captured-basis kernel)
27
+ and pairs with `@githolon/dsl` 0.72.0 codegen.
28
+
1
29
  ## 0.47.0
2
30
 
3
31
  - **Fix: `NomosBridge.createWorkspace` hardcoded `domain: "workspaces"`** in the underlying signed offer —
@@ -34,6 +34,10 @@ export 'src/home.dart';
34
34
  // THE WEBVIEW BRIDGE (app-binding boundary) — the async Dart runtime that drives the REAL githolon running
35
35
  // in a WebView, so the generated typed client never needs a `MemoryPersistence` fake.
36
36
  export 'src/webview_bridge.dart';
37
+ // SESSION-BOUND CLIENTS (architecture/session_bound_clients.md) — the typed session/client
38
+ // contract: NomosClientInterface (`XClient.interface`), session.assertCompatible(...), and the two
39
+ // typed mismatch errors (DomainClientSessionMismatch / DomainNotInstalledForSession).
40
+ export 'src/session_bound.dart';
37
41
  // The PROVIDER boundary (server-side capability-provider SDK runtime: TaskOutcome /
38
42
  // TaskSuccess / TaskFailure / TaskResult / TaskProvider / HostClock). The symmetric twin
39
43
  // of `offer.dart`; the generated per-task provider classes build on it.
@@ -0,0 +1,309 @@
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
+ /// SESSION-BOUND CLIENTS (architecture/session_bound_clients.md) — the typed contract between a
7
+ /// generated `<Domain>Client` and the [NomosSession] it is constructed with.
8
+ ///
9
+ /// THE FOUR-LAYER SPLIT: the ENGINE ([NomosBridge]/[NomosEngine]) is ONLY the multi-workspace
10
+ /// runtime (mount/connect/session registry/sync/custody/follow-ref); a SESSION is exactly one
11
+ /// workspace; a GENERATED CLIENT is exactly one session + one domain interface expectation + one
12
+ /// actor context; everything else is a stateless value. This file carries:
13
+ ///
14
+ /// * [NomosClientInterface] — the static descriptor every generated client exposes
15
+ /// (`XClient.interface`): the domain key + law hash + structural interface (#72) it expects.
16
+ /// * [NomosSessionCompat.assertCompatible] — the ONE preflight (used by `XClient.bind(session)`
17
+ /// and by test harnesses) that verifies the session's workspace serves this client's law,
18
+ /// distinguishing the four mismatch causes.
19
+ /// * [DomainClientSessionMismatch] — thrown at client construction (bind / first dispatch) when
20
+ /// the session's workspace does not serve the expected domain compatibly.
21
+ /// * [DomainNotInstalledForSession] — the context-rich replacement for the kernel's generic
22
+ /// "no active installed law declares domain 'x'" refusal, thrown wherever that error used to
23
+ /// surface (bridge dispatch, birth lanes) with the full session context.
24
+ library;
25
+
26
+ import 'dart:convert';
27
+
28
+ import 'webview_bridge.dart';
29
+
30
+ /// The STATIC INTERFACE DESCRIPTOR a generated client exposes as `XClient.interface` — what the
31
+ /// client EXPECTS of the workspace it binds to: the domain key, the law hash it was generated
32
+ /// against (advisory — shape wins), and the structural interface (#72) it negotiates by.
33
+ class NomosClientInterface {
34
+ /// The domain key this client dispatches under (e.g. `co2_platform`).
35
+ final String domain;
36
+
37
+ /// sha256 of the `.package.usda` this client was generated against (advisory provenance).
38
+ final String expectedDomainHash;
39
+
40
+ /// sha256 of the canonical structural interface JSON (from the baked compat manifest), when known.
41
+ final String? expectedInterfaceHash;
42
+
43
+ /// The structural interface (#72) as JSON — the directives + read shapes this client depends
44
+ /// on. Empty ⇒ no structural requirement was baked (hash-only provenance).
45
+ final String interfaceJson;
46
+
47
+ /// The compiler-baked compat manifest (§5) as JSON, when present — diagnosis only, never a gate.
48
+ final String? compatJson;
49
+
50
+ const NomosClientInterface({
51
+ required this.domain,
52
+ required this.expectedDomainHash,
53
+ this.expectedInterfaceHash,
54
+ this.interfaceJson = '',
55
+ this.compatJson,
56
+ });
57
+
58
+ /// The `{domain: interface}` requirement map the shape-compat check consumes, or null when no
59
+ /// structural interface was baked.
60
+ Map<String, Object?>? get requirement => interfaceJson.isEmpty
61
+ ? null
62
+ : {domain: (jsonDecode(interfaceJson) as Map).cast<String, Object?>()};
63
+ }
64
+
65
+ /// The four distinguishable causes of a session/client mismatch (ask 9 of
66
+ /// architecture/session_bound_clients.md). Carried on [DomainClientSessionMismatch.cause].
67
+ enum SessionMismatchCause {
68
+ /// The cloud (or a peer) holds the law but this session's LOCAL custody has not synced it yet —
69
+ /// pull/sync and re-bind.
70
+ liveCloudHasLawButLocalStale,
71
+
72
+ /// The session's workspace runs law, but not THIS domain — the client is bound to the wrong
73
+ /// session (a different package is mounted here).
74
+ wrongPackageMounted,
75
+
76
+ /// No installed law anywhere serves this domain — deploy the domain package first.
77
+ trulyNotDeployed,
78
+
79
+ /// The domain IS installed but its shape is older than what this client requires — the client
80
+ /// expects a newer interface (recompile against the deployed law, or evolve the law).
81
+ clientExpectsNewerInterface,
82
+ }
83
+
84
+ /// Thrown by `XClient.bind(session)` (and by the lazy first-dispatch check of the synchronous
85
+ /// constructor) when the session's workspace does not serve the client's expected domain
86
+ /// compatibly — at PROVIDER CONSTRUCTION, not two clicks into the app.
87
+ class DomainClientSessionMismatch implements Exception {
88
+ /// The runner-level session name the client was bound to.
89
+ final String? sessionId;
90
+
91
+ /// The workspace that session is pinned to.
92
+ final String? sessionWorkspace;
93
+
94
+ /// The domain key the client expects installed.
95
+ final String expectedDomainKey;
96
+
97
+ /// The law hash the client was generated against (advisory).
98
+ final String expectedDomainHash;
99
+
100
+ /// The structural interface hash the client was generated against, when known.
101
+ final String? expectedInterfaceHash;
102
+
103
+ /// The domain keys the session's workspace ACTUALLY serves (best-effort; empty when the
104
+ /// workspace exposes no on-ledger interface yet).
105
+ final List<String> activeDomainKeys;
106
+
107
+ /// The law hashes actually installed on the session's workspace (best-effort).
108
+ final List<String> activeDomainHashes;
109
+
110
+ /// The classified cause, when the preflight could distinguish one.
111
+ final SessionMismatchCause? cause;
112
+
113
+ /// A human hint naming the fix (sync, re-bind, deploy, recompile), plus the raw shape verdict.
114
+ final String hint;
115
+
116
+ const DomainClientSessionMismatch({
117
+ required this.expectedDomainKey,
118
+ required this.expectedDomainHash,
119
+ this.expectedInterfaceHash,
120
+ this.sessionId,
121
+ this.sessionWorkspace,
122
+ this.activeDomainKeys = const [],
123
+ this.activeDomainHashes = const [],
124
+ this.cause,
125
+ required this.hint,
126
+ });
127
+
128
+ @override
129
+ String toString() =>
130
+ 'DomainClientSessionMismatch: session "${sessionId ?? '(unbound)'}" (workspace '
131
+ '"${sessionWorkspace ?? '?'}") does not serve domain "$expectedDomainKey" compatibly'
132
+ '${cause != null ? ' [${cause!.name}]' : ''} — $hint '
133
+ '(expected law $expectedDomainHash'
134
+ '${expectedInterfaceHash != null ? ' / interface $expectedInterfaceHash' : ''}; '
135
+ 'active domains: ${activeDomainKeys.isEmpty ? '(unknown)' : activeDomainKeys.join(', ')}; '
136
+ 'active law: ${activeDomainHashes.isEmpty ? '(unknown)' : activeDomainHashes.join(', ')})';
137
+ }
138
+
139
+ /// The context-rich replacement for the kernel's generic
140
+ /// `no active installed law declares domain 'x' … deploy the domain package first` refusal —
141
+ /// thrown wherever that error used to surface (bridge dispatch, birth lanes) so an app dev sees
142
+ /// WHICH session refused, what it actually runs, and the likely causes.
143
+ class DomainNotInstalledForSession implements Exception {
144
+ /// The domain key the dispatch named.
145
+ final String expectedDomainKey;
146
+
147
+ /// The law hash the dispatch carried (advisory), when any.
148
+ final String? expectedDomainHash;
149
+
150
+ /// The runner session the dispatch routed to (null on a bare single-session bridge).
151
+ final String? sessionId;
152
+
153
+ /// The workspace that session is pinned to.
154
+ final String? sessionWorkspace;
155
+
156
+ /// The workspace the caller believed it was writing to (usually == [sessionWorkspace];
157
+ /// divergence is itself the diagnosis).
158
+ final String? requestedWorkspace;
159
+
160
+ /// The domain keys the session's workspace ACTUALLY serves (best-effort).
161
+ final List<String> activeDomainKeys;
162
+
163
+ /// The law hashes actually installed on the session's workspace (best-effort).
164
+ final List<String> activeDomainHashes;
165
+
166
+ /// The candidate explanations, most likely first.
167
+ final List<String> possibleCauses;
168
+
169
+ /// The original kernel refusal.
170
+ final Object underlying;
171
+
172
+ const DomainNotInstalledForSession({
173
+ required this.expectedDomainKey,
174
+ this.expectedDomainHash,
175
+ this.sessionId,
176
+ this.sessionWorkspace,
177
+ this.requestedWorkspace,
178
+ this.activeDomainKeys = const [],
179
+ this.activeDomainHashes = const [],
180
+ this.possibleCauses = defaultPossibleCauses,
181
+ required this.underlying,
182
+ });
183
+
184
+ /// The four causes the spec names, most likely first.
185
+ static const List<String> defaultPossibleCauses = [
186
+ 'the client is bound to the WRONG session (another workspace holds this domain)',
187
+ 'a birth/write targeted a parent workspace that is not this session\'s workspace',
188
+ 'local custody is STALE — the cloud holds the law but this session has not synced it (pull/sync)',
189
+ 'the domain is truly not deployed to this workspace yet (deploy the domain package)',
190
+ ];
191
+
192
+ @override
193
+ String toString() =>
194
+ 'DomainNotInstalledForSession: domain "$expectedDomainKey" is not installed on session '
195
+ '"${sessionId ?? '(default)'}" (workspace "${sessionWorkspace ?? '?'}"'
196
+ '${requestedWorkspace != null && requestedWorkspace != sessionWorkspace ? ', requested "$requestedWorkspace"' : ''}). '
197
+ 'Active domains: ${activeDomainKeys.isEmpty ? '(unknown)' : activeDomainKeys.join(', ')}; '
198
+ 'active law: ${activeDomainHashes.isEmpty ? '(unknown)' : activeDomainHashes.join(', ')}. '
199
+ 'Possible causes: ${possibleCauses.map((c) => '• $c').join(' ')} '
200
+ '[underlying: $underlying]';
201
+ }
202
+
203
+ /// THE ONE PREFLIGHT — `session.assertCompatible(XClient.interface)`. Used by the generated
204
+ /// `XClient.bind(session)` async factory, by the lazy first-dispatch check of the synchronous
205
+ /// constructor, and directly from tests (ask 9). Throws [DomainClientSessionMismatch] with the
206
+ /// cause classified; returns normally when the session serves the client's law compatibly.
207
+ extension NomosSessionCompat on NomosSession {
208
+ Future<void> assertCompatible(NomosClientInterface iface) async {
209
+ // 1. REACHABILITY — the session's workspace must answer a read at all.
210
+ List<String> activeKeys = const [];
211
+ try {
212
+ final offer = await interfaceOffer();
213
+ if (offer != null) activeKeys = offer.keys.toList()..sort();
214
+ } catch (e) {
215
+ throw DomainClientSessionMismatch(
216
+ expectedDomainKey: iface.domain,
217
+ expectedDomainHash: iface.expectedDomainHash,
218
+ expectedInterfaceHash: iface.expectedInterfaceHash,
219
+ sessionId: workspace,
220
+ sessionWorkspace: workspace,
221
+ hint: 'the session workspace is not reachable/mounted on this plane: $e',
222
+ );
223
+ }
224
+
225
+ // 2. HASH AXIS (diagnosis) — the installed law hashes, via the baked compat manifest when present.
226
+ List<String> activeHashes = const [];
227
+ if (iface.compatJson != null && iface.compatJson!.isNotEmpty) {
228
+ try {
229
+ final c = await checkCompat(
230
+ (jsonDecode(iface.compatJson!) as Map).cast<String, Object?>(),
231
+ iface.requirement);
232
+ if (c != null) activeHashes = c.workspaceRuns;
233
+ } catch (_) {/* diagnosis only — never fail the preflight on the hash axis */}
234
+ }
235
+
236
+ // 3. THE SHAPE AXIS (#72) — the one compatibility notion. No baked interface ⇒ nothing more to check.
237
+ final requirement = iface.requirement;
238
+ if (requirement == null) return;
239
+ Map<String, Object?> verdict;
240
+ try {
241
+ final raw = await checkCompatibility(requirement);
242
+ verdict = (raw['verdict'] is Map)
243
+ ? (raw['verdict'] as Map).cast<String, Object?>()
244
+ : raw;
245
+ } catch (e) {
246
+ throw DomainClientSessionMismatch(
247
+ expectedDomainKey: iface.domain,
248
+ expectedDomainHash: iface.expectedDomainHash,
249
+ expectedInterfaceHash: iface.expectedInterfaceHash,
250
+ sessionId: workspace,
251
+ sessionWorkspace: workspace,
252
+ activeDomainKeys: activeKeys,
253
+ activeDomainHashes: activeHashes,
254
+ hint: 'the shape-compatibility check itself failed: $e',
255
+ );
256
+ }
257
+ if (verdict['ok'] == true) return;
258
+
259
+ final missing =
260
+ ((verdict['missing'] as List?) ?? const []).map((e) => '$e').toList();
261
+ final incompatible =
262
+ ((verdict['incompatible'] as List?) ?? const []).map((e) => '$e').toList();
263
+
264
+ // 4. CLASSIFY the four causes (ask 9).
265
+ SessionMismatchCause cause;
266
+ String hint;
267
+ if (incompatible.isNotEmpty) {
268
+ cause = SessionMismatchCause.clientExpectsNewerInterface;
269
+ hint = 'the domain is installed but type-divergent from what this client requires '
270
+ '(${incompatible.join('; ')}) — recompile the client against the deployed law, or evolve the law';
271
+ } else if (activeKeys.isNotEmpty && !activeKeys.contains(iface.domain)) {
272
+ cause = SessionMismatchCause.wrongPackageMounted;
273
+ hint = 'this workspace runs a DIFFERENT package (${activeKeys.join(', ')}) — '
274
+ 'bind this client to the session whose workspace holds "${iface.domain}"';
275
+ } else {
276
+ // The domain (or its whole offer) is absent here. Stale-vs-never-deployed: if this session is
277
+ // cloud-bound and behind, a sync may bring the law; otherwise it was never deployed.
278
+ var behind = 0;
279
+ var cloudBound = false;
280
+ try {
281
+ final m = await metrics();
282
+ behind = ((m['behind'] as num?) ?? 0).toInt();
283
+ final listed = await listWorkspaces();
284
+ cloudBound = listed.any((w) =>
285
+ w['workspace'] == workspace && w['syncPosture'] == 'cloud-bound');
286
+ } catch (_) {/* best-effort */}
287
+ if (cloudBound && behind > 0) {
288
+ cause = SessionMismatchCause.liveCloudHasLawButLocalStale;
289
+ hint = 'local custody is $behind commit(s) behind its cloud — sync/pull, then re-bind '
290
+ '(missing: ${missing.join('; ')})';
291
+ } else {
292
+ cause = SessionMismatchCause.trulyNotDeployed;
293
+ hint = 'no installed law on this workspace declares "${iface.domain}" — '
294
+ 'deploy the domain package first (missing: ${missing.join('; ')})';
295
+ }
296
+ }
297
+ throw DomainClientSessionMismatch(
298
+ expectedDomainKey: iface.domain,
299
+ expectedDomainHash: iface.expectedDomainHash,
300
+ expectedInterfaceHash: iface.expectedInterfaceHash,
301
+ sessionId: workspace,
302
+ sessionWorkspace: workspace,
303
+ activeDomainKeys: activeKeys,
304
+ activeDomainHashes: activeHashes,
305
+ cause: cause,
306
+ hint: hint,
307
+ );
308
+ }
309
+ }
@@ -36,6 +36,26 @@ import 'dart:io';
36
36
  import 'node_runner_transport.dart';
37
37
  import 'webview_bridge.dart' show NomosBridge, NomosSession;
38
38
 
39
+ /// The offline-install lane configuration a harness keeps so the CUSTODY DIAGNOSTIC helpers
40
+ /// (ask 9 of architecture/session_bound_clients.md) can re-run installs after construction:
41
+ /// [seedWorkspaceFromDeployJson] / [resetWorkspaceCustody].
42
+ class _InstallLane {
43
+ final String node;
44
+ final bool useInRepoScript;
45
+ final String inRepoScript;
46
+ final String githolonCmd;
47
+ final String? runtimeDir;
48
+ final String clientId;
49
+ const _InstallLane({
50
+ required this.node,
51
+ required this.useInRepoScript,
52
+ required this.inRepoScript,
53
+ required this.githolonCmd,
54
+ required this.runtimeDir,
55
+ required this.clientId,
56
+ });
57
+ }
58
+
39
59
  class NomosTestHarnessException implements Exception {
40
60
  final String message;
41
61
  NomosTestHarnessException(this.message);
@@ -81,13 +101,23 @@ class NomosTestHarness {
81
101
  List<String> lawHashes(String workspace) =>
82
102
  List.unmodifiable(_lawHashes[workspace] ?? const []);
83
103
 
104
+ /// workspace → the base64 restore snapshot it was seeded from (for [resetWorkspaceCustody]).
105
+ final Map<String, String> _snapshots;
106
+
107
+ /// The offline-install lane config (for [seedWorkspaceFromDeployJson]); null when unknown.
108
+ final _InstallLane? _installLane;
109
+
84
110
  NomosTestHarness._(this.bridge, this.workspace, this._transport, this._workdir,
85
111
  this._onDiagnostic, this._sessionNames,
86
112
  {Map<String, String> devicePublics = const {},
87
113
  this.frameworkHash,
88
- Map<String, List<String>> lawHashes = const {}})
114
+ Map<String, List<String>> lawHashes = const {},
115
+ Map<String, String> snapshots = const {},
116
+ _InstallLane? installLane})
89
117
  : _devicePublics = devicePublics,
90
- _lawHashes = lawHashes;
118
+ _lawHashes = Map.of(lawHashes),
119
+ _snapshots = Map.of(snapshots),
120
+ _installLane = installLane;
91
121
 
92
122
  /// The base64 device PUBLIC key a `deviceKeyed` [cluster] session was opened with (null otherwise). Its
93
123
  /// `sha256(base64decode(...))` is the attestor keyHash a reader enrols to trust this source's signed reads.
@@ -233,7 +263,15 @@ class NomosTestHarness {
233
263
  workspace: ((info['lawHashes'] as List?) ?? const [])
234
264
  .map((e) => e.toString())
235
265
  .toList()
236
- });
266
+ },
267
+ snapshots: {workspace: base64Encode(await File(snapshot).readAsBytes())},
268
+ installLane: _InstallLane(
269
+ node: node,
270
+ useInRepoScript: useInRepoScript,
271
+ inRepoScript: resolvedHelper,
272
+ githolonCmd: githolonCmd,
273
+ runtimeDir: runtimeDir,
274
+ clientId: clientId));
237
275
  if (autoTearDown && registerTearDown != null) {
238
276
  registerTearDown(harness.dispose);
239
277
  }
@@ -386,7 +424,15 @@ class NomosTestHarness {
386
424
  bridge, names.first, transport, workdir, onDiagnostic, names,
387
425
  devicePublics: devicePublics,
388
426
  frameworkHash: frameworkHash,
389
- lawHashes: lawHashesByWs);
427
+ lawHashes: lawHashesByWs,
428
+ snapshots: snapshotB64,
429
+ installLane: _InstallLane(
430
+ node: node,
431
+ useInRepoScript: useInRepoScript,
432
+ inRepoScript: resolvedHelper,
433
+ githolonCmd: githolonCmd,
434
+ runtimeDir: runtimeDir,
435
+ clientId: clientId));
390
436
  if (autoTearDown && registerTearDown != null) {
391
437
  registerTearDown(harness.dispose);
392
438
  }
@@ -404,6 +450,92 @@ class NomosTestHarness {
404
450
  }
405
451
  }
406
452
 
453
+ // ── CUSTODY DIAGNOSTIC HELPERS (architecture/session_bound_clients.md ask 9) ──
454
+
455
+ /// The raw multi-workspace plane under [bridge] (the session handle a single-workspace harness
456
+ /// hands out is a view on it).
457
+ NomosBridge get _plane {
458
+ final b = bridge;
459
+ return b is NomosSession ? b.parent : b;
460
+ }
461
+
462
+ /// RESET a workspace's LOCAL custody to the snapshot it was seeded from — the "stale/corrupt
463
+ /// local custody" repair move in tests. Reconnects the session in place (watches on the old
464
+ /// holon are stopped by the runner's re-connect semantics).
465
+ Future<void> resetWorkspaceCustody(String workspace) async {
466
+ final snap = _snapshots[workspace];
467
+ if (snap == null) {
468
+ throw NomosTestHarnessException(
469
+ 'resetWorkspaceCustody("$workspace"): no seed snapshot held for this workspace — '
470
+ 'seeded workspaces: ${_snapshots.keys.toList()}');
471
+ }
472
+ await _plane.connect(
473
+ workspace: workspace,
474
+ session: workspace,
475
+ clientId: '${_installLane?.clientId ?? 'test-runner'}-$workspace',
476
+ restoreFrom: snap,
477
+ allowUnborn: true,
478
+ );
479
+ }
480
+
481
+ /// SEED a NEW workspace on this plane from `build/<name>.deploy.json` law packages — the
482
+ /// mid-test twin of [cluster]'s per-workspace install: runs the SAME offline install lane,
483
+ /// then connects the workspace as its own session. Returns its [NomosSession].
484
+ Future<NomosSession> seedWorkspaceFromDeployJson({
485
+ required String workspace,
486
+ required List<String> deployJsonPaths,
487
+ }) async {
488
+ final lane = _installLane;
489
+ if (lane == null) {
490
+ throw NomosTestHarnessException(
491
+ 'seedWorkspaceFromDeployJson: this harness holds no install-lane config');
492
+ }
493
+ if (deployJsonPaths.isEmpty) {
494
+ throw NomosTestHarnessException(
495
+ 'seedWorkspaceFromDeployJson("$workspace"): pass the build/<name>.deploy.json paths');
496
+ }
497
+ final snap = '${_workdir.path}/$workspace.bin';
498
+ final info = await _runInstall(
499
+ node: lane.node,
500
+ useInRepoScript: lane.useInRepoScript,
501
+ inRepoScript: lane.inRepoScript,
502
+ githolonCmd: lane.githolonCmd,
503
+ deployJsonPaths: deployJsonPaths,
504
+ workspace: workspace,
505
+ snapshot: snap,
506
+ runtimeDir: lane.runtimeDir,
507
+ onDiagnostic: _onDiagnostic);
508
+ final b64 = base64Encode(await File(snap).readAsBytes());
509
+ _snapshots[workspace] = b64;
510
+ _lawHashes[workspace] = ((info['lawHashes'] as List?) ?? const [])
511
+ .map((e) => e.toString())
512
+ .toList();
513
+ await _plane.connect(
514
+ workspace: workspace,
515
+ session: workspace,
516
+ clientId: '${lane.clientId}-$workspace',
517
+ restoreFrom: b64,
518
+ allowUnborn: true,
519
+ );
520
+ if (!_sessionNames.contains(workspace)) _sessionNames.add(workspace);
521
+ return _plane.session(workspace);
522
+ }
523
+
524
+ /// ASSERT a domain key is INSTALLED on a workspace of this plane — the cheap, named diagnostic
525
+ /// for "why does my dispatch refuse". Throws [NomosTestHarnessException] naming the ACTIVE
526
+ /// domain keys when absent. (For the full shape check use
527
+ /// `harness.session(ws).assertCompatible(XClient.interface)`.)
528
+ Future<void> assertDomainInstalled(
529
+ {required String workspace, required String domain}) async {
530
+ final offer = await _plane.session(workspace).interfaceOffer();
531
+ final keys = (offer?.keys.toList() ?? const <String>[])..sort();
532
+ if (!keys.contains(domain)) {
533
+ throw NomosTestHarnessException(
534
+ 'domain "$domain" is NOT installed on workspace "$workspace" — active domains: '
535
+ '${keys.isEmpty ? '(none observable — no on-ledger interface yet, or law truly not deployed)' : keys.join(', ')}');
536
+ }
537
+ }
538
+
407
539
  /// Deterministic, idempotent, total-ordered teardown: stop the runner (SIGTERM → 5s deadline → SIGKILL,
408
540
  /// via [NodeRunnerTransport.dispose]), then delete the temp dir. Safe to call twice; safe to call after a
409
541
  /// failed setup. Nothing left running, nothing left on disk.