@githolon/dsl 0.56.0 → 0.57.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.
@@ -1,6 +1,11 @@
1
1
  /// The HEADLESS transport library — `dart:io` only, so it lives outside the main
2
2
  /// `package:nomos_client/nomos_client.dart` barrel (which stays web-safe).
3
3
  ///
4
+ /// MULTI-WORKSPACE BY DESIGN. A [NomosBridge] is a device's LOCAL HOLARCHY PLANE: one bridge / one
5
+ /// transport, and MANY live workspaces on it (the home, each estate, each catalogue, locally-born children).
6
+ /// Each workspace is a LOCAL githolon — cloud sync is an optional per-workspace posture, not a prerequisite.
7
+ /// Open workspaces and hold their [NomosSession] handles:
8
+ ///
4
9
  /// ```dart
5
10
  /// import 'package:nomos_client/nomos_client.dart';
6
11
  /// import 'package:nomos_client/nomos_headless.dart';
@@ -8,9 +13,19 @@
8
13
  /// final transport = await NodeRunnerTransport.start(resolveFrom: 'path/to/app');
9
14
  /// final bridge = NomosBridge(transport);
10
15
  /// await bridge.ready;
11
- /// await bridge.connect(cloud: ..., workspace: ..., clientId: 'test-runner');
16
+ /// // open each workspace on the plane (local-only here — no `cloud` needed):
17
+ /// await bridge.connect(workspace: 'home', session: 'home', restoreFrom: homeBytes);
18
+ /// await bridge.connect(workspace: 'estate-42', session: 'estate-42', restoreFrom: estateBytes);
19
+ /// // bind a generated client to a workspace via its session handle:
20
+ /// final estate = EstateClient(bridge.session('estate-42'));
21
+ /// final plane = await bridge.listWorkspaces(); // enumerate + sync posture
12
22
  /// ```
13
23
  ///
24
+ /// The single-workspace `bridge.connect(...)` / `bridge.query(...)` methods are sugar over an inferred
25
+ /// default session (unchanged). For TESTS, [NomosTestHarness] is a thin wrapper: `.ephemeral(...)` installs
26
+ /// one law + opens one session; `.cluster(...)` installs N laws + opens N sessions on ONE plane — it adds
27
+ /// no session logic of its own, it just drives the same production [NomosBridge.session] surface.
28
+ ///
14
29
  /// The runner child process is the SAME `@githolon/client` every peer runs; the
15
30
  /// generated Dart clients drive it through [NomosBridge] exactly as in the app.
16
31
  library;
@@ -34,7 +34,7 @@ import 'dart:convert';
34
34
  import 'dart:io';
35
35
 
36
36
  import 'node_runner_transport.dart';
37
- import 'webview_bridge.dart' show NomosBridge;
37
+ import 'webview_bridge.dart' show NomosBridge, NomosSession;
38
38
 
39
39
  class NomosTestHarnessException implements Exception {
40
40
  final String message;
@@ -55,8 +55,50 @@ class NomosTestHarness {
55
55
  final void Function(String line)? _onDiagnostic;
56
56
  bool _disposed = false;
57
57
 
58
+ /// The workspace names this harness connected. For [ephemeral] this is the one [workspace]; for [cluster]
59
+ /// it is every workspace named in the call. Use [session] to get its handle.
60
+ final List<String> _sessionNames;
61
+
62
+ /// workspace → the base64 device PUBLIC key it was opened with (only for [cluster]'s `deviceKeyed` set).
63
+ final Map<String, String> _devicePublics;
64
+
65
+ /// The nomos framework controller's content hash from the offline install — the `frameworkHash` a
66
+ /// `.births()` genesis installs (not derivable client-side). Same for every workspace on the plane.
67
+ final String? frameworkHash;
68
+
69
+ /// workspace → the tenant law hashes installed into it (in deploy order); `lawHashes[ws].first` is that
70
+ /// package's domainHash — the `lawHash` a `.births()` recipe names for the child's law install.
71
+ final Map<String, List<String>> _lawHashes;
72
+
73
+ /// The tenant law hashes installed into [workspace] (deploy order); empty if unknown.
74
+ List<String> lawHashes(String workspace) =>
75
+ List.unmodifiable(_lawHashes[workspace] ?? const []);
76
+
58
77
  NomosTestHarness._(this.bridge, this.workspace, this._transport, this._workdir,
59
- this._onDiagnostic);
78
+ this._onDiagnostic, this._sessionNames,
79
+ {Map<String, String> devicePublics = const {},
80
+ this.frameworkHash,
81
+ Map<String, List<String>> lawHashes = const {}})
82
+ : _devicePublics = devicePublics,
83
+ _lawHashes = lawHashes;
84
+
85
+ /// The base64 device PUBLIC key a `deviceKeyed` [cluster] session was opened with (null otherwise). Its
86
+ /// `sha256(base64decode(...))` is the attestor keyHash a reader enrols to trust this source's signed reads.
87
+ String? devicePublicKey(String workspace) => _devicePublics[workspace];
88
+
89
+ /// The workspace names on this device's local plane (one for [ephemeral], N for [cluster]).
90
+ List<String> get sessions => List.unmodifiable(_sessionNames);
91
+
92
+ /// A [NomosSession] handle pinned to one connected [workspace] — hand it to a generated typed client
93
+ /// (`EstateClient(harness.session('estate'))`). A THIN delegate to the production [NomosBridge.session]:
94
+ /// the harness owns no session logic, it just installs the laws + opens the sessions on the one plane.
95
+ NomosSession session(String workspace) {
96
+ if (!_sessionNames.contains(workspace)) {
97
+ throw NomosTestHarnessException(
98
+ 'no session "$workspace" on this harness — connected sessions: $_sessionNames');
99
+ }
100
+ return bridge.session(workspace);
101
+ }
60
102
 
61
103
  /// Install [deployJsonPaths] into a fresh LOCAL workspace and boot a bridge over it — NO cloud contact.
62
104
  ///
@@ -114,7 +156,7 @@ class NomosTestHarness {
114
156
  NodeRunnerTransport? transport;
115
157
  try {
116
158
  final snapshot = '${workdir.path}/snapshot.bin';
117
- await _runInstall(
159
+ final info = await _runInstall(
118
160
  node: node,
119
161
  useInRepoScript: useInRepoScript,
120
162
  inRepoScript: resolvedHelper,
@@ -147,8 +189,14 @@ class NomosTestHarness {
147
189
  clientId: clientId,
148
190
  allowUnborn: true,
149
191
  );
150
- final harness =
151
- NomosTestHarness._(bridge, workspace, transport, workdir, onDiagnostic);
192
+ final harness = NomosTestHarness._(
193
+ bridge, workspace, transport, workdir, onDiagnostic, [workspace],
194
+ frameworkHash: info['frameworkHash']?.toString(),
195
+ lawHashes: {
196
+ workspace: ((info['lawHashes'] as List?) ?? const [])
197
+ .map((e) => e.toString())
198
+ .toList()
199
+ });
152
200
  if (autoTearDown && registerTearDown != null) {
153
201
  registerTearDown(harness.dispose);
154
202
  }
@@ -167,6 +215,147 @@ class NomosTestHarness {
167
215
  }
168
216
  }
169
217
 
218
+ /// A COLOCATED TEST CLUSTER — install MANY workspaces' law offline and connect one SESSION per workspace on
219
+ /// ONE engine plane (one runner, one transport, one [bridge]), so a cross-workspace scenario (attested
220
+ /// reads, foreign-source imports) runs in-process with ZERO network. Each workspace's law is installed via
221
+ /// the SAME offline lane [ephemeral] uses (one snapshot per workspace), then `connect`ed as its own session;
222
+ /// the per-connect restore snapshot loads it into the shared plane.
223
+ ///
224
+ /// [workspaces] — `{ workspaceName: [deployJsonPaths] }` (installed in order per workspace). Get a
225
+ /// session-scoped view with [session] and bind it to a generated client: `EstateClient(harness.session(
226
+ /// 'estate'))`. All other params mirror [ephemeral]. [clientId] is suffixed per session.
227
+ /// [deviceKeyed] names workspaces to open WITH a fresh in-kernel device key as their signing `authorSecret`
228
+ /// (the warrant default — every write is signed, replay-provably). Required for a source of ATTESTED READS:
229
+ /// the colocation resolver serves+signs a foreign read with the source session's device key, so a reader
230
+ /// enrols `sha256(that device public key)`. Get the public via [devicePublicKey].
231
+ static Future<NomosTestHarness> cluster({
232
+ required Map<String, List<String>> workspaces,
233
+ Set<String> deviceKeyed = const {},
234
+ String clientId = 'test-runner',
235
+ String? runtimeDir,
236
+ String? nodeEntry,
237
+ String? installHelper,
238
+ String? resolveFrom,
239
+ String node = 'node',
240
+ bool autoTearDown = true,
241
+ void Function(Future<void> Function())? registerTearDown,
242
+ void Function(String line)? onDiagnostic,
243
+ Duration readyTimeout = const Duration(seconds: 30),
244
+ }) async {
245
+ if (workspaces.isEmpty) {
246
+ throw NomosTestHarnessException(
247
+ 'no workspaces — pass { workspaceName: [build/<name>.deploy.json] } for each colocated workspace');
248
+ }
249
+ for (final e in workspaces.entries) {
250
+ if (e.value.isEmpty) {
251
+ throw NomosTestHarnessException(
252
+ 'workspace "${e.key}" has no deployJsonPaths — pass its build/<name>.deploy.json');
253
+ }
254
+ }
255
+ final entry = nodeEntry ??
256
+ await _resolveEntry(node, resolveFrom ?? Directory.current.path);
257
+ final resolvedHelper =
258
+ installHelper ?? '${File(entry).parent.path}/offline-install.mjs';
259
+ final useInRepoScript =
260
+ resolvedHelper.endsWith('.mjs') && File(resolvedHelper).existsSync();
261
+ final githolonCmd = installHelper == null ? 'npx githolon' : resolvedHelper;
262
+
263
+ final workdir = await Directory.systemTemp.createTemp('nomos-cluster-');
264
+ NodeRunnerTransport? transport;
265
+ try {
266
+ // 1. install each workspace's law offline → one snapshot per workspace (colocated, still separate law).
267
+ final snapshots = <String, String>{};
268
+ final lawHashesByWs = <String, List<String>>{};
269
+ String? frameworkHash;
270
+ for (final e in workspaces.entries) {
271
+ final snap = '${workdir.path}/${e.key}.bin';
272
+ final info = await _runInstall(
273
+ node: node,
274
+ useInRepoScript: useInRepoScript,
275
+ inRepoScript: resolvedHelper,
276
+ githolonCmd: githolonCmd,
277
+ deployJsonPaths: e.value,
278
+ workspace: e.key,
279
+ snapshot: snap,
280
+ runtimeDir: runtimeDir,
281
+ onDiagnostic: onDiagnostic);
282
+ snapshots[e.key] = snap;
283
+ lawHashesByWs[e.key] = ((info['lawHashes'] as List?) ?? const [])
284
+ .map((x) => x.toString())
285
+ .toList();
286
+ frameworkHash ??= info['frameworkHash']?.toString();
287
+ }
288
+
289
+ // 2. ONE runner / ONE bridge for the whole plane — no env restore default; each connect brings its own.
290
+ transport = await NodeRunnerTransport.start(
291
+ entry: entry,
292
+ resolveFrom: resolveFrom,
293
+ node: node,
294
+ environment: {
295
+ 'NOMOS_OFFLINE': '1',
296
+ if (runtimeDir != null) 'NOMOS_OFFLINE_RUNTIME': runtimeDir,
297
+ },
298
+ onDiagnostic: onDiagnostic,
299
+ );
300
+ final bridge = NomosBridge(transport);
301
+ await bridge.ready.timeout(readyTimeout,
302
+ onTimeout: () => throw NomosTestHarnessException(
303
+ 'the offline runner never signalled ready within $readyTimeout '
304
+ '(is `node` on PATH and ~/.holon/runtime warm?)'));
305
+
306
+ // 3. connect one SESSION per workspace, each restoring its OWN snapshot — LOCAL-ONLY (no cloud;
307
+ // these are local githolons on the device plane, offline by nature).
308
+ final snapshotB64 = <String, String>{};
309
+ for (final e in workspaces.entries) {
310
+ snapshotB64[e.key] = base64Encode(await File(snapshots[e.key]!).readAsBytes());
311
+ await bridge.connect(
312
+ workspace: e.key,
313
+ session: e.key,
314
+ clientId: '$clientId-${e.key}',
315
+ restoreFrom: snapshotB64[e.key],
316
+ allowUnborn: true,
317
+ );
318
+ }
319
+
320
+ // 3b. DEVICE-KEY the named sessions: mint a fresh in-kernel key on each (now that it is mounted) and
321
+ // RE-OPEN it with that key as its signing authorSecret — the resolver's attested-read signing key.
322
+ final devicePublics = <String, String>{};
323
+ for (final ws in deviceKeyed) {
324
+ final kp = await bridge.session(ws).authorKeygen();
325
+ devicePublics[ws] = (kp['public'] ?? '').toString();
326
+ await bridge.connect(
327
+ workspace: ws,
328
+ session: ws,
329
+ clientId: '$clientId-$ws',
330
+ restoreFrom: snapshotB64[ws],
331
+ authorSecret: (kp['secret'] ?? '').toString(),
332
+ allowUnborn: true,
333
+ );
334
+ }
335
+
336
+ final names = workspaces.keys.toList();
337
+ final harness = NomosTestHarness._(
338
+ bridge, names.first, transport, workdir, onDiagnostic, names,
339
+ devicePublics: devicePublics,
340
+ frameworkHash: frameworkHash,
341
+ lawHashes: lawHashesByWs);
342
+ if (autoTearDown && registerTearDown != null) {
343
+ registerTearDown(harness.dispose);
344
+ }
345
+ return harness;
346
+ } catch (_) {
347
+ if (transport != null) {
348
+ try {
349
+ await transport.dispose();
350
+ } catch (_) {}
351
+ }
352
+ try {
353
+ await workdir.delete(recursive: true);
354
+ } catch (_) {}
355
+ rethrow;
356
+ }
357
+ }
358
+
170
359
  /// Deterministic, idempotent, total-ordered teardown: stop the runner (SIGTERM → 5s deadline → SIGKILL,
171
360
  /// via [NodeRunnerTransport.dispose]), then delete the temp dir. Safe to call twice; safe to call after a
172
361
  /// failed setup. Nothing left running, nothing left on disk.
@@ -196,7 +385,7 @@ class NomosTestHarness {
196
385
 
197
386
  // ── offline install (a short-lived node child; produces the restore snapshot) ──
198
387
 
199
- static Future<void> _runInstall({
388
+ static Future<Map<String, Object?>> _runInstall({
200
389
  required String node,
201
390
  required bool useInRepoScript,
202
391
  required String inRepoScript,
@@ -255,6 +444,9 @@ class NomosTestHarness {
255
444
  throw NomosTestHarnessException(
256
445
  'offline law install produced no snapshot (stdout: ${out.isEmpty ? "(empty)" : out})');
257
446
  }
447
+ // { ok, snapshot, workspace, lawHashes, frameworkHash } — the caller keeps lawHashes/frameworkHash so a
448
+ // `.births()` directive can be driven (its genesis installs the framework + law by hash).
449
+ return decoded;
258
450
  }
259
451
 
260
452
  static Future<String> _resolveEntry(String node, String from) async {
@@ -765,11 +765,26 @@ class NomosEncryption {
765
765
  openedAt: openedAt);
766
766
  }
767
767
 
768
- /// The result of [NomosBridge.offerCreates]: the new local [head] plus every aggregate the plan minted.
768
+ /// The result of [NomosBridge.offerCreates]: the new local [head], every aggregate the plan minted, and —
769
+ /// for a `.births()` directive — the child workspace(s) the offer brought into being on this local plane.
769
770
  class NomosOfferOutcome {
770
771
  final String head;
771
772
  final List<NomosCreated> created;
772
- const NomosOfferOutcome({required this.head, required this.created});
773
+
774
+ /// The born child workspace name(s), when the offer ran a `.births()` directive (else empty). Each is a
775
+ /// LOCAL workspace on this device's plane; `bridge.session(name)` returns its handle. (Opening a born
776
+ /// child as its own live session across a colocated cluster is gated on the shared engine plane, #42.)
777
+ final List<String> born;
778
+
779
+ /// The raw per-child birth outcomes the holon reported (`{ workspace, genesisHead, … }`), when present.
780
+ final List<Map<String, Object?>> birthOutcomes;
781
+
782
+ const NomosOfferOutcome({
783
+ required this.head,
784
+ required this.created,
785
+ this.born = const [],
786
+ this.birthOutcomes = const [],
787
+ });
773
788
  }
774
789
 
775
790
  /// THE BRIDGE. Construct over a [BridgeTransport], `await ready`, then `connect(...)` and drive the holon.
@@ -805,6 +820,11 @@ class NomosBridge {
805
820
  SyncStatus _status = const SyncStatus();
806
821
  Timer? _metricsTimer;
807
822
  final Completer<void> _ready = Completer<void>();
823
+ // THE LOCAL PLANE'S WORKSPACES. A NomosBridge is a device's local holarchy plane; each connected
824
+ // workspace is a live session on it. [_sessionNames] tracks what [connect] has opened; [_sessionViews]
825
+ // caches the per-workspace [NomosSession] handle so `bridge.session(ws)` is stable + cheap.
826
+ final Set<String> _sessionNames = {};
827
+ final Map<String, NomosSession> _sessionViews = {};
808
828
  int _seq = 0;
809
829
  final String _traceId = _newTraceId();
810
830
  late final StreamSubscription<Map<String, Object?>> _sub;
@@ -813,6 +833,10 @@ class NomosBridge {
813
833
  _sub = _transport.messages.listen(_onMessage);
814
834
  }
815
835
 
836
+ /// A SESSION VIEW's super-constructor: shares the parent's [_transport] but does NOT subscribe (the parent
837
+ /// owns the one message loop + all correlation). Only [NomosSession] uses it.
838
+ NomosBridge._view(this._transport);
839
+
816
840
  static Random _traceRandom() {
817
841
  try {
818
842
  return Random.secure();
@@ -941,6 +965,7 @@ class NomosBridge {
941
965
 
942
966
  /// Send one request and await the typed reply (the runner answers ok/error). The ONLY low-level call —
943
967
  /// every typed method below is a thin wrapper, so the request/reply contract lives in exactly one place.
968
+ /// A [NomosSession] overrides this to stamp `args.session` and route through the parent's ONE transport.
944
969
  Future<Object?> _call(String op, [Map<String, Object?> args = const {}]) {
945
970
  final id = ++_seq;
946
971
  final completer = Completer<Object?>();
@@ -971,19 +996,50 @@ class NomosBridge {
971
996
 
972
997
  // ── the runtime-aware surface (the boundary the generated Dart client wraps) ──
973
998
 
974
- /// Open the user/estate workspace: pull its ledger, boot the local holon under the installed law.
999
+ /// THE PRIMARY MULTI-WORKSPACE ENTRY. Return a [NomosSession] handle pinned to [workspace] — the object a
1000
+ /// Flutter app holds one of per live workspace (home, each estate, each catalogue, …). The handle is
1001
+ /// stable + cheap (cached) and can be created BEFORE the workspace is connected (lazy): construct it, hand
1002
+ /// it to a generated client (`EstateClient(bridge.session('estate'))`), and `connect` the workspace when
1003
+ /// ready — reads/writes flow once it is open. A device typically holds many: `bridge.session('home')`,
1004
+ /// `bridge.session('estate-42')`, … all on ONE bridge/transport (this device's local plane).
1005
+ NomosSession session(String workspace) =>
1006
+ _sessionViews.putIfAbsent(workspace, () => NomosSession(this, workspace));
1007
+
1008
+ /// The workspace names [connect] has opened on this plane (the live sessions).
1009
+ List<String> get connectedSessions => List.unmodifiable(_sessionNames);
1010
+
1011
+ /// ENUMERATE THE LOCAL PLANE — every workspace open on this device + its sync posture:
1012
+ /// `[{ session, workspace, mounted, syncPosture: "local-only" | "cloud-bound" }]`. A local-only workspace
1013
+ /// has no cloud counterpart (it was opened with no `cloud`); a cloud-bound one syncs per its own posture.
1014
+ Future<List<Map<String, Object?>>> listWorkspaces() async =>
1015
+ (((_asMap(await _call('listWorkspaces'))['workspaces']) as List?) ?? const [])
1016
+ .map((e) => (e as Map).cast<String, Object?>())
1017
+ .toList();
1018
+
1019
+ /// Open a workspace on this plane: boot its LOCAL githolon under the installed law (from [restoreFrom]
1020
+ /// custody, or a cloud pull when [cloud] is given). This is the sugar the single-workspace path uses; a
1021
+ /// multi-workspace app names a [session] per workspace and holds their [session] handles.
975
1022
  Future<void> connect(
976
- {required String cloud,
1023
+ {String? cloud,
977
1024
  required String workspace,
978
1025
  String? clientId,
979
1026
  String? authToken,
980
1027
  String? authorSecret,
981
1028
  String? restoreFrom,
982
1029
  bool allowUnborn = false,
983
- bool recover = false}) async {
1030
+ bool recover = false,
1031
+ String? session}) async {
1032
+ final ws = session ?? workspace;
1033
+ _sessionNames.add(ws);
984
1034
  final r = _asMap(await _call('connect', {
985
- 'cloud': cloud,
1035
+ // [cloud] is OPTIONAL — omit it to open a LOCAL-ONLY workspace (a local githolon with no cloud
1036
+ // counterpart; the runner uses a `local://` sentinel and sync is a no-op). Pass it to make the
1037
+ // workspace cloud-bound (per-workspace sync posture). A device's plane freely mixes both.
1038
+ if (cloud != null) 'cloud': cloud,
986
1039
  'workspace': workspace,
1040
+ // MULTI-WORKSPACE: name the [session] this connect opens on this device's local plane (default =
1041
+ // [workspace]). Omitted on the single-workspace sugar path (byte-identical wire to before).
1042
+ if (session != null) 'session': session,
987
1043
  if (clientId != null) 'clientId': clientId,
988
1044
  if (authToken != null) 'authToken': authToken,
989
1045
  // The device signing secret (base64, from [authorKeygen]) — the warrant default for every offer.
@@ -1015,12 +1071,20 @@ class NomosBridge {
1015
1071
  /// `encodeIntentOfferBytes`, with `security.actor` included iff [actor] is set). The generated Dart client
1016
1072
  /// calls THIS — it never hand-builds offer bytes, so there is exactly ONE copy of the wire format. Returns
1017
1073
  /// the new local head; durable on [sync]. ([offer] with opaque bytes stays for back-compat carriers.)
1074
+ /// [attestations] hand-injects the signed foreign-read envelopes a captured-read directive consumes
1075
+ /// (each `{queryId, result, attestation}` — the shape the kernel's attested-read gate re-derives against
1076
+ /// its trust policy); [force] carries the deploy/evolve force flag through. Both are OMITTED from the wire
1077
+ /// when absent, so a plain offer is byte-identical to before. [session] pins the colocated workspace in a
1078
+ /// cluster harness (absent ⇒ the runner's sole-session default).
1018
1079
  Future<String> offerDirective({
1019
1080
  required String domain,
1020
1081
  required String directiveId,
1021
1082
  required Object? payload,
1022
1083
  String? actor,
1023
1084
  String? domainHash,
1085
+ List<Map<String, Object?>>? attestations,
1086
+ bool? force,
1087
+ String? session,
1024
1088
  }) async {
1025
1089
  final r = _asMap(await _call('offerDirective', {
1026
1090
  'domain': domain,
@@ -1028,6 +1092,9 @@ class NomosBridge {
1028
1092
  'payload': payload,
1029
1093
  if (actor != null) 'actor': actor,
1030
1094
  if (domainHash != null) 'domainHash': domainHash,
1095
+ if (attestations != null) 'attestations': attestations,
1096
+ if (force != null) 'force': force,
1097
+ if (session != null) 'session': session,
1031
1098
  }));
1032
1099
  return r['head'].toString();
1033
1100
  }
@@ -1042,6 +1109,9 @@ class NomosBridge {
1042
1109
  required Object? payload,
1043
1110
  String? actor,
1044
1111
  String? domainHash,
1112
+ List<Map<String, Object?>>? attestations,
1113
+ bool? force,
1114
+ String? session,
1045
1115
  }) async {
1046
1116
  final r = _asMap(await _call('offerDirective', {
1047
1117
  'domain': domain,
@@ -1049,7 +1119,26 @@ class NomosBridge {
1049
1119
  'payload': payload,
1050
1120
  if (actor != null) 'actor': actor,
1051
1121
  if (domainHash != null) 'domainHash': domainHash,
1122
+ if (attestations != null) 'attestations': attestations,
1123
+ if (force != null) 'force': force,
1124
+ if (session != null) 'session': session,
1052
1125
  }));
1126
+ final births = ((r['birthOutcomes'] as List?) ?? const [])
1127
+ .whereType<Map>()
1128
+ .map((m) => m.cast<String, Object?>())
1129
+ .toList();
1130
+ // `born` elements may be plain workspace-name strings OR `{workspace,…}` objects (both shapes ship);
1131
+ // fall back to the birth outcomes' workspace names when no bare `born` list was sent.
1132
+ final bornRaw = (r['born'] as List?);
1133
+ final bornNames = <String>[
1134
+ if (bornRaw != null)
1135
+ ...bornRaw.map((e) =>
1136
+ (e is Map ? (e['workspace'] ?? '') : e).toString())
1137
+ else
1138
+ ...births
1139
+ .map((m) => (m['workspace'] ?? '').toString())
1140
+ .where((s) => s.isNotEmpty),
1141
+ ]..removeWhere((s) => s.isEmpty);
1053
1142
  return NomosOfferOutcome(
1054
1143
  head: r['head'].toString(),
1055
1144
  created: ((r['created'] as List?) ?? const [])
@@ -1058,6 +1147,8 @@ class NomosBridge {
1058
1147
  type: (m['type'] ?? '').toString(),
1059
1148
  id: (m['id'] ?? '').toString()))
1060
1149
  .toList(),
1150
+ born: bornNames,
1151
+ birthOutcomes: births,
1061
1152
  );
1062
1153
  }
1063
1154
 
@@ -1082,13 +1173,47 @@ class NomosBridge {
1082
1173
  required Object? payload,
1083
1174
  String? actor,
1084
1175
  String? domainHash,
1176
+ List<Map<String, Object?>>? attestations,
1177
+ bool? force,
1178
+ String? session,
1085
1179
  }) =>
1086
1180
  offerDirective(
1087
1181
  domain: domain,
1088
1182
  directiveId: directiveId,
1089
1183
  payload: payload,
1090
1184
  actor: actor,
1091
- domainHash: domainHash);
1185
+ domainHash: domainHash,
1186
+ attestations: attestations,
1187
+ force: force,
1188
+ session: session);
1189
+
1190
+ /// ATTESTED READ — serve + SIGN a declared query over a COLOCATED source session's certified fold (the
1191
+ /// dynamic-source trust lane; architecture/dynamic_source_attested_reads.md). In a [NomosTestHarness.cluster]
1192
+ /// (one engine plane, many sessions) this fulfils the read IN-PROCESS from the [from] session and returns
1193
+ /// the signed envelope `{ ok, rows, attestation:{ source_genesis, … } }` — hand it back as an
1194
+ /// [offerDirective] `attestations` element, or read `attestation.source_genesis` to enrol the source in a
1195
+ /// reader's allow-list. [args] are the query params. [session] is the caller/reader session (defaults to
1196
+ /// this view's bound session).
1197
+ /// [attestorSecret] is the source's base64 read-attestor secret (from [authorKeygen]); when omitted the
1198
+ /// source session's connect-time `authorSecret` signs. [nowMs] stamps the freshness clock (defaults to the
1199
+ /// source's own clock). The returned `entry` is `{queryId, result, attestation:{source_genesis, public_key,
1200
+ /// …}}` — pass the whole `entry` as one [offerDirective] `attestations` element.
1201
+ Future<Map<String, Object?>> attestedRead({
1202
+ required String from,
1203
+ required String queryId,
1204
+ Map<String, Object?> args = const {},
1205
+ String? attestorSecret,
1206
+ int? nowMs,
1207
+ String? session,
1208
+ }) async =>
1209
+ _asMap(await _call('attestedRead', {
1210
+ 'from': from,
1211
+ 'queryId': queryId,
1212
+ 'args': args,
1213
+ if (attestorSecret != null) 'attestorSecret': attestorSecret,
1214
+ if (nowMs != null) 'nowMs': nowMs,
1215
+ if (session != null) 'session': session,
1216
+ }));
1092
1217
 
1093
1218
  /// MAKE A WORKSPACE — the one-call tenant path. A child workspace under a platform/[parent] is a SIGNED
1094
1219
  /// control-plane offer; the holon seals `createWorkspace` (or [directiveId] `birthChild`) with the
@@ -1265,19 +1390,22 @@ class NomosBridge {
1265
1390
  Future<List<Map<String, dynamic>>> query(String queryId,
1266
1391
  [Map<String, Object?> params = const {},
1267
1392
  List<NomosScopeKey>? keys,
1268
- String? principal]) async =>
1393
+ String? principal,
1394
+ String? session]) async =>
1269
1395
  _asRows(await _call('query', {
1270
1396
  'queryId': queryId,
1271
1397
  'params': params,
1272
1398
  ...?_readAuthArgs(keys, principal),
1399
+ if (session != null) 'session': session,
1273
1400
  }));
1274
1401
 
1275
1402
  /// A single aggregate by id (the by-id read). `keys`/`principal`: see [query].
1276
1403
  Future<List<Map<String, dynamic>>> queryById(String id,
1277
- [List<NomosScopeKey>? keys, String? principal]) async =>
1404
+ [List<NomosScopeKey>? keys, String? principal, String? session]) async =>
1278
1405
  _asRows(await _call('queryById', {
1279
1406
  'id': id,
1280
1407
  ...?_readAuthArgs(keys, principal),
1408
+ if (session != null) 'session': session,
1281
1409
  }));
1282
1410
 
1283
1411
  /// The read auth sidecar (private reads + E2E): `keys`/`principal` as top-level call args, or null when
@@ -1334,23 +1462,36 @@ class NomosBridge {
1334
1462
  /// THE SPATIAL READ: rows of the declared spatial index's of-type whose geometry
1335
1463
  /// intersects [bbox] — an R*Tree probe on the local projection, same row shape as [query].
1336
1464
  Future<List<Map<String, dynamic>>> spatialWithin(
1337
- String spatialId, NomosBbox bbox) async =>
1338
- _asRows(await _call(
1339
- 'spatialWithin', {'spatialId': spatialId, 'bbox': bbox.toJson()}));
1465
+ String spatialId, NomosBbox bbox,
1466
+ [String? session]) async =>
1467
+ _asRows(await _call('spatialWithin', {
1468
+ 'spatialId': spatialId,
1469
+ 'bbox': bbox.toJson(),
1470
+ if (session != null) 'session': session,
1471
+ }));
1340
1472
 
1341
1473
  /// A maintained O(1) count / sum.
1342
- Future<int> count(String countId, [String group = '']) async =>
1343
- ((_asMap(await _call('count', {'countId': countId, 'group': group}))[
1344
- 'count']) as num)
1474
+ Future<int> count(String countId, [String group = '', String? session]) async =>
1475
+ ((_asMap(await _call('count', {
1476
+ 'countId': countId,
1477
+ 'group': group,
1478
+ if (session != null) 'session': session,
1479
+ }))['count']) as num)
1345
1480
  .toInt();
1346
- Future<int> sum(String sumId, [String group = '']) async =>
1347
- ((_asMap(await _call('sum', {'sumId': sumId, 'group': group}))['sum'])
1348
- as num)
1481
+ Future<int> sum(String sumId, [String group = '', String? session]) async =>
1482
+ ((_asMap(await _call('sum', {
1483
+ 'sumId': sumId,
1484
+ 'group': group,
1485
+ if (session != null) 'session': session,
1486
+ }))['sum']) as num)
1349
1487
  .toInt();
1350
1488
 
1351
1489
  /// Push local writes → the session branch → cloud admission, and pull canonical main back.
1352
- Future<Map<String, Object?>> sync({bool admit = true}) async =>
1353
- _asMap(await _call('sync', {'admit': admit}));
1490
+ Future<Map<String, Object?>> sync({bool admit = true, String? session}) async =>
1491
+ _asMap(await _call('sync', {
1492
+ 'admit': admit,
1493
+ if (session != null) 'session': session,
1494
+ }));
1354
1495
 
1355
1496
  /// PERSIST: a base64 snapshot of the whole local holon (the bare nomos.git incl. un-synced writes + the
1356
1497
  /// DLQ). Store it on disk and pass it back as `connect(restoreFrom:)` next launch — the app then boots from
@@ -1469,8 +1610,10 @@ class NomosBridge {
1469
1610
  }
1470
1611
 
1471
1612
  /// Ledger size (commits) + branch-vs-main (ahead) + projection bytes — the dashboard's slow state.
1472
- Future<Map<String, Object?>> metrics() async =>
1473
- _asMap(await _call('metrics'));
1613
+ Future<Map<String, Object?>> metrics([String? session]) async =>
1614
+ _asMap(await _call('metrics', {
1615
+ if (session != null) 'session': session,
1616
+ }));
1474
1617
 
1475
1618
  /// TIER-2 SYNC SIZE — how many bytes this workspace would transfer to catch up to main, WITHOUT downloading
1476
1619
  /// them (a HEAD on the serving pack; the host answers Content-Length only, cached ~15s). Sum across your open
@@ -1486,14 +1629,15 @@ class NomosBridge {
1486
1629
  /// Reactive local read: a stream of row-sets that re-emits on every local change. Cancelling the
1487
1630
  /// subscription tells the runner to drop the watch.
1488
1631
  Stream<List<dynamic>> watch(String queryId,
1489
- [Map<String, Object?> params = const {}]) =>
1490
- _watchStream(kind: 'query', queryId: queryId, params: params);
1491
- Stream<List<dynamic>> watchById(String id) =>
1492
- _watchStream(kind: 'byId', id: id);
1632
+ [Map<String, Object?> params = const {}, String? session]) =>
1633
+ _watchStream(kind: 'query', queryId: queryId, params: params, session: session);
1634
+ Stream<List<dynamic>> watchById(String id, [String? session]) =>
1635
+ _watchStream(kind: 'byId', id: id, session: session);
1493
1636
 
1494
1637
  /// Reactive SPATIAL read: re-emits the R*Tree probe's rows whenever they change locally.
1495
- Stream<List<dynamic>> watchSpatial(String spatialId, NomosBbox bbox) =>
1496
- _watchStream(kind: 'spatial', spatialId: spatialId, bbox: bbox);
1638
+ Stream<List<dynamic>> watchSpatial(String spatialId, NomosBbox bbox,
1639
+ [String? session]) =>
1640
+ _watchStream(kind: 'spatial', spatialId: spatialId, bbox: bbox, session: session);
1497
1641
 
1498
1642
  /// SHAPE COMPATIBILITY (#72): one-shot verdict for this client's structural [requirement]
1499
1643
  /// (its baked `…InterfaceJson`, decoded) against the workspace's ON-LEDGER offer (auto-sourced).
@@ -1568,7 +1712,8 @@ class NomosBridge {
1568
1712
  Map<String, Object?>? params,
1569
1713
  String? id,
1570
1714
  String? spatialId,
1571
- NomosBbox? bbox}) {
1715
+ NomosBbox? bbox,
1716
+ String? session}) {
1572
1717
  final subId = 'w${++_seq}';
1573
1718
  late final StreamController<List<dynamic>> ctrl;
1574
1719
  ctrl = StreamController<List<dynamic>>(
@@ -1580,6 +1725,7 @@ class NomosBridge {
1580
1725
  if (id != null) 'id': id,
1581
1726
  if (spatialId != null) 'spatialId': spatialId,
1582
1727
  if (bbox != null) 'bbox': bbox.toJson(),
1728
+ if (session != null) 'session': session,
1583
1729
  }),
1584
1730
  onCancel: () async {
1585
1731
  _watches.remove(subId);
@@ -1626,3 +1772,87 @@ class NomosBridge {
1626
1772
  if (!_syncStatusCtrl.isClosed) await _syncStatusCtrl.close();
1627
1773
  }
1628
1774
  }
1775
+
1776
+ /// A LIVE WORKSPACE on a device's local plane — the FIRST-CLASS multi-workspace object. Everything about
1777
+ /// Nomos is multi-workspace: a running app holds ONE [NomosBridge] (this device's local holarchy plane) and
1778
+ /// MANY [NomosSession]s on it — the user's home, each estate, each catalogue, any locally-born child — a
1779
+ /// dozen or hundreds, interacting offline. Get one with `bridge.session('estate')` and hand it to a generated
1780
+ /// typed client: `EstateClient(bridge.session('estate'))`.
1781
+ ///
1782
+ /// A session is a LOCAL githolon (the real byte-identical kernel + ledger + gate), NOT a cloud connection —
1783
+ /// cloud sync is an OPTIONAL per-workspace posture (a workspace opened with no `cloud` is local-only and
1784
+ /// never converges upstream). Its authority and truth are LOCAL; a hosted remote is replaceable custody.
1785
+ ///
1786
+ /// Implementation: it EXTENDS [NomosBridge] (so it IS a `NomosBridge` wherever a generated client expects
1787
+ /// `final NomosBridge bridge;` — ZERO codegen change) but holds NO state of its own: it shares the [parent]
1788
+ /// plane's ONE transport, correlation, and watch streams, and overrides the sole low-level [_call] to stamp
1789
+ /// `args.session` and route through the parent — so request/reply, watches, and telemetry all resolve on the
1790
+ /// one message loop with no aliasing. The single-workspace [NomosBridge] methods are documented sugar over an
1791
+ /// inferred default session; a multi-workspace app works through session handles.
1792
+ class NomosSession extends NomosBridge {
1793
+ /// The device's local plane every op routes through (the shared [NomosBridge]).
1794
+ final NomosBridge parent;
1795
+
1796
+ /// The workspace name this session is pinned to.
1797
+ final String workspace;
1798
+
1799
+ NomosSession(this.parent, this.workspace) : super._view(parent._transport);
1800
+
1801
+ /// Sibling workspaces resolve on the shared plane — delegate to the parent's registry (never nest a
1802
+ /// session inside a session).
1803
+ @override
1804
+ NomosSession session(String workspace) => parent.session(workspace);
1805
+
1806
+ @override
1807
+ Future<Object?> _call(String op, [Map<String, Object?> args = const {}]) =>
1808
+ parent._call(op,
1809
+ args.containsKey('session') ? args : {...args, 'session': workspace});
1810
+
1811
+ // The parent owns the ONE message loop + lifecycle; a session never subscribes, never signals ready of its
1812
+ // own, and never tears the plane down (the harness owns `parent.dispose()`).
1813
+ @override
1814
+ Future<void> get ready => parent.ready;
1815
+ @override
1816
+ Future<void> dispose() async {}
1817
+
1818
+ // Reactive reads register on the PARENT (which listens) so watch events land — route with our session.
1819
+ @override
1820
+ Stream<List<dynamic>> watch(String queryId,
1821
+ [Map<String, Object?> params = const {}, String? session]) =>
1822
+ parent.watch(queryId, params, session ?? workspace);
1823
+ @override
1824
+ Stream<List<dynamic>> watchById(String id, [String? session]) =>
1825
+ parent.watchById(id, session ?? workspace);
1826
+ @override
1827
+ Stream<List<dynamic>> watchSpatial(String spatialId, NomosBbox bbox,
1828
+ [String? session]) =>
1829
+ parent.watchSpatial(spatialId, bbox, session ?? workspace);
1830
+ @override
1831
+ Stream<Map<String, Object?>> watchCompatibility(
1832
+ Map<String, Object?> requirement) =>
1833
+ parent.watchCompatibility(requirement);
1834
+
1835
+ // Live snapshots / streams reflect the shared plane — read them off the parent's controllers.
1836
+ @override
1837
+ NomosCompat? get compat => parent.compat;
1838
+ @override
1839
+ Stream<NomosCompat> get onLawChanged => parent.onLawChanged;
1840
+ @override
1841
+ Map<String, Object?>? get lastRecovery => parent.lastRecovery;
1842
+ @override
1843
+ Stream<Map<String, Object?>> get syncEvents => parent.syncEvents;
1844
+ @override
1845
+ Stream<TransferProgress> get transferProgress => parent.transferProgress;
1846
+ @override
1847
+ Stream<SyncStatus> get syncStatus => parent.syncStatus;
1848
+ @override
1849
+ SyncStatus get currentStatus => parent.currentStatus;
1850
+ @override
1851
+ Stream<Map<String, Object?>> get signalEvents => parent.signalEvents;
1852
+ @override
1853
+ Stream<Map<String, Object?>> get flameEvents => parent.flameEvents;
1854
+ @override
1855
+ Stream<Map<String, Object?>> get spanEvents => parent.spanEvents;
1856
+ @override
1857
+ List<Map<String, Object?>> get recentSpans => parent.recentSpans;
1858
+ }
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.35.0
14
+ version: 0.36.0
15
15
 
16
16
  environment:
17
17
  sdk: ^3.5.0
@@ -0,0 +1,185 @@
1
+ // THE CLUSTER / ATTESTED-READ ACCEPTANCE — DART LEG (co2's test-cluster ask). Prove the exact
2
+ // dynamic-source attested-read scenario (architecture/dynamic_source_attested_reads.md) end-to-end from
3
+ // Dart, ONE engine plane, TWO colocated workspaces, ZERO network:
4
+ //
5
+ // • NomosTestHarness.cluster boots ONE runner holding a `catalogue` session (the source) and an `estate`
6
+ // session (the reader), each with its own law installed offline.
7
+ // • the catalogue session seeds a listing; the estate session serves + SIGNS it via the in-process
8
+ // `attestedRead` op (the colocated source lane) and reads the source's GENESIS off the envelope.
9
+ // • NEGATIVE arm: offering `importListing` with that envelope BEFORE enrolling the catalogue refuses
10
+ // `AttestedReadUntrustedSource` — surfaced to Dart as a NomosBridgeError carrying that name.
11
+ // • HAPPY arm: enrol the source genesis + attestor key, offer `importListing` carrying the signed
12
+ // envelope, and read the imported row back through the TYPED EstateClient bound to `harness.session
13
+ // ('estate')` — proving a NomosSession satisfies the generated client's `NomosBridge` constructor.
14
+ @TestOn('vm')
15
+ library;
16
+
17
+ import 'dart:convert';
18
+ import 'dart:io';
19
+
20
+ import 'package:crypto/crypto.dart';
21
+ import 'package:nomos_client/nomos_client.dart' show NomosOfferOutcome;
22
+ import 'package:nomos_client/nomos_headless.dart';
23
+ import 'package:test/test.dart';
24
+
25
+ // estate.client.dart re-exports estate.dart (payloads/read-models) AND the nomos_client symbols it needs
26
+ // (NomosBridgeError, NomosBridge, …), so this one import carries the whole typed surface.
27
+ import '../../../bench/scale/fixtures/catalogue-reader/build/dart/src/generated/estate.client.dart';
28
+
29
+ String get _root => '${Directory.current.path}/../..';
30
+ String get _repoEntry => '$_root/cloud/web-client/runner/node-entry.mjs';
31
+ String get _catDeploy => '$_root/bench/scale/fixtures/catalogue-src/build/cat.deploy.json';
32
+ String get _estateDeploy => '$_root/bench/scale/fixtures/catalogue-reader/build/estate.deploy.json';
33
+ String get _estateClientDart =>
34
+ '$_root/bench/scale/fixtures/catalogue-reader/build/dart/src/generated/estate.client.dart';
35
+ String get _runtimeDir => '${Platform.environment['HOME'] ?? ''}/.holon/runtime';
36
+
37
+ String? _missingPrereq() {
38
+ if (!File(_repoEntry).existsSync()) return 'in-repo runner not found at $_repoEntry';
39
+ for (final p in [_catDeploy, _estateDeploy, _estateClientDart]) {
40
+ if (!File(p).existsSync()) {
41
+ return 'compile the catalogue fixtures with dart output: '
42
+ 'cd bench/scale/fixtures/<catalogue-src|catalogue-reader> && node ../../../../dsl/compile_package.mjs '
43
+ '(needs `dart: { support: "package" }` in nomos.package.mjs) — missing $p';
44
+ }
45
+ }
46
+ if (!File('$_runtimeDir/holon.wasm').existsSync() ||
47
+ !File('$_runtimeDir/packages.json').existsSync()) {
48
+ return 'warm ~/.holon/runtime first (run any `githolon` command once)';
49
+ }
50
+ return null;
51
+ }
52
+
53
+ /// sha256hex of the attestor's raw public bytes — the `keyHash` the reader enrolls (parity with the
54
+ /// smoke's `sha256hexBytes(base64decode(public))`).
55
+ String _keyHashOf(String base64Public) =>
56
+ sha256.convert(base64Decode(base64Public)).toString();
57
+
58
+ void main() {
59
+ test('cluster attested reads: colocated source served + signed → enrolled → typed import read back',
60
+ () async {
61
+ final skip = _missingPrereq();
62
+ if (skip != null) {
63
+ markTestSkipped(skip);
64
+ return;
65
+ }
66
+
67
+ final harness = await NomosTestHarness.cluster(
68
+ workspaces: {
69
+ 'catalogue': [_catDeploy],
70
+ 'estate': [_estateDeploy],
71
+ },
72
+ // The catalogue SOURCE signs its attested reads with its device key — the colocation resolver uses it,
73
+ // and the reader enrols sha256(that public key). (The reader needs no device key: it authors as an actor.)
74
+ deviceKeyed: {'catalogue'},
75
+ nodeEntry: _repoEntry,
76
+ runtimeDir: _runtimeDir,
77
+ );
78
+ try {
79
+ expect(harness.sessions.toSet(), {'catalogue', 'estate'});
80
+
81
+ // THE PRODUCTION MULTI-WORKSPACE SURFACE: one bridge = this device's local plane; `bridge.session(ws)`
82
+ // is the first-class handle. Enumerate the plane — both workspaces are LOCAL-ONLY (no cloud posture).
83
+ final plane = await harness.bridge.listWorkspaces();
84
+ expect(plane.map((w) => w['workspace']).toSet(), {'catalogue', 'estate'});
85
+ expect(plane.every((w) => w['syncPosture'] == 'local-only'), isTrue,
86
+ reason: 'the cluster opens local githolons, not cloud connections');
87
+
88
+ // Get the session handles through the production entry (the harness delegates to bridge.session).
89
+ final catalogue = harness.bridge.session('catalogue');
90
+ final estate = harness.bridge.session('estate');
91
+ expect(identical(estate, harness.session('estate')), isTrue,
92
+ reason: 'session handles are stable + cached on the plane');
93
+
94
+ // 1. seed a listing in the CATALOGUE source (raw offer — the source's own law).
95
+ final seeded = await catalogue.offerCreates(
96
+ domain: 'cat',
97
+ directiveId: 'listCatalogue',
98
+ payload: {
99
+ 'listingId': 'L-100',
100
+ 'title': 'Oak dresser',
101
+ 'priceGbp': '420',
102
+ 'listedAt': '2026-07-03T00:00:00Z',
103
+ },
104
+ actor: 'user:seller',
105
+ );
106
+ expect(seeded.head, isNotEmpty);
107
+
108
+ // 2. Read the source's GENESIS (its true identity) + its signing key off ONE attested read. No attestor
109
+ // secret: the source session signs with its DEVICE key (its connect authorSecret), which the
110
+ // colocation resolver also uses — so the enrolled keyHash matches what the zero-injection lane signs.
111
+ final served = await estate.attestedRead(
112
+ from: 'catalogue',
113
+ queryId: 'listingById',
114
+ args: {'listingId': 'L-100'},
115
+ );
116
+ final entry = (served['entry'] as Map).cast<String, Object?>();
117
+ final attestation = (entry['attestation'] as Map).cast<String, Object?>();
118
+ final sourceGenesis = (attestation['source_genesis'] ?? '').toString();
119
+ final signingKey = (attestation['public_key'] ?? '').toString();
120
+ final keyHash = _keyHashOf(signingKey);
121
+ expect(sourceGenesis.length, greaterThanOrEqualTo(40),
122
+ reason: 'the source genesis oid resolves off the signed envelope');
123
+ expect(signingKey, harness.devicePublicKey('catalogue'),
124
+ reason: 'the read is signed by the source session device key the resolver will use');
125
+
126
+ // The one importListing offer, offered TWICE (unenrolled then enrolled). ZERO injection: the payload
127
+ // names `catalogueWs`, and the runner's colocation resolver serves+signs the foreign read IN-PROCESS —
128
+ // NO `attestations` param. The gate judges the served envelope's genesis against the reader's allow-list.
129
+ Future<NomosOfferOutcome> importOnce(String importedAt) => estate.offerCreates(
130
+ domain: 'estate',
131
+ directiveId: 'importListing',
132
+ payload: ImportListingPayload(
133
+ listingId: 'L-100',
134
+ catalogueWs: 'catalogue',
135
+ importedAt: importedAt,
136
+ ).toPayloadJson(),
137
+ actor: 'user:estate-owner',
138
+ );
139
+
140
+ // 3. NEGATIVE — BEFORE enrolment the source genesis is not in the allow-list → UntrustedSource
141
+ // (the resolver still serves+signs; the gate refuses on the untrusted genesis).
142
+ Object? negError;
143
+ try {
144
+ await importOnce('2026-07-03T12:00:00Z');
145
+ } catch (e) {
146
+ negError = e;
147
+ }
148
+ expect(negError, isA<NomosBridgeError>(),
149
+ reason: 'an unenrolled source must refuse the import');
150
+ expect(negError.toString(), contains('AttestedReadUntrustedSource'),
151
+ reason: 'the typed refusal name must surface to Dart');
152
+
153
+ // 4. ENROL the catalogue: its genesis + the source device keyHash become folded law-state in the
154
+ // reader's own chain (typed write — the reader's own directive, no attestation).
155
+ final app = EstateClient(estate, actor: 'user:estate-owner');
156
+ final enrolled = await app.enrollCatalogue(EnrollCataloguePayload(
157
+ sourceGenesis: sourceGenesis,
158
+ keyHashes: [keyHash],
159
+ label: 'Acme Catalogue',
160
+ enrolledAt: '2026-07-03T10:00:00Z',
161
+ ));
162
+ expect(enrolled.head, isNotEmpty);
163
+
164
+ // 5. HAPPY — the SAME zero-injection offer now admits: the resolver serves+signs, the gate judges the
165
+ // genesis against the folded allow-list, and the catalogue title lands on EstateListing.
166
+ final imported = await importOnce('2026-07-03T12:05:00Z');
167
+ expect(imported.head, isNotEmpty);
168
+ expect(imported.created, isNotEmpty,
169
+ reason: 'the importListing plan minted the EstateListing');
170
+ final createdId = imported.created.first.id;
171
+
172
+ // 6. TYPED read-back through the EstateClient bound to the estate SESSION (by the minted id).
173
+ final rows = await app.estateListingById(createdId);
174
+ expect(rows, isNotEmpty,
175
+ reason: 'the imported EstateListing reads back on the estate session');
176
+ final row = rows.first;
177
+ expect(row.title, 'Oak dresser',
178
+ reason: 'the imported title was read through the policy edge, typed');
179
+ expect(row.catalogue, 'catalogue');
180
+ expect(row.listingRef, 'L-100');
181
+ } finally {
182
+ await harness.dispose();
183
+ }
184
+ }, timeout: const Timeout(Duration(minutes: 3)));
185
+ }
@@ -0,0 +1,146 @@
1
+ // LOCAL BIRTHS — DART LEG. An offline `.births()` directive dispatch: the estate-birth `home` law's
2
+ // `birthEstateWorkspace` OFFERS a birth on THIS device's local plane — no cloud. Three things are proven:
3
+ //
4
+ // 1. THE DART API PATH: the offer surfaces the typed birth verdict — `born` (the child workspace name) +
5
+ // `birthOutcomes` (the per-child fate) — off NomosOfferOutcome. A birth is observable from Dart.
6
+ // 2. THE OFFLINE BIRTH SUCCEEDS (status=born): the child genesis (installDomain×2 + seed) folds through the
7
+ // CHILD's own gate, fully offline. This needed a lane fix — the client holon's wasm provider now stages
8
+ // the stage-0 bootstrap package at the preopen root (`/work/domain.package.usda`), the SAME file the
9
+ // adapter engine stages, which `run_birth_effect` reads as the genesis-lane executor for the child's
10
+ // step-0 `bootstrap/installDomain`. Without it the birth failed with "bare JavaScript is not a domain
11
+ // package" (see cloud/web-client/src/kernel_provider_wasm.mjs boot).
12
+ // 3. A FAILED birth carries a TYPED reason to Dart (deliberate bad framework hash) — never a silent drop.
13
+ //
14
+ // THE NAMED GAP (task #42): opening the born child as a SIBLING SESSION (`bridge.session(child)` → typed
15
+ // query) requires the SHARED ENGINE PLANE — today each runner session is its own single-workspace engine, so
16
+ // a sibling session can't see the child custody born in another. We do NOT fake that loop; the child handle
17
+ // is addressable, and we stop there.
18
+ @TestOn('vm')
19
+ library;
20
+
21
+ import 'dart:io';
22
+
23
+ import 'package:nomos_client/nomos_client.dart' show NomosOfferOutcome;
24
+ import 'package:nomos_client/nomos_headless.dart';
25
+ import 'package:test/test.dart';
26
+
27
+ String get _root => '${Directory.current.path}/../..';
28
+ String get _repoEntry => '$_root/cloud/web-client/runner/node-entry.mjs';
29
+ String get _birthDeploy =>
30
+ '$_root/bench/scale/fixtures/estate-birth/build/estate-birth.deploy.json';
31
+ String get _runtimeDir => '${Platform.environment['HOME'] ?? ''}/.holon/runtime';
32
+
33
+ String? _missingPrereq() {
34
+ if (!File(_repoEntry).existsSync()) return 'in-repo runner not found at $_repoEntry';
35
+ if (!File(_birthDeploy).existsSync()) {
36
+ return 'compile the fixture: cd bench/scale/fixtures/estate-birth && node ../../../../dsl/compile_package.mjs — missing $_birthDeploy';
37
+ }
38
+ if (!File('$_runtimeDir/holon.wasm').existsSync() ||
39
+ !File('$_runtimeDir/packages.json').existsSync()) {
40
+ return 'warm ~/.holon/runtime first (run any `githolon` command once)';
41
+ }
42
+ return null;
43
+ }
44
+
45
+ const _owner = 'sample-owner';
46
+
47
+ /// Set up a single-session plane over the estate-birth law + OFFER `birthEstateWorkspace` on it. Returns the
48
+ /// harness (for teardown) + the offer outcome (born / birthOutcomes surfaced). [frameworkHash] overrides the
49
+ /// real framework hash (pass a bogus one to force a genesis failure).
50
+ Future<(NomosTestHarness, NomosOfferOutcome)> _birth({String? frameworkHash}) async {
51
+ final harness = await NomosTestHarness.ephemeral(
52
+ deployJsonPaths: [_birthDeploy],
53
+ nodeEntry: _repoEntry,
54
+ runtimeDir: _runtimeDir,
55
+ workspace: 'estate-birth',
56
+ );
57
+ // The genesis recipe installs the framework + child law BY HASH — the harness surfaces both (frameworkHash
58
+ // is not derivable client-side; lawHash is this package's own domainHash).
59
+ final fw = frameworkHash ?? harness.frameworkHash;
60
+ final lawHash = harness.lawHashes('estate-birth').first;
61
+ final outcome = await harness.bridge.offerCreates(
62
+ domain: 'home',
63
+ directiveId: 'birthEstateWorkspace',
64
+ payload: {
65
+ 'owner': _owner,
66
+ 'frameworkHash': fw,
67
+ 'lawHash': lawHash,
68
+ 'bornAt': '2026-07-05T00:00:00Z',
69
+ },
70
+ actor: _owner,
71
+ );
72
+ return (harness, outcome);
73
+ }
74
+
75
+ void main() {
76
+ test('offline .births() dispatch surfaces born + birthOutcomes TYPED to Dart', () async {
77
+ final skip = _missingPrereq();
78
+ if (skip != null) {
79
+ markTestSkipped(skip);
80
+ return;
81
+ }
82
+ final (harness, outcome) = await _birth();
83
+ try {
84
+ expect(outcome.head, isNotEmpty,
85
+ reason: 'the birth offer admitted on the local plane');
86
+ // THE API PATH (co2's ask): the birth verdict is typed back to Dart.
87
+ expect(outcome.born, isNotEmpty,
88
+ reason: 'the offer-effect allocated the estate child workspace on the local plane');
89
+ expect(outcome.born.single, startsWith('estate-'),
90
+ reason: 'the born workspace is estate-<sha256(owner)>');
91
+ expect(outcome.birthOutcomes, isNotEmpty,
92
+ reason: 'the typed birth verdict rides beside born');
93
+ expect(outcome.birthOutcomes.first['workspace'], outcome.born.single);
94
+ expect(outcome.birthOutcomes.first['status'], isNotNull);
95
+
96
+ // NAMED GAP (#42): the child handle is addressable on the plane, but opening it as its own session
97
+ // across the runner's per-session engines needs the shared engine plane — we stop at the handle.
98
+ expect(harness.bridge.session(outcome.born.single).workspace, outcome.born.single);
99
+ } finally {
100
+ await harness.dispose();
101
+ }
102
+ }, timeout: const Timeout(Duration(minutes: 3)));
103
+
104
+ test('offline birth genesis SUCCEEDS (status=born) — the child is folded through its own gate', () async {
105
+ final skip = _missingPrereq();
106
+ if (skip != null) {
107
+ markTestSkipped(skip);
108
+ return;
109
+ }
110
+ final (harness, outcome) = await _birth();
111
+ try {
112
+ // The lane is fixed (the provider stages the stage-0 bootstrap package at the preopen root), so the
113
+ // child genesis folds clean and the born verdict is `born` — hard-asserted, no skip.
114
+ expect(outcome.born, hasLength(1));
115
+ expect(outcome.birthOutcomes, hasLength(1));
116
+ expect(outcome.birthOutcomes.first['status'], 'born',
117
+ reason: 'the child genesis (installDomain×2 + seed) folded through the child\'s own gate offline');
118
+ expect(outcome.birthOutcomes.first['workspace'], outcome.born.single);
119
+ } finally {
120
+ await harness.dispose();
121
+ }
122
+ }, timeout: const Timeout(Duration(minutes: 3)));
123
+
124
+ test('birth FAILURE carries a typed reason to Dart (deliberate bad framework hash)', () async {
125
+ final skip = _missingPrereq();
126
+ if (skip != null) {
127
+ markTestSkipped(skip);
128
+ return;
129
+ }
130
+ // A well-formed but WRONG framework hash the parent does not fold → the child's step-0 install can't be
131
+ // satisfied → the genesis fold refuses. The offer still admits on the parent (the birth ORDER is valid);
132
+ // the child's fate comes back as a TYPED failure outcome, not a thrown error or a silent drop.
133
+ final badHash = 'deadbeef' * 8; // 64 hex chars, not a package the parent holds
134
+ final (harness, outcome) = await _birth(frameworkHash: badHash);
135
+ try {
136
+ expect(outcome.birthOutcomes, isNotEmpty,
137
+ reason: 'a failed birth still reports its typed verdict to Dart');
138
+ expect(outcome.birthOutcomes.first['status'], isNot('born'),
139
+ reason: 'an unresolvable framework hash cannot yield a born child');
140
+ expect((outcome.birthOutcomes.first['reason'] ?? '').toString(), isNotEmpty,
141
+ reason: 'the failure carries a human-legible reason (never a silent drop)');
142
+ } finally {
143
+ await harness.dispose();
144
+ }
145
+ }, timeout: const Timeout(Duration(minutes: 3)));
146
+ }
@@ -10,7 +10,6 @@ import 'dart:io';
10
10
  import 'package:nomos_client/nomos_headless.dart';
11
11
  import 'package:test/test.dart';
12
12
 
13
- import '../../../bench/scale/fixtures/co2-acceptance/build/dart/src/generated/estate.dart';
14
13
  import '../../../bench/scale/fixtures/co2-acceptance/build/dart/src/generated/estate.client.dart';
15
14
 
16
15
  String get _root => '${Directory.current.path}/../..';
@@ -12,7 +12,6 @@ library;
12
12
 
13
13
  import 'dart:io';
14
14
 
15
- import 'package:nomos_client/nomos_client.dart';
16
15
  import 'package:nomos_client/nomos_headless.dart';
17
16
  import 'package:test/test.dart';
18
17
 
@@ -239,6 +239,18 @@ Future<Object?> _invoke(
239
239
  return b.sync(admit: a['admit'] as bool);
240
240
  case 'lawStatus':
241
241
  return b.lawStatus(a['hash'] as String);
242
+ case 'offerDirective':
243
+ return b.offerDirective(
244
+ domain: a['domain'] as String,
245
+ directiveId: a['directiveId'] as String,
246
+ payload: a['payload'],
247
+ actor: a['actor'] as String?,
248
+ domainHash: a['domainHash'] as String?,
249
+ attestations: (a['attestations'] as List?)
250
+ ?.map((e) => (e as Map).cast<String, Object?>())
251
+ .toList(),
252
+ force: a['force'] as bool?,
253
+ );
242
254
  default:
243
255
  fail('corpus op not wired into the Dart bridge: $op');
244
256
  }
@@ -248,6 +260,7 @@ Future<Object?> _invoke(
248
260
  Object? _expectedTyped(String op, Map<String, Object?> result) {
249
261
  switch (op) {
250
262
  case 'offer':
263
+ case 'offerDirective':
251
264
  return result['head'];
252
265
  case 'query':
253
266
  case 'queryById':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.56.0",
3
+ "version": "0.57.1",
4
4
  "type": "module",
5
5
  "description": "Nomos 2 domain-authoring DSL: aggregates + directives in TS, executed and encoded to the Rust kernel's wire shapes.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",