@githolon/dsl 0.78.0 → 0.80.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,27 @@
1
+ ## 0.55.0
2
+
3
+ - **Security: enrollSigner self-binds** (device can only enroll itself; cross-principal forgery closed
4
+ by construction, offline-secure). **Client lifecycle fixes** from the adversarial sweep: watches
5
+ survive LRU park/remount (realm-owned rebind registry — no more silent watch death), DLQ recovery
6
+ never drops salvage on offline/unborn planes, a failed connect leaves no phantom session, evicted
7
+ handles throw typed `nomos.session.disposed`, and `query()`/`queryById()` throw on a read-forbidden
8
+ verdict. Per-offer `authorSecret` threads to the signing path. Requires bundle >= 0.80.0.
9
+
10
+ ## 0.54.2
11
+
12
+ - Runner-bundle floor -> 0.79.0 (the fixed-group npm lift; 0.54.1 was published mid-release with the
13
+ 0.78.1 floor — runtime-equivalent, this aligns the published package with the paired bundle exactly).
14
+
15
+ ## 0.54.1
16
+
17
+ - **Fix: principal normalization mismatch in signer recovery** — enrollment wrote
18
+ `identity:user:<uid>#signer` (the kernel-canonical captured principal) while the posture/preflight
19
+ reads stripped `user:` and reported false `missing-signer-enrollment` immediately after a
20
+ successful repair. One canonical normalizer now routes every surface; co2's five-step acceptance
21
+ (repair → immediate enrolled:true → canAuthor ok → birth admits) is the regression test, with
22
+ `user:`-shaped principals, proven failing-then-passing. Typed authz diagnosis now also rides the
23
+ `createWorkspace` relay (no more generic 422).
24
+
1
25
  ## 0.54.0
2
26
 
3
27
  - **Parent-signer recovery** (the reborn-parent scar): `ensureParentSignerEnrollment(...)` —
@@ -48,7 +48,7 @@ const int kWebviewBridgeVersion = 1;
48
48
  /// pre-stamp bundle, which reports nothing) is a stale baked asset and fails loudly at boot.
49
49
  /// MUST move with the paired @githolon/client release — a release-hygiene test pairs it with the
50
50
  /// runner stamp (the pubspec-pins pattern).
51
- const String kExpectedRunnerBundleVersion = '0.78.0';
51
+ const String kExpectedRunnerBundleVersion = '0.80.0';
52
52
 
53
53
  /// A STALE BAKED RUNNER BUNDLE: the app's built assets carry an older nomos-runner.js than this
54
54
  /// nomos_client release ships — Flutter's build cache kept it across a pub upgrade. Thrown from
@@ -1050,7 +1050,10 @@ class NomosBridge {
1050
1050
  String? session,
1051
1051
  int? maxMounted}) async {
1052
1052
  final ws = session ?? workspace;
1053
- _eventPlane._sessionNames.add(ws);
1053
+ // Record the session name ONLY on a SUCCESSFUL open. Adding it before the await left a PHANTOM session
1054
+ // behind on a failed connect (a corrupt restoreFrom, an unreachable cloud): acquireSession/follow fast-path
1055
+ // on `_sessionNames.contains` and would hand back a dead handle whose every op refuses "session not
1056
+ // connected", and ifAbsentCredentials would never fire. The `_call` throwing skips the add below.
1054
1057
  final r = _asMap(await _call('connect', {
1055
1058
  // [cloud] is OPTIONAL — omit it to open a LOCAL-ONLY workspace (a local githolon with no cloud
1056
1059
  // counterpart; the runner uses a `local://` sentinel and sync is a no-op). Pass it to make the
@@ -1077,6 +1080,7 @@ class NomosBridge {
1077
1080
  // fail closed (the break surfaces as an error on the sync/pull that hit it; the holon stays local).
1078
1081
  if (onCustodyBreak != null) 'onCustodyBreak': onCustodyBreak.wire,
1079
1082
  }));
1083
+ _eventPlane._sessionNames.add(ws); // the open succeeded — the session is real now
1080
1084
  _eventPlane._lastRecovery = (r['recovery'] as Map?)?.cast<String, Object?>();
1081
1085
  }
1082
1086
 
@@ -1199,6 +1203,9 @@ class NomosBridge {
1199
1203
  List<Map<String, Object?>>? attestations,
1200
1204
  bool? force,
1201
1205
  String? session,
1206
+ // Per-offer signing key (base64 device secret): sign THIS offer with a specific device key instead of
1207
+ // the connect-time one (the runner maps it onto `opts.signWith`). Same contract as [offerOutcome].
1208
+ String? authorSecret,
1202
1209
  }) async {
1203
1210
  final r = _asMap(await _call('offerDirective', {
1204
1211
  'domain': domain,
@@ -1209,6 +1216,7 @@ class NomosBridge {
1209
1216
  if (attestations != null) 'attestations': attestations,
1210
1217
  if (force != null) 'force': force,
1211
1218
  if (session != null) 'session': session,
1219
+ if (authorSecret != null) 'authorSecret': authorSecret,
1212
1220
  }));
1213
1221
  return r['head'].toString();
1214
1222
  }
@@ -1564,7 +1572,20 @@ class NomosBridge {
1564
1572
  throw await _wrapDomainNotInstalled(e,
1565
1573
  domain: domain, domainHash: domainHash, requestedWorkspace: parent);
1566
1574
  }
1567
- return (r['verdict'] as Map?)?.cast<String, Object?>() ?? r;
1575
+ final verdict = (r['verdict'] as Map?)?.cast<String, Object?>() ?? r;
1576
+ // STRUCTURED AUTHZ REFUSAL (co2 DX): a refused relay used to surface only the generic 422 body.
1577
+ // The JS relay now classifies the kernel refusal into `nomos.authz.*` errorInfo — mint the SAME
1578
+ // typed error the offerDirective lane throws ([ParentSignerEnrollmentMissing]/[AuthzRefusedError])
1579
+ // so the app sees the diagnosis (actor/keyHash/object/relation/cause), not prose. Non-authz
1580
+ // refusals keep the verdict-return contract unchanged.
1581
+ if (verdict['ok'] == false) {
1582
+ final info = verdict['errorInfo'];
1583
+ final code = info is Map ? (info['code'] ?? '').toString() : '';
1584
+ if (code.startsWith('nomos.authz.')) {
1585
+ throw nomosErrorFromInfo(NomosErrorInfo.fromJson(info));
1586
+ }
1587
+ }
1588
+ return verdict;
1568
1589
  }
1569
1590
 
1570
1591
  /// SHARE a workspace/record with a user — the one-call cross-custody grant that lands in their home's
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.4.0"
194
+ version: "0.4.1"
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.54.0
14
+ version: 0.55.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.4.0
23
+ nomos_types: ^0.4.1
24
24
  json_schema: ^5.2.2
25
25
  crypto: ^3.0.0
26
26
 
@@ -0,0 +1,469 @@
1
+ // ADVERSARIAL FINDER — MULTI-WORKSPACE REALM INTERFERENCE (one engine, many sessions).
2
+ // Hammers the cross-session seams of the local holarchy plane (co2's production shape: a
3
+ // dozen+ live workspaces, mixed laws) through the REAL runner + REAL kernel, fully offline:
4
+ // 1. concurrent offers to different sessions (no cross-chain bleed, independent heads)
5
+ // 2. watches on A while B floods (no tick bleed / starvation, per-subscription ordering)
6
+ // 3. acquireSession racing a FAILED connect (the phantom-session window)
7
+ // 4. pendingConflicts/activityLog isolation across same-named queries / sibling floods
8
+ // 5. session re-connect (atomic replace) during in-flight offers — nothing hangs, typed only
9
+ // 6. born child resident-then-session — one consistent view
10
+ // 7. error isolation — one session's refusal never poisons siblings
11
+ // 8. runner-level LRU (maxMounted) — watch survival + state preservation across park/remount
12
+ //
13
+ // A FAILING expectation here is a FINDING (evidence of a seam bug), not necessarily a
14
+ // regression gate. Timing assertions use generous CI margins.
15
+ @TestOn('vm')
16
+ @Timeout(Duration(minutes: 12))
17
+ library;
18
+
19
+ import 'dart:async';
20
+ import 'dart:convert';
21
+ import 'dart:io';
22
+
23
+ import 'package:nomos_client/nomos_headless.dart';
24
+ import 'package:nomos_types/nomos_types.dart' show NomosRef;
25
+ import 'package:test/test.dart';
26
+
27
+ String get _root => '${Directory.current.path}/../..';
28
+ String get _repoEntry => '$_root/cloud/web-client/runner/node-entry.mjs';
29
+ String get _installer => '$_root/cloud/web-client/runner/offline-install.mjs';
30
+ String get _guestbook => '$_root/examples/guestbook/build/guestbook.deploy.json';
31
+ String get _surfaced => '$_root/bench/scale/fixtures/surfaced/build/surfaced.deploy.json';
32
+ String get _attributed => '$_root/bench/scale/fixtures/attributed/build/attributed.deploy.json';
33
+ String get _estateBirth => '$_root/bench/scale/fixtures/estate-birth/build/estate-birth.deploy.json';
34
+ String get _runtimeDir => '${Platform.environment['HOME'] ?? ''}/.holon/runtime';
35
+
36
+ String? _missingPrereq() {
37
+ for (final p in [_repoEntry, _installer, _guestbook, _surfaced, _attributed, _estateBirth]) {
38
+ if (!File(p).existsSync()) return 'missing $p (compile the fixture / example first)';
39
+ }
40
+ if (!File('$_runtimeDir/holon.wasm').existsSync() ||
41
+ !File('$_runtimeDir/packages.json').existsSync()) {
42
+ return 'warm ~/.holon/runtime first (run any `githolon` command once)';
43
+ }
44
+ return null;
45
+ }
46
+
47
+ String _nowIso() => DateTime.now().toUtc().toIso8601String();
48
+
49
+ Future<String> _sign(NomosSession s, String author, String message) =>
50
+ s.offerDirective(
51
+ domain: 'guestbook',
52
+ directiveId: 'signGuestbook',
53
+ payload: {'author': author, 'message': message, 'mood': 'delighted', 'signedAt': _nowIso()},
54
+ actor: author);
55
+
56
+ void main() {
57
+ final skip = _missingPrereq();
58
+
59
+ group('shared cluster (6 workspaces, mixed laws)', () {
60
+ late NomosTestHarness harness;
61
+ late NomosBridge plane;
62
+
63
+ setUpAll(() async {
64
+ if (skip != null) return;
65
+ harness = await NomosTestHarness.cluster(
66
+ workspaces: {
67
+ 'home-birth': [_estateBirth],
68
+ 'surf': [_surfaced],
69
+ 'attr': [_attributed],
70
+ 'gb-1': [_guestbook],
71
+ 'gb-2': [_guestbook],
72
+ 'gb-3': [_guestbook],
73
+ },
74
+ nodeEntry: _repoEntry,
75
+ runtimeDir: _runtimeDir,
76
+ );
77
+ plane = harness.bridge;
78
+ });
79
+
80
+ tearDownAll(() async {
81
+ if (skip != null) return;
82
+ await harness.dispose();
83
+ });
84
+
85
+ test('1. concurrent offers to different sessions: no cross-chain bleed, independent heads',
86
+ () async {
87
+ if (skip != null) return markTestSkipped(skip);
88
+ const gbs = ['gb-1', 'gb-2', 'gb-3'];
89
+ final sw = Stopwatch()..start();
90
+ final futs = <Future<Object?>>[
91
+ for (final ws in gbs)
92
+ for (var i = 0; i < 4; i++) _sign(harness.session(ws), 'author-$ws', 'm$i'),
93
+ for (var i = 0; i < 4; i++)
94
+ harness.session('surf').offerDirective(
95
+ domain: 'docs',
96
+ directiveId: 'createDoc',
97
+ payload: {'owner': 'owner-surf', 'title': 'doc-$i'},
98
+ actor: 'owner-surf'),
99
+ for (var i = 0; i < 4; i++)
100
+ harness.session('attr').offerDirective(
101
+ domain: 'sites',
102
+ directiveId: 'createSite',
103
+ payload: {'owner': 'owner-attr', 'name': 'site-$i'},
104
+ actor: 'owner-attr'),
105
+ ];
106
+ final heads = await Future.wait(futs); // 20 concurrent offers across 5 sessions
107
+ sw.stop();
108
+ expect(heads, everyElement(isNotNull));
109
+ // ack latency stays sane under concurrency (generous CI margin)
110
+ expect(sw.elapsed, lessThan(const Duration(seconds: 90)),
111
+ reason: '20 concurrent offers should settle well under 90s');
112
+
113
+ // each chain holds ONLY its own writes
114
+ for (final ws in gbs) {
115
+ final mine = await harness.session(ws).query('entriesByAuthor', {'author': 'author-$ws'});
116
+ expect(mine, hasLength(4), reason: '$ws holds its own 4 writes');
117
+ for (final other in gbs.where((o) => o != ws)) {
118
+ final theirs =
119
+ await harness.session(ws).query('entriesByAuthor', {'author': 'author-$other'});
120
+ expect(theirs, isEmpty, reason: "$ws must not hold $other's writes (cross-session bleed)");
121
+ }
122
+ }
123
+ expect(await harness.session('surf').query('docsByOwner', {'owner': 'owner-surf'}),
124
+ hasLength(4));
125
+ expect(await harness.session('attr').query('sitesByOwner', {'owner': 'owner-attr'}),
126
+ hasLength(4));
127
+
128
+ // heads are independent per session
129
+ final tips = <String>{};
130
+ for (final ws in [...gbs, 'surf', 'attr']) {
131
+ tips.add(await harness.session(ws).head());
132
+ }
133
+ expect(tips, hasLength(5), reason: 'every session has its own ledger tip');
134
+ });
135
+
136
+ test('2. a watch on session A fires while session B floods (no starvation, no bleed, ordered)',
137
+ () async {
138
+ if (skip != null) return markTestSkipped(skip);
139
+ final a = harness.session('gb-1');
140
+ final b = harness.session('gb-2');
141
+ final idle = harness.session('attr');
142
+
143
+ final emissionsA = <int>[];
144
+ final emissionsIdle = <int>[];
145
+ final subA = a.watch('entriesByAuthor', {'author': 'watched'}).listen(
146
+ (rows) => emissionsA.add(rows.length));
147
+ final subIdle = idle.watch('sitesByOwner', {'owner': 'nobody-here'}).listen(
148
+ (rows) => emissionsIdle.add(rows.length));
149
+ await Future<void>.delayed(const Duration(milliseconds: 600)); // initial emissions
150
+ final idleBase = emissionsIdle.length;
151
+
152
+ // B floods 25 writes while A takes 3 interleaved writes
153
+ final flood = () async {
154
+ for (var i = 0; i < 25; i++) {
155
+ await _sign(b, 'flooder', 'f$i');
156
+ }
157
+ }();
158
+ final aWrites = () async {
159
+ for (var i = 0; i < 3; i++) {
160
+ await Future<void>.delayed(const Duration(milliseconds: 60));
161
+ await _sign(a, 'watched', 'w$i');
162
+ }
163
+ }();
164
+ await Future.wait([flood, aWrites]);
165
+
166
+ // A's watch must reach 3 rows promptly despite the sibling flood (generous margin)
167
+ final deadline = DateTime.now().add(const Duration(seconds: 15));
168
+ while (DateTime.now().isBefore(deadline) &&
169
+ (emissionsA.isEmpty || emissionsA.last < 3)) {
170
+ await Future<void>.delayed(const Duration(milliseconds: 150));
171
+ }
172
+ await subA.cancel();
173
+ await subIdle.cancel();
174
+ expect(emissionsA.isNotEmpty && emissionsA.last == 3, isTrue,
175
+ reason: "A's watch starved or missed rows under B's flood: $emissionsA");
176
+ for (var i = 1; i < emissionsA.length; i++) {
177
+ expect(emissionsA[i], greaterThanOrEqualTo(emissionsA[i - 1]),
178
+ reason: 'per-subscription ordering must be monotonic: $emissionsA');
179
+ }
180
+ expect(emissionsIdle.length, idleBase,
181
+ reason: "the idle sibling watch got spurious emissions from B's flood: $emissionsIdle");
182
+ });
183
+
184
+ test('3a. follow(ref) + racing acquireSession during a sibling offer flood', () async {
185
+ if (skip != null) return markTestSkipped(skip);
186
+ final flood = () async {
187
+ for (var i = 0; i < 10; i++) {
188
+ await _sign(harness.session('gb-3'), 'race-flood', 'f$i');
189
+ }
190
+ }();
191
+ // follow an already-open sibling mid-flood → same handle, functional
192
+ final followed = await plane
193
+ .follow(NomosRef(workspace: 'gb-1', type: 'nomos.Workspace', id: 'gb-1'));
194
+ expect(followed.workspace, 'gb-1');
195
+ expect(await followed.query('entriesByAuthor', {'author': 'nobody'}), isEmpty);
196
+
197
+ // concurrent acquireSession single-flights to the SAME handle
198
+ final pair = await Future.wait([
199
+ plane.acquireSession('gb-2'),
200
+ plane.acquireSession('gb-2'),
201
+ ]);
202
+ expect(identical(pair[0], pair[1]), isTrue,
203
+ reason: 'two concurrent acquireSession calls must yield the same session');
204
+ await flood;
205
+ });
206
+
207
+ test('3b. FINDER: a FAILED connect must not leave a phantom session acquireSession trusts',
208
+ () async {
209
+ if (skip != null) return markTestSkipped(skip);
210
+ // a garbage restoreFrom makes the runner's connect throw AFTER the Dart bridge has
211
+ // (prematurely?) registered the session name on the plane.
212
+ Object? err;
213
+ try {
214
+ await plane.connect(
215
+ workspace: 'phantom-ws', session: 'phantom-ws', restoreFrom: '%%%not-base64%%%');
216
+ } catch (e) {
217
+ err = e;
218
+ }
219
+ expect(err, isNotNull, reason: 'the corrupt-restore connect must throw');
220
+
221
+ // CORRECT behavior: acquireSession must either ask for credentials, throw a named
222
+ // StateError, or return a session whose ops actually work. Returning a handle whose
223
+ // every op refuses "session not connected" is the phantom-session bug.
224
+ var credentialsAsked = false;
225
+ Object? acquireOutcome;
226
+ try {
227
+ final s = await plane.acquireSession('phantom-ws', ifAbsentCredentials: () async {
228
+ credentialsAsked = true;
229
+ throw StateError('credentials path taken (correct) — stop here');
230
+ });
231
+ // it returned a session: its ops must work, else it's a dead phantom handle
232
+ await s.query('entriesByAuthor', {'author': 'x'});
233
+ acquireOutcome = 'live session';
234
+ } catch (e) {
235
+ acquireOutcome = e;
236
+ }
237
+ final phantom = !credentialsAsked &&
238
+ acquireOutcome is! StateError &&
239
+ acquireOutcome != 'live session';
240
+ expect(phantom, isFalse,
241
+ reason: 'PHANTOM SESSION: after a failed connect, acquireSession("phantom-ws") '
242
+ 'returned a dead handle (ops refuse) without asking for credentials — '
243
+ 'observed: $acquireOutcome');
244
+ });
245
+
246
+ test('4. pendingConflicts/activityLog on A are unaffected by B (same-named queries isolated)',
247
+ () async {
248
+ if (skip != null) return markTestSkipped(skip);
249
+ final surf = harness.session('surf');
250
+ final before = await surf.pendingConflicts();
251
+
252
+ // sibling activity: a guestbook flood + surfaced-relevant field writes on ANOTHER session's law
253
+ for (var i = 0; i < 10; i++) {
254
+ await _sign(harness.session('gb-1'), 'iso-flood', 'f$i');
255
+ }
256
+ final after = await surf.pendingConflicts();
257
+ expect(after, equals(before),
258
+ reason: "surf's pendingConflicts moved on a sibling's writes (cache/key bleed)");
259
+
260
+ // activityLog rows belong to the session's OWN chain only
261
+ final actGb2 = await harness.session('gb-2').activityLog(limit: 100);
262
+ final foreign = actGb2.where((r) =>
263
+ r['directiveId'] == 'createSite' ||
264
+ r['directiveId'] == 'createDoc' ||
265
+ (r['payload'] is Map && (r['payload'] as Map)['author'] == 'iso-flood'));
266
+ expect(foreign, isEmpty,
267
+ reason: "gb-2's activityLog carries sibling-session intents: ${foreign.toList()}");
268
+
269
+ // the SAME query id with the SAME params on two sessions answers per-session
270
+ final q1 = await harness.session('gb-1').query('entriesByAuthor', {'author': 'iso-flood'});
271
+ final q2 = await harness.session('gb-2').query('entriesByAuthor', {'author': 'iso-flood'});
272
+ expect(q1, hasLength(10));
273
+ expect(q2, isEmpty,
274
+ reason: 'same-named query with same params leaked rows across sessions (cache keying)');
275
+ });
276
+
277
+ test('5. re-connect (atomic replace) during in-flight offers: everything settles typed',
278
+ () async {
279
+ if (skip != null) return markTestSkipped(skip);
280
+ final s = harness.session('gb-3');
281
+ // fire 10 offers WITHOUT awaiting, then immediately replace the session (new clientId
282
+ // → identity change → dispose-then-connect under the hood)
283
+ final futs = [
284
+ for (var i = 0; i < 10; i++)
285
+ _sign(s, 'replace-race', 'r$i').then<Object?>((h) => 'ok:$h', onError: (Object e) => e)
286
+ ];
287
+ await plane.connect(workspace: 'gb-3', session: 'gb-3', clientId: 'replacer-1');
288
+
289
+ final settled = await Future.wait(futs).timeout(const Duration(seconds: 60),
290
+ onTimeout: () =>
291
+ throw StateError('offers in flight across an atomic replace HUNG (never settled)'));
292
+ // every outcome is either an admitted head or a TYPED error — never a hang, never null
293
+ for (final r in settled) {
294
+ expect(r, isNotNull);
295
+ if (r is! String) {
296
+ expect(r, isA<Exception>(), reason: 'a race loser must fail typed, got: $r');
297
+ }
298
+ }
299
+
300
+ // the replaced session is consistent: chain verifies, reads serve, writes admit
301
+ final v = await harness.session('gb-3').verifyChain();
302
+ expect(v['valid'], isTrue,
303
+ reason: 'in-flight offers interleaved with the atomic replace corrupted the chain: $v');
304
+ final admitted = settled.whereType<String>().length;
305
+ final rows =
306
+ await harness.session('gb-3').query('entriesByAuthor', {'author': 'replace-race'});
307
+ expect(rows.length, admitted,
308
+ reason: '$admitted offers acked ok but ${rows.length} rows visible after the replace '
309
+ '(an acked write was lost or duplicated across the replace window)');
310
+ await _sign(harness.session('gb-3'), 'post-replace', 'alive');
311
+ expect(await harness.session('gb-3').query('entriesByAuthor', {'author': 'post-replace'}),
312
+ hasLength(1));
313
+ // siblings untouched by gb-3's replace
314
+ expect(await harness.session('gb-1').query('entriesByAuthor', {'author': 'nobody'}), isEmpty);
315
+ });
316
+
317
+ test('6. born child resident-then-session: one consistent view, replace-safe', () async {
318
+ if (skip != null) return markTestSkipped(skip);
319
+ final fw = harness.frameworkHash;
320
+ final law = harness.lawHashes('home-birth').first;
321
+ final outcome = await harness.session('home-birth').offerCreates(
322
+ domain: 'home',
323
+ directiveId: 'birthEstateWorkspace',
324
+ payload: {
325
+ 'owner': 'adv-owner',
326
+ 'frameworkHash': fw,
327
+ 'lawHash': law,
328
+ 'bornAt': _nowIso(),
329
+ },
330
+ actor: 'adv-owner',
331
+ );
332
+ expect(outcome.born, hasLength(1));
333
+ expect(outcome.birthOutcomes.first['status'], 'born');
334
+ final child = outcome.born.single;
335
+
336
+ // the child is resident BEFORE any session opens it — listWorkspaces sees it
337
+ final listedPre = await plane.listWorkspaces();
338
+ expect(listedPre.map((w) => w['workspace']), contains(child),
339
+ reason: 'a born child is resident on the plane before it is connected');
340
+
341
+ // open it as a session (mount in place) and read the genesis seed
342
+ await plane.connect(workspace: child, session: child);
343
+ final rows1 = await plane.session(child).query('estatesByOwner', {'owner': 'adv-owner'});
344
+ expect(rows1, hasLength(1));
345
+
346
+ // write into the child, then RE-open it with a new clientId (identity replace on a
347
+ // born-resident workspace) — the write must survive (one holon's truth, not two)
348
+ await plane.session(child).offerDirective(
349
+ domain: 'estate',
350
+ directiveId: 'renameEstate',
351
+ payload: {'estateName': child, 'newName': 'renamed'},
352
+ actor: 'adv-owner').catchError((Object _) => ''); // directive may not exist — best-effort
353
+ await _noThrow(() => plane.connect(workspace: child, session: child, clientId: 'child-re'));
354
+ final rows2 = await plane.session(child).query('estatesByOwner', {'owner': 'adv-owner'});
355
+ expect(rows2, hasLength(1),
356
+ reason: 're-opening a born-resident child must present the SAME holon truth');
357
+
358
+ // the parent is unaffected
359
+ expect(
360
+ await plane.session('home-birth').verifyChain().then((v) => v['valid']), isTrue);
361
+ });
362
+
363
+ test('7. error isolation: a refusal on one session never poisons siblings', () async {
364
+ if (skip != null) return markTestSkipped(skip);
365
+ // unknown directive → typed refusal
366
+ await expectLater(
367
+ harness.session('gb-1').offerDirective(
368
+ domain: 'guestbook', directiveId: 'noSuchDirective', payload: {'x': 1}),
369
+ throwsA(anything));
370
+ // schema-violating payload → typed refusal
371
+ await expectLater(
372
+ harness.session('attr').offerDirective(
373
+ domain: 'sites', directiveId: 'createSite', payload: {'owner': '', 'name': ''}),
374
+ throwsA(anything));
375
+ // unknown query → typed refusal, not a hang
376
+ await expectLater(
377
+ harness.session('surf').query('noSuchQuery', {'k': 'v'}), throwsA(anything));
378
+
379
+ // every sibling still serves reads AND admits writes
380
+ for (final ws in ['gb-1', 'gb-2', 'gb-3', 'surf', 'attr', 'home-birth']) {
381
+ final h = await harness.session(ws).head();
382
+ expect(h, isNotEmpty, reason: '$ws must still answer after a sibling refusal');
383
+ }
384
+ await _sign(harness.session('gb-2'), 'healthy', 'still-alive');
385
+ expect(await harness.session('gb-2').query('entriesByAuthor', {'author': 'healthy'}),
386
+ hasLength(1));
387
+ });
388
+ });
389
+
390
+ group('runner-level LRU (maxMounted: 2)', () {
391
+ test('8. FINDER: watches + session state across LRU park/remount', () async {
392
+ if (skip != null) return markTestSkipped(skip);
393
+ final workdir = await Directory.systemTemp.createTemp('adv-lru-');
394
+ NodeRunnerTransport? transport;
395
+ try {
396
+ // build 3 guestbook snapshots via the proven offline install lane
397
+ final snaps = <String, String>{};
398
+ for (final ws in ['lru-1', 'lru-2', 'lru-3']) {
399
+ final out = '${workdir.path}/$ws.bin';
400
+ final r = await Process.run('node', [
401
+ _installer, '--deploy', _guestbook, '--workspace', ws, '--out', out,
402
+ '--runtime', _runtimeDir,
403
+ ]);
404
+ expect(r.exitCode, 0, reason: 'offline install of $ws failed: ${r.stderr}');
405
+ snaps[ws] = base64Encode(await File(out).readAsBytes());
406
+ }
407
+ transport = await NodeRunnerTransport.start(
408
+ entry: _repoEntry, node: 'node', environment: {'NOMOS_OFFLINE': '1'});
409
+ final bridge = NomosBridge(transport);
410
+ await bridge.ready.timeout(const Duration(seconds: 30));
411
+
412
+ // FIRST connect fixes the realm budget: maxMounted 2
413
+ await bridge.connect(
414
+ workspace: 'lru-1', session: 'lru-1', restoreFrom: snaps['lru-1'],
415
+ allowUnborn: true, maxMounted: 2);
416
+ final s1 = bridge.session('lru-1');
417
+ await _sign(s1, 'survivor', 'before-eviction'); // un-synced local write
418
+
419
+ final emissions = <int>[];
420
+ Object? watchError;
421
+ final sub = s1.watch('entriesByAuthor', {'author': 'survivor'}).listen(
422
+ (rows) => emissions.add(rows.length),
423
+ onError: (Object e) => watchError = e);
424
+ await Future<void>.delayed(const Duration(milliseconds: 800));
425
+ expect(emissions.isNotEmpty && emissions.last == 1, isTrue,
426
+ reason: 'precondition: the watch saw the pre-eviction row: $emissions');
427
+
428
+ // mount two more workspaces → lru-1 is LRU-evicted (parked)
429
+ await bridge.connect(
430
+ workspace: 'lru-2', session: 'lru-2', restoreFrom: snaps['lru-2'], allowUnborn: true);
431
+ await bridge.connect(
432
+ workspace: 'lru-3', session: 'lru-3', restoreFrom: snaps['lru-3'], allowUnborn: true);
433
+ final listed = await bridge.listWorkspaces();
434
+ final l1 = listed.firstWhere((w) => w['workspace'] == 'lru-1');
435
+ expect(l1['parked'], isTrue, reason: 'lru-1 should be LRU-parked: $listed');
436
+
437
+ // (a) session ops transparently remount from the parked snapshot, state intact
438
+ final rows = await s1.query('entriesByAuthor', {'author': 'survivor'});
439
+ expect(rows, hasLength(1),
440
+ reason: 'the un-synced local write must survive the park/remount cycle');
441
+
442
+ // (b) the watch must SURVIVE the eviction+remount (or at least error TYPED —
443
+ // a silently-dead stream is the bug)
444
+ await _sign(s1, 'survivor', 'after-remount');
445
+ final deadline = DateTime.now().add(const Duration(seconds: 10));
446
+ while (DateTime.now().isBefore(deadline) &&
447
+ (emissions.isEmpty || emissions.last < 2) &&
448
+ watchError == null) {
449
+ await Future<void>.delayed(const Duration(milliseconds: 200));
450
+ }
451
+ await sub.cancel();
452
+ final died = (emissions.isEmpty || emissions.last < 2) && watchError == null;
453
+ expect(died, isFalse,
454
+ reason: 'SILENT WATCH DEATH: the lru-1 watch stopped emitting after an LRU '
455
+ 'eviction+remount, with NO error event — emissions=$emissions '
456
+ '(a Flutter app bound to this stream shows stale data forever)');
457
+ } finally {
458
+ try { await transport?.dispose(); } catch (_) {}
459
+ try { await workdir.delete(recursive: true); } catch (_) {}
460
+ }
461
+ });
462
+ });
463
+ }
464
+
465
+ Future<void> _noThrow(Future<void> Function() fn) async {
466
+ try {
467
+ await fn();
468
+ } catch (_) {}
469
+ }
@@ -29,6 +29,11 @@ String get _runtimeDir => '${Platform.environment['HOME'] ?? ''}/.holon/runtime'
29
29
 
30
30
  const _ws = 'estate-birth-signed';
31
31
  const _uid = 'sample-owner';
32
+ // THE co2 REGRESSION SHAPE: the principal arrives ALREADY `user:`-qualified (the captured-actor wire
33
+ // form — exactly what NomosScope/co2 pass). KERNEL TRUTH: the gate checks
34
+ // `identity:<captured principal VERBATIM>`, so every read/repair below must resolve the object to
35
+ // `identity:user:<uid>` — a stripped `identity:<uid>` is the normalization bug this test pins.
36
+ const _principal = 'user:$_uid';
32
37
 
33
38
  String? _missingPrereq() {
34
39
  if (!File(_repoEntry).existsSync()) return 'in-repo runner not found at $_repoEntry';
@@ -68,15 +73,16 @@ void main() {
68
73
 
69
74
  // 1. THE PREFLIGHT, broken state: the verdict names the failing premise BEFORE any authoring.
70
75
  final v0 = await session.canAuthor('birthEstateWorkspace',
71
- ws: _ws, principal: _uid, publicKey: pub);
76
+ ws: _ws, principal: _principal, publicKey: pub);
72
77
  expect(v0['ok'], isNot(true));
73
78
  expect(v0['cause'], 'missing-signer-enrollment');
74
- expect(v0['actor'], 'user:$_uid');
79
+ expect(v0['actor'], _principal,
80
+ reason: 'the canonical actor is the principal VERBATIM — never stripped, never double-prefixed');
75
81
  expect((v0['keyHash'] ?? '').toString(), hasLength(64));
76
82
 
77
83
  // ...and the TYPED variant throws the ONE error with the ONE remedy.
78
84
  await expectLater(
79
- session.canAuthorTyped('birthEstateWorkspace', ws: _ws, principal: _uid, publicKey: pub),
85
+ session.canAuthorTyped('birthEstateWorkspace', ws: _ws, principal: _principal, publicKey: pub),
80
86
  throwsA(isA<ParentSignerEnrollmentMissing>()
81
87
  .having((e) => e.recommendedAction, 'recommendedAction',
82
88
  NomosRecommendedAction.ensureParentSignerEnrollment)
@@ -85,15 +91,17 @@ void main() {
85
91
 
86
92
  // 2. THE POSTURE READ agrees (diagnostic; nothing authored).
87
93
  final posture0 = await session.parentSignerEnrollment(
88
- parent: _ws, principal: _uid, publicKey: pub);
94
+ parent: _ws, principal: _principal, publicKey: pub);
89
95
  expect(posture0['enrolled'], false);
90
- expect(posture0['object'], 'identity:$_uid');
96
+ // KERNEL TRUTH (executor::signer_object): the object is identity:<captured principal VERBATIM>
97
+ // — `identity:user:<uid>`, NOT a stripped `identity:<uid>` (the co2 normalization bug).
98
+ expect(posture0['object'], 'identity:$_principal');
91
99
  expect(posture0['relation'], 'signer');
92
100
 
93
101
  // 3. THE REPAIR — idempotent; the signing directive is DISCOVERED from the installed law
94
102
  // (this fixture composes it under domain "home", NOT the framework "workspaces").
95
103
  final r1 = await session.ensureParentSignerEnrollment(
96
- parent: _ws, principal: _uid, publicKey: pub, authorSecret: secret);
104
+ parent: _ws, principal: _principal, publicKey: pub, authorSecret: secret);
97
105
  expect(r1['enrolled'], true);
98
106
  expect(r1['repaired'], true);
99
107
  expect(r1['domain'], 'home',
@@ -101,15 +109,24 @@ void main() {
101
109
 
102
110
  // 4. Re-run = pure no-op (the idempotence contract).
103
111
  final r2 = await session.ensureParentSignerEnrollment(
104
- parent: _ws, principal: _uid, publicKey: pub, authorSecret: secret);
112
+ parent: _ws, principal: _principal, publicKey: pub, authorSecret: secret);
105
113
  expect(r2['enrolled'], true);
106
114
  expect(r2['repaired'], false);
107
115
 
108
116
  // 5. The preflight flips OK…
109
117
  final v1 = await session.canAuthor('birthEstateWorkspace',
110
- ws: _ws, principal: _uid, publicKey: pub);
118
+ ws: _ws, principal: _principal, publicKey: pub);
111
119
  expect(v1['ok'], true, reason: 'causes: ${v1['causes']}');
112
120
 
121
+ // 5b. co2's step-5 regression VERBATIM: immediately after repaired:true, the POSTURE read (with
122
+ // the SAME user:-qualified principal) reports enrolled:true against the canonical object.
123
+ final posture1 = await session.parentSignerEnrollment(
124
+ parent: _ws, principal: _principal, publicKey: pub);
125
+ expect(posture1['enrolled'], true,
126
+ reason: 'the repair wrote identity:$_principal — a second normalizer that strips user: '
127
+ 'would read identity:$_uid and wrongly report missing-signer-enrollment');
128
+ expect(posture1['object'], 'identity:$_principal');
129
+
113
130
  // 6. …and the SIGNED sibling birth ADMITS (per-offer signing key — the repaired device).
114
131
  final lawHash = harness.lawHashes(_ws).first;
115
132
  final outcome = await session.offerCreates(
@@ -349,6 +349,52 @@ void main() {
349
349
  });
350
350
  });
351
351
 
352
+ // ── #9 — a per-offer authorSecret reaches the runner op (so the runner maps it onto opts.signWith → the
353
+ // author path signs THIS offer with the intended enrolled key, not the connect-time one). Both offer lanes. ──
354
+ group('per-offer authorSecret threads to the runner op (#9)', () {
355
+ test('offerOutcome carries authorSecret to the runner op args', () async {
356
+ final transport =
357
+ ReplayTransport({'head': 'H1', 'created': <Object?>[], 'workspace': 'ws'});
358
+ final bridge = NomosBridge(transport);
359
+ await bridge.ready;
360
+ await bridge.offerOutcome(
361
+ domain: 'home',
362
+ directiveId: 'writeNote',
363
+ payload: {'author': 'ada'},
364
+ authorSecret: 'ENROLLED-KEY-B64');
365
+ final args = (transport.lastRequest!['args'] as Map).cast<String, Object?>();
366
+ expect(transport.lastRequest!['op'], 'offerDirective');
367
+ expect(args['authorSecret'], 'ENROLLED-KEY-B64',
368
+ reason: 'the per-offer signing key must reach the runner op (→ opts.signWith)');
369
+ await bridge.dispose();
370
+ });
371
+
372
+ test('offerDirective carries authorSecret to the runner op args', () async {
373
+ final transport = ReplayTransport({'head': 'H1'});
374
+ final bridge = NomosBridge(transport);
375
+ await bridge.ready;
376
+ await bridge.offerDirective(
377
+ domain: 'home',
378
+ directiveId: 'writeNote',
379
+ payload: {'author': 'ada'},
380
+ authorSecret: 'ENROLLED-KEY-B64');
381
+ final args = (transport.lastRequest!['args'] as Map).cast<String, Object?>();
382
+ expect(args['authorSecret'], 'ENROLLED-KEY-B64');
383
+ await bridge.dispose();
384
+ });
385
+
386
+ test('no authorSecret ⇒ the op omits it (byte-identical to the pre-#9 contract)', () async {
387
+ final transport = ReplayTransport({'head': 'H1', 'created': <Object?>[], 'workspace': 'ws'});
388
+ final bridge = NomosBridge(transport);
389
+ await bridge.ready;
390
+ await bridge.offerOutcome(
391
+ domain: 'home', directiveId: 'writeNote', payload: {'author': 'ada'});
392
+ final args = (transport.lastRequest!['args'] as Map).cast<String, Object?>();
393
+ expect(args.containsKey('authorSecret'), isFalse);
394
+ await bridge.dispose();
395
+ });
396
+ });
397
+
352
398
  late FakeRunner runner;
353
399
  late NomosBridge bridge;
354
400
  setUp(() {
@@ -1,3 +1,8 @@
1
+ ## 0.4.1
2
+
3
+ - `canonicalPrincipal` / `signerObjectOf` — THE one principal normalizer (kernel truth:
4
+ `identity:<actor-verbatim>`, never stripped). All signer-recovery surfaces route through it.
5
+
1
6
  ## 0.4.0
2
7
 
3
8
  - Typed authorization diagnosis errors: `AuthzRefusedError` ({workspace, actor, keyHash, object,
@@ -54,3 +54,26 @@ class NomosDeviceSecret {
54
54
  @override
55
55
  String toString() => 'NomosDeviceSecret(***)';
56
56
  }
57
+
58
+ /// THE ONE PRINCIPAL NORMALIZER (Dart surface — mirrors `@githolon/client` `canonicalPrincipal`).
59
+ ///
60
+ /// KERNEL TRUTH (`executor/src/lib.rs::signer_object` + `verify_author_and_derive`): the
61
+ /// authorization gate checks `identity:<captured principal VERBATIM>` where the captured principal
62
+ /// is the actor string the offer presented (`user:<uid>` on every SDK lane). The kernel never strips
63
+ /// or qualifies; neither may we. NEVER strip `user:`; qualify a bare auth-provider uid to
64
+ /// `user:<uid>` exactly once, HERE. Every producer and consumer of `identity:*#signer` objects
65
+ /// routes through this — a second normalizer is the bug (the co2 principal-normalization mismatch).
66
+ ///
67
+ /// - `user:abc` → `user:abc` (verbatim)
68
+ /// - `abc` → `user:abc` (bare uid, qualified once)
69
+ /// - any other namespaced subject (`key:<hash>`, …) rides verbatim
70
+ /// - empty/whitespace → `''`
71
+ String canonicalPrincipal(String? principal) {
72
+ final s = (principal ?? '').trim();
73
+ if (s.isEmpty) return '';
74
+ return s.contains(':') ? s : 'user:$s';
75
+ }
76
+
77
+ /// The Zanzibar OBJECT for a principal's signer relation — `identity:<canonical principal>`
78
+ /// (mirrors `executor::signer_object` over the captured principal).
79
+ String signerObjectOf(String? principal) => 'identity:${canonicalPrincipal(principal)}';
@@ -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.4.0
14
+ version: 0.4.1
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.78.0",
3
+ "version": "0.80.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",
@@ -74,7 +74,7 @@ import { domainHash, emitManifestBytes } from "./manifest.js";
74
74
  import { collectValueObjectDescriptors } from "./value_object.js";
75
75
  import { emitUsd } from "./usd.js";
76
76
  import { expandCapabilityExports } from "./capability_exports.js";
77
- import { RelationTuple } from "./framework/relations.js";
77
+ import { RelationTuple, relationTuplesByObject } from "./framework/relations.js";
78
78
  import { parentAttestedBirthMarker } from "./births.js";
79
79
  import { discoverUnsanctionedEnsures, sanctioningEnsureDirective } from "./secondary_ensure.js";
80
80
  import { assertIdOfLint } from "./idof_lint.js";
@@ -277,6 +277,21 @@ export function composeDomainModule(spec: DomainModuleSpec): DomainModule {
277
277
  }
278
278
  }
279
279
  const relationTypes = [...(spec.relationTypes ?? [])];
280
+ // RELATION-USING LAW AUTO-COMPOSES ITS AUDIT READ (co2 recovery DX — same idiom as the meter
281
+ // queries above): a domain that declares relation law (a relation schema, a `.requires(...)` gated
282
+ // directive, or relate-family/signer directives ensuring RelationTuple) gets the framework's
283
+ // `relationTuplesByObject` query appended (dedup by id) so the signer-recovery lanes
284
+ // (parentSignerEnrollment / ensureParentSignerEnrollment / canAuthor) can read the tuples on any
285
+ // workspace running the law — no tenant re-export to forget. ERA/OMIT RULE: a relation-free domain
286
+ // appends nothing (byte-identical module/package); a relation-using one gains the query on its NEXT
287
+ // recompile (a named, additive hash move the #58 evolve gate admits silently).
288
+ const usesRelations =
289
+ relationTypes.length > 0 ||
290
+ directives.some((d) => (d as { requiresRelationGate?: unknown }).requiresRelationGate !== undefined) ||
291
+ directives.some((d) => d.aggregateId === RelationTuple.id);
292
+ if (usesRelations && !queries.some((q) => q.id === relationTuplesByObject.id)) {
293
+ queries.push(relationTuplesByObject);
294
+ }
280
295
  // FIRST-CLASS VALUE OBJECTS (value_object.ts): the deduped, sorted descriptors of every
281
296
  // named VO the domain's aggregates carry (transitive nested VOs included) — the input to
282
297
  // the additive `nomosValueObjects` USD key. Omit-when-empty (era rule): a VO-free domain's
@@ -396,6 +411,10 @@ export function composeDomainModule(spec: DomainModuleSpec): DomainModule {
396
411
  // narrow scope. Compile-time only, no manifest/wire/hash effect.
397
412
  assertIdOfLint(directives, spec.domain ?? spec.name);
398
413
 
414
+ // (NO signer-placement guard — `enrollSigner` now SELF-BINDS to `ctx.principal()`, so composing it into a
415
+ // tenant domain cannot forge a signer for another principal; the cross-principal-forge surface the guard
416
+ // closed is dissolved by construction. See `dsl/src/framework/signing_identity.ts::enrollSigner`.)
417
+
399
418
  // THE SURFACED-RESOLVER LINT (surfaced_lint.ts — the fail-closed teeth): every
400
419
  // `.merge(Surfaced(...))` field must have EXACTLY ONE `.resolves()` directive, whose plan
401
420
  // provably set()s the field from a payload field. Returns the resolver bindings the typed
@@ -264,6 +264,34 @@ export function introspectPayload(
264
264
  return out;
265
265
  }
266
266
 
267
+ /**
268
+ * The aggregate ids a directive set references via `idOf(Agg)` in its TOP-LEVEL payload fields
269
+ * (dart_codegen_parity.md P2). A cross-domain `idOf` types the field as the sibling domain's typed
270
+ * id class (`ListingId`) — which lives in the OWNING domain's generated file, so the referencing
271
+ * domain's file must import it (flat: a relative `<domain>.dart`; layered: the sibling TYPES file).
272
+ * Returns EVERY idOf target (same- and cross-domain); the caller filters to the cross-domain ones it
273
+ * must import (a same-domain target's id class is declared locally). Nullable/optional-wrapped
274
+ * idOf fields are unwrapped by `idOfMetaOf`.
275
+ */
276
+ export function collectPayloadIdOfTargets(directives: readonly { payloadSchema?: z.ZodTypeAny }[]): Set<string> {
277
+ const out = new Set<string>();
278
+ for (const d of directives) {
279
+ const schema = d.payloadSchema;
280
+ if (schema === undefined || zodTypeName(schema) !== "ZodObject") continue;
281
+ let shape: Record<string, z.ZodTypeAny>;
282
+ try {
283
+ shape = zodObjectShape(schema);
284
+ } catch {
285
+ continue;
286
+ }
287
+ for (const zt of Object.values(shape)) {
288
+ const target = idOfMetaOf(zt as z.ZodTypeAny);
289
+ if (target !== undefined) out.add(target);
290
+ }
291
+ }
292
+ return out;
293
+ }
294
+
267
295
  /** Introspect ONE (already-unwrapped) Zod field into its Dart spec. Handles the
268
296
  * four shapes a tenant domain uses: scalar (string/int/bool), enum, string[], and a
269
297
  * NESTED object (e.g. attach/detach's `target:{targetType,targetId}`) — the engine
@@ -153,6 +153,15 @@ export interface DartBridgeClientOptions {
153
153
  * whose refs are all same-domain / foreign is byte-identical to before this existed.
154
154
  */
155
155
  readonly packageAggregateDomains?: Readonly<Record<string, { domain: string; name: string }>>;
156
+ /**
157
+ * LAYERED-PACKAGES `barrelExports: 'explicit'` MODE: the full package URIs of the per-domain TYPES
158
+ * files this client references (e.g. `package:advl_types/src/generated/estate.dart`). Under 'explicit'
159
+ * the types-package barrel ({@link typesImportUri}) does NOT flat-export per-domain aggregate/read-model/
160
+ * payload classes, so the client's references (read models, payloads, ref extensions, typed ids, birth
161
+ * payloads) would be undefined through the barrel alone — it must import the defining domain files
162
+ * directly. Empty/omitted ⇒ the barrel carries everything (the 'all' / flat path, byte-identical).
163
+ */
164
+ readonly explicitTypeImports?: readonly string[];
156
165
  }
157
166
 
158
167
  /** Generate the `<name>.client.dart` async bridge client for one domain module. */
@@ -732,6 +741,10 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
732
741
  // Layered-packages mode: the sibling types live in their OWN package — one barrel import
733
742
  // replaces both the relative types import and the relative framework import.
734
743
  ...(opts.typesImportUri !== undefined ? [`import '${opts.typesImportUri}';`] : [`import '${mod.name}.dart';`]),
744
+ // EXPLICIT-BARREL layered mode: the types barrel withholds per-domain classes, so import the defining
745
+ // per-domain TYPES files directly (this domain's own + any sibling whose classes this client names).
746
+ // The client header already `ignore_for_file: unused_import`, so an over-broad set is harmless.
747
+ ...(opts.explicitTypeImports ?? []).map((uri) => `import '${uri}';`),
735
748
  // ACTIVITY FEED (flat mode): the shared pure `renderActivity` lives in the sibling `activity_registry.dart`
736
749
  // (in layered mode it resolves through the types-package barrel above). Only when the package opts in.
737
750
  ...(opts.emitActivity === true && opts.activityImportUri !== undefined ? [`import '${opts.activityImportUri}';`] : []),
@@ -58,6 +58,7 @@ import {
58
58
  generateDartContracts,
59
59
  generateDartActivityRegistry,
60
60
  collectActivityDescriptors,
61
+ collectPayloadIdOfTargets,
61
62
  type DartImport,
62
63
  type DartRefImport,
63
64
  type DomainModule,
@@ -70,6 +71,7 @@ import {
70
71
  isFrameworkDirective,
71
72
  isFrameworkQuery,
72
73
  } from "./framework/framework_aggregates.js";
74
+ import { RelationTuple, relationTuplesByObject } from "./framework/relations.js";
73
75
  import { finishCount, type AnyCount } from "./count.js";
74
76
  import type { QueryDecl } from "./query.js";
75
77
  import type { SpatialDecl } from "./spatial.js";
@@ -692,6 +694,7 @@ function emitDart(
692
694
  names?: Readonly<Record<string, string>>,
693
695
  ): string[] {
694
696
  const domainSources: string[] = []; // for flat-mode alias validation (dart.names)
697
+ const sourceByFile = new Map<string, string>(); // fileBase → src, for precise alias-import selection
695
698
  const genDir = path.join(dartOut, DART_GENERATED_SUBDIR);
696
699
  removeLegacyFlatDartLayout(dartOut, modules.map((m) => m.name));
697
700
  rmSync(genDir, { recursive: true, force: true });
@@ -775,6 +778,7 @@ function emitDart(
775
778
  "utf8",
776
779
  );
777
780
  domainSources.push(src);
781
+ sourceByFile.set(fileBase, src);
778
782
  written.push(p);
779
783
  return p;
780
784
  };
@@ -847,6 +851,13 @@ function emitDart(
847
851
  if (targetFile !== undefined && targetFile !== modForDart.name) foreignRefImportUris.add(`${targetFile}.dart`);
848
852
  }
849
853
  }
854
+ // AUTO cross-domain `idOf(Agg)` imports (dart_codegen_parity.md P2): a payload field typed with a
855
+ // SIBLING domain's typed id class (`ListingId`) needs that domain's generated file, exactly as a
856
+ // foreignRef target does. Same-domain idOf targets declare their id class locally — skip them.
857
+ for (const target of collectPayloadIdOfTargets(modForDart.directives)) {
858
+ const targetFile = aggIdToDartFile.get(target);
859
+ if (targetFile !== undefined && targetFile !== modForDart.name) foreignRefImportUris.add(`${targetFile}.dart`);
860
+ }
850
861
  const mergedDartImports = [
851
862
  ...(modForDart.dartImports ?? []),
852
863
  ...[...foreignRefImportUris]
@@ -886,10 +897,21 @@ function emitDart(
886
897
  "// Regenerated on every compile; generated implementation lives under src/generated/.",
887
898
  "library;",
888
899
  "",
889
- ...aliasImportLines(names, [
890
- ...(hasFrameworkSymbols ? [`${genRel}/${FRAMEWORK_FILE}.dart`] : []),
891
- ...modules.map((m) => `${genRel}/${m.name}.dart`),
892
- ]),
900
+ // Only import files that actually DECLARE an alias target — a `typedef A = B` needs B in scope, but a
901
+ // file no alias references would be an `unused_import` warning (it is already re-exported below). This
902
+ // mirrors the layered barrel's `aliasImportUris` precision.
903
+ ...aliasImportLines(
904
+ names,
905
+ (() => {
906
+ const aliasTargets = new Set(Object.keys(names ?? {}));
907
+ return [
908
+ ...(hasFrameworkSymbols ? [FRAMEWORK_FILE] : []),
909
+ ...modules.map((m) => m.name),
910
+ ]
911
+ .filter((f) => [...declaredDartTypeNames(sourceByFile.get(f) ?? "")].some((n) => aliasTargets.has(n)))
912
+ .map((f) => `${genRel}/${f}.dart`);
913
+ })(),
914
+ ),
893
915
  supportImport === "vendored"
894
916
  ? `export '${genRel}/nomos_client.dart';`
895
917
  : `export 'package:nomos_client/nomos_client.dart';`,
@@ -924,11 +946,11 @@ const DART_TYPES_IMPORT = `import 'package:nomos_types/nomos_types.dart';`;
924
946
  * just the floor generated code needs — a lagging pin forces tenants into dependency_overrides. */
925
947
  // 0.3.0: SurfacedValue<T>/SurfacedObservation/PendingConflictRow — generated Surfaced-field typing +
926
948
  // the pendingConflicts read rows.
927
- const NOMOS_TYPES_DEP = "^0.4.0";
949
+ const NOMOS_TYPES_DEP = "^0.4.1";
928
950
  // 0.48.x: session-bound clients (XClient.bind/NomosClientInterface/DomainClientSessionMismatch/
929
951
  // DomainNotInstalledForSession, engine.follow, pendingConflicts lanes) — the generated client
930
952
  // REQUIRES this surface. 0.48.1 fixes NomosSession.watchPendingConflicts delegation.
931
- const NOMOS_CLIENT_DEP = "^0.54.0";
953
+ const NOMOS_CLIENT_DEP = "^0.55.0";
932
954
  // DRIFT TRIPWIRE: pubspec_pins.test.ts asserts these equal the CURRENT versions in dsl/dart/pubspec.yaml
933
955
  // and dsl/dart_types/pubspec.yaml — a Dart release without this bump fails the suite, so tenants never
934
956
  // land back in dependency_overrides (the exact co2 report this closes, third occurrence).
@@ -1063,13 +1085,18 @@ function emitDartPackages(
1063
1085
  const m = /^import\s+'([^']+)'/.exec(line.trim());
1064
1086
  if (
1065
1087
  m !== null &&
1088
+ // `dart:*` is a pure SDK library (dart:convert for jsonEncode of a json field, dart:math, …) — it
1089
+ // carries NO cross-PACKAGE coupling, which is the only thing this gate exists to prevent. The
1090
+ // purpose is keeping nomos_client (the runtime/bridge surface) out of the pure types layer, not
1091
+ // banning the Dart SDK. Allow every dart:* import; keep the cross-package allowlist otherwise.
1092
+ !m[1]!.startsWith("dart:") &&
1066
1093
  m[1] !== "package:nomos_types/nomos_types.dart" &&
1067
1094
  m[1] !== `${FRAMEWORK_FILE}.dart` &&
1068
1095
  !siblingTypeFiles.has(m[1]!)
1069
1096
  ) {
1070
1097
  fail(
1071
1098
  `types-package purity: generated '${fileBase}.dart' imports '${m[1]}' — a types file may only ` +
1072
- `import the nomos_types core, the framework file, or a sibling generated domain file`,
1099
+ `import the nomos_types core, the framework file, a sibling generated domain file, or a dart:* SDK library`,
1073
1100
  );
1074
1101
  }
1075
1102
  }
@@ -1112,6 +1139,12 @@ function emitDartPackages(
1112
1139
  if (tf !== undefined && tf !== dd.modForDart.name) frImports.add(`${tf}.dart`);
1113
1140
  }
1114
1141
  }
1142
+ // AUTO cross-domain `idOf(Agg)` imports (dart_codegen_parity.md P2): a payload field typed with a
1143
+ // SIBLING domain's typed id class needs that domain's TYPES file (a permitted sibling import).
1144
+ for (const target of collectPayloadIdOfTargets(dd.modForDart.directives)) {
1145
+ const tf = aggIdToTypeFile.get(target);
1146
+ if (tf !== undefined && tf !== dd.modForDart.name) frImports.add(`${tf}.dart`);
1147
+ }
1115
1148
  const merged = [
1116
1149
  ...(dd.modForDart.dartImports ?? []),
1117
1150
  ...[...frImports].filter((uri) => !(dd.modForDart.dartImports ?? []).some((i) => i.uri === uri)).sort().map((uri) => ({ uri })),
@@ -1206,6 +1239,12 @@ function emitDartPackages(
1206
1239
  typesImportUri,
1207
1240
  ...(packageActivity.length > 0 ? { emitActivity: true } : {}),
1208
1241
  packageAggregateDomains: buildPackageAggregateDomains(modules),
1242
+ // Under 'explicit' the types barrel withholds per-domain classes — import every domain's TYPES
1243
+ // file directly so the client's read-model/payload/ref/typed-id references resolve. (The no-dup-
1244
+ // symbol guarantee across domain files means an over-broad set never introduces ambiguity.)
1245
+ ...(explicitBarrel
1246
+ ? { explicitTypeImports: modules.map((m) => `package:${packages.types}/${genRel}/${m.name}.dart`) }
1247
+ : {}),
1209
1248
  });
1210
1249
  const cp = path.join(clientGen, `${mod.name}.client.dart`);
1211
1250
  writeFileSync(cp, clientSrc, "utf8");
@@ -1288,6 +1327,15 @@ function emitDartPackages(
1288
1327
  }
1289
1328
  const compatGen = freshGenDir(compatName);
1290
1329
  const typesImportForCompat = `package:${packages.types}/${packages.types}.dart`;
1330
+ // Under `barrelExports: 'explicit'` the types barrel does NOT flat-export per-domain aggregate/
1331
+ // read-model classes, so a `typedef Legacy = CatTagReadModel;` cannot resolve its target through the
1332
+ // barrel alone — it must import the DEFINING per-domain types file directly (exactly as the no-compat
1333
+ // explicit types barrel does to back its own typedefs). Under 'all' the barrel exports everything, so
1334
+ // no extra import is needed (byte-identical to before). Only the files that actually declare an alias
1335
+ // target are imported (mirroring `aliasImportUris`), keeping the shim minimal.
1336
+ const compatTargetImports = explicitBarrel
1337
+ ? aliasImportUris.map((rel) => `package:${packages.types}/${rel}`)
1338
+ : [];
1291
1339
  const compatFile = [
1292
1340
  "// GENERATED by nomos-compile — MIGRATION-ONLY compat aliases. DO NOT EDIT.",
1293
1341
  `//`,
@@ -1295,9 +1343,11 @@ function emitDartPackages(
1295
1343
  `// names in \`${packages.types}\`. Every symbol below is a plain \`typedef\` over the real generated`,
1296
1344
  `// type; it exists solely to keep pre-rename imports compiling during a migration. It adds NO`,
1297
1345
  `// behaviour and SHOULD NOT accrete new entries — new code imports \`${packages.types}\` directly.`,
1346
+ "// ignore_for_file: unused_import",
1298
1347
  "library;",
1299
1348
  "",
1300
1349
  `import '${typesImportForCompat}';`,
1350
+ ...compatTargetImports.map((uri) => `import '${uri}';`),
1301
1351
  ...dartAliasLines(names, declared),
1302
1352
  "",
1303
1353
  ];
@@ -1588,6 +1638,26 @@ async function main(): Promise<void> {
1588
1638
  if (!haveQuery.has(q.id)) queries.push(q);
1589
1639
  }
1590
1640
  }
1641
+ // RELATION-USING LAW AUTO-COMPOSES ITS AUDIT READ (co2 recovery DX, same idiom as the birth-meter
1642
+ // queries above): any domain that declares relation law — a relation schema, a `.requires(...)`
1643
+ // gated directive, or the signer/grant primitives (directives ensuring `RelationTuple`) — gets the
1644
+ // framework's `relationTuplesByObject` query appended (dedup by id), so the recovery lanes
1645
+ // (parentSignerEnrollment / ensureParentSignerEnrollment / canAuthor) can read the signer tuples
1646
+ // on ANY workspace running that law without the tenant remembering the re-export. ERA/OMIT RULE:
1647
+ // a domain with no relation law emits NOTHING new (byte-identical package); a relation-using
1648
+ // domain's package/openapi gain one query on its NEXT recompile — a named, expected hash move
1649
+ // (the stable-ids doctrine: recompiles move identity/package hashes; already-deployed artifacts
1650
+ // are untouched, and a query add is additive so the #58 evolve gate admits it silently).
1651
+ {
1652
+ const dirsAll = directivesOf(merged);
1653
+ const usesRelations =
1654
+ dirsAll.some((dir) => (dir as { requiresRelationGate?: unknown }).requiresRelationGate !== undefined) ||
1655
+ dirsAll.some((dir) => (dir as { aggregateId?: string }).aggregateId === RelationTuple.id) ||
1656
+ discover<RelationTypeDecl>(merged, isRelationTypeDecl).length > 0;
1657
+ if (usesRelations && !queries.some((q) => q.id === relationTuplesByObject.id)) {
1658
+ queries.push(relationTuplesByObject);
1659
+ }
1660
+ }
1591
1661
  const counts = [
1592
1662
  ...discover<AnyCount>(merged, isCountDecl),
1593
1663
  ...resolveNamed<AnyCount>(merged, d.counts, "counts"),
@@ -185,37 +185,50 @@ export const revokeSignerEpochStrictlyIncreases = workspaceInvariant("RevokeSign
185
185
  * - **NO `.requires`** — DELIBERATE. At genesis the founding signer enrolls on a still-WARRANT-FREE
186
186
  * prefix (it precedes `seedGenesis`), so `verify_captured_principal` is in its legacy fast path and
187
187
  * admits it UNSIGNED; there is no admin/signer yet to gate it, so a `.requires` would re-create the
188
- * bootstrap chicken-and-egg. The forge surface a bare `relateSubject` would open is closed by
189
- * DOMAIN PLACEMENT: this directive is composed into the CONTROL-PLANE governance law (domain
190
- * `workspaces`/`nomos`), which the open session lane structurally refuses
191
- * (`adapter/engine.mjs` — only the birth door + the secret-gated `/author` + admin
192
- * lanes author it), so no tenant can forge a signer for another principal.
188
+ * bootstrap chicken-and-egg.
189
+ *
190
+ * SELF-BINDING (2026-07 — the cross-principal-forge close). The enrolled object is the CAPTURED authoring
191
+ * principal `ctx.principal()`, NEVER a payload field — so a device can ONLY ever enrol a key for ITS OWN
192
+ * identity; a cross-principal forge (fill-in-any-name) is structurally impossible, on EVERY lane, with no
193
+ * placement guard and no IdP token (it works OFFLINE: a valid session vouches for its own new key; replay
194
+ * re-verifies the signature chain). The FOUNDING case is empirically covered: even pre-warrant/unsigned the
195
+ * intent captures its authoring principal (the birth/founder actor `user:<uid>`; `derive_signed_principal`
196
+ * on a warrant-free prefix keeps the captured value verbatim; `engine_entry::portsFromHost` seeds
197
+ * `ctx.principal()` from it), so the founder self-binds their OWN key at genesis — no carve-out needed.
198
+ * UNLIKE {@link enrollSignerSelf}, this founding lane carries NO verified-principal requirement (a virgin
199
+ * pre-warrant chain has no `verifiedVia` provenance yet to derive `principal_verified` from); it is the
200
+ * unsigned bootstrap enrol. Additional devices on a WARRANTED chain use {@link enrollSignerSelf}
201
+ * (verified) or {@link enrollFirstDevice} (keyless verified).
193
202
  *
194
203
  * THE ORDER IS THE CONTRACT (verified, zero gate change): `enrollSigner` (E, unsigned/pre-warrant) →
195
204
  * `seedGenesis` (W, unsigned/pre-warrant, the warrant lands in its output) → `grantAdmin` (A, the FIRST
196
205
  * SIGNED intent — its prefix now carries the warrant, the E-tuple proves the key, the virgin `#admin` is
197
- * admitted by the genesis clause). NB: a GENERAL tenant-callable enrolment (additional devices) needs a
198
- * self-binding (captured principal == payload principal) or admin-gating — see {@link enrollSignerSelf}.
206
+ * admitted by the genesis clause).
199
207
  */
200
208
  export const enrollSigner = directive("enrollSigner")
201
209
  .ensures(RelationTuple)
202
210
  .payload(
203
211
  z.object({
204
- principal: z.string().min(1), // the auth-provider uid the key may author as
205
212
  keyHash: sha256Hex, // sha256hex of the ed25519 device PUBLIC key
206
213
  grantedBy: z.string().min(1),
207
214
  grantedAt: z.number().int().nonnegative(), // epoch ms, caller-stamped (determinism)
208
215
  }),
209
216
  )
210
- .plan((p) =>
211
- writeTuple({
212
- object: signerObject(p.principal),
217
+ .plan((p, ctx) => {
218
+ // SELF-BIND: the object is the CAPTURED authoring principal's identity (read the raw principal from the
219
+ // endorsed evidence — the framework may; an author cannot smuggle a free-form principal). A device can
220
+ // only ever bind a key to itself, so there is no cross-principal forge surface (the reason the old
221
+ // placement guard existed is dissolved).
222
+ const me = ctx.principal().asAuthoritySubject<string>("enrol a founding device signer key for my own identity");
223
+ const rawPrincipal = endorsementOf(me)?.principal ?? "";
224
+ return writeTuple({
225
+ object: signerObject(rawPrincipal),
213
226
  relation: SIGNER_RELATION,
214
227
  subject: "key:" + p.keyHash,
215
228
  grantedBy: p.grantedBy,
216
229
  grantedAt: String(p.grantedAt),
217
- }),
218
- );
230
+ });
231
+ });
219
232
 
220
233
  /**
221
234
  * THE SELF-BIND ENROLL (VA-1 / B2). A device enrolls a key for ITS OWN verified identity — the bound