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