@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.
- package/dart/lib/nomos_headless.dart +16 -1
- package/dart/lib/src/test_harness.dart +184 -6
- package/dart/lib/src/webview_bridge.dart +255 -29
- package/dart/pubspec.yaml +1 -1
- package/dart/test/cluster_attested_test.dart +185 -0
- package/dart/test/co2_acceptance_test.dart +0 -1
- package/dart/test/vo_read_typing_test.dart +0 -1
- package/dart/test/webview_bridge_test.dart +13 -0
- package/package.json +1 -1
|
@@ -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
|
-
///
|
|
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
|
-
|
|
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<
|
|
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]
|
|
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
|
-
|
|
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
|
-
///
|
|
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
|
-
{
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
1338
|
-
|
|
1339
|
-
|
|
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', {
|
|
1344
|
-
|
|
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', {
|
|
1348
|
-
|
|
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', {
|
|
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
|
-
|
|
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.
|
|
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}/../..';
|
|
@@ -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.
|
|
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",
|