@githolon/dsl 0.74.0 → 0.75.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dart/CHANGELOG.md CHANGED
@@ -1,3 +1,31 @@
1
+ ## 0.51.0
2
+
3
+ - **The burn-down wave.** Dead-letter queue surface on the session plane: `deadLetters()`,
4
+ `retryDeadLetter()`, `discardDeadLetter()`, `salvage()`, `absorbSalvage()` — with the local-only
5
+ retry/absorb fixes and the salvage-shape parking fix (refused work is NEVER silently dropped).
6
+ `nomos_headless.dart` exports the full session surface. Requires Nomos Cloud ≥ the 2026-07-11
7
+ burn-down deploy for the fleet DLQ sweep lanes (`/dlq-summary`, `/dlq-sweep`) and the
8
+ content-addressed large-deploy staging lane.
9
+
10
+ ## 0.50.1
11
+
12
+ - **Structural: the session-delegation-gap class is closed.** All event-dispatch registries
13
+ (watch subscriptions, compat watches, custody/sync/signal/flame/span/transfer/status
14
+ controllers, span buffers, session registries) now route through one `_eventPlane` seam that
15
+ resolves to the plane owning the message loop — the base class is correct for `NomosSession`
16
+ automatically, and all 17 hand-written per-method session overrides are DELETED (including the
17
+ 0.48.1 `watchPendingConflicts` fix, whose regression test still passes — the proof). Five
18
+ latent unreported instances of the same bug fixed en passant: `session.custodyEvents` (dead
19
+ stream), `connectedSessions` (empty), `connect()`-on-session state stamping, `checkCompat()`
20
+ caching, `noteLocalSpan()` (dead buffer). A source-level tripwire test fails the suite if a
21
+ future Stream-returning method bypasses the seam.
22
+ - **The first-five-minutes gauntlet** — one checklist test walking the entire promised surface
23
+ (deviceKeyed cluster + phase diagnostics, the blessed session-bound shape, local autoSync
24
+ posture with zero-fetch assertion, acquireSession single-flight, reactive first-snapshots,
25
+ attested keyed reads, custody reset/re-seed, deadline-bounded disposal) — now runs in CI on
26
+ every client/runner/codegen change. Every one of this week's four tenant-reported lifecycle
27
+ bugs appears as an explicit leg.
28
+
1
29
  ## 0.50.0
2
30
 
3
31
  - **No silent hangs — the standing harness property.** Every `NomosTestHarness` lifecycle phase now
@@ -39,3 +39,11 @@ library;
39
39
 
40
40
  export 'src/node_runner_transport.dart';
41
41
  export 'src/test_harness.dart';
42
+ // THE FULL SESSION SURFACE (task #86 gap 1): a headless test types everything the harness hands out —
43
+ // [NomosSessionCredentials] (the `acquireSession` ifAbsentCredentials callback's return type),
44
+ // [NomosBridge]/[NomosSession]/[NomosBbox]/[NomosCompat], the E2E sealing client
45
+ // ([NomosKeyStore]/[WrappedKeyStore]/[NomosEncryption]), and the typed session-bound errors —
46
+ // WITHOUT also importing the web barrel. Both files are web-safe (they are exported from
47
+ // `nomos_client.dart` too); re-exporting them here just makes the headless import self-sufficient.
48
+ export 'src/webview_bridge.dart';
49
+ export 'src/session_bound.dart';
@@ -769,6 +769,26 @@ class NomosBridge {
769
769
 
770
770
  static String _spanIdFor(int id) => id.toRadixString(16).padLeft(16, '0');
771
771
 
772
+ /// THE EVENT PLANE — the ONE bridge whose mutable dispatch registries every event/push resolves against.
773
+ ///
774
+ /// WHY THIS EXISTS (the closed bug class): a [NomosSession] extends this class but shares its [parent]'s
775
+ /// transport + message loop — [_onMessage] only ever runs on the PARENT, so events resolve against the
776
+ /// PARENT's controller maps ([_watches], [_compatWatches], the broadcast StreamControllers, [_spanBuffer],
777
+ /// [_sessionNames], …). Any Stream-returning method / stateful getter that touched `this`'s copies on a
778
+ /// session registered on a DEAD registry and never emitted (real shipped instances: `watchPendingConflicts`
779
+ /// pre-0.48.1, and the five hand-written session watch overrides this seam retired).
780
+ ///
781
+ /// THE RULE (tripwire-enforced by `test/event_plane_seam_test.dart`): EVERY member that reads or writes
782
+ /// event-dispatch / plane state MUST do so via `_eventPlane.<field>` — never a bare field access. Requests
783
+ /// still go through `this._call` (a session's override stamps `args.session`), so a session's watch
784
+ /// registers its controller on the PARENT's registry while the wire op still carries the SESSION's
785
+ /// workspace. Chases to the root, so nested delegation stays correct.
786
+ NomosBridge get _eventPlane => this;
787
+
788
+ /// The session name this view is pinned to (null on the plane itself). Lets base implementations be
789
+ /// session-aware (per-workspace compat caching / law-change streams) with no per-method overrides.
790
+ String? get _sessionName => null;
791
+
772
792
  /// Completes once the runner has posted `{ event: "ready" }` (the wasm + client are booted).
773
793
  Future<void> get ready => _ready.future;
774
794
 
@@ -926,8 +946,8 @@ class NomosBridge {
926
946
  /// it to a generated client (`EstateClient(bridge.session('estate'))`), and `connect` the workspace when
927
947
  /// ready — reads/writes flow once it is open. A device typically holds many: `bridge.session('home')`,
928
948
  /// `bridge.session('estate-42')`, … all on ONE bridge/transport (this device's local plane).
929
- NomosSession session(String workspace) =>
930
- _sessionViews.putIfAbsent(workspace, () => NomosSession(this, workspace));
949
+ NomosSession session(String workspace) => _eventPlane._sessionViews
950
+ .putIfAbsent(workspace, () => NomosSession(_eventPlane, workspace));
931
951
 
932
952
  /// A [NomosForeignReader] backed by THIS bridge's multi-workspace plane — what a generated `ForeignRef`
933
953
  /// (a `t.foreignRef` cross-workspace locator) resolves against: `row.data.listing.resolve(bridge.foreignReader())`.
@@ -939,7 +959,8 @@ class NomosBridge {
939
959
  NomosForeignReader foreignReader({String? cloud}) => _BridgeForeignReader(this, cloud);
940
960
 
941
961
  /// The workspace names [connect] has opened on this plane (the live sessions).
942
- List<String> get connectedSessions => List.unmodifiable(_sessionNames);
962
+ List<String> get connectedSessions =>
963
+ List.unmodifiable(_eventPlane._sessionNames);
943
964
 
944
965
  /// ENUMERATE THE LOCAL PLANE — every workspace open on this device + its sync posture:
945
966
  /// `[{ session, workspace, mounted, syncPosture: "local-only" | "cloud-bound" }]`. A local-only workspace
@@ -965,7 +986,7 @@ class NomosBridge {
965
986
  String? session,
966
987
  int? maxMounted}) async {
967
988
  final ws = session ?? workspace;
968
- _sessionNames.add(ws);
989
+ _eventPlane._sessionNames.add(ws);
969
990
  final r = _asMap(await _call('connect', {
970
991
  // [cloud] is OPTIONAL — omit it to open a LOCAL-ONLY workspace (a local githolon with no cloud
971
992
  // counterpart; the runner uses a `local://` sentinel and sync is a no-op). Pass it to make the
@@ -992,13 +1013,13 @@ class NomosBridge {
992
1013
  // fail closed (the break surfaces as an error on the sync/pull that hit it; the holon stays local).
993
1014
  if (onCustodyBreak != null) 'onCustodyBreak': onCustodyBreak.wire,
994
1015
  }));
995
- _lastRecovery = (r['recovery'] as Map?)?.cast<String, Object?>();
1016
+ _eventPlane._lastRecovery = (r['recovery'] as Map?)?.cast<String, Object?>();
996
1017
  }
997
1018
 
998
1019
  /// The custody lifecycle stream (rebirth_and_custody_lifecycle.md) — a rebirth/divergence surfaces here as a
999
1020
  /// typed [NomosCustodyEvent]. Bind a Flutter break dialog / posture banner to it; for a `CustodyBreakPolicy.
1000
1021
  /// ask` session, an event of `kind == 'ask'` awaits [resolveCustodyBreak].
1001
- Stream<NomosCustodyEvent> get custodyEvents => _custodyEvents.stream;
1022
+ Stream<NomosCustodyEvent> get custodyEvents => _eventPlane._custodyEvents.stream;
1002
1023
 
1003
1024
  /// Where a session sits on the custody spectrum (incarnation, posture, pending/salvaged local work,
1004
1025
  /// supersession history) — for a status banner or a "you're offline on old truth" indicator.
@@ -1032,10 +1053,54 @@ class NomosBridge {
1032
1053
  Future<Map<String, Object?>> discardSalvaged(String id, {String? session}) async =>
1033
1054
  _asMap(await _call('discardSalvaged', {'id': id, if (session != null) 'session': session}));
1034
1055
 
1056
+ // ── THE DEAD-LETTER QUEUE (refused-but-legitimate work is NEVER lost) — the Dart mirror of the
1057
+ // JS holon's DLQ surface (`deadLetters()` / `retryDeadLetter()` / `discardDeadLetter()`). A
1058
+ // DOMAIN rejection (the law couldn't merge it — edge admission or a local replay refusal)
1059
+ // parks the FULL intent durably in the workspace tree (it rides export()/restore); the app
1060
+ // inspects, the domain dev ships a law fix, retry unjams. ──
1061
+
1062
+ /// The parked dead letters — metadata only (`{id, oid, source, error, domain, directiveId, at}`);
1063
+ /// the intent bytes stay in the durable queue.
1064
+ Future<List<Map<String, Object?>>> deadLetters({String? session}) async {
1065
+ final r = _asMap(await _call('deadLetters', {if (session != null) 'session': session}));
1066
+ return ((r['dead'] as List?) ?? const [])
1067
+ .map((e) => (e as Map).cast<String, Object?>())
1068
+ .toList();
1069
+ }
1070
+
1071
+ /// Retry one dead letter (after the law fix landed): pulls canon first (it may already be
1072
+ /// admitted), else re-offers the parked bytes locally. `{ok, head?|alreadyOnMain?|error?}` —
1073
+ /// a refusal leaves the entry parked (with the fresh error), never lost.
1074
+ Future<Map<String, Object?>> retryDeadLetter(String id, {String? session}) async =>
1075
+ _asMap(await _call('retryDeadLetter', {'id': id, if (session != null) 'session': session}));
1076
+
1077
+ /// Discard one dead letter — the app's EXPLICIT choice to drop the work. `{ok}`.
1078
+ Future<Map<String, Object?>> discardDeadLetter(String id, {String? session}) async =>
1079
+ _asMap(await _call('discardDeadLetter', {'id': id, if (session != null) 'session': session}));
1080
+
1081
+ /// SALVAGE — best-effort extraction of this holon's local-only authored work (un-synced pending
1082
+ /// writes + the DLQ), each with its full `intentB64` bytes: `{pendingWrites, deadLetters}`. The
1083
+ /// read half of the recovery lane (`connect(recover: true)` runs salvage → [absorbSalvage]).
1084
+ Future<Map<String, Object?>> salvage({String? session}) async =>
1085
+ _asMap(await _call('salvage', {if (session != null) 'session': session}));
1086
+
1087
+ /// ABSORB salvaged work into THIS holon: pending writes are re-offered under the current law
1088
+ /// (a refusal dead-letters — never lost), and dead-letter entries are re-queued into the DLQ.
1089
+ /// Returns the recovery report `{salvagedWrites, requeuedDeadLetters, unsalvageable}`.
1090
+ Future<Map<String, Object?>> absorbSalvage(
1091
+ {List<Map<String, Object?>> pendingWrites = const [],
1092
+ List<Map<String, Object?>> deadLetters = const [],
1093
+ String? session}) async =>
1094
+ _asMap(await _call('absorbSalvage', {
1095
+ 'pendingWrites': pendingWrites,
1096
+ 'deadLetters': deadLetters,
1097
+ if (session != null) 'session': session,
1098
+ }));
1099
+
1035
1100
  /// The salvage report from the most recent `connect(recover: true)` — `{ salvagedWrites, requeuedDeadLetters,
1036
1101
  /// unsalvageable }`, or null if the connection didn't recover. Show "recovered N writes" / surface
1037
1102
  /// `unsalvageable` (writes the current law refused — kept in the DLQ, never dropped).
1038
- Map<String, Object?>? get lastRecovery => _lastRecovery;
1103
+ Map<String, Object?>? get lastRecovery => _eventPlane._lastRecovery;
1039
1104
  Map<String, Object?>? _lastRecovery;
1040
1105
 
1041
1106
  /// LOCAL-FIRST offer: carry opaque intent bytes into the GitHolon. Returns the new local head; durable on [sync].
@@ -1249,11 +1314,14 @@ class NomosBridge {
1249
1314
  Future<NomosSession> acquireSession(String workspace,
1250
1315
  {Future<NomosSessionCredentials> Function()? ifAbsentCredentials}) {
1251
1316
  // Fast path: a live session needs no runner round-trip (and never asks for credentials).
1252
- if (_sessionNames.contains(workspace)) return Future.value(session(workspace));
1317
+ final plane = _eventPlane;
1318
+ if (plane._sessionNames.contains(workspace)) {
1319
+ return Future.value(session(workspace));
1320
+ }
1253
1321
  // SINGLE-FLIGHT: one in-flight open per workspace per plane; concurrent callers join it.
1254
- return _acquiring.putIfAbsent(workspace, () async {
1322
+ return plane._acquiring.putIfAbsent(workspace, () async {
1255
1323
  try {
1256
- if (_sessionNames.contains(workspace)) return session(workspace);
1324
+ if (plane._sessionNames.contains(workspace)) return session(workspace);
1257
1325
  // Consult the plane's TRUTHFUL registry (mounted ∪ parked ∪ resident custody — the runner's
1258
1326
  // realm.list()): anything the device already holds opens locally, no cloud, no credentials.
1259
1327
  var resident = false;
@@ -1285,7 +1353,7 @@ class NomosBridge {
1285
1353
  return session(workspace);
1286
1354
  } finally {
1287
1355
  // completed (either way) — the next acquire re-evaluates fresh (a live session hits the fast path).
1288
- _acquiring.remove(workspace);
1356
+ plane._acquiring.remove(workspace);
1289
1357
  }
1290
1358
  });
1291
1359
  }
@@ -1712,18 +1780,19 @@ class NomosBridge {
1712
1780
  }
1713
1781
 
1714
1782
  /// Background-sync telemetry: each autoSync round (`event:'sync'`) and doorbell socket state (`event:'link'`).
1715
- Stream<Map<String, Object?>> get syncEvents => _syncEvents.stream;
1783
+ Stream<Map<String, Object?>> get syncEvents => _eventPlane._syncEvents.stream;
1716
1784
 
1717
1785
  /// LIVE pack byte-progress — the real loading bar. Each event carries `received`/`total` (the host's
1718
1786
  /// actual `Content-Length`, known before the body) + `kind` ('delta'|'full'). Emits only while a `/pack`
1719
1787
  /// transfer is moving (small syncs ride the inline offer-result piggyback — instant, no bar).
1720
- Stream<TransferProgress> get transferProgress => _transferEvents.stream;
1788
+ Stream<TransferProgress> get transferProgress =>
1789
+ _eventPlane._transferEvents.stream;
1721
1790
 
1722
1791
  /// THE APP-READY SYNC STATE — a truthful aggregation of sync rounds, the doorbell, transfer progress, and a
1723
1792
  /// periodic metrics poll. Map straight to a sync-state page. Starts a lightweight 4s metrics poll while
1724
1793
  /// listened to (stops on cancel). [currentStatus] is the latest snapshot for a non-stream read.
1725
- Stream<SyncStatus> get syncStatus => _syncStatusCtrl.stream;
1726
- SyncStatus get currentStatus => _status;
1794
+ Stream<SyncStatus> get syncStatus => _eventPlane._syncStatusCtrl.stream;
1795
+ SyncStatus get currentStatus => _eventPlane._status;
1727
1796
 
1728
1797
  void _pushStatus(SyncStatus s) {
1729
1798
  _status = s;
@@ -1761,20 +1830,24 @@ class NomosBridge {
1761
1830
  }
1762
1831
 
1763
1832
  /// Ephemeral signal-holon projections (`event:'signal'`), currently peer frontiers / lag for the workspace.
1764
- Stream<Map<String, Object?>> get signalEvents => _signalEvents.stream;
1833
+ Stream<Map<String, Object?>> get signalEvents =>
1834
+ _eventPlane._signalEvents.stream;
1765
1835
 
1766
1836
  /// THE REALTIME FLAMEPATH: one event per admission the edge broadcasts over the doorbell —
1767
1837
  /// `{ at, oid, edge:{totalMs,scanMs,fetchMs,gateFoldMs,sealMs,intents}, clients:[{client,n}] }`.
1768
1838
  /// Every subscriber sees EVERY admission (yours and your peers') — the cloud track of the flame chart.
1769
- Stream<Map<String, Object?>> get flameEvents => _flameEvents.stream;
1839
+ Stream<Map<String, Object?>> get flameEvents =>
1840
+ _eventPlane._flameEvents.stream;
1770
1841
 
1771
1842
  /// LIVE per-layer spans of the local pipeline (begin/end as they happen): mutex wait, githolon author,
1772
1843
  /// push, converge … The host renders in-progress bars so a hang is visible WHILE it blocks, not after.
1773
- Stream<Map<String, Object?>> get spanEvents => _spanEvents.stream;
1844
+ Stream<Map<String, Object?>> get spanEvents =>
1845
+ _eventPlane._spanEvents.stream;
1774
1846
 
1775
1847
  /// The buffered recent span events — replay these on first build to recover the BOOT flamepath
1776
1848
  /// (fetch wasm / clone ledger / instantiate) that streamed during connect(), before the UI subscribed.
1777
- List<Map<String, Object?>> get recentSpans => List.unmodifiable(_spanBuffer);
1849
+ List<Map<String, Object?>> get recentSpans =>
1850
+ List.unmodifiable(_eventPlane._spanBuffer);
1778
1851
 
1779
1852
  int _localSpanSeq = 0;
1780
1853
 
@@ -1790,14 +1863,17 @@ class NomosBridge {
1790
1863
  required int t1,
1791
1864
  String? id,
1792
1865
  }) {
1793
- final sid = id ?? 'local-${_localSpanSeq++}';
1866
+ final plane = _eventPlane;
1867
+ final sid = id ?? 'local-${plane._localSpanSeq++}';
1794
1868
  for (final m in <Map<String, Object?>>[
1795
1869
  {'event': 'span', 'span': {'phase': 'begin', 'id': sid, 'track': track, 'name': name, 'kind': kind, 't0': t0}},
1796
1870
  {'event': 'span', 'span': {'phase': 'end', 'id': sid, 't1': t1}},
1797
1871
  ]) {
1798
- _spanBuffer.add(m);
1799
- if (_spanBuffer.length > 400) _spanBuffer.removeRange(0, _spanBuffer.length - 400);
1800
- if (!_spanEvents.isClosed) _spanEvents.add(m);
1872
+ plane._spanBuffer.add(m);
1873
+ if (plane._spanBuffer.length > 400) {
1874
+ plane._spanBuffer.removeRange(0, plane._spanBuffer.length - 400);
1875
+ }
1876
+ if (!plane._spanEvents.isClosed) plane._spanEvents.add(m);
1801
1877
  }
1802
1878
  }
1803
1879
 
@@ -1860,7 +1936,12 @@ class NomosBridge {
1860
1936
  if (requirement != null) 'requirement': requirement,
1861
1937
  }));
1862
1938
  final v = NomosCompat.fromJson((r['compat'] as Map?)?.cast<String, Object?>());
1863
- if (v != null) _lastCompat = v;
1939
+ if (v != null) {
1940
+ _eventPlane._lastCompat = v;
1941
+ // a session's verdict is per-workspace state on the PLANE (so `session.compat` reads it back).
1942
+ final n = _sessionName;
1943
+ if (n != null) _eventPlane._lastCompatBySession[n] = v;
1944
+ }
1864
1945
  return v;
1865
1946
  }
1866
1947
 
@@ -1892,7 +1973,15 @@ class NomosBridge {
1892
1973
  }
1893
1974
 
1894
1975
  /// The last computed compat verdict (set by [checkCompat] / a `lawChanged` event). Null until bind.
1895
- NomosCompat? get compat => _lastCompat;
1976
+ /// LAW-scoped: a [NomosSession] answers for ITS workspace, falling back to the plane-wide value when the
1977
+ /// runner's events carried no session key (older bundles).
1978
+ NomosCompat? get compat {
1979
+ final n = _sessionName;
1980
+ if (n != null) {
1981
+ return _eventPlane._lastCompatBySession[n] ?? _eventPlane._lastCompat;
1982
+ }
1983
+ return _eventPlane._lastCompat;
1984
+ }
1896
1985
 
1897
1986
  /// THE FEATURE FLAG (dark-launch): true iff [member] exists on the workspace's offer AND is
1898
1987
  /// structurally compatible. Ship UI with a member dark, deploy the law, it lights up — no redeploy.
@@ -1904,7 +1993,16 @@ class NomosBridge {
1904
1993
 
1905
1994
  /// COMPAT SUPERPOWER live events: emits the fresh verdict whenever a synced install/upgrade/retire
1906
1995
  /// moves the workspace's offer under this live client. The app re-checks feature flags reactively.
1907
- Stream<NomosCompat> get onLawChanged => _lawChanged.stream;
1996
+ /// LAW-scoped like [compat]: a [NomosSession] listens to ITS workspace's law changes only.
1997
+ Stream<NomosCompat> get onLawChanged {
1998
+ final n = _sessionName;
1999
+ if (n != null) {
2000
+ return (_eventPlane._lawChangedBySession[n] ??=
2001
+ StreamController<NomosCompat>.broadcast())
2002
+ .stream;
2003
+ }
2004
+ return _eventPlane._lawChanged.stream;
2005
+ }
1908
2006
 
1909
2007
  /// SHAPE COMPATIBILITY as a LIVE BINDING (#72): a stream of verdicts that re-emits whenever the
1910
2008
  /// workspace's installed law changes (another peer installs/upgrades/retires a domain and it
@@ -1912,7 +2010,9 @@ class NomosBridge {
1912
2010
  Stream<Map<String, Object?>> watchCompatibility(
1913
2011
  Map<String, Object?> requirement,
1914
2012
  [String? session]) {
1915
- final subId = 'c${++_seq}';
2013
+ // Controllers live on the EVENT PLANE (where _onMessage dispatches); the registration `_call` stays
2014
+ // THIS instance's (a session's override stamps `args.session`) — see [_eventPlane].
2015
+ final subId = 'c${++_eventPlane._seq}';
1916
2016
  late final StreamController<Map<String, Object?>> ctrl;
1917
2017
  ctrl = StreamController<Map<String, Object?>>(
1918
2018
  onListen: () => _call('watch', {
@@ -1922,13 +2022,13 @@ class NomosBridge {
1922
2022
  if (session != null) 'session': session,
1923
2023
  }),
1924
2024
  onCancel: () async {
1925
- _compatWatches.remove(subId);
2025
+ _eventPlane._compatWatches.remove(subId);
1926
2026
  try {
1927
2027
  await _call('unwatch', {'subId': subId});
1928
2028
  } catch (_) {}
1929
2029
  },
1930
2030
  );
1931
- _compatWatches[subId] = ctrl;
2031
+ _eventPlane._compatWatches[subId] = ctrl;
1932
2032
  return ctrl.stream;
1933
2033
  }
1934
2034
 
@@ -1941,7 +2041,9 @@ class NomosBridge {
1941
2041
  NomosBbox? bbox,
1942
2042
  String? base,
1943
2043
  String? session}) {
1944
- final subId = 'w${++_seq}';
2044
+ // Controllers live on the EVENT PLANE (where _onMessage dispatches); the registration `_call` stays
2045
+ // THIS instance's (a session's override stamps `args.session`) — see [_eventPlane].
2046
+ final subId = 'w${++_eventPlane._seq}';
1945
2047
  late final StreamController<List<dynamic>> ctrl;
1946
2048
  ctrl = StreamController<List<dynamic>>(
1947
2049
  onListen: () => _call('watch', {
@@ -1956,13 +2058,13 @@ class NomosBridge {
1956
2058
  if (session != null) 'session': session,
1957
2059
  }),
1958
2060
  onCancel: () async {
1959
- _watches.remove(subId);
2061
+ _eventPlane._watches.remove(subId);
1960
2062
  try {
1961
2063
  await _call('unwatch', {'subId': subId});
1962
2064
  } catch (_) {}
1963
2065
  },
1964
2066
  );
1965
- _watches[subId] = ctrl;
2067
+ _eventPlane._watches[subId] = ctrl;
1966
2068
  return ctrl.stream;
1967
2069
  }
1968
2070
 
@@ -2027,6 +2129,16 @@ class NomosSession extends NomosBridge {
2027
2129
 
2028
2130
  NomosSession(this.parent, this.workspace) : super._view(parent._transport);
2029
2131
 
2132
+ /// THE SEAM (see [NomosBridge._eventPlane]): all event dispatch + plane state resolves on the PARENT's
2133
+ /// registries (chased to the root, so nested delegation stays correct). This ONE override is why the base
2134
+ /// class's Stream-returning methods work unmodified on a session — no per-method overrides.
2135
+ @override
2136
+ NomosBridge get _eventPlane => parent._eventPlane;
2137
+
2138
+ /// Base implementations scope law-state (compat / onLawChanged) to THIS workspace via this.
2139
+ @override
2140
+ String? get _sessionName => workspace;
2141
+
2030
2142
  /// Sibling workspaces resolve on the shared plane — delegate to the parent's registry (never nest a
2031
2143
  /// session inside a session).
2032
2144
  @override
@@ -2058,58 +2170,11 @@ class NomosSession extends NomosBridge {
2058
2170
  @override
2059
2171
  Future<void> dispose() async {}
2060
2172
 
2061
- // Reactive reads register on the PARENT (which listens) so watch events land — route with our session.
2062
- @override
2063
- Stream<List<dynamic>> watch(String queryId,
2064
- [Map<String, Object?> params = const {}, String? session]) =>
2065
- parent.watch(queryId, params, session ?? workspace);
2066
- @override
2067
- Stream<List<dynamic>> watchById(String id, [String? session]) =>
2068
- parent.watchById(id, session ?? workspace);
2069
- @override
2070
- Stream<List<dynamic>> watchSpatial(String spatialId, NomosBbox bbox,
2071
- [String? session]) =>
2072
- parent.watchSpatial(spatialId, bbox, session ?? workspace);
2073
- @override
2074
- Stream<List<dynamic>> watchActivity({String? base, String? session}) =>
2075
- parent.watchActivity(base: base, session: session ?? workspace);
2076
- @override
2077
- Stream<Map<String, Object?>> watchCompatibility(
2078
- Map<String, Object?> requirement,
2079
- [String? session]) =>
2080
- parent.watchCompatibility(requirement, session ?? workspace);
2081
- @override
2082
- Stream<List<dynamic>> watchPendingConflicts([String? session]) =>
2083
- parent.watchPendingConflicts(session ?? workspace);
2084
-
2085
- // Live snapshots / streams reflect the shared plane — read them off the parent's controllers.
2086
- // compat/onLawChanged are LAW-scoped, so a session answers for ITS workspace (falling back to the
2087
- // plane-wide value when the runner's events carry no session key — older bundles).
2088
- @override
2089
- NomosCompat? get compat =>
2090
- parent._lastCompatBySession[workspace] ?? parent.compat;
2091
- @override
2092
- Stream<NomosCompat> get onLawChanged => (parent._lawChangedBySession[
2093
- workspace] ??=
2094
- StreamController<NomosCompat>.broadcast()).stream;
2095
- @override
2096
- Map<String, Object?>? get lastRecovery => parent.lastRecovery;
2097
- @override
2098
- Stream<Map<String, Object?>> get syncEvents => parent.syncEvents;
2099
- @override
2100
- Stream<TransferProgress> get transferProgress => parent.transferProgress;
2101
- @override
2102
- Stream<SyncStatus> get syncStatus => parent.syncStatus;
2103
- @override
2104
- SyncStatus get currentStatus => parent.currentStatus;
2105
- @override
2106
- Stream<Map<String, Object?>> get signalEvents => parent.signalEvents;
2107
- @override
2108
- Stream<Map<String, Object?>> get flameEvents => parent.flameEvents;
2109
- @override
2110
- Stream<Map<String, Object?>> get spanEvents => parent.spanEvents;
2111
- @override
2112
- List<Map<String, Object?>> get recentSpans => parent.recentSpans;
2173
+ // NO per-method Stream/watch/snapshot overrides live here anymore — the base class routes ALL event
2174
+ // dispatch + plane state through [_eventPlane] (and law-scopes compat/onLawChanged via [_sessionName]),
2175
+ // and the watch ops still carry this session's workspace because their registration goes through the
2176
+ // overridden [_call]. Adding a `Stream`-returning override below is almost certainly re-opening the
2177
+ // dead-registry bug class — see `test/event_plane_seam_test.dart`.
2113
2178
  }
2114
2179
 
2115
2180
  /// THE BRIDGE-BACKED FOREIGN READER — a [NomosForeignReader] over a device's multi-workspace [NomosBridge]
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.50.0
14
+ version: 0.51.0
15
15
 
16
16
  environment:
17
17
  sdk: ^3.5.0
@@ -0,0 +1,132 @@
1
+ // ACQUIRE-SESSION: THE GENUINE CLOUD-OPEN LANE (task #86 gaps 1+2). The gauntlet proved the
2
+ // credential-FREE lanes (known workspace, single-flight); this test drives the one lane it could
3
+ // not: a workspace that is NOWHERE on the local plane, where [ifAbsentCredentials] MUST be invoked
4
+ // — exactly once, even under a concurrent double-acquire — and the connect is made with what it
5
+ // returns. Runs hermetically on the offline runner (the "cloud" is the runner's offline shim, so
6
+ // no network is ever contacted; what is under test is the acquire protocol, not the transport).
7
+ //
8
+ // This file also IS the gap-1 regression: it imports ONLY `nomos_headless.dart` and TYPES a
9
+ // `Future<NomosSessionCredentials> Function()` callback — before the barrel fix that did not compile.
10
+ @TestOn('vm')
11
+ library;
12
+
13
+ import 'dart:async';
14
+ import 'dart:io';
15
+
16
+ import 'package:nomos_client/nomos_headless.dart';
17
+ import 'package:test/test.dart';
18
+
19
+ String get _root => '${Directory.current.path}/../..';
20
+ String get _repoEntry => '$_root/cloud/web-client/runner/node-entry.mjs';
21
+ String get _docsDeploy => '$_root/bench/scale/fixtures/surfaced/build/surfaced.deploy.json';
22
+ String get _runtimeDir =>
23
+ Platform.environment['NOMOS_DART_RUNTIME'] ??
24
+ '${Platform.environment['HOME'] ?? ''}/.holon/runtime';
25
+
26
+ String? _missingPrereq() {
27
+ if (!File(_repoEntry).existsSync()) return 'in-repo runner not found at $_repoEntry';
28
+ if (!File(_docsDeploy).existsSync()) {
29
+ return 'missing fixture $_docsDeploy — compile it: '
30
+ '(cd bench/scale/fixtures/surfaced && node ../../../../dsl/compile_package.mjs)';
31
+ }
32
+ if (!File('$_runtimeDir/holon.wasm').existsSync() ||
33
+ !File('$_runtimeDir/packages.json').existsSync()) {
34
+ return 'warm ~/.holon/runtime first (run any `githolon` command once)';
35
+ }
36
+ return null;
37
+ }
38
+
39
+ Future<T> _leg<T>(String name, Future<T> Function() body) async {
40
+ final sw = Stopwatch()..start();
41
+ // ignore: avoid_print
42
+ print('▶ ACQUIRE $name …');
43
+ try {
44
+ final r = await body();
45
+ // ignore: avoid_print
46
+ print('✔ ACQUIRE $name (${sw.elapsedMilliseconds}ms)');
47
+ return r;
48
+ } catch (e, st) {
49
+ throw TestFailure('ACQUIRE $name FAILED after ${sw.elapsedMilliseconds}ms:\n$e\n$st');
50
+ }
51
+ }
52
+
53
+ void main() {
54
+ test('acquireSession: the absent-workspace lane invokes ifAbsentCredentials exactly once',
55
+ () async {
56
+ final missing = _missingPrereq();
57
+ if (missing != null) {
58
+ markTestSkipped(missing);
59
+ return;
60
+ }
61
+ final harness = await NomosTestHarness.ephemeral(
62
+ deployJsonPaths: [_docsDeploy],
63
+ workspace: 'acquire-home',
64
+ nodeEntry: _repoEntry,
65
+ runtimeDir: _runtimeDir,
66
+ phaseTimeout: const Duration(seconds: 120),
67
+ );
68
+ try {
69
+ final plane = harness.bridge;
70
+
71
+ // ── LEG 1 (gap 1, compile-time + runtime): the callback TYPE is reachable from the headless
72
+ // barrel alone, and a credentials object constructs with the documented fields. ──
73
+ await _leg('leg 1: NomosSessionCredentials is exported from nomos_headless', () async {
74
+ // ignore: prefer_function_declarations_over_variables
75
+ final Future<NomosSessionCredentials> Function() typedCallback = () async =>
76
+ const NomosSessionCredentials(cloud: 'https://offline.invalid', allowUnborn: true);
77
+ final c = await typedCallback();
78
+ expect(c.cloud, 'https://offline.invalid');
79
+ expect(c.allowUnborn, isTrue);
80
+ });
81
+
82
+ // ── LEG 2: a genuinely-absent workspace WITHOUT a callback is a NAMED StateError, never a
83
+ // silent cloudless mount. ──
84
+ await _leg('leg 2: absent workspace without credentials refuses (StateError)', () async {
85
+ await expectLater(
86
+ plane.acquireSession('nowhere-at-all'),
87
+ throwsA(isA<StateError>().having((e) => e.toString(), 'message',
88
+ contains('ifAbsentCredentials'))));
89
+ });
90
+
91
+ // ── LEG 3: THE CLOUD-OPEN LANE — an absent workspace + a concurrent double-acquire: the
92
+ // callback fires EXACTLY ONCE (single-flight), the connect is made with its credentials,
93
+ // and both callers get the SAME mounted session. ──
94
+ var invocations = 0;
95
+ await _leg('leg 3: concurrent double-acquire → one credentials invocation, one handle',
96
+ () async {
97
+ Future<NomosSessionCredentials> creds() async {
98
+ invocations++;
99
+ return const NomosSessionCredentials(
100
+ cloud: 'https://offline.invalid', clientId: 'acquired-client', allowUnborn: true);
101
+ }
102
+
103
+ final acquired = await Future.wait([
104
+ plane.acquireSession('cloud-opened-ws', ifAbsentCredentials: creds),
105
+ plane.acquireSession('cloud-opened-ws', ifAbsentCredentials: creds),
106
+ ]).timeout(const Duration(seconds: 60),
107
+ onTimeout: () => throw StateError('the cloud-open acquire never completed'));
108
+ expect(invocations, 1,
109
+ reason: 'a concurrent double-acquire must invoke the credentials callback ONCE');
110
+ expect(identical(acquired[0], acquired[1]), isTrue,
111
+ reason: 'both concurrent acquirers get the SAME session handle');
112
+ expect(acquired[0].workspace, 'cloud-opened-ws');
113
+ final listed = await plane.listWorkspaces();
114
+ expect(listed.map((w) => w['session'].toString()), contains('cloud-opened-ws'),
115
+ reason: 'the acquired workspace is mounted on the plane');
116
+ });
117
+
118
+ // ── LEG 4: a re-acquire of the now-mounted workspace hits the fast path — the callback is
119
+ // NEVER invoked again. ──
120
+ await _leg('leg 4: re-acquire is credential-free (fast path)', () async {
121
+ final again = await plane.acquireSession('cloud-opened-ws',
122
+ ifAbsentCredentials: () async =>
123
+ fail('the workspace is mounted — credentials must not be requested'));
124
+ expect(again.workspace, 'cloud-opened-ws');
125
+ expect(invocations, 1);
126
+ });
127
+ } finally {
128
+ await harness.dispose().timeout(const Duration(seconds: 30),
129
+ onTimeout: () => throw StateError('harness disposal hung'));
130
+ }
131
+ }, timeout: const Timeout(Duration(minutes: 5)));
132
+ }
@@ -23,7 +23,6 @@ library;
23
23
 
24
24
  import 'dart:io';
25
25
 
26
- import 'package:nomos_client/nomos_client.dart' show NomosOfferOutcome;
27
26
  import 'package:nomos_client/nomos_headless.dart';
28
27
  import 'package:test/test.dart';
29
28