@githolon/dsl 0.70.2 → 0.72.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.
@@ -31,6 +31,15 @@ import 'package:nomos_types/nomos_types.dart'
31
31
  NomosCustodyEvent,
32
32
  NomosCustodyStatus;
33
33
 
34
+ import 'session_bound.dart' show DomainNotInstalledForSession;
35
+
36
+ /// THE ENGINE (architecture/session_bound_clients.md): the multi-workspace RUNTIME — mount/connect,
37
+ /// the session registry, sync, custody + recovery, the home ceremony, and [NomosBridge.follow]
38
+ /// (ref → mounted session). NOT the surface for domain writes: those live on the generated
39
+ /// `<Domain>Client` bound to a [NomosSession]. `NomosEngine` is the doctrine-named alias of the
40
+ /// bridge root so app code reads as the four-layer split (engine → session → client → values).
41
+ typedef NomosEngine = NomosBridge;
42
+
34
43
  /// The protocol version the runner and the bridge agree on (`webview-runner.mjs` `WEBVIEW_BRIDGE_VERSION`).
35
44
  const int kWebviewBridgeVersion = 1;
36
45
 
@@ -523,7 +532,7 @@ class NomosEncryption {
523
532
  });
524
533
  }
525
534
  final epoch = (await _currentEpoch(scope)) + 1; // forward-only: strictly increments
526
- final head = await bridge.offerDirective(
535
+ final head = (await bridge.offerOutcome(
527
536
  domain: domain,
528
537
  directiveId: 'advanceKeyEpoch',
529
538
  payload: {
@@ -534,7 +543,8 @@ class NomosEncryption {
534
543
  },
535
544
  actor: actor,
536
545
  domainHash: domainHash,
537
- );
546
+ ))
547
+ .head;
538
548
  return NomosEpochResult(head: head, epoch: epoch, roster: members);
539
549
  }
540
550
 
@@ -553,7 +563,7 @@ class NomosEncryption {
553
563
  String? openedAt,
554
564
  }) async {
555
565
  final s = _defScope(scope);
556
- await bridge.offerDirective(
566
+ await bridge.offerOutcome(
557
567
  domain: domain,
558
568
  directiveId: grantDirective,
559
569
  payload: grantPayload,
@@ -581,7 +591,7 @@ class NomosEncryption {
581
591
  String? openedAt,
582
592
  }) async {
583
593
  final s = _defScope(scope);
584
- await bridge.offerDirective(
594
+ await bridge.offerOutcome(
585
595
  domain: domain,
586
596
  directiveId: revokeDirective,
587
597
  payload: revokePayload,
@@ -629,13 +639,26 @@ class NomosOfferOutcome {
629
639
  /// The raw per-child birth outcomes the holon reported (`{ workspace, genesisHead, … }`), when present.
630
640
  final List<Map<String, Object?>> birthOutcomes;
631
641
 
642
+ /// LEGACY GATEWAY LANE ONLY: the parent gateway's verdict, when this outcome wraps a deprecated
643
+ /// cross-workspace `createWorkspace` relay (the old generated-birth shape). Null on the one
644
+ /// local offer lane.
645
+ final Map<String, Object?>? gatewayVerdict;
646
+
632
647
  const NomosOfferOutcome({
633
648
  required this.head,
634
649
  required this.created,
635
650
  this.workspace = '',
636
651
  this.born = const [],
637
652
  this.birthOutcomes = const [],
653
+ this.gatewayVerdict,
638
654
  });
655
+
656
+ /// The born child workspace(s) as followable [NomosRef]s — hand one to [NomosBridge.follow]
657
+ /// (`final estate = await engine.follow(outcome.bornRefs.single);`) so app code never carries a
658
+ /// loose workspace string. The ref's type is the reserved `nomos.Workspace` locator tag.
659
+ List<NomosRef> get bornRefs => born
660
+ .map((w) => NomosRef(workspace: w, type: 'nomos.Workspace', id: w))
661
+ .toList();
639
662
  }
640
663
 
641
664
  /// THE BRIDGE. Construct over a [BridgeTransport], `await ready`, then `connect(...)` and drive the holon.
@@ -985,6 +1008,9 @@ class NomosBridge {
985
1008
  Map<String, Object?>? _lastRecovery;
986
1009
 
987
1010
  /// LOCAL-FIRST offer: carry opaque intent bytes into the GitHolon. Returns the new local head; durable on [sync].
1011
+ @Deprecated('Engine-level generic writes are the footgun (session_bound_clients.md ask 1/7): '
1012
+ 'dispatch through a generated <Domain>Client bound to a NomosSession '
1013
+ '(XClient.bind(session)). Retained for CLIs/admin tooling/generated internals only.')
988
1014
  Future<String> offer({required String intentBytes}) async {
989
1015
  final r = _asMap(await _call('offer', {'intentBytes': intentBytes}));
990
1016
  return r['head'].toString();
@@ -1000,6 +1026,10 @@ class NomosBridge {
1000
1026
  /// its trust policy); [force] carries the deploy/evolve force flag through. Both are OMITTED from the wire
1001
1027
  /// when absent, so a plain offer is byte-identical to before. [session] pins the colocated workspace in a
1002
1028
  /// cluster harness (absent ⇒ the runner's sole-session default).
1029
+ @Deprecated('Engine-level string-y writes are the footgun (session_bound_clients.md ask 1/7): '
1030
+ 'dispatch through a generated <Domain>Client bound to a NomosSession '
1031
+ '(XClient.bind(session) — no domain/directive strings at call sites). '
1032
+ 'Retained for CLIs/admin tooling/generated internals only.')
1003
1033
  Future<String> offerDirective({
1004
1034
  required String domain,
1005
1035
  required String directiveId,
@@ -1027,6 +1057,9 @@ class NomosBridge {
1027
1057
  /// inside the sealed intent (callers never supply one); the runner hands back what was minted, so a
1028
1058
  /// generated create method returns the typed id with NO query-after round-trip. [created] is empty for
1029
1059
  /// pure mutations.
1060
+ @Deprecated('Engine-level string-y writes are the footgun (session_bound_clients.md ask 1/7): '
1061
+ 'dispatch through a generated <Domain>Client bound to a NomosSession. '
1062
+ 'Retained for CLIs/admin tooling/generated internals only.')
1030
1063
  Future<NomosOfferOutcome> offerCreates({
1031
1064
  required String domain,
1032
1065
  required String directiveId,
@@ -1063,16 +1096,22 @@ class NomosBridge {
1063
1096
  bool? force,
1064
1097
  String? session,
1065
1098
  }) async {
1066
- final r = _asMap(await _call('offerDirective', {
1067
- 'domain': domain,
1068
- 'directiveId': directiveId,
1069
- 'payload': payload,
1070
- if (actor != null) 'actor': actor,
1071
- if (domainHash != null) 'domainHash': domainHash,
1072
- if (attestations != null) 'attestations': attestations,
1073
- if (force != null) 'force': force,
1074
- if (session != null) 'session': session,
1075
- }));
1099
+ final Map<String, Object?> r;
1100
+ try {
1101
+ r = _asMap(await _call('offerDirective', {
1102
+ 'domain': domain,
1103
+ 'directiveId': directiveId,
1104
+ 'payload': payload,
1105
+ if (actor != null) 'actor': actor,
1106
+ if (domainHash != null) 'domainHash': domainHash,
1107
+ if (attestations != null) 'attestations': attestations,
1108
+ if (force != null) 'force': force,
1109
+ if (session != null) 'session': session,
1110
+ }));
1111
+ } catch (e) {
1112
+ throw await _wrapDomainNotInstalled(e,
1113
+ domain: domain, domainHash: domainHash, requestedWorkspace: session);
1114
+ }
1076
1115
  final births = ((r['birthOutcomes'] as List?) ?? const [])
1077
1116
  .whereType<Map>()
1078
1117
  .map((m) => m.cast<String, Object?>())
@@ -1106,6 +1145,66 @@ class NomosBridge {
1106
1145
  );
1107
1146
  }
1108
1147
 
1148
+ /// CONTEXT-RICH "domain not installed" (session_bound_clients.md ask 6): the kernel's generic
1149
+ /// `no active installed law declares domain 'x'` refusal is WRAPPED here with the session
1150
+ /// context (which session, what it actually runs, the likely causes) — the string itself
1151
+ /// originates in the sealed kernel, so the bridge (not the kernel) owns the ergonomics.
1152
+ /// Anything that isn't that refusal passes through unchanged.
1153
+ Future<Object> _wrapDomainNotInstalled(Object e,
1154
+ {required String domain, String? domainHash, String? requestedWorkspace}) async {
1155
+ if (!RegExp(r"no active installed law declares domain '").hasMatch(e.toString())) return e;
1156
+ final self = this;
1157
+ final sessionWs = self is NomosSession ? self.workspace : null;
1158
+ var activeKeys = const <String>[];
1159
+ try {
1160
+ final offer = await interfaceOffer();
1161
+ if (offer != null) activeKeys = offer.keys.toList()..sort();
1162
+ } catch (_) {/* best-effort enrichment — the typed error still carries the context we have */}
1163
+ return DomainNotInstalledForSession(
1164
+ expectedDomainKey: domain,
1165
+ expectedDomainHash: domainHash,
1166
+ sessionId: requestedWorkspace ?? sessionWs,
1167
+ sessionWorkspace: sessionWs,
1168
+ requestedWorkspace: requestedWorkspace ?? sessionWs,
1169
+ activeDomainKeys: activeKeys,
1170
+ activeDomainHashes: compat?.workspaceRuns ?? const [],
1171
+ underlying: e,
1172
+ );
1173
+ }
1174
+
1175
+ /// THE WORKSPACE'S ON-LEDGER STRUCTURAL OFFER (#72): the per-domain interface embedded in each
1176
+ /// installed package, read off the LOCAL projection (offline). Keys = the ACTIVE domain keys the
1177
+ /// workspace serves. Null when no on-ledger interface is observed yet (pre-#72e law).
1178
+ Future<Map<String, Object?>?> interfaceOffer({String? session}) async {
1179
+ final r = _asMap(await _call('interfaceOffer', {if (session != null) 'session': session}));
1180
+ return (r['offer'] as Map?)?.cast<String, Object?>();
1181
+ }
1182
+
1183
+ /// FOLLOW A [NomosRef] — THE cross-workspace primitive (session_bound_clients.md ask 8): mount
1184
+ /// the referenced workspace on this device's plane (idempotent — an already-open workspace is
1185
+ /// just returned) and hand back its [NomosSession]. Typed client construction is then one line:
1186
+ /// `final estate = await engine.follow(outcome.bornRefs.single); final app = await EstateClient.bind(estate);`
1187
+ /// No loose `{workspaceId, aggregateId}` string pairs in app code. [cloud] binds the mount to a
1188
+ /// cloud (omit for the local plane — a locally-born child mounts in place, no re-clone).
1189
+ Future<NomosSession> follow(NomosRef ref,
1190
+ {String? cloud, String? clientId, String? authToken, bool allowUnborn = false}) async {
1191
+ final ws = ref.workspace;
1192
+ if (ws.isEmpty) {
1193
+ throw ArgumentError.value(ref, 'ref',
1194
+ 'follow: the NomosRef carries no workspace — only a workspace-complete ref is followable');
1195
+ }
1196
+ if (!_sessionNames.contains(ws)) {
1197
+ await connect(
1198
+ cloud: cloud,
1199
+ workspace: ws,
1200
+ session: ws,
1201
+ clientId: clientId,
1202
+ authToken: authToken,
1203
+ allowUnborn: allowUnborn);
1204
+ }
1205
+ return session(ws);
1206
+ }
1207
+
1109
1208
  /// Reserve a KERNEL-MINTED typed id (`<TypeTag>_<uuidv7>`) via the holon's front-door mint — for app-side
1110
1209
  /// references (routing keys, optimistic UI). `.creates` payloads still carry NO id: the LAW mints those
1111
1210
  /// (see [offerCreates], which returns them).
@@ -1121,6 +1220,9 @@ class NomosBridge {
1121
1220
  /// OFFER AN INTENT — the write verb. Plan a directive (domain/directiveId/payload) as the verified [actor];
1122
1221
  /// the holon encodes the v2 offer envelope via the single codec and admits it. Returns the new local head.
1123
1222
  /// (This is the canonical name; [offerDirective] is the legacy alias the generated clients still call.)
1223
+ @Deprecated('Engine-level string-y writes are the footgun (session_bound_clients.md ask 1/7): '
1224
+ 'dispatch through a generated <Domain>Client bound to a NomosSession. '
1225
+ 'Retained for CLIs/admin tooling/generated internals only.')
1124
1226
  Future<String> offerIntent({
1125
1227
  required String domain,
1126
1228
  required String directiveId,
@@ -1169,10 +1271,22 @@ class NomosBridge {
1169
1271
  }));
1170
1272
 
1171
1273
  /// MAKE A WORKSPACE — the one-call tenant path. A child workspace under a platform/[parent] is a SIGNED
1172
- /// control-plane offer; the holon seals `createWorkspace` (or [directiveId] `birthChild`) with the
1173
- /// requester's device key ([authorSecret], as [actor], e.g. `user:<uid>`) and relays it to the parent's
1174
- /// gateway. Returns the worker verdict (`{ok, status, body}`). The tenant move is one line:
1274
+ /// control-plane offer; the holon seals `createWorkspace` (or [directiveId] `birthChild`, or ANY
1275
+ /// tenant-defined `.births()` directive — see [domain]) with the requester's device key ([authorSecret],
1276
+ /// as [actor], e.g. `user:<uid>`) and relays it to the parent's gateway. Returns the worker verdict
1277
+ /// (`{ok, status, body}`). The tenant move is one line:
1175
1278
  /// `await bridge.createWorkspace(name: 'foo', parent: 'my-platform', actor: 'user:$uid', authorSecret: key)`.
1279
+ ///
1280
+ /// [domain] defaults to `'workspaces'` (the generic root-style governance law every platform's
1281
+ /// createWorkspace/birthChild rides). To birth through the PARENT'S OWN custom platform law instead (e.g.
1282
+ /// a tenant `birthEstateWorkspace` under a `co2_platform` domain), pass that domain's key + [domainHash]:
1283
+ /// `bridge.createWorkspace(domain: 'co2_platform', directiveId: 'birthEstateWorkspace', domainHash: ...)`.
1284
+ /// The kernel looks up `(domain, directiveId)` in the ACTIVE installed law — [domain] must name the law
1285
+ /// that actually declares [directiveId], never assume `'workspaces'`.
1286
+ @Deprecated('Engine-level generic writes with a loose parent string are the footgun '
1287
+ '(session_bound_clients.md asks 1/4/7): births go through the generated <Domain>Client '
1288
+ 'bound to the PARENT NomosSession (its birth method infers parent = the session workspace). '
1289
+ 'Retained for CLIs/admin tooling/generated compatibility shims only.')
1176
1290
  Future<Map<String, Object?>> createWorkspace({
1177
1291
  required String parent,
1178
1292
  required String actor,
@@ -1180,21 +1294,29 @@ class NomosBridge {
1180
1294
  String? name,
1181
1295
  Object? payload,
1182
1296
  String directiveId = 'createWorkspace',
1297
+ String domain = 'workspaces',
1183
1298
  String? domainHash,
1184
1299
  String? authToken,
1185
1300
  }) async {
1186
1301
  // [authorSecret] defaults to the connection's signing key (the home key NomosScope.home loaded) —
1187
1302
  // a frontend dev never threads a key through.
1188
- final r = _asMap(await _call('createWorkspace', {
1189
- 'parent': parent,
1190
- 'actor': actor,
1191
- if (authorSecret != null) 'authorSecret': authorSecret,
1192
- if (name != null) 'name': name,
1193
- if (payload != null) 'payload': payload,
1194
- 'directiveId': directiveId,
1195
- if (domainHash != null) 'domainHash': domainHash,
1196
- if (authToken != null) 'authToken': authToken,
1197
- }));
1303
+ final Map<String, Object?> r;
1304
+ try {
1305
+ r = _asMap(await _call('createWorkspace', {
1306
+ 'parent': parent,
1307
+ 'actor': actor,
1308
+ if (authorSecret != null) 'authorSecret': authorSecret,
1309
+ if (name != null) 'name': name,
1310
+ if (payload != null) 'payload': payload,
1311
+ 'directiveId': directiveId,
1312
+ 'domain': domain,
1313
+ if (domainHash != null) 'domainHash': domainHash,
1314
+ if (authToken != null) 'authToken': authToken,
1315
+ }));
1316
+ } catch (e) {
1317
+ throw await _wrapDomainNotInstalled(e,
1318
+ domain: domain, domainHash: domainHash, requestedWorkspace: parent);
1319
+ }
1198
1320
  return (r['verdict'] as Map?)?.cast<String, Object?>() ?? r;
1199
1321
  }
1200
1322
 
@@ -1459,6 +1581,18 @@ class NomosBridge {
1459
1581
  }))['sum']) as num)
1460
1582
  .toInt();
1461
1583
 
1584
+ /// THE PENDING-CONFLICTS READ (Surfaced merge conflicts — the DX layer): the MAINTAINED,
1585
+ /// workspace-wide "decisions needed" index (reserved id `nomosPendingConflicts`). One row per
1586
+ /// (aggregate, field) whose folded state carries a Surfaced pending set of 2+ observations —
1587
+ /// `{aggregateType, aggregateId, field, observations: [{value, author, occurredAt}]}`.
1588
+ /// DERIVED, NOT DRAINED: a resolution write folding in removes its row by derivation.
1589
+ /// Empty for a Surfaced-free workspace. (The generated typed client wraps these rows as
1590
+ /// `PendingConflictRow` and pairs them with the designated `resolve…` helpers.)
1591
+ Future<List<Map<String, dynamic>>> pendingConflicts([String? session]) async =>
1592
+ _asRows(await _call('pendingConflicts', {
1593
+ if (session != null) 'session': session,
1594
+ }));
1595
+
1462
1596
  /// Push local writes → the session branch → cloud admission, and pull canonical main back.
1463
1597
  Future<Map<String, Object?>> sync({bool admit = true, String? session}) async =>
1464
1598
  _asMap(await _call('sync', {
@@ -1612,6 +1746,11 @@ class NomosBridge {
1612
1746
  [String? session]) =>
1613
1747
  _watchStream(kind: 'spatial', spatialId: spatialId, bbox: bbox, session: session);
1614
1748
 
1749
+ /// Reactive PENDING-CONFLICTS read: re-emits the maintained Surfaced "decisions needed"
1750
+ /// rows whenever the index changes (derived, not drained — a resolution write clears its row).
1751
+ Stream<List<dynamic>> watchPendingConflicts([String? session]) =>
1752
+ _watchStream(kind: 'pendingConflicts', session: session);
1753
+
1615
1754
  /// SHAPE COMPATIBILITY (#72): one-shot verdict for this client's structural [requirement]
1616
1755
  /// (its baked `…InterfaceJson`, decoded) against the workspace's ON-LEDGER offer (auto-sourced).
1617
1756
  /// `{ ok, missing[], incompatible[] }` — names the exact directive/aggregate/field, not a hash.
@@ -1808,6 +1947,13 @@ class NomosSession extends NomosBridge {
1808
1947
  @override
1809
1948
  NomosSession session(String workspace) => parent.session(workspace);
1810
1949
 
1950
+ /// A followed ref mounts on the SHARED plane — delegate to the parent (a session never owns mounts).
1951
+ @override
1952
+ Future<NomosSession> follow(NomosRef ref,
1953
+ {String? cloud, String? clientId, String? authToken, bool allowUnborn = false}) =>
1954
+ parent.follow(ref,
1955
+ cloud: cloud, clientId: clientId, authToken: authToken, allowUnborn: allowUnborn);
1956
+
1811
1957
  @override
1812
1958
  Future<Object?> _call(String op, [Map<String, Object?> args = const {}]) =>
1813
1959
  parent._call(op,
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.46.0
14
+ version: 0.47.0
15
15
 
16
16
  environment:
17
17
  sdk: ^3.5.0
@@ -18,7 +18,6 @@ import 'dart:convert';
18
18
  import 'dart:io';
19
19
 
20
20
  import 'package:crypto/crypto.dart';
21
- import 'package:nomos_client/nomos_client.dart' show NomosOfferOutcome;
22
21
  import 'package:nomos_client/nomos_headless.dart';
23
22
  import 'package:test/test.dart';
24
23
 
@@ -0,0 +1,255 @@
1
+ // SESSION-BOUND CLIENTS — THE ACCEPTANCE (architecture/session_bound_clients.md). The blessed app
2
+ // shape end-to-end, against the REAL wasm on a local plane, with ZERO workspace/domain/directive
3
+ // strings in the app-side code:
4
+ //
5
+ // engine → mount → Client.bind(session) (preflight) → a PARENTLESS birth landing in the session
6
+ // workspace → engine.follow(ref) → a second client → a directive on it.
7
+ //
8
+ // Plus the typed failure modes: bind on a deliberately WRONG session throws
9
+ // DomainClientSessionMismatch with every spec field; a dispatch naming an uninstalled domain
10
+ // surfaces as DomainNotInstalledForSession (the context-rich replacement of the kernel's generic
11
+ // "no active installed law declares domain 'x'"); and the deprecated engine-level shims still
12
+ // function (compatibility is deprecation, not breakage).
13
+ @TestOn('vm')
14
+ library;
15
+
16
+ import 'dart:io';
17
+
18
+ // ignore: deprecated_member_use
19
+ import 'package:nomos_client/nomos_headless.dart';
20
+ import 'package:test/test.dart';
21
+
22
+ // home.client.dart re-exports home.dart + the nomos_client surface (NomosSession, the typed errors,
23
+ // NomosOfferOutcome, …); estate.client.dart adds the child-law client. One import each = the whole
24
+ // typed surface. ZERO raw strings needed below.
25
+ import '../../../bench/scale/fixtures/estate-birth/build/dart/src/generated/home.client.dart';
26
+ import '../../../bench/scale/fixtures/estate-birth/build/dart/src/generated/estate.client.dart'
27
+ show EstateClient, SeedEstateWorkspacePayload;
28
+
29
+ String get _root => '${Directory.current.path}/../..';
30
+ String get _repoEntry => '$_root/cloud/web-client/runner/node-entry.mjs';
31
+ String get _deployJson =>
32
+ '$_root/bench/scale/fixtures/estate-birth/build/estate-birth.deploy.json';
33
+ String get _otherDeployJson =>
34
+ '$_root/bench/scale/fixtures/surfaced/build/surfaced.deploy.json';
35
+ String get _runtimeDir =>
36
+ Platform.environment['NOMOS_DART_RUNTIME'] ??
37
+ '${Platform.environment['HOME'] ?? ''}/.holon/runtime';
38
+
39
+ const _workspace = 'estate-birth';
40
+ const _owner = 'user:session-bound-owner';
41
+
42
+ String? _missingPrereq() {
43
+ if (!File(_repoEntry).existsSync()) return 'in-repo runner not found at $_repoEntry';
44
+ if (!File(_deployJson).existsSync() || !File(_otherDeployJson).existsSync()) {
45
+ return 'compile the fixtures: (cd bench/scale/fixtures/estate-birth && node ../../../../dsl/compile_package.mjs); '
46
+ '(cd bench/scale/fixtures/surfaced && node ../../../../dsl/compile_package.mjs)';
47
+ }
48
+ if (!File('$_runtimeDir/holon.wasm').existsSync() ||
49
+ !File('$_runtimeDir/packages.json').existsSync()) {
50
+ return 'warm ~/.holon/runtime first (run any `githolon` command once)';
51
+ }
52
+ return null;
53
+ }
54
+
55
+ Future<NomosTestHarness> _harness() => NomosTestHarness.ephemeral(
56
+ deployJsonPaths: [_deployJson],
57
+ nodeEntry: _repoEntry,
58
+ runtimeDir: _runtimeDir,
59
+ workspace: _workspace,
60
+ );
61
+
62
+ void main() {
63
+ test(
64
+ 'THE BLESSED SHAPE: mount → bind (preflight) → parentless birth in the session workspace → '
65
+ 'follow(ref) → second client → directive — zero routing strings in app code', () async {
66
+ final skip = _missingPrereq();
67
+ if (skip != null) {
68
+ markTestSkipped(skip);
69
+ return;
70
+ }
71
+ final harness = await _harness();
72
+ try {
73
+ // ENGINE + SESSION: the harness bridge IS the session handle pinned to the fixture workspace.
74
+ final home = harness.session(harness.workspace);
75
+
76
+ // BIND (ask 2/5): the async preflight — reachable, domain installed, shape-compatible.
77
+ final homeClient = await HomeClient.bind(home, actor: _owner);
78
+ expect(homeClient.session, isNotNull);
79
+ expect(homeClient.session!.workspace, harness.workspace,
80
+ reason: 'inspectable session context (ask 3)');
81
+
82
+ // PARENTLESS BIRTH (ask 4): no parent parameter — the parent IS the session workspace.
83
+ final birth = await homeClient.birthEstateWorkspace(
84
+ payload: BirthEstateWorkspacePayload(
85
+ owner: _owner,
86
+ frameworkHash: harness.frameworkHash!,
87
+ lawHash: harness.lawHashes(harness.workspace).first,
88
+ bornAt: DateTime.now().toUtc().toIso8601String(),
89
+ ),
90
+ );
91
+ expect(birth.workspace, harness.workspace,
92
+ reason: 'the birth offer landed in the SESSION workspace, no loose parent');
93
+ expect(birth.gatewayVerdict, isNull, reason: 'the local offer lane, not the legacy relay');
94
+ expect(birth.born, hasLength(1));
95
+ expect(birth.birthOutcomes.first['status'], 'born');
96
+
97
+ // FOLLOW THE REF (ask 8): NomosRef in, mounted session out — never a string pair.
98
+ final estate = await harness.bridge.follow(birth.bornRefs.single);
99
+ expect(estate.workspace, birth.born.single);
100
+
101
+ // SECOND CLIENT + a typed read AND a directive on it.
102
+ final estateClient = await EstateClient.bind(estate, actor: _owner);
103
+ final rows = await estateClient.estatesByOwner(owner: _owner);
104
+ expect(rows, hasLength(1), reason: 'the child genesis seed reads back typed');
105
+ final seeded = rows.single;
106
+ // A directive on the second client: re-ensure the singleton with the SAME seeded values
107
+ // (`.ensures` — idempotent; the squat invariant re-accepts its own binding).
108
+ final reseed = await estateClient.seedEstateWorkspace(SeedEstateWorkspacePayload(
109
+ owner: seeded.owner!,
110
+ estateName: seeded.estateName!,
111
+ bornAt: seeded.bornAt!,
112
+ ));
113
+ expect(reseed.head, isNotEmpty);
114
+
115
+ // ask 9: the harness diagnostic names the installed domains.
116
+ await harness.assertDomainInstalled(
117
+ workspace: estate.workspace, domain: EstateClient.interface.domain);
118
+ } finally {
119
+ await harness.dispose();
120
+ }
121
+ }, timeout: const Timeout(Duration(minutes: 4)));
122
+
123
+ test('bind on a deliberately WRONG session throws DomainClientSessionMismatch with every field',
124
+ () async {
125
+ final skip = _missingPrereq();
126
+ if (skip != null) {
127
+ markTestSkipped(skip);
128
+ return;
129
+ }
130
+ // TWO workspaces, TWO different laws, ONE plane — binding HomeClient to the docs
131
+ // workspace is the deliberate wrong-session case (wrong-package-mounted).
132
+ final harness = await NomosTestHarness.cluster(
133
+ nodeEntry: _repoEntry,
134
+ runtimeDir: _runtimeDir,
135
+ workspaces: {
136
+ _workspace: [_deployJson],
137
+ 'docs-ws': [_otherDeployJson],
138
+ },
139
+ );
140
+ try {
141
+ final wrong = harness.session('docs-ws');
142
+ Object? thrown;
143
+ try {
144
+ await HomeClient.bind(wrong);
145
+ } catch (e) {
146
+ thrown = e;
147
+ }
148
+ expect(thrown, isA<DomainClientSessionMismatch>(),
149
+ reason: 'the mismatch fires at CONSTRUCTION, not two clicks into the app');
150
+ final m = thrown! as DomainClientSessionMismatch;
151
+ expect(m.sessionId, 'docs-ws');
152
+ expect(m.sessionWorkspace, 'docs-ws');
153
+ expect(m.expectedDomainKey, HomeClient.interface.domain);
154
+ expect(m.expectedDomainHash, HomeClient.interface.expectedDomainHash);
155
+ expect(m.expectedInterfaceHash, HomeClient.interface.expectedInterfaceHash);
156
+ expect(m.activeDomainKeys, isNot(contains(m.expectedDomainKey)));
157
+ expect(m.cause, SessionMismatchCause.wrongPackageMounted,
158
+ reason: 'the docs workspace RUNS law — just not this domain');
159
+ expect(m.hint, isNotEmpty);
160
+ expect(m.toString(), contains(m.expectedDomainKey));
161
+
162
+ // The RIGHT session still binds (the plane is unharmed).
163
+ await HomeClient.bind(harness.session(harness.workspace));
164
+
165
+ // session.assertCompatible is directly usable from tests (ask 9), same typed refusal.
166
+ await expectLater(wrong.assertCompatible(EstateClient.interface),
167
+ throwsA(isA<DomainClientSessionMismatch>()));
168
+ } finally {
169
+ await harness.dispose();
170
+ }
171
+ }, timeout: const Timeout(Duration(minutes: 4)));
172
+
173
+ test(
174
+ 'a dispatch naming an UNINSTALLED domain surfaces as DomainNotInstalledForSession '
175
+ '(context-rich, ask 6) — and the deprecated engine shims still function (ask 1/7)', () async {
176
+ final skip = _missingPrereq();
177
+ if (skip != null) {
178
+ markTestSkipped(skip);
179
+ return;
180
+ }
181
+ final harness = await _harness();
182
+ try {
183
+ final session = harness.session(harness.workspace);
184
+ Object? thrown;
185
+ try {
186
+ await session.offerOutcome(
187
+ domain: 'not_installed_domain', directiveId: 'doIt', payload: const {});
188
+ } catch (e) {
189
+ thrown = e;
190
+ }
191
+ expect(thrown, isA<DomainNotInstalledForSession>());
192
+ final err = thrown! as DomainNotInstalledForSession;
193
+ expect(err.expectedDomainKey, 'not_installed_domain');
194
+ expect(err.sessionWorkspace, harness.workspace);
195
+ expect(err.requestedWorkspace, harness.workspace);
196
+ expect(err.activeDomainKeys, contains(HomeClient.interface.domain),
197
+ reason: 'the error NAMES what the workspace actually runs');
198
+ expect(err.possibleCauses, DomainNotInstalledForSession.defaultPossibleCauses);
199
+ expect(err.underlying.toString(), contains('no active installed law declares domain'));
200
+ expect(err.toString(), contains('Possible causes'));
201
+
202
+ // THE DEPRECATED SHIM still functions: the engine-level generic write authors fine.
203
+ // ignore: deprecated_member_use
204
+ final head = await session.offerDirective(
205
+ domain: HomeClient.interface.domain,
206
+ directiveId: 'birthEstateWorkspace',
207
+ payload: {
208
+ 'owner': 'user:legacy-lane-owner',
209
+ 'frameworkHash': harness.frameworkHash,
210
+ 'lawHash': harness.lawHashes(harness.workspace).first,
211
+ 'bornAt': DateTime.now().toUtc().toIso8601String(),
212
+ },
213
+ actor: 'user:legacy-lane-owner',
214
+ );
215
+ expect(head, isNotEmpty, reason: 'deprecation, not breakage — the old lane still writes');
216
+ } finally {
217
+ await harness.dispose();
218
+ }
219
+ }, timeout: const Timeout(Duration(minutes: 4)));
220
+
221
+ test('the SYNC constructor lazily preflights on first dispatch (the can\'t-await path)',
222
+ () async {
223
+ final skip = _missingPrereq();
224
+ if (skip != null) {
225
+ markTestSkipped(skip);
226
+ return;
227
+ }
228
+ final harness = await NomosTestHarness.cluster(
229
+ nodeEntry: _repoEntry,
230
+ runtimeDir: _runtimeDir,
231
+ workspaces: {
232
+ _workspace: [_deployJson],
233
+ 'docs-ws': [_otherDeployJson],
234
+ },
235
+ );
236
+ try {
237
+ // Constructing synchronously on the wrong session succeeds (no await possible here)…
238
+ final client = HomeClient(harness.session('docs-ws'), actor: _owner);
239
+ // …but the FIRST dispatch runs the same preflight and throws the same typed mismatch.
240
+ await expectLater(
241
+ client.birthEstateWorkspace(
242
+ payload: BirthEstateWorkspacePayload(
243
+ owner: _owner,
244
+ frameworkHash: harness.frameworkHash!,
245
+ lawHash: harness.lawHashes(harness.workspace).first,
246
+ bornAt: DateTime.now().toUtc().toIso8601String(),
247
+ ),
248
+ ),
249
+ throwsA(isA<DomainClientSessionMismatch>()),
250
+ );
251
+ } finally {
252
+ await harness.dispose();
253
+ }
254
+ }, timeout: const Timeout(Duration(minutes: 4)));
255
+ }
@@ -1,3 +1,11 @@
1
+ ## 0.2.0
2
+
3
+ - `NomosPrincipal` / `NomosDeviceSecret` — branded identity types for the generated cross-workspace birth
4
+ methods (`.births()` directives). `actor`/`authorSecret` were previously bare, adjacent, same-shaped
5
+ strings — a transposition footgun with no compiler check. `NomosPrincipal.user(uid)`/`.parse('user:<uid>')`
6
+ and the opaque `NomosDeviceSecret` (redacts in `toString()`) make swapping them a type error. Purely
7
+ additive.
8
+
1
9
  ## 0.1.4
2
10
 
3
11
  - Custody-lifecycle types: `NomosCustodyBreak`, `NomosCustodyStatus`, `NomosCustodyEvent`,
@@ -33,6 +33,12 @@ export 'src/errors.dart';
33
33
  export 'src/foreign_ref.dart';
34
34
  export 'src/activity.dart';
35
35
  export 'src/nomos_ref.dart';
36
+ // Branded identity wrappers (NomosPrincipal / NomosDeviceSecret) for the generated cross-workspace
37
+ // birth methods — actor/authorSecret are distinct types, so a transposition is a compile error.
38
+ export 'src/principal.dart';
36
39
  // The CUSTODY-LIFECYCLE types (rebirth_and_custody_lifecycle.md): NomosCustodyBreak / NomosCustodyStatus /
37
40
  // CustodyBreakPolicy — custody incompatibility as a first-class, typed, app-decidable event. Pure data.
38
41
  export 'src/custody.dart';
42
+ // SURFACED MERGE CONFLICTS (architecture/surfaced_merge_conflict.md): the read-collapse types for a
43
+ // `.merge(Surfaced(...))` field — SurfacedValue<T> (resolved scalar | pending observations). Pure data.
44
+ export 'src/surfaced.dart';