@githolon/dsl 0.79.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,17 @@
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
+
1
15
  ## 0.54.1
2
16
 
3
17
  - **Fix: principal normalization mismatch in signer recovery** — enrollment wrote
@@ -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.1';
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
  }
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.1
14
+ version: 0.55.0
15
15
 
16
16
  environment:
17
17
  sdk: ^3.5.0
@@ -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
+ }
@@ -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(() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.79.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",
@@ -411,6 +411,10 @@ export function composeDomainModule(spec: DomainModuleSpec): DomainModule {
411
411
  // narrow scope. Compile-time only, no manifest/wire/hash effect.
412
412
  assertIdOfLint(directives, spec.domain ?? spec.name);
413
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
+
414
418
  // THE SURFACED-RESOLVER LINT (surfaced_lint.ts — the fail-closed teeth): every
415
419
  // `.merge(Surfaced(...))` field must have EXACTLY ONE `.resolves()` directive, whose plan
416
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,
@@ -693,6 +694,7 @@ function emitDart(
693
694
  names?: Readonly<Record<string, string>>,
694
695
  ): string[] {
695
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
696
698
  const genDir = path.join(dartOut, DART_GENERATED_SUBDIR);
697
699
  removeLegacyFlatDartLayout(dartOut, modules.map((m) => m.name));
698
700
  rmSync(genDir, { recursive: true, force: true });
@@ -776,6 +778,7 @@ function emitDart(
776
778
  "utf8",
777
779
  );
778
780
  domainSources.push(src);
781
+ sourceByFile.set(fileBase, src);
779
782
  written.push(p);
780
783
  return p;
781
784
  };
@@ -848,6 +851,13 @@ function emitDart(
848
851
  if (targetFile !== undefined && targetFile !== modForDart.name) foreignRefImportUris.add(`${targetFile}.dart`);
849
852
  }
850
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
+ }
851
861
  const mergedDartImports = [
852
862
  ...(modForDart.dartImports ?? []),
853
863
  ...[...foreignRefImportUris]
@@ -887,10 +897,21 @@ function emitDart(
887
897
  "// Regenerated on every compile; generated implementation lives under src/generated/.",
888
898
  "library;",
889
899
  "",
890
- ...aliasImportLines(names, [
891
- ...(hasFrameworkSymbols ? [`${genRel}/${FRAMEWORK_FILE}.dart`] : []),
892
- ...modules.map((m) => `${genRel}/${m.name}.dart`),
893
- ]),
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
+ ),
894
915
  supportImport === "vendored"
895
916
  ? `export '${genRel}/nomos_client.dart';`
896
917
  : `export 'package:nomos_client/nomos_client.dart';`,
@@ -929,7 +950,7 @@ const NOMOS_TYPES_DEP = "^0.4.1";
929
950
  // 0.48.x: session-bound clients (XClient.bind/NomosClientInterface/DomainClientSessionMismatch/
930
951
  // DomainNotInstalledForSession, engine.follow, pendingConflicts lanes) — the generated client
931
952
  // REQUIRES this surface. 0.48.1 fixes NomosSession.watchPendingConflicts delegation.
932
- const NOMOS_CLIENT_DEP = "^0.54.1";
953
+ const NOMOS_CLIENT_DEP = "^0.55.0";
933
954
  // DRIFT TRIPWIRE: pubspec_pins.test.ts asserts these equal the CURRENT versions in dsl/dart/pubspec.yaml
934
955
  // and dsl/dart_types/pubspec.yaml — a Dart release without this bump fails the suite, so tenants never
935
956
  // land back in dependency_overrides (the exact co2 report this closes, third occurrence).
@@ -1064,13 +1085,18 @@ function emitDartPackages(
1064
1085
  const m = /^import\s+'([^']+)'/.exec(line.trim());
1065
1086
  if (
1066
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:") &&
1067
1093
  m[1] !== "package:nomos_types/nomos_types.dart" &&
1068
1094
  m[1] !== `${FRAMEWORK_FILE}.dart` &&
1069
1095
  !siblingTypeFiles.has(m[1]!)
1070
1096
  ) {
1071
1097
  fail(
1072
1098
  `types-package purity: generated '${fileBase}.dart' imports '${m[1]}' — a types file may only ` +
1073
- `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`,
1074
1100
  );
1075
1101
  }
1076
1102
  }
@@ -1113,6 +1139,12 @@ function emitDartPackages(
1113
1139
  if (tf !== undefined && tf !== dd.modForDart.name) frImports.add(`${tf}.dart`);
1114
1140
  }
1115
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
+ }
1116
1148
  const merged = [
1117
1149
  ...(dd.modForDart.dartImports ?? []),
1118
1150
  ...[...frImports].filter((uri) => !(dd.modForDart.dartImports ?? []).some((i) => i.uri === uri)).sort().map((uri) => ({ uri })),
@@ -1207,6 +1239,12 @@ function emitDartPackages(
1207
1239
  typesImportUri,
1208
1240
  ...(packageActivity.length > 0 ? { emitActivity: true } : {}),
1209
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
+ : {}),
1210
1248
  });
1211
1249
  const cp = path.join(clientGen, `${mod.name}.client.dart`);
1212
1250
  writeFileSync(cp, clientSrc, "utf8");
@@ -1289,6 +1327,15 @@ function emitDartPackages(
1289
1327
  }
1290
1328
  const compatGen = freshGenDir(compatName);
1291
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
+ : [];
1292
1339
  const compatFile = [
1293
1340
  "// GENERATED by nomos-compile — MIGRATION-ONLY compat aliases. DO NOT EDIT.",
1294
1341
  `//`,
@@ -1296,9 +1343,11 @@ function emitDartPackages(
1296
1343
  `// names in \`${packages.types}\`. Every symbol below is a plain \`typedef\` over the real generated`,
1297
1344
  `// type; it exists solely to keep pre-rename imports compiling during a migration. It adds NO`,
1298
1345
  `// behaviour and SHOULD NOT accrete new entries — new code imports \`${packages.types}\` directly.`,
1346
+ "// ignore_for_file: unused_import",
1299
1347
  "library;",
1300
1348
  "",
1301
1349
  `import '${typesImportForCompat}';`,
1350
+ ...compatTargetImports.map((uri) => `import '${uri}';`),
1302
1351
  ...dartAliasLines(names, declared),
1303
1352
  "",
1304
1353
  ];
@@ -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