@githolon/dsl 0.79.0 → 0.80.1
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 +14 -0
- package/dart/lib/src/webview_bridge.dart +10 -2
- package/dart/pubspec.yaml +1 -1
- package/dart/test/adv_realm_interference_test.dart +469 -0
- package/dart/test/webview_bridge_test.dart +46 -0
- package/package.json +1 -1
- package/src/build_package.ts +24 -10
- package/src/codegen_dart.ts +28 -0
- package/src/codegen_dart_client.ts +13 -0
- package/src/compile_package_main.ts +55 -6
- package/src/framework/signing_identity.ts +26 -13
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
3
|
+
"version": "0.80.1",
|
|
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",
|
package/src/build_package.ts
CHANGED
|
@@ -197,12 +197,23 @@ export function composeDomainModule(spec: DomainModuleSpec): DomainModule {
|
|
|
197
197
|
const byId = new Map<string, AggregateHandle>();
|
|
198
198
|
for (const a of [...aggregatesOf(merged), ...(spec.extraAggregates ?? [])]) byId.set(a.id, a);
|
|
199
199
|
const directives = directivesOf(merged);
|
|
200
|
-
// RELATIONS AUTO-ENSURE: a
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
|
|
200
|
+
// RELATIONS AUTO-ENSURE + AUTO-COMPOSE (co2 recovery DX): a domain that USES relation law — declares a
|
|
201
|
+
// relation schema, a `.requires(...)`-gated directive, or relate-family/signer directives ensuring
|
|
202
|
+
// RelationTuple — needs the framework RelationTuple aggregate IN the schema on TWO counts: (1) the deploy
|
|
203
|
+
// gate must learn the type or a `writeTuple` silently fails admission, and (2) the auto-composed
|
|
204
|
+
// `relationTuplesByObject` query (appended below) `.returns(RelationTuple)`, so the read projection
|
|
205
|
+
// (`readProjectionForModule`/`readKeyFieldKind`) cannot resolve its return schema unless RelationTuple is
|
|
206
|
+
// one of the module's aggregates. Injecting it HERE (before the aggregate snapshot) makes auto-compose
|
|
207
|
+
// BYTE-EQUIVALENT to a tenant that explicitly `export { relationTuplesByObject, RelationTuple }` — the
|
|
208
|
+
// same aggregate + query enter the manifest, and the USD prim emission sorts aggregates by id, so
|
|
209
|
+
// insertion order does not perturb the hash. Era-safe: only a relation-using domain gains the aggregate,
|
|
210
|
+
// so no existing (relation-free) manifest hash drifts, and a tenant that already had the explicit export
|
|
211
|
+
// sees NO further move (identical bytes).
|
|
212
|
+
const usesRelations =
|
|
213
|
+
(spec.relationTypes ?? []).length > 0 ||
|
|
214
|
+
directives.some((d) => (d as { requiresRelationGate?: unknown }).requiresRelationGate !== undefined) ||
|
|
215
|
+
directives.some((d) => d.aggregateId === RelationTuple.id);
|
|
216
|
+
if (usesRelations && !byId.has(RelationTuple.id)) {
|
|
206
217
|
byId.set(RelationTuple.id, RelationTuple);
|
|
207
218
|
}
|
|
208
219
|
// BIRTH-METER AUTO-ENSURE (metered birth, slice A — the same "discoverable without re-exports"
|
|
@@ -285,10 +296,9 @@ export function composeDomainModule(spec: DomainModuleSpec): DomainModule {
|
|
|
285
296
|
// workspace running the law — no tenant re-export to forget. ERA/OMIT RULE: a relation-free domain
|
|
286
297
|
// appends nothing (byte-identical module/package); a relation-using one gains the query on its NEXT
|
|
287
298
|
// recompile (a named, additive hash move the #58 evolve gate admits silently).
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
directives.some((d) => d.aggregateId === RelationTuple.id);
|
|
299
|
+
// `usesRelations` was computed above the aggregate snapshot (so RelationTuple could be injected into the
|
|
300
|
+
// schema); reuse it here to append the audit query. The two MUST agree — the aggregate and the query it
|
|
301
|
+
// returns have to enter the manifest together, or the read projection can't resolve the return schema.
|
|
292
302
|
if (usesRelations && !queries.some((q) => q.id === relationTuplesByObject.id)) {
|
|
293
303
|
queries.push(relationTuplesByObject);
|
|
294
304
|
}
|
|
@@ -411,6 +421,10 @@ export function composeDomainModule(spec: DomainModuleSpec): DomainModule {
|
|
|
411
421
|
// narrow scope. Compile-time only, no manifest/wire/hash effect.
|
|
412
422
|
assertIdOfLint(directives, spec.domain ?? spec.name);
|
|
413
423
|
|
|
424
|
+
// (NO signer-placement guard — `enrollSigner` now SELF-BINDS to `ctx.principal()`, so composing it into a
|
|
425
|
+
// tenant domain cannot forge a signer for another principal; the cross-principal-forge surface the guard
|
|
426
|
+
// closed is dissolved by construction. See `dsl/src/framework/signing_identity.ts::enrollSigner`.)
|
|
427
|
+
|
|
414
428
|
// THE SURFACED-RESOLVER LINT (surfaced_lint.ts — the fail-closed teeth): every
|
|
415
429
|
// `.merge(Surfaced(...))` field must have EXACTLY ONE `.resolves()` directive, whose plan
|
|
416
430
|
// provably set()s the field from a payload field. Returns the resolver bindings the typed
|
package/src/codegen_dart.ts
CHANGED
|
@@ -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
|
-
|
|
891
|
-
|
|
892
|
-
|
|
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.
|
|
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,
|
|
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.
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
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).
|
|
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
|
-
|
|
212
|
-
|
|
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
|