@githolon/dsl 0.56.0 → 0.57.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,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,8 @@ 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]);
152
194
  if (autoTearDown && registerTearDown != null) {
153
195
  registerTearDown(harness.dispose);
154
196
  }
@@ -167,6 +209,139 @@ class NomosTestHarness {
167
209
  }
168
210
  }
169
211
 
212
+ /// A COLOCATED TEST CLUSTER — install MANY workspaces' law offline and connect one SESSION per workspace on
213
+ /// ONE engine plane (one runner, one transport, one [bridge]), so a cross-workspace scenario (attested
214
+ /// reads, foreign-source imports) runs in-process with ZERO network. Each workspace's law is installed via
215
+ /// the SAME offline lane [ephemeral] uses (one snapshot per workspace), then `connect`ed as its own session;
216
+ /// the per-connect restore snapshot loads it into the shared plane.
217
+ ///
218
+ /// [workspaces] — `{ workspaceName: [deployJsonPaths] }` (installed in order per workspace). Get a
219
+ /// session-scoped view with [session] and bind it to a generated client: `EstateClient(harness.session(
220
+ /// 'estate'))`. All other params mirror [ephemeral]. [clientId] is suffixed per session.
221
+ /// [deviceKeyed] names workspaces to open WITH a fresh in-kernel device key as their signing `authorSecret`
222
+ /// (the warrant default — every write is signed, replay-provably). Required for a source of ATTESTED READS:
223
+ /// the colocation resolver serves+signs a foreign read with the source session's device key, so a reader
224
+ /// enrols `sha256(that device public key)`. Get the public via [devicePublicKey].
225
+ static Future<NomosTestHarness> cluster({
226
+ required Map<String, List<String>> workspaces,
227
+ Set<String> deviceKeyed = const {},
228
+ String clientId = 'test-runner',
229
+ String? runtimeDir,
230
+ String? nodeEntry,
231
+ String? installHelper,
232
+ String? resolveFrom,
233
+ String node = 'node',
234
+ bool autoTearDown = true,
235
+ void Function(Future<void> Function())? registerTearDown,
236
+ void Function(String line)? onDiagnostic,
237
+ Duration readyTimeout = const Duration(seconds: 30),
238
+ }) async {
239
+ if (workspaces.isEmpty) {
240
+ throw NomosTestHarnessException(
241
+ 'no workspaces — pass { workspaceName: [build/<name>.deploy.json] } for each colocated workspace');
242
+ }
243
+ for (final e in workspaces.entries) {
244
+ if (e.value.isEmpty) {
245
+ throw NomosTestHarnessException(
246
+ 'workspace "${e.key}" has no deployJsonPaths — pass its build/<name>.deploy.json');
247
+ }
248
+ }
249
+ final entry = nodeEntry ??
250
+ await _resolveEntry(node, resolveFrom ?? Directory.current.path);
251
+ final resolvedHelper =
252
+ installHelper ?? '${File(entry).parent.path}/offline-install.mjs';
253
+ final useInRepoScript =
254
+ resolvedHelper.endsWith('.mjs') && File(resolvedHelper).existsSync();
255
+ final githolonCmd = installHelper == null ? 'npx githolon' : resolvedHelper;
256
+
257
+ final workdir = await Directory.systemTemp.createTemp('nomos-cluster-');
258
+ NodeRunnerTransport? transport;
259
+ try {
260
+ // 1. install each workspace's law offline → one snapshot per workspace (colocated, still separate law).
261
+ final snapshots = <String, String>{};
262
+ for (final e in workspaces.entries) {
263
+ final snap = '${workdir.path}/${e.key}.bin';
264
+ await _runInstall(
265
+ node: node,
266
+ useInRepoScript: useInRepoScript,
267
+ inRepoScript: resolvedHelper,
268
+ githolonCmd: githolonCmd,
269
+ deployJsonPaths: e.value,
270
+ workspace: e.key,
271
+ snapshot: snap,
272
+ runtimeDir: runtimeDir,
273
+ onDiagnostic: onDiagnostic);
274
+ snapshots[e.key] = snap;
275
+ }
276
+
277
+ // 2. ONE runner / ONE bridge for the whole plane — no env restore default; each connect brings its own.
278
+ transport = await NodeRunnerTransport.start(
279
+ entry: entry,
280
+ resolveFrom: resolveFrom,
281
+ node: node,
282
+ environment: {
283
+ 'NOMOS_OFFLINE': '1',
284
+ if (runtimeDir != null) 'NOMOS_OFFLINE_RUNTIME': runtimeDir,
285
+ },
286
+ onDiagnostic: onDiagnostic,
287
+ );
288
+ final bridge = NomosBridge(transport);
289
+ await bridge.ready.timeout(readyTimeout,
290
+ onTimeout: () => throw NomosTestHarnessException(
291
+ 'the offline runner never signalled ready within $readyTimeout '
292
+ '(is `node` on PATH and ~/.holon/runtime warm?)'));
293
+
294
+ // 3. connect one SESSION per workspace, each restoring its OWN snapshot — LOCAL-ONLY (no cloud;
295
+ // these are local githolons on the device plane, offline by nature).
296
+ final snapshotB64 = <String, String>{};
297
+ for (final e in workspaces.entries) {
298
+ snapshotB64[e.key] = base64Encode(await File(snapshots[e.key]!).readAsBytes());
299
+ await bridge.connect(
300
+ workspace: e.key,
301
+ session: e.key,
302
+ clientId: '$clientId-${e.key}',
303
+ restoreFrom: snapshotB64[e.key],
304
+ allowUnborn: true,
305
+ );
306
+ }
307
+
308
+ // 3b. DEVICE-KEY the named sessions: mint a fresh in-kernel key on each (now that it is mounted) and
309
+ // RE-OPEN it with that key as its signing authorSecret — the resolver's attested-read signing key.
310
+ final devicePublics = <String, String>{};
311
+ for (final ws in deviceKeyed) {
312
+ final kp = await bridge.session(ws).authorKeygen();
313
+ devicePublics[ws] = (kp['public'] ?? '').toString();
314
+ await bridge.connect(
315
+ workspace: ws,
316
+ session: ws,
317
+ clientId: '$clientId-$ws',
318
+ restoreFrom: snapshotB64[ws],
319
+ authorSecret: (kp['secret'] ?? '').toString(),
320
+ allowUnborn: true,
321
+ );
322
+ }
323
+
324
+ final names = workspaces.keys.toList();
325
+ final harness = NomosTestHarness._(
326
+ bridge, names.first, transport, workdir, onDiagnostic, names,
327
+ devicePublics);
328
+ if (autoTearDown && registerTearDown != null) {
329
+ registerTearDown(harness.dispose);
330
+ }
331
+ return harness;
332
+ } catch (_) {
333
+ if (transport != null) {
334
+ try {
335
+ await transport.dispose();
336
+ } catch (_) {}
337
+ }
338
+ try {
339
+ await workdir.delete(recursive: true);
340
+ } catch (_) {}
341
+ rethrow;
342
+ }
343
+ }
344
+
170
345
  /// Deterministic, idempotent, total-ordered teardown: stop the runner (SIGTERM → 5s deadline → SIGKILL,
171
346
  /// via [NodeRunnerTransport.dispose]), then delete the temp dir. Safe to call twice; safe to call after a
172
347
  /// failed setup. Nothing left running, nothing left on disk.
@@ -196,7 +371,7 @@ class NomosTestHarness {
196
371
 
197
372
  // ── offline install (a short-lived node child; produces the restore snapshot) ──
198
373
 
199
- static Future<void> _runInstall({
374
+ static Future<Map<String, Object?>> _runInstall({
200
375
  required String node,
201
376
  required bool useInRepoScript,
202
377
  required String inRepoScript,
@@ -255,6 +430,9 @@ class NomosTestHarness {
255
430
  throw NomosTestHarnessException(
256
431
  'offline law install produced no snapshot (stdout: ${out.isEmpty ? "(empty)" : out})');
257
432
  }
433
+ // { ok, snapshot, workspace, lawHashes, frameworkHash } — the caller keeps lawHashes/frameworkHash so a
434
+ // `.births()` directive can be driven (its genesis installs the framework + law by hash).
435
+ return decoded;
258
436
  }
259
437
 
260
438
  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,22 @@ 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
+ final bornNames = <String>[
1131
+ ...((r['born'] as List?) ?? const []).map((e) => e.toString()),
1132
+ // Fall back to the workspace field of each birth outcome when a bare `born` list wasn't sent.
1133
+ if ((r['born'] as List?) == null)
1134
+ ...births
1135
+ .map((m) => (m['workspace'] ?? '').toString())
1136
+ .where((s) => s.isNotEmpty),
1137
+ ];
1053
1138
  return NomosOfferOutcome(
1054
1139
  head: r['head'].toString(),
1055
1140
  created: ((r['created'] as List?) ?? const [])
@@ -1058,6 +1143,8 @@ class NomosBridge {
1058
1143
  type: (m['type'] ?? '').toString(),
1059
1144
  id: (m['id'] ?? '').toString()))
1060
1145
  .toList(),
1146
+ born: bornNames,
1147
+ birthOutcomes: births,
1061
1148
  );
1062
1149
  }
1063
1150
 
@@ -1082,13 +1169,47 @@ class NomosBridge {
1082
1169
  required Object? payload,
1083
1170
  String? actor,
1084
1171
  String? domainHash,
1172
+ List<Map<String, Object?>>? attestations,
1173
+ bool? force,
1174
+ String? session,
1085
1175
  }) =>
1086
1176
  offerDirective(
1087
1177
  domain: domain,
1088
1178
  directiveId: directiveId,
1089
1179
  payload: payload,
1090
1180
  actor: actor,
1091
- domainHash: domainHash);
1181
+ domainHash: domainHash,
1182
+ attestations: attestations,
1183
+ force: force,
1184
+ session: session);
1185
+
1186
+ /// ATTESTED READ — serve + SIGN a declared query over a COLOCATED source session's certified fold (the
1187
+ /// dynamic-source trust lane; architecture/dynamic_source_attested_reads.md). In a [NomosTestHarness.cluster]
1188
+ /// (one engine plane, many sessions) this fulfils the read IN-PROCESS from the [from] session and returns
1189
+ /// the signed envelope `{ ok, rows, attestation:{ source_genesis, … } }` — hand it back as an
1190
+ /// [offerDirective] `attestations` element, or read `attestation.source_genesis` to enrol the source in a
1191
+ /// reader's allow-list. [args] are the query params. [session] is the caller/reader session (defaults to
1192
+ /// this view's bound session).
1193
+ /// [attestorSecret] is the source's base64 read-attestor secret (from [authorKeygen]); when omitted the
1194
+ /// source session's connect-time `authorSecret` signs. [nowMs] stamps the freshness clock (defaults to the
1195
+ /// source's own clock). The returned `entry` is `{queryId, result, attestation:{source_genesis, public_key,
1196
+ /// …}}` — pass the whole `entry` as one [offerDirective] `attestations` element.
1197
+ Future<Map<String, Object?>> attestedRead({
1198
+ required String from,
1199
+ required String queryId,
1200
+ Map<String, Object?> args = const {},
1201
+ String? attestorSecret,
1202
+ int? nowMs,
1203
+ String? session,
1204
+ }) async =>
1205
+ _asMap(await _call('attestedRead', {
1206
+ 'from': from,
1207
+ 'queryId': queryId,
1208
+ 'args': args,
1209
+ if (attestorSecret != null) 'attestorSecret': attestorSecret,
1210
+ if (nowMs != null) 'nowMs': nowMs,
1211
+ if (session != null) 'session': session,
1212
+ }));
1092
1213
 
1093
1214
  /// MAKE A WORKSPACE — the one-call tenant path. A child workspace under a platform/[parent] is a SIGNED
1094
1215
  /// control-plane offer; the holon seals `createWorkspace` (or [directiveId] `birthChild`) with the
@@ -1265,19 +1386,22 @@ class NomosBridge {
1265
1386
  Future<List<Map<String, dynamic>>> query(String queryId,
1266
1387
  [Map<String, Object?> params = const {},
1267
1388
  List<NomosScopeKey>? keys,
1268
- String? principal]) async =>
1389
+ String? principal,
1390
+ String? session]) async =>
1269
1391
  _asRows(await _call('query', {
1270
1392
  'queryId': queryId,
1271
1393
  'params': params,
1272
1394
  ...?_readAuthArgs(keys, principal),
1395
+ if (session != null) 'session': session,
1273
1396
  }));
1274
1397
 
1275
1398
  /// A single aggregate by id (the by-id read). `keys`/`principal`: see [query].
1276
1399
  Future<List<Map<String, dynamic>>> queryById(String id,
1277
- [List<NomosScopeKey>? keys, String? principal]) async =>
1400
+ [List<NomosScopeKey>? keys, String? principal, String? session]) async =>
1278
1401
  _asRows(await _call('queryById', {
1279
1402
  'id': id,
1280
1403
  ...?_readAuthArgs(keys, principal),
1404
+ if (session != null) 'session': session,
1281
1405
  }));
1282
1406
 
1283
1407
  /// The read auth sidecar (private reads + E2E): `keys`/`principal` as top-level call args, or null when
@@ -1334,23 +1458,36 @@ class NomosBridge {
1334
1458
  /// THE SPATIAL READ: rows of the declared spatial index's of-type whose geometry
1335
1459
  /// intersects [bbox] — an R*Tree probe on the local projection, same row shape as [query].
1336
1460
  Future<List<Map<String, dynamic>>> spatialWithin(
1337
- String spatialId, NomosBbox bbox) async =>
1338
- _asRows(await _call(
1339
- 'spatialWithin', {'spatialId': spatialId, 'bbox': bbox.toJson()}));
1461
+ String spatialId, NomosBbox bbox,
1462
+ [String? session]) async =>
1463
+ _asRows(await _call('spatialWithin', {
1464
+ 'spatialId': spatialId,
1465
+ 'bbox': bbox.toJson(),
1466
+ if (session != null) 'session': session,
1467
+ }));
1340
1468
 
1341
1469
  /// 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)
1470
+ Future<int> count(String countId, [String group = '', String? session]) async =>
1471
+ ((_asMap(await _call('count', {
1472
+ 'countId': countId,
1473
+ 'group': group,
1474
+ if (session != null) 'session': session,
1475
+ }))['count']) as num)
1345
1476
  .toInt();
1346
- Future<int> sum(String sumId, [String group = '']) async =>
1347
- ((_asMap(await _call('sum', {'sumId': sumId, 'group': group}))['sum'])
1348
- as num)
1477
+ Future<int> sum(String sumId, [String group = '', String? session]) async =>
1478
+ ((_asMap(await _call('sum', {
1479
+ 'sumId': sumId,
1480
+ 'group': group,
1481
+ if (session != null) 'session': session,
1482
+ }))['sum']) as num)
1349
1483
  .toInt();
1350
1484
 
1351
1485
  /// 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}));
1486
+ Future<Map<String, Object?>> sync({bool admit = true, String? session}) async =>
1487
+ _asMap(await _call('sync', {
1488
+ 'admit': admit,
1489
+ if (session != null) 'session': session,
1490
+ }));
1354
1491
 
1355
1492
  /// PERSIST: a base64 snapshot of the whole local holon (the bare nomos.git incl. un-synced writes + the
1356
1493
  /// DLQ). Store it on disk and pass it back as `connect(restoreFrom:)` next launch — the app then boots from
@@ -1469,8 +1606,10 @@ class NomosBridge {
1469
1606
  }
1470
1607
 
1471
1608
  /// 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'));
1609
+ Future<Map<String, Object?>> metrics([String? session]) async =>
1610
+ _asMap(await _call('metrics', {
1611
+ if (session != null) 'session': session,
1612
+ }));
1474
1613
 
1475
1614
  /// TIER-2 SYNC SIZE — how many bytes this workspace would transfer to catch up to main, WITHOUT downloading
1476
1615
  /// them (a HEAD on the serving pack; the host answers Content-Length only, cached ~15s). Sum across your open
@@ -1486,14 +1625,15 @@ class NomosBridge {
1486
1625
  /// Reactive local read: a stream of row-sets that re-emits on every local change. Cancelling the
1487
1626
  /// subscription tells the runner to drop the watch.
1488
1627
  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);
1628
+ [Map<String, Object?> params = const {}, String? session]) =>
1629
+ _watchStream(kind: 'query', queryId: queryId, params: params, session: session);
1630
+ Stream<List<dynamic>> watchById(String id, [String? session]) =>
1631
+ _watchStream(kind: 'byId', id: id, session: session);
1493
1632
 
1494
1633
  /// 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);
1634
+ Stream<List<dynamic>> watchSpatial(String spatialId, NomosBbox bbox,
1635
+ [String? session]) =>
1636
+ _watchStream(kind: 'spatial', spatialId: spatialId, bbox: bbox, session: session);
1497
1637
 
1498
1638
  /// SHAPE COMPATIBILITY (#72): one-shot verdict for this client's structural [requirement]
1499
1639
  /// (its baked `…InterfaceJson`, decoded) against the workspace's ON-LEDGER offer (auto-sourced).
@@ -1568,7 +1708,8 @@ class NomosBridge {
1568
1708
  Map<String, Object?>? params,
1569
1709
  String? id,
1570
1710
  String? spatialId,
1571
- NomosBbox? bbox}) {
1711
+ NomosBbox? bbox,
1712
+ String? session}) {
1572
1713
  final subId = 'w${++_seq}';
1573
1714
  late final StreamController<List<dynamic>> ctrl;
1574
1715
  ctrl = StreamController<List<dynamic>>(
@@ -1580,6 +1721,7 @@ class NomosBridge {
1580
1721
  if (id != null) 'id': id,
1581
1722
  if (spatialId != null) 'spatialId': spatialId,
1582
1723
  if (bbox != null) 'bbox': bbox.toJson(),
1724
+ if (session != null) 'session': session,
1583
1725
  }),
1584
1726
  onCancel: () async {
1585
1727
  _watches.remove(subId);
@@ -1626,3 +1768,87 @@ class NomosBridge {
1626
1768
  if (!_syncStatusCtrl.isClosed) await _syncStatusCtrl.close();
1627
1769
  }
1628
1770
  }
1771
+
1772
+ /// A LIVE WORKSPACE on a device's local plane — the FIRST-CLASS multi-workspace object. Everything about
1773
+ /// Nomos is multi-workspace: a running app holds ONE [NomosBridge] (this device's local holarchy plane) and
1774
+ /// MANY [NomosSession]s on it — the user's home, each estate, each catalogue, any locally-born child — a
1775
+ /// dozen or hundreds, interacting offline. Get one with `bridge.session('estate')` and hand it to a generated
1776
+ /// typed client: `EstateClient(bridge.session('estate'))`.
1777
+ ///
1778
+ /// A session is a LOCAL githolon (the real byte-identical kernel + ledger + gate), NOT a cloud connection —
1779
+ /// cloud sync is an OPTIONAL per-workspace posture (a workspace opened with no `cloud` is local-only and
1780
+ /// never converges upstream). Its authority and truth are LOCAL; a hosted remote is replaceable custody.
1781
+ ///
1782
+ /// Implementation: it EXTENDS [NomosBridge] (so it IS a `NomosBridge` wherever a generated client expects
1783
+ /// `final NomosBridge bridge;` — ZERO codegen change) but holds NO state of its own: it shares the [parent]
1784
+ /// plane's ONE transport, correlation, and watch streams, and overrides the sole low-level [_call] to stamp
1785
+ /// `args.session` and route through the parent — so request/reply, watches, and telemetry all resolve on the
1786
+ /// one message loop with no aliasing. The single-workspace [NomosBridge] methods are documented sugar over an
1787
+ /// inferred default session; a multi-workspace app works through session handles.
1788
+ class NomosSession extends NomosBridge {
1789
+ /// The device's local plane every op routes through (the shared [NomosBridge]).
1790
+ final NomosBridge parent;
1791
+
1792
+ /// The workspace name this session is pinned to.
1793
+ final String workspace;
1794
+
1795
+ NomosSession(this.parent, this.workspace) : super._view(parent._transport);
1796
+
1797
+ /// Sibling workspaces resolve on the shared plane — delegate to the parent's registry (never nest a
1798
+ /// session inside a session).
1799
+ @override
1800
+ NomosSession session(String workspace) => parent.session(workspace);
1801
+
1802
+ @override
1803
+ Future<Object?> _call(String op, [Map<String, Object?> args = const {}]) =>
1804
+ parent._call(op,
1805
+ args.containsKey('session') ? args : {...args, 'session': workspace});
1806
+
1807
+ // The parent owns the ONE message loop + lifecycle; a session never subscribes, never signals ready of its
1808
+ // own, and never tears the plane down (the harness owns `parent.dispose()`).
1809
+ @override
1810
+ Future<void> get ready => parent.ready;
1811
+ @override
1812
+ Future<void> dispose() async {}
1813
+
1814
+ // Reactive reads register on the PARENT (which listens) so watch events land — route with our session.
1815
+ @override
1816
+ Stream<List<dynamic>> watch(String queryId,
1817
+ [Map<String, Object?> params = const {}, String? session]) =>
1818
+ parent.watch(queryId, params, session ?? workspace);
1819
+ @override
1820
+ Stream<List<dynamic>> watchById(String id, [String? session]) =>
1821
+ parent.watchById(id, session ?? workspace);
1822
+ @override
1823
+ Stream<List<dynamic>> watchSpatial(String spatialId, NomosBbox bbox,
1824
+ [String? session]) =>
1825
+ parent.watchSpatial(spatialId, bbox, session ?? workspace);
1826
+ @override
1827
+ Stream<Map<String, Object?>> watchCompatibility(
1828
+ Map<String, Object?> requirement) =>
1829
+ parent.watchCompatibility(requirement);
1830
+
1831
+ // Live snapshots / streams reflect the shared plane — read them off the parent's controllers.
1832
+ @override
1833
+ NomosCompat? get compat => parent.compat;
1834
+ @override
1835
+ Stream<NomosCompat> get onLawChanged => parent.onLawChanged;
1836
+ @override
1837
+ Map<String, Object?>? get lastRecovery => parent.lastRecovery;
1838
+ @override
1839
+ Stream<Map<String, Object?>> get syncEvents => parent.syncEvents;
1840
+ @override
1841
+ Stream<TransferProgress> get transferProgress => parent.transferProgress;
1842
+ @override
1843
+ Stream<SyncStatus> get syncStatus => parent.syncStatus;
1844
+ @override
1845
+ SyncStatus get currentStatus => parent.currentStatus;
1846
+ @override
1847
+ Stream<Map<String, Object?>> get signalEvents => parent.signalEvents;
1848
+ @override
1849
+ Stream<Map<String, Object?>> get flameEvents => parent.flameEvents;
1850
+ @override
1851
+ Stream<Map<String, Object?>> get spanEvents => parent.spanEvents;
1852
+ @override
1853
+ List<Map<String, Object?>> get recentSpans => parent.recentSpans;
1854
+ }
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
+ }
@@ -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.0",
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",