@githolon/dsl 0.73.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.
@@ -0,0 +1,108 @@
1
+ // CO2'S ACCEPTANCE (verbatim, 2026-07): `NomosTestHarness.cluster(deviceKeyed: …)` re-opens the keyed
2
+ // session (keygen → keyed reconnect) and MUST NEVER hang — the reported wedge was phase 3b, the second
3
+ // `connect` on an already-open session. Acceptance:
4
+ // 1. cluster(..., deviceKeyed: {workspace}) completes (co2's exact shape: 4 sessions, one
5
+ // deviceKeyed, colon-namespaced workspace names)
6
+ // 2. devicePublicKey(workspace) is populated
7
+ // 3. a colocated attested read succeeds (served + SIGNED with the device key the re-open installed)
8
+ // 4. harness disposal completes without being killed
9
+ // Phase diagnostics (their ask 5) are asserted too: 3b logs enter/exit for keygen + keyed reconnect.
10
+ @TestOn('vm')
11
+ @Timeout(Duration(minutes: 3))
12
+ library;
13
+
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 _catDeploy => '$_root/bench/scale/fixtures/catalogue-src/build/cat.deploy.json';
22
+ String get _estateDeploy => '$_root/bench/scale/fixtures/catalogue-reader/build/estate.deploy.json';
23
+ String get _runtimeDir => '${Platform.environment['HOME'] ?? ''}/.holon/runtime';
24
+
25
+ String? _missingPrereq() {
26
+ if (!File(_repoEntry).existsSync()) return 'in-repo runner not found at $_repoEntry';
27
+ for (final p in [_catDeploy, _estateDeploy]) {
28
+ if (!File(p).existsSync()) return 'missing fixture $p (compile bench/scale/fixtures first)';
29
+ }
30
+ if (!File('$_runtimeDir/holon.wasm').existsSync() ||
31
+ !File('$_runtimeDir/packages.json').existsSync()) {
32
+ return 'warm ~/.holon/runtime first (run any `githolon` command once)';
33
+ }
34
+ return null;
35
+ }
36
+
37
+ void main() {
38
+ test('cluster(deviceKeyed) completes: keyed re-open, device key populated, colocated attested read, disposal',
39
+ () async {
40
+ final skip = _missingPrereq();
41
+ if (skip != null) {
42
+ markTestSkipped(skip);
43
+ return;
44
+ }
45
+
46
+ const keyed = 'catalogue:test-catalogue';
47
+ final log = <String>[];
48
+ // 1. cluster construction with a deviceKeyed workspace COMPLETES (the reported hang was here).
49
+ final harness = await NomosTestHarness.cluster(
50
+ workspaces: {
51
+ keyed: [_catDeploy],
52
+ 'estate:a': [_estateDeploy],
53
+ 'estate:b': [_estateDeploy],
54
+ 'estate:c': [_estateDeploy],
55
+ },
56
+ deviceKeyed: {keyed},
57
+ nodeEntry: _repoEntry,
58
+ runtimeDir: _runtimeDir,
59
+ onDiagnostic: log.add,
60
+ );
61
+ try {
62
+ expect(harness.sessions.toSet(),
63
+ {keyed, 'estate:a', 'estate:b', 'estate:c'});
64
+
65
+ // 2. devicePublicKey is populated for the keyed session.
66
+ final public = harness.devicePublicKey(keyed);
67
+ expect(public, isNotNull);
68
+ expect(public, isNotEmpty,
69
+ reason: 'the 3b keygen public half must be recorded on the harness');
70
+
71
+ // ask 5 — phase diagnostics: 3b logs enter AND exit for keygen + keyed reconnect.
72
+ expect(log.any((l) => l.contains('3b: device keygen')), isTrue,
73
+ reason: 'phase 3b keygen must log');
74
+ expect(log.any((l) => l.contains("3b: keyed reconnect of '$keyed'") && l.contains('…')),
75
+ isTrue, reason: 'phase 3b reconnect must log its enter line');
76
+ expect(log.any((l) => l.contains("3b: keyed reconnect of '$keyed'") && l.contains('done')),
77
+ isTrue, reason: 'phase 3b reconnect must log its exit line');
78
+
79
+ // 3. a COLOCATED ATTESTED READ succeeds, signed with the device key the re-open installed
80
+ // (no attestorSecret injected — the source session signs with its connect authorSecret).
81
+ final cat = harness.session(keyed);
82
+ final seeded = await cat.offerCreates(
83
+ domain: 'cat',
84
+ directiveId: 'listCatalogue',
85
+ payload: {
86
+ 'listingId': 'L-1',
87
+ 'title': 'Walnut desk',
88
+ 'priceGbp': '900',
89
+ 'listedAt': '2026-07-11T00:00:00Z',
90
+ },
91
+ actor: 'user:seller',
92
+ );
93
+ expect(seeded.head, isNotEmpty);
94
+ final served = await harness.session('estate:a').attestedRead(
95
+ from: keyed,
96
+ queryId: 'listingById',
97
+ args: {'listingId': 'L-1'},
98
+ );
99
+ final entry = (served['entry'] as Map).cast<String, Object?>();
100
+ final attestation = (entry['attestation'] as Map).cast<String, Object?>();
101
+ expect((attestation['public_key'] ?? '').toString(), public,
102
+ reason: 'the colocated read is signed by the re-opened session device key');
103
+ } finally {
104
+ // 4. disposal completes (the harness deadline guarantees it can never wedge the test runner).
105
+ await harness.dispose();
106
+ }
107
+ });
108
+ }
@@ -0,0 +1,203 @@
1
+ // THE DEAD-LETTER QUEUE FROM THE DART SESSION SURFACE (task #86 gap 7). The DLQ promise —
2
+ // "refused-but-legitimate work is NEVER lost" — driven end-to-end through the Dart bridge:
3
+ // real authored work (its full intent bytes, read back via the production `salvage` lane) is
4
+ // parked as a dead letter on a workspace whose law cannot merge it (the exact shape a domain
5
+ // rejection parks — same `deadLetter()` path the converge/recover lanes use), then:
6
+ // deadLetters() surfaces it with provenance → retry REFUSES while the law is absent (the
7
+ // entry stays, error refreshed) → the law fix ships (nomos/installDomain) → retry ADMITS and
8
+ // the work lands, readable → a second entry is explicitly DISCARDED → the queue drains.
9
+ // Fully offline (local-only sessions) — which is itself a regression: retryDeadLetter/
10
+ // absorbSalvage used to throw "no remote main" on a local-only holon (fixed with this test).
11
+ @TestOn('vm')
12
+ library;
13
+
14
+ import 'dart:async';
15
+ import 'dart:convert';
16
+ import 'dart:io';
17
+
18
+ import 'package:nomos_client/nomos_headless.dart';
19
+ import 'package:test/test.dart';
20
+
21
+ String get _root => '${Directory.current.path}/../..';
22
+ String get _repoEntry => '$_root/cloud/web-client/runner/node-entry.mjs';
23
+ String get _geoDeploy => '$_root/bench/scale/fixtures/estate-geo/build/estate-geo.deploy.json';
24
+ String get _docsDeploy => '$_root/bench/scale/fixtures/surfaced/build/surfaced.deploy.json';
25
+ String get _runtimeDir =>
26
+ Platform.environment['NOMOS_DART_RUNTIME'] ??
27
+ '${Platform.environment['HOME'] ?? ''}/.holon/runtime';
28
+
29
+ String? _missingPrereq() {
30
+ if (!File(_repoEntry).existsSync()) return 'in-repo runner not found at $_repoEntry';
31
+ for (final e in {
32
+ _geoDeploy: 'bench/scale/fixtures/estate-geo',
33
+ _docsDeploy: 'bench/scale/fixtures/surfaced',
34
+ }.entries) {
35
+ if (!File(e.key).existsSync()) {
36
+ return 'missing fixture ${e.key} — compile it: (cd ${e.value} && node ../../../../dsl/compile_package.mjs)';
37
+ }
38
+ }
39
+ if (!File('$_runtimeDir/holon.wasm').existsSync() ||
40
+ !File('$_runtimeDir/packages.json').existsSync()) {
41
+ return 'warm ~/.holon/runtime first (run any `githolon` command once)';
42
+ }
43
+ return null;
44
+ }
45
+
46
+ Future<T> _leg<T>(String name, Future<T> Function() body) async {
47
+ final sw = Stopwatch()..start();
48
+ // ignore: avoid_print
49
+ print('▶ DLQ $name …');
50
+ try {
51
+ final r = await body();
52
+ // ignore: avoid_print
53
+ print('✔ DLQ $name (${sw.elapsedMilliseconds}ms)');
54
+ return r;
55
+ } catch (e, st) {
56
+ throw TestFailure('DLQ $name FAILED after ${sw.elapsedMilliseconds}ms:\n$e\n$st');
57
+ }
58
+ }
59
+
60
+ void main() {
61
+ test('DLQ: park → surface → refused retry → law fix → retry admits → discard drains',
62
+ () async {
63
+ final missing = _missingPrereq();
64
+ if (missing != null) {
65
+ markTestSkipped(missing);
66
+ return;
67
+ }
68
+ final harness = await NomosTestHarness.cluster(
69
+ workspaces: {
70
+ 'writer': [_geoDeploy], // holds the estate law — where the work is authored
71
+ 'app': [_docsDeploy], // the jammed workspace — NO estate law yet
72
+ },
73
+ nodeEntry: _repoEntry,
74
+ runtimeDir: _runtimeDir,
75
+ phaseTimeout: const Duration(seconds: 120),
76
+ );
77
+ try {
78
+ final writer = harness.session('writer');
79
+ final app = harness.session('app');
80
+ final geoHash = harness.lawHashes('writer').first;
81
+
82
+ // ── LEG 1: author REAL work under the estate law, and read its full intent bytes back
83
+ // through the production salvage lane. ──
84
+ final work = await _leg('leg 1: author two writes + salvage their intent bytes', () async {
85
+ Future<void> place(String name) => writer.offerOutcome(
86
+ domain: 'estate',
87
+ directiveId: 'placeSite',
88
+ payload: {
89
+ 'name': name,
90
+ 'point': {
91
+ 'type': 'Point',
92
+ 'coordinates': [-2.24, 53.48]
93
+ },
94
+ },
95
+ actor: 'user:marta',
96
+ domainHash: geoHash,
97
+ );
98
+ await place('a day of work I refuse to lose');
99
+ await place('the second day');
100
+ final s = await writer.salvage();
101
+ final pending = ((s['pendingWrites'] as List?) ?? const [])
102
+ .whereType<Map>()
103
+ .map((m) => m.cast<String, Object?>())
104
+ .where((w) {
105
+ try {
106
+ final doc =
107
+ jsonDecode(utf8.decode(base64Decode(w['intentB64'].toString()))) as Map;
108
+ return (doc['payload'] as Map?)?['domain'] == 'estate';
109
+ } catch (_) {
110
+ return false;
111
+ }
112
+ }).toList();
113
+ expect(pending, hasLength(2),
114
+ reason: 'salvage() returns the authored estate intents with their full bytes');
115
+ expect(pending.every((w) => (w['id'] ?? '').toString().isNotEmpty), isTrue);
116
+ return pending;
117
+ });
118
+
119
+ // ── LEG 2: park both on the jammed workspace (the shape a domain rejection parks) and
120
+ // assert deadLetters() surfaces them with full provenance. ──
121
+ await _leg('leg 2: park as dead letters → deadLetters() surfaces provenance', () async {
122
+ final r = await app.absorbSalvage(deadLetters: [
123
+ for (final w in work)
124
+ {
125
+ 'id': w['id'],
126
+ 'oid': w['id'], // provenance slot; the entry is deduped/looked up by id
127
+ 'intentB64': w['intentB64'],
128
+ 'source': 'local',
129
+ 'error': "no active installed law declares domain 'estate'",
130
+ }
131
+ ]);
132
+ expect(r['requeuedDeadLetters'], 2);
133
+ final dead = await app.deadLetters();
134
+ expect(dead, hasLength(2));
135
+ final entry = dead.first;
136
+ expect(entry['domain'], 'estate',
137
+ reason: 'the DLQ decodes domain/directiveId provenance from the parked bytes');
138
+ expect(entry['directiveId'], 'placeSite');
139
+ expect(entry['source'], 'local');
140
+ expect(entry.containsKey('intentB64'), isFalse,
141
+ reason: 'deadLetters() is metadata-only; the bytes stay in the durable queue');
142
+ });
143
+
144
+ // ── LEG 3: retry while the law is still absent — a REFUSAL, not a loss (and, regression:
145
+ // not a "no remote main" throw on this local-only session). ──
146
+ final firstId = work.first['id'].toString();
147
+ await _leg('leg 3: retry refuses while the law is absent; the entry stays', () async {
148
+ final r = await app.retryDeadLetter(firstId);
149
+ expect(r['ok'], isFalse, reason: 'the law still cannot merge it: $r');
150
+ final dead = await app.deadLetters();
151
+ expect(dead, hasLength(2), reason: 'a refused retry NEVER drops the work');
152
+ expect(
153
+ dead
154
+ .firstWhere((d) => d['id'] == firstId)['error']
155
+ .toString(),
156
+ isNotEmpty,
157
+ reason: 'the entry carries the fresh refusal');
158
+ });
159
+
160
+ // ── LEG 4: the domain dev ships the law fix; retry UNJAMS and the work lands, readable. ──
161
+ await _leg('leg 4: law fix (installDomain) → retry admits → the work is on the ledger',
162
+ () async {
163
+ final usda = (jsonDecode(File(_geoDeploy).readAsStringSync())
164
+ as Map)['packageUsda']
165
+ .toString();
166
+ await app.offerOutcome(
167
+ domain: 'nomos',
168
+ directiveId: 'installDomain',
169
+ payload: {
170
+ 'domainHash': geoHash,
171
+ 'packageUsda': usda,
172
+ 'installedBy': 'dlq-test-law-fix',
173
+ 'dependencies': <Object?>[],
174
+ 'finalizers': <Object?>[],
175
+ },
176
+ domainHash: harness.frameworkHash,
177
+ );
178
+ final r = await app.retryDeadLetter(firstId);
179
+ expect(r['ok'], isTrue, reason: 'the unjam after the law fix: $r');
180
+ final dead = await app.deadLetters();
181
+ expect(dead, hasLength(1), reason: 'the retried entry resolved out of the queue');
182
+ final rows =
183
+ await app.query('sitesByName', {'name': 'a day of work I refuse to lose'});
184
+ expect(rows, hasLength(1), reason: "the user's work landed and reads back");
185
+ });
186
+
187
+ // ── LEG 5: discard is the app's explicit drop; the queue drains; a re-retry of a gone id
188
+ // answers a typed refusal. ──
189
+ await _leg('leg 5: discard drains; retry of a gone id refuses by name', () async {
190
+ final secondId = work[1]['id'].toString();
191
+ final d = await app.discardDeadLetter(secondId);
192
+ expect(d['ok'], isTrue);
193
+ expect(await app.deadLetters(), isEmpty);
194
+ final gone = await app.retryDeadLetter(secondId);
195
+ expect(gone['ok'], isFalse);
196
+ expect(gone['error'].toString(), contains('no dead letter'));
197
+ });
198
+ } finally {
199
+ await harness.dispose().timeout(const Duration(seconds: 30),
200
+ onTimeout: () => throw StateError('harness disposal hung'));
201
+ }
202
+ }, timeout: const Timeout(Duration(minutes: 6)));
203
+ }
@@ -0,0 +1,188 @@
1
+ // THE EVENT-PLANE TRIPWIRE (source-level, no mirrors).
2
+ //
3
+ // THE BUG CLASS THIS CLOSES: NomosSession extends NomosBridge but shares the parent's transport —
4
+ // _onMessage only ever runs on the PARENT, so any Stream-returning member (or stateful snapshot) that
5
+ // touches `this`'s controller registries registers on a session's DEAD registry and never emits
6
+ // (shipped instances: watchPendingConflicts pre-0.48.1 + the five hand-written session watch overrides).
7
+ // The structural fix: ONE overridable seam — `NomosBridge get _eventPlane` — and every event-dispatch /
8
+ // plane-state access goes through it.
9
+ //
10
+ // WHAT THIS TEST ASSERTS (each chosen to FAIL if the bug class is re-opened):
11
+ // 1. Every public Stream-returning member declared on NomosBridge routes through the seam: its body
12
+ // either references `_eventPlane` directly or delegates to `_watchStream` (whose own body must).
13
+ // 2. The controller registries (`_watches[` / `_compatWatches[`) are only ever indexed via
14
+ // `_eventPlane.` inside Stream-returning members — a new method with its own local registry, or a
15
+ // bare `this._watches[subId] = ctrl`, trips it.
16
+ // 3. NomosSession declares NO Stream-returning members and NO StreamController state of its own — a
17
+ // re-added per-method override (the old workaround, i.e. the smell) trips it.
18
+ // 4. The seam override on NomosSession chases to the root (`parent._eventPlane`, not `parent`).
19
+ //
20
+ // HONESTY NOTE: this is a text-level check, not semantic analysis. It cannot prove an `_eventPlane`
21
+ // mention is the LOAD-BEARING one — but every re-occurrence of the bug so far shipped as a member whose
22
+ // body never named the parent plane at all, which this catches. Keep the seam's doc comment in
23
+ // webview_bridge.dart in sync if the routing rule ever changes.
24
+
25
+ import 'dart:io';
26
+
27
+ import 'package:test/test.dart';
28
+
29
+ /// Extract `class <name> ... { ... }`'s body via brace matching.
30
+ String classBody(String src, String name) {
31
+ final decl = RegExp('class $name[\\s<]').firstMatch(src);
32
+ if (decl == null) fail('class $name not found');
33
+ final open = src.indexOf('{', decl.start);
34
+ var depth = 0;
35
+ for (var i = open; i < src.length; i++) {
36
+ final c = src[i];
37
+ if (c == '{') depth++;
38
+ if (c == '}') {
39
+ depth--;
40
+ if (depth == 0) return src.substring(open + 1, i);
41
+ }
42
+ }
43
+ fail('unbalanced braces for class $name');
44
+ }
45
+
46
+ class Member {
47
+ Member(this.name, this.body);
48
+ final String name;
49
+ final String body; // the full text from `=>`/`{` to the member's end
50
+ }
51
+
52
+ /// Collect every member whose declared return type is `Stream<...>` (methods AND getters).
53
+ /// Text-level: finds `Stream<` at member position, then captures the arrow-or-block body.
54
+ List<Member> streamMembers(String body) {
55
+ final out = <Member>[];
56
+ final decl = RegExp(
57
+ r'\n Stream<[^=;{]*?(?:get\s+(\w+)|(\w+)\s*\()',
58
+ dotAll: true);
59
+ for (final m in decl.allMatches(body)) {
60
+ final name = m.group(1) ?? m.group(2)!;
61
+ // scan from the end of the declaration match to the member's end (arrow → `;` at depth 0;
62
+ // block → matching `}`), tracking (), {}, [] together so `=>` inside args doesn't confuse it.
63
+ // for a method the regex consumed the opening `(` — rewind one so paren depth balances.
64
+ var i = m.group(2) != null ? m.end - 1 : m.end;
65
+ var depth = 0;
66
+ var start = -1; // where the body proper begins
67
+ var isBlock = false;
68
+ for (; i < body.length; i++) {
69
+ final c = body[i];
70
+ if (c == '(' || c == '[') depth++;
71
+ if (c == ')' || c == ']') depth--;
72
+ if (depth == 0 && start < 0) {
73
+ if (c == '=' && i + 1 < body.length && body[i + 1] == '>') {
74
+ start = i;
75
+ continue;
76
+ }
77
+ if (c == '{') {
78
+ start = i;
79
+ isBlock = true;
80
+ depth = 1;
81
+ continue;
82
+ }
83
+ continue;
84
+ }
85
+ if (start < 0) continue;
86
+ if (isBlock) {
87
+ if (c == '{') depth++;
88
+ if (c == '}') {
89
+ depth--;
90
+ if (depth == 0) break;
91
+ }
92
+ } else {
93
+ if (c == '{') depth++;
94
+ if (c == '}') depth--;
95
+ if (c == ';' && depth == 0) break;
96
+ }
97
+ }
98
+ out.add(Member(name, body.substring(m.start, i + 1)));
99
+ }
100
+ return out;
101
+ }
102
+
103
+ void main() {
104
+ final src = File('lib/src/webview_bridge.dart').readAsStringSync();
105
+ final bridge = classBody(src, 'NomosBridge');
106
+ final session = classBody(src, 'NomosSession');
107
+ final bridgeStreams = streamMembers(bridge);
108
+
109
+ test('found the known Stream surface on NomosBridge (parser sanity)', () {
110
+ final names = bridgeStreams.map((m) => m.name).toSet();
111
+ // if one of these is renamed/removed, update this list deliberately.
112
+ for (final expected in [
113
+ 'custodyEvents', 'syncEvents', 'transferProgress', 'syncStatus',
114
+ 'signalEvents', 'flameEvents', 'spanEvents', 'onLawChanged',
115
+ 'watch', 'watchById', 'watchSpatial', 'watchPendingConflicts',
116
+ 'watchActivity', 'watchCompatibility', '_watchStream',
117
+ ]) {
118
+ expect(names, contains(expected),
119
+ reason: 'the source parser lost sight of $expected — fix the '
120
+ 'parser (or update the list if the member was deliberately '
121
+ 'renamed)');
122
+ }
123
+ });
124
+
125
+ test('every NomosBridge Stream member routes through the _eventPlane seam',
126
+ () {
127
+ for (final m in bridgeStreams) {
128
+ final routed = m.body.contains('_eventPlane') ||
129
+ RegExp(r'\b_watchStream\(').hasMatch(m.body);
130
+ expect(routed, isTrue,
131
+ reason: 'NomosBridge.${m.name} returns a Stream but its body '
132
+ 'references neither _eventPlane nor _watchStream — on a '
133
+ 'NomosSession its events would resolve against a DEAD '
134
+ 'registry (the watchPendingConflicts 0.48.1 bug class). '
135
+ 'Register controllers/state on _eventPlane.<field>.');
136
+ }
137
+ // and the shared helper itself must land its registry on the plane.
138
+ final ws = bridgeStreams.firstWhere((m) => m.name == '_watchStream');
139
+ expect(ws.body, contains('_eventPlane._watches[subId] = ctrl'),
140
+ reason: '_watchStream must register its controller on the event '
141
+ 'plane\'s registry');
142
+ final wc = bridgeStreams.firstWhere((m) => m.name == 'watchCompatibility');
143
+ expect(wc.body, contains('_eventPlane._compatWatches[subId] = ctrl'),
144
+ reason: 'watchCompatibility must register its controller on the '
145
+ 'event plane\'s registry');
146
+ });
147
+
148
+ test('controller registries are only indexed via the seam in Stream members',
149
+ () {
150
+ for (final m in bridgeStreams) {
151
+ for (final reg in ['_watches[', '_compatWatches[']) {
152
+ var from = 0;
153
+ while (true) {
154
+ final at = m.body.indexOf(reg, from);
155
+ if (at < 0) break;
156
+ from = at + reg.length;
157
+ final prefix =
158
+ m.body.substring(at < 12 ? 0 : at - 12, at);
159
+ expect(prefix, endsWith('_eventPlane.'),
160
+ reason: 'NomosBridge.${m.name} indexes $reg without the '
161
+ '_eventPlane. prefix — a bare registry access is the '
162
+ 'dead-registry bug on sessions');
163
+ }
164
+ }
165
+ }
166
+ });
167
+
168
+ test('NomosSession declares no Stream members and no controller state', () {
169
+ final sessionStreams = streamMembers(session);
170
+ expect(sessionStreams, isEmpty,
171
+ reason: 'NomosSession re-declares Stream member(s) '
172
+ '${sessionStreams.map((m) => m.name).toList()} — the per-method '
173
+ 'override was the WORKAROUND for the dead-registry bug, not the '
174
+ 'fix. Make the base implementation _eventPlane-correct instead.');
175
+ expect(session.contains('StreamController'), isFalse,
176
+ reason: 'NomosSession holds StreamController state of its own — '
177
+ 'event state belongs on the plane (_eventPlane)');
178
+ });
179
+
180
+ test('the session seam chases to the root plane', () {
181
+ expect(
182
+ RegExp(r'NomosBridge get _eventPlane => parent\._eventPlane;')
183
+ .hasMatch(session),
184
+ isTrue,
185
+ reason: 'NomosSession._eventPlane must be parent._eventPlane (not '
186
+ 'parent) so nested delegation stays correct');
187
+ });
188
+ }