@githolon/dsl 0.72.0 → 0.73.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,46 @@
1
+ ## 0.49.0
2
+
3
+ - **Fix: local-only `autoSync()` deadlocked the multi-workspace plane.** A `local://` session's
4
+ autoSync registered the full cloud machinery (doorbell WebSocket retry loop, fallback timers,
5
+ intent-offer POSTs that could never land) — the future never completed, the runner dispatcher
6
+ starved, sibling queries and `listWorkspaces()` hung, and teardown wedged. `autoSync()` on a
7
+ local-only session now registers nothing and returns immediately; `sync()` is a true no-op
8
+ (`localOnly: true` summary); a local:// holon performs ZERO network attempts. (co2's acceptance
9
+ test implemented verbatim; failed before, passes after.)
10
+ - **New: `acquireSession(workspace, {ifAbsentCredentials})`** — atomic single-flight session
11
+ acquisition: reuses a connected session, joins an in-flight open (concurrent calls yield the
12
+ same handle, one connect), opens local-only for workspaces the plane already knows
13
+ (mounted/parked/resident custody — credentials never invoked), and only calls
14
+ `ifAbsentCredentials` (exactly once) when a genuine cloud open is required.
15
+
16
+ ## 0.48.1
17
+
18
+ - **Fix: `NomosSession.watchPendingConflicts()` never emitted.** The session overrode every other
19
+ reactive read to register on the parent plane (which owns the message loop) but omitted this one —
20
+ a session-bound generated client's conflict stream registered on the session's own dead controller
21
+ registry and never delivered an event. Now delegates like `watch`/`watchById`/`watchActivity`.
22
+ Regression test: a session-level subscription must emit its first snapshot. (Reported by co2 with
23
+ the exact fix — thank you.)
24
+
25
+ ## 0.48.0
26
+
27
+ - **Session-bound clients (architecture/session_bound_clients.md)** — generated clients now bind to
28
+ exactly ONE `NomosSession`: `XClient.bind(session)` async preflight factory (structural interface
29
+ compat, typed `DomainClientSessionMismatch` at construction), all dispatch through the session,
30
+ births infer the parent from the session (no loose parent strings), `engine.follow(NomosRef)`
31
+ mounts by ref, `NomosOfferOutcome.bornRefs`. The kernel's generic "no active installed law declares
32
+ domain 'x'" is wrapped into the context-rich `DomainNotInstalledForSession` (session identity, both
33
+ workspaces, active domain keys/hashes, the four possible causes).
34
+ - Root-bridge generic writes (`offer`/`offerDirective`/`offerIntent`/`offerCreates`/`createWorkspace`)
35
+ are `@Deprecated` in app code — kept for CLI/admin tooling; the session-bound generated surface is
36
+ the path.
37
+ - `NomosSession.pendingConflicts()` / `watchPendingConflicts()` — the maintained Surfaced-conflict
38
+ "decisions needed" read (derived, not drained).
39
+ - Test harness: `resetWorkspaceCustody`, `seedWorkspaceFromDeployJson`, `assertDomainInstalled`,
40
+ `session.assertCompatible(XClient.interface)`.
41
+ - Requires Nomos Cloud ≥ the 2026-07-11 deploy (wasm 72604ff7 — the Surfaced/captured-basis kernel)
42
+ and pairs with `@githolon/dsl` 0.72.0 codegen.
43
+
1
44
  ## 0.47.0
2
45
 
3
46
  - **Fix: `NomosBridge.createWorkspace` hardcoded `domain: "workspaces"`** in the underlying signed offer —
@@ -661,6 +661,34 @@ class NomosOfferOutcome {
661
661
  .toList();
662
662
  }
663
663
 
664
+ /// What a GENUINE cloud open needs — returned by an [NomosBridge.acquireSession] `ifAbsentCredentials`
665
+ /// callback, invoked ONLY when the workspace is nowhere on the local plane (never for a local-only or
666
+ /// already-mounted workspace). Mirrors the cloud-shaped subset of [NomosBridge.connect]'s parameters.
667
+ class NomosSessionCredentials {
668
+ /// The cloud base URL the workspace syncs from (e.g. `https://nomos.captainapp.co.uk`).
669
+ final String cloud;
670
+
671
+ /// The verified principal's access token (`x-nomos-auth`), when the workspace requires one.
672
+ final String? authToken;
673
+
674
+ /// The client id for this device's replica (defaults per the runner when omitted).
675
+ final String? clientId;
676
+
677
+ /// The device signing secret (base64, from `authorKeygen`) — the warrant default for every offer.
678
+ final String? authorSecret;
679
+
680
+ /// Open even when the workspace has no remote main yet (an unborn workspace reads empty).
681
+ final bool allowUnborn;
682
+
683
+ const NomosSessionCredentials({
684
+ required this.cloud,
685
+ this.authToken,
686
+ this.clientId,
687
+ this.authorSecret,
688
+ this.allowUnborn = false,
689
+ });
690
+ }
691
+
664
692
  /// THE BRIDGE. Construct over a [BridgeTransport], `await ready`, then `connect(...)` and drive the holon.
665
693
  class NomosBridge {
666
694
  final BridgeTransport _transport;
@@ -708,6 +736,9 @@ class NomosBridge {
708
736
  // caches the per-workspace [NomosSession] handle so `bridge.session(ws)` is stable + cheap.
709
737
  final Set<String> _sessionNames = {};
710
738
  final Map<String, NomosSession> _sessionViews = {};
739
+ // [acquireSession]'s per-workspace single-flight: workspace → the in-flight open every concurrent
740
+ // acquirer joins (removed when it completes; a live session then hits the fast path instead).
741
+ final Map<String, Future<NomosSession>> _acquiring = {};
711
742
  int _seq = 0;
712
743
  final String _traceId = _newTraceId();
713
744
  late final StreamSubscription<Map<String, Object?>> _sub;
@@ -1205,6 +1236,60 @@ class NomosBridge {
1205
1236
  return session(ws);
1206
1237
  }
1207
1238
 
1239
+ /// ACQUIRE A SESSION ATOMICALLY — the plug-and-play multi-workspace open (co2 DX ask). Semantics:
1240
+ /// • an already-connected session is returned as-is (no work, no credentials);
1241
+ /// • a workspace the local plane already holds (mounted by another path, LRU-parked, or with resident
1242
+ /// custody — e.g. a locally-born child) is opened LOCAL-ONLY, in place (no cloud, no credentials);
1243
+ /// • two concurrent [acquireSession] calls for the same workspace SINGLE-FLIGHT: the second joins the
1244
+ /// first's in-flight open and both yield the SAME [NomosSession] — one connect, never two;
1245
+ /// • ONLY when a genuine cloud open is required (the workspace is nowhere on this device) is
1246
+ /// [ifAbsentCredentials] invoked — exactly once — and the connect made with what it returns.
1247
+ /// Omitting [ifAbsentCredentials] makes a genuinely-absent workspace a named [StateError] instead of a
1248
+ /// silent cloudless mount.
1249
+ Future<NomosSession> acquireSession(String workspace,
1250
+ {Future<NomosSessionCredentials> Function()? ifAbsentCredentials}) {
1251
+ // 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));
1253
+ // SINGLE-FLIGHT: one in-flight open per workspace per plane; concurrent callers join it.
1254
+ return _acquiring.putIfAbsent(workspace, () async {
1255
+ try {
1256
+ if (_sessionNames.contains(workspace)) return session(workspace);
1257
+ // Consult the plane's TRUTHFUL registry (mounted ∪ parked ∪ resident custody — the runner's
1258
+ // realm.list()): anything the device already holds opens locally, no cloud, no credentials.
1259
+ var resident = false;
1260
+ try {
1261
+ resident = (await listWorkspaces())
1262
+ .any((w) => w['workspace']?.toString() == workspace);
1263
+ } catch (_) {
1264
+ // a pre-connect plane has no registry yet — fall through to the credentials decision below.
1265
+ }
1266
+ if (resident) {
1267
+ await connect(workspace: workspace, session: workspace, allowUnborn: true);
1268
+ return session(workspace);
1269
+ }
1270
+ if (ifAbsentCredentials == null) {
1271
+ throw StateError(
1272
+ 'acquireSession("$workspace"): the workspace is not on this device\'s plane and no '
1273
+ 'ifAbsentCredentials was given — a cloud open is genuinely required');
1274
+ }
1275
+ final c = await ifAbsentCredentials();
1276
+ await connect(
1277
+ cloud: c.cloud,
1278
+ workspace: workspace,
1279
+ session: workspace,
1280
+ clientId: c.clientId,
1281
+ authToken: c.authToken,
1282
+ authorSecret: c.authorSecret,
1283
+ allowUnborn: c.allowUnborn,
1284
+ );
1285
+ return session(workspace);
1286
+ } finally {
1287
+ // completed (either way) — the next acquire re-evaluates fresh (a live session hits the fast path).
1288
+ _acquiring.remove(workspace);
1289
+ }
1290
+ });
1291
+ }
1292
+
1208
1293
  /// Reserve a KERNEL-MINTED typed id (`<TypeTag>_<uuidv7>`) via the holon's front-door mint — for app-side
1209
1294
  /// references (routing keys, optimistic UI). `.creates` payloads still carry NO id: the LAW mints those
1210
1295
  /// (see [offerCreates], which returns them).
@@ -1947,6 +2032,13 @@ class NomosSession extends NomosBridge {
1947
2032
  @override
1948
2033
  NomosSession session(String workspace) => parent.session(workspace);
1949
2034
 
2035
+ /// An acquired session mounts on the SHARED plane — delegate to the parent (the plane owns the
2036
+ /// single-flight registry; a session never owns mounts).
2037
+ @override
2038
+ Future<NomosSession> acquireSession(String workspace,
2039
+ {Future<NomosSessionCredentials> Function()? ifAbsentCredentials}) =>
2040
+ parent.acquireSession(workspace, ifAbsentCredentials: ifAbsentCredentials);
2041
+
1950
2042
  /// A followed ref mounts on the SHARED plane — delegate to the parent (a session never owns mounts).
1951
2043
  @override
1952
2044
  Future<NomosSession> follow(NomosRef ref,
@@ -1986,6 +2078,9 @@ class NomosSession extends NomosBridge {
1986
2078
  Map<String, Object?> requirement,
1987
2079
  [String? session]) =>
1988
2080
  parent.watchCompatibility(requirement, session ?? workspace);
2081
+ @override
2082
+ Stream<List<dynamic>> watchPendingConflicts([String? session]) =>
2083
+ parent.watchPendingConflicts(session ?? workspace);
1989
2084
 
1990
2085
  // Live snapshots / streams reflect the shared plane — read them off the parent's controllers.
1991
2086
  // compat/onLawChanged are LAW-scoped, so a session answers for ITS workspace (falling back to the
package/dart/pubspec.lock CHANGED
@@ -191,7 +191,7 @@ packages:
191
191
  path: "../dart_types"
192
192
  relative: true
193
193
  source: path
194
- version: "0.1.2"
194
+ version: "0.3.0"
195
195
  package_config:
196
196
  dependency: transitive
197
197
  description:
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.47.0
14
+ version: 0.49.0
15
15
 
16
16
  environment:
17
17
  sdk: ^3.5.0
@@ -20,7 +20,7 @@ dependencies:
20
20
  # The pure-data core (wire types, offer codec, NomosReads, schema validation, error
21
21
  # info). nomos_client depends on and re-exports it, so existing imports are unchanged.
22
22
  # For local monorepo dev, pubspec_overrides.yaml points this at ../dart_types.
23
- nomos_types: ^0.1.4
23
+ nomos_types: ^0.3.0
24
24
  json_schema: ^5.2.2
25
25
  crypto: ^3.0.0
26
26
 
@@ -0,0 +1,152 @@
1
+ // acquireSession — the ATOMIC session-open (co2 DX ask): reuses a mounted session, opens a plane-resident
2
+ // (parked / resident-custody) workspace locally, JOINS an in-flight open (single-flight per workspace), and
3
+ // invokes the credentials callback ONLY when a cloud open is genuinely required. Proven hermetically over a
4
+ // fake transport (the same style as webview_bridge_test.dart) — the live-runner leg rides
5
+ // cluster_local_autosync_test.dart.
6
+ import 'dart:async';
7
+
8
+ import 'package:nomos_client/nomos_client.dart';
9
+ import 'package:test/test.dart';
10
+
11
+ /// A fake runner: answers connect (after a real async gap, so concurrency is observable) and
12
+ /// listWorkspaces (from [resident]); records every request.
13
+ class FakeRunner implements BridgeTransport {
14
+ final _incoming = StreamController<Map<String, Object?>>.broadcast();
15
+ final List<Map<String, Object?>> sent = [];
16
+ final List<String> resident; // workspaces the plane already holds (realm.list())
17
+
18
+ FakeRunner({this.resident = const []}) {
19
+ scheduleMicrotask(
20
+ () => _incoming.add({'v': kWebviewBridgeVersion, 'event': 'ready'}));
21
+ }
22
+
23
+ int get connectCount => sent.where((m) => m['op'] == 'connect').length;
24
+ Map<String, Object?> connectArgs(int i) =>
25
+ (sent.where((m) => m['op'] == 'connect').elementAt(i)['args'] as Map)
26
+ .cast<String, Object?>();
27
+
28
+ @override
29
+ Stream<Map<String, Object?>> get messages => _incoming.stream;
30
+
31
+ @override
32
+ void send(Map<String, Object?> message) {
33
+ sent.add(message);
34
+ final id = message['id'] as int;
35
+ final op = message['op'] as String;
36
+ final args = (message['args'] as Map).cast<String, Object?>();
37
+ Future<void>.delayed(const Duration(milliseconds: 5), () {
38
+ Object? result;
39
+ switch (op) {
40
+ case 'connect':
41
+ result = {'workspace': args['workspace']};
42
+ break;
43
+ case 'listWorkspaces':
44
+ result = {
45
+ 'workspaces': [
46
+ for (final ws in resident)
47
+ {
48
+ 'session': ws,
49
+ 'workspace': ws,
50
+ 'mounted': false,
51
+ 'parked': true,
52
+ 'syncPosture': 'local-only'
53
+ }
54
+ ]
55
+ };
56
+ break;
57
+ default:
58
+ result = {};
59
+ }
60
+ _incoming
61
+ .add({'v': kWebviewBridgeVersion, 'id': id, 'ok': true, 'result': result});
62
+ });
63
+ }
64
+ }
65
+
66
+ void main() {
67
+ test('an already-connected session is reused — credentials NEVER invoked, no second connect', () async {
68
+ final runner = FakeRunner();
69
+ final bridge = NomosBridge(runner);
70
+ await bridge.ready;
71
+ await bridge.connect(workspace: 'home', session: 'home');
72
+ expect(runner.connectCount, 1);
73
+
74
+ final s = await bridge.acquireSession('home',
75
+ ifAbsentCredentials: () async =>
76
+ fail('credentials must not be requested for a mounted session'));
77
+ expect(identical(s, bridge.session('home')), isTrue);
78
+ expect(runner.connectCount, 1, reason: 'no re-connect for a live session');
79
+ await bridge.dispose();
80
+ });
81
+
82
+ test('a plane-resident (parked) workspace opens LOCALLY — no cloud, credentials NOT invoked', () async {
83
+ final runner = FakeRunner(resident: ['estate']);
84
+ final bridge = NomosBridge(runner);
85
+ await bridge.ready;
86
+
87
+ var asked = false;
88
+ final s = await bridge.acquireSession('estate', ifAbsentCredentials: () async {
89
+ asked = true;
90
+ return const NomosSessionCredentials(cloud: 'https://never.invalid');
91
+ });
92
+ expect(asked, isFalse, reason: 'a resident workspace never asks for credentials');
93
+ expect(s.workspace, 'estate');
94
+ expect(runner.connectCount, 1);
95
+ expect(runner.connectArgs(0).containsKey('cloud'), isFalse,
96
+ reason: 'a resident open is local-only — no cloud in the connect');
97
+ await bridge.dispose();
98
+ });
99
+
100
+ test('concurrent acquisition SINGLE-FLIGHTS: one open, one credentials call, the same session', () async {
101
+ final runner = FakeRunner();
102
+ final bridge = NomosBridge(runner);
103
+ await bridge.ready;
104
+
105
+ var credentialCalls = 0;
106
+ Future<NomosSessionCredentials> creds() async {
107
+ credentialCalls++;
108
+ await Future<void>.delayed(const Duration(milliseconds: 10));
109
+ return const NomosSessionCredentials(
110
+ cloud: 'https://cloud.example', authToken: 'tok', allowUnborn: true);
111
+ }
112
+
113
+ final results = await Future.wait([
114
+ bridge.acquireSession('cloudy', ifAbsentCredentials: creds),
115
+ bridge.acquireSession('cloudy', ifAbsentCredentials: creds),
116
+ bridge.acquireSession('cloudy', ifAbsentCredentials: creds),
117
+ ]);
118
+ expect(credentialCalls, 1, reason: 'the credentials callback fires exactly once');
119
+ expect(runner.connectCount, 1, reason: 'ONE connect for three concurrent acquires');
120
+ expect(identical(results[0], results[1]), isTrue);
121
+ expect(identical(results[1], results[2]), isTrue);
122
+ final args = runner.connectArgs(0);
123
+ expect(args['cloud'], 'https://cloud.example');
124
+ expect(args['authToken'], 'tok');
125
+ expect(args['allowUnborn'], isTrue);
126
+ await bridge.dispose();
127
+ });
128
+
129
+ test('genuinely absent + no credentials callback → a named StateError (never a silent cloudless mount)',
130
+ () async {
131
+ final runner = FakeRunner();
132
+ final bridge = NomosBridge(runner);
133
+ await bridge.ready;
134
+ await expectLater(bridge.acquireSession('nowhere'), throwsStateError);
135
+ expect(runner.connectCount, 0);
136
+ await bridge.dispose();
137
+ });
138
+
139
+ test('a NomosSession delegates acquireSession to the parent plane (one registry)', () async {
140
+ final runner = FakeRunner();
141
+ final bridge = NomosBridge(runner);
142
+ await bridge.ready;
143
+ await bridge.connect(workspace: 'home', session: 'home');
144
+ await bridge.connect(workspace: 'estate', session: 'estate');
145
+
146
+ final viaSession = await bridge.session('home').acquireSession('estate',
147
+ ifAbsentCredentials: () async =>
148
+ fail('estate is already mounted — no credentials'));
149
+ expect(identical(viaSession, bridge.session('estate')), isTrue);
150
+ await bridge.dispose();
151
+ });
152
+ }
@@ -0,0 +1,119 @@
1
+ // CO2 ACCEPTANCE — local-only autoSync must not block the multi-workspace plane (their report, verbatim):
2
+ // - Open multiple local-only sessions with NomosTestHarness.cluster.
3
+ // - await homeSession.autoSync() completes promptly.
4
+ // - await bridge.listWorkspaces() and a query on a sibling estate session still complete.
5
+ // - stopAutoSync() and harness disposal complete.
6
+ // - No local://.../intent-offers fetch attempts occur.
7
+ // autoSync() REGISTERS background behavior and returns immediately; for a local-only (local://) session it
8
+ // performs NO network work and must not monopolize the runner request dispatcher.
9
+ @TestOn('vm')
10
+ library;
11
+
12
+ import 'dart:io';
13
+
14
+ import 'package:nomos_client/nomos_headless.dart';
15
+ import 'package:test/test.dart';
16
+
17
+ String get _root => '${Directory.current.path}/../..';
18
+ String get _repoEntry => '$_root/cloud/web-client/runner/node-entry.mjs';
19
+ String get _guestbookDeploy => '$_root/examples/guestbook/build/guestbook.deploy.json';
20
+ String get _runtimeDir => '${Platform.environment['HOME'] ?? ''}/.holon/runtime';
21
+
22
+ String? _missingPrereq() {
23
+ if (!File(_repoEntry).existsSync()) return 'in-repo runner not found at $_repoEntry';
24
+ if (!File(_guestbookDeploy).existsSync()) {
25
+ return 'compile the guestbook example: cd examples/guestbook && npx nomos-compile — missing $_guestbookDeploy';
26
+ }
27
+ if (!File('$_runtimeDir/holon.wasm').existsSync() ||
28
+ !File('$_runtimeDir/packages.json').existsSync()) {
29
+ return 'warm ~/.holon/runtime first (run any `githolon` command once)';
30
+ }
31
+ return null;
32
+ }
33
+
34
+ void main() {
35
+ test(
36
+ 'local-only autoSync() returns promptly, never fetches, and does not block the plane (co2 acceptance)',
37
+ () async {
38
+ final skip = _missingPrereq();
39
+ if (skip != null) {
40
+ markTestSkipped(skip);
41
+ return;
42
+ }
43
+
44
+ // 1. Open multiple local-only sessions with NomosTestHarness.cluster (four, one bridge).
45
+ final names = ['home', 'estate-a', 'estate-b', 'estate-c'];
46
+ final diagnostics = <String>[];
47
+ final harness = await NomosTestHarness.cluster(
48
+ workspaces: {for (final ws in names) ws: [_guestbookDeploy]},
49
+ nodeEntry: _repoEntry,
50
+ runtimeDir: _runtimeDir,
51
+ onDiagnostic: diagnostics.add,
52
+ );
53
+ try {
54
+ final home = harness.session('home');
55
+
56
+ // A realistic home holds local writes (ahead of any base) — this is what drives the up-leg machinery.
57
+ await home.offerDirective(
58
+ domain: 'guestbook',
59
+ directiveId: 'signGuestbook',
60
+ payload: {
61
+ 'author': 'ada',
62
+ 'message': 'offline write before autoSync',
63
+ 'mood': 'delighted',
64
+ 'signedAt': '2026-07-11T00:00:00Z',
65
+ },
66
+ actor: 'ada',
67
+ );
68
+
69
+ // 2. await homeSession.autoSync() completes promptly.
70
+ await home.autoSync().timeout(const Duration(seconds: 10),
71
+ onTimeout: () => throw StateError(
72
+ 'autoSync() on a local-only session did not complete within 10s (the reported deadlock)'));
73
+
74
+ // 3. await bridge.listWorkspaces() and a query on a sibling estate session still complete.
75
+ final listed = await harness.bridge.listWorkspaces().timeout(
76
+ const Duration(seconds: 10),
77
+ onTimeout: () => throw StateError(
78
+ 'listWorkspaces() hung after a local-only autoSync (dispatcher monopolized)'));
79
+ expect(listed.map((w) => w['session'].toString()).toSet(),
80
+ containsAll(names.toSet()));
81
+ final sibling = await harness
82
+ .session('estate-a')
83
+ .query('entriesByAuthor', {'author': 'nobody'}).timeout(
84
+ const Duration(seconds: 10),
85
+ onTimeout: () => throw StateError(
86
+ 'a sibling estate query hung after a local-only autoSync'));
87
+ expect(sibling, isEmpty);
88
+
89
+ // Give any wrongly-registered background sync machinery time to fire so its fetch attempts would show.
90
+ await Future<void>.delayed(const Duration(seconds: 2));
91
+
92
+ // 4. stopAutoSync() and harness disposal complete (disposal asserted in finally / teardown).
93
+ await home.stopAutoSync().timeout(const Duration(seconds: 10),
94
+ onTimeout: () =>
95
+ throw StateError('stopAutoSync() hung on a local-only session'));
96
+
97
+ // Bonus (live-runner leg of the acquireSession DX ask): a mounted local workspace is acquired
98
+ // WITHOUT credentials, and yields the same cached session handle.
99
+ final acquired = await harness.bridge.acquireSession('estate-b',
100
+ ifAbsentCredentials: () async =>
101
+ fail('estate-b is mounted on the plane — credentials must not be requested'));
102
+ expect(identical(acquired, harness.session('estate-b')), isTrue);
103
+
104
+ // 5. No local://.../intent-offers fetch attempts occur (attempted-and-refused still counts as attempted).
105
+ final offending = diagnostics
106
+ .where((l) => l.contains('intent-offers') || l.contains('refusing network fetch'))
107
+ .toList();
108
+ expect(offending, isEmpty,
109
+ reason:
110
+ 'a local-only session must perform NO network work — saw:\n${offending.join('\n')}');
111
+ } finally {
112
+ await harness
113
+ .dispose()
114
+ .timeout(const Duration(seconds: 20), onTimeout: () {
115
+ throw StateError('harness disposal hung (the reported teardown hang)');
116
+ });
117
+ }
118
+ }, timeout: const Timeout(Duration(minutes: 3)));
119
+ }
@@ -120,6 +120,40 @@ void main() {
120
120
  }
121
121
  }, timeout: const Timeout(Duration(minutes: 4)));
122
122
 
123
+ test(
124
+ 'session.watchPendingConflicts() EMITS (the 0.48.0 regression: the session-level stream '
125
+ 'registered on an object with no message loop and never emitted)', () async {
126
+ final skip = _missingPrereq();
127
+ if (skip != null) {
128
+ markTestSkipped(skip);
129
+ return;
130
+ }
131
+ final harness = await _harness();
132
+ try {
133
+ final session = harness.session(harness.workspace);
134
+ // Subscribe FIRST on the SESSION (the co2 shape: a session-bound generated client's
135
+ // conflict stream), then author any write so a watch tick fires. The broken delegation
136
+ // registered the sub on the session's own dead controller registry — the stream NEVER
137
+ // emitted, regardless of ticks. The fix delegates to the parent plane like every other
138
+ // reactive read; the first tick must deliver a snapshot (empty — no Surfaced law here).
139
+ final first = session.watchPendingConflicts().first;
140
+ final client = await HomeClient.bind(session, actor: _owner);
141
+ await client.birthEstateWorkspace(
142
+ payload: BirthEstateWorkspacePayload(
143
+ owner: _owner,
144
+ frameworkHash: harness.frameworkHash!,
145
+ lawHash: harness.lawHashes(harness.workspace).first,
146
+ bornAt: DateTime.now().toUtc().toIso8601String(),
147
+ ),
148
+ );
149
+ final rows = await first.timeout(const Duration(seconds: 30));
150
+ expect(rows, isEmpty,
151
+ reason: 'no Surfaced law in this fixture — the point is the stream EMITS at all');
152
+ } finally {
153
+ await harness.dispose();
154
+ }
155
+ }, timeout: const Timeout(Duration(minutes: 4)));
156
+
123
157
  test('bind on a deliberately WRONG session throws DomainClientSessionMismatch with every field',
124
158
  () async {
125
159
  final skip = _missingPrereq();
@@ -1,3 +1,12 @@
1
+ ## 0.3.0
2
+
3
+ - `SurfacedValue<T>` — the typed read surface for `Surfaced(inner)` merge-driver fields: sealed
4
+ `SurfacedResolved<T>` / `SurfacedPending<T>` with `fromJson` envelope detection, `isPending`,
5
+ `valueOrNull`, and `SurfacedObservation<T> {value, occurredAt, author?}` (nullable author — the
6
+ writer principal captured at the apply lane). `surfacedEnvelopeKey`/`surfacedAuthorKey` constants.
7
+ - `PendingConflictRow` — the workspace-wide "decisions needed" row served by the maintained
8
+ `pendingConflicts` read: `{aggregateType, aggregateId, field, observations}`.
9
+
1
10
  ## 0.2.0
2
11
 
3
12
  - `NomosPrincipal` / `NomosDeviceSecret` — branded identity types for the generated cross-workspace birth
@@ -11,7 +11,7 @@ description: >-
11
11
  Pure kernel wire/data types for Nomos Dart frontends — no transport, no bridge,
12
12
  no runtime. The hand-written serde-JSON-compatible types plus the offer codec,
13
13
  reactive read engine, schema validation, and data-only error/trace types.
14
- version: 0.2.0
14
+ version: 0.3.0
15
15
 
16
16
  environment:
17
17
  sdk: ^3.5.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.72.0",
3
+ "version": "0.73.0",
4
4
  "type": "module",
5
5
  "description": "Nomos 2 domain-authoring DSL: aggregates + directives in TS, executed and encoded to the Rust kernel's wire shapes.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -898,12 +898,16 @@ const DART_TYPES_IMPORT = `import 'package:nomos_types/nomos_types.dart';`;
898
898
  /** Version constraints the scaffolded pubspecs pin — move together with the Dart packages' releases.
899
899
  * Caret on 0.x is EXACT-MINOR (`^0.45.0` = `<0.46.0`), so these must track the CURRENT release, not
900
900
  * just the floor generated code needs — a lagging pin forces tenants into dependency_overrides. */
901
- // 0.1.4: custody-lifecycle types (CustodyBreakPolicy et al).
902
- // 0.2.0: branded birth identities (NomosPrincipal/NomosDeviceSecret) — the generated cross-workspace
903
- // `.births()` methods type actor/authorSecret as these, so a layered package needs nomos_types 0.2.0.
904
- const NOMOS_TYPES_DEP = "^0.2.0";
905
- // 0.46.0: custody lifecycle on NomosBridge/NomosSession (onCustodyBreak/custodyStatus/salvage lanes).
906
- const NOMOS_CLIENT_DEP = "^0.46.0";
901
+ // 0.3.0: SurfacedValue<T>/SurfacedObservation/PendingConflictRow — generated Surfaced-field typing +
902
+ // the pendingConflicts read rows.
903
+ const NOMOS_TYPES_DEP = "^0.3.0";
904
+ // 0.48.x: session-bound clients (XClient.bind/NomosClientInterface/DomainClientSessionMismatch/
905
+ // DomainNotInstalledForSession, engine.follow, pendingConflicts lanes) — the generated client
906
+ // REQUIRES this surface. 0.48.1 fixes NomosSession.watchPendingConflicts delegation.
907
+ const NOMOS_CLIENT_DEP = "^0.49.0";
908
+ // DRIFT TRIPWIRE: pubspec_pins.test.ts asserts these equal the CURRENT versions in dsl/dart/pubspec.yaml
909
+ // and dsl/dart_types/pubspec.yaml — a Dart release without this bump fails the suite, so tenants never
910
+ // land back in dependency_overrides (the exact co2 report this closes, third occurrence).
907
911
 
908
912
  const isPubPackageName = (s: string): boolean => /^[a-z][a-z0-9_]*$/.test(s);
909
913