@githolon/dsl 0.103.3 → 0.103.5

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.
Files changed (42) hide show
  1. package/dart/CHANGELOG.md +15 -0
  2. package/dart/lib/src/runner/node-entry.mjs +411 -149
  3. package/dart/lib/src/runner/offline-install.mjs +222 -99
  4. package/dart/lib/src/session_bound.dart +111 -2
  5. package/dart/lib/src/test_harness.dart +117 -66
  6. package/dart/lib/src/webview_bridge.dart +726 -61
  7. package/dart/pubspec.lock +1 -1
  8. package/dart/pubspec.yaml +4 -5
  9. package/dart/test/acquire_session_test.dart +62 -5
  10. package/dart/test/cloud_bound_topology_test.dart +59 -38
  11. package/dart/test/session_bound_test.dart +25 -10
  12. package/dart/test/webview_bridge_test.dart +357 -9
  13. package/dart_types/CHANGELOG.md +5 -0
  14. package/dart_types/lib/nomos_types.dart +1 -0
  15. package/dart_types/lib/src/git_object_id.dart +37 -0
  16. package/dart_types/pubspec.yaml +1 -1
  17. package/package.json +1 -1
  18. package/runtime/generated-application-host.js +425 -145
  19. package/src/births.ts +16 -1
  20. package/src/business_birth.ts +67 -46
  21. package/src/codegen_dart.ts +68 -12
  22. package/src/codegen_dart_application.ts +114 -19
  23. package/src/codegen_dart_client.ts +149 -6
  24. package/src/codegen_proof.ts +16 -9
  25. package/src/codegen_provider_dart.ts +5 -0
  26. package/src/codegen_ts.ts +115 -19
  27. package/src/compile_package_main.ts +142 -40
  28. package/src/fields.ts +43 -0
  29. package/src/framework/attested_receipts.ts +34 -1
  30. package/src/framework/domain_lifecycle.ts +140 -83
  31. package/src/framework/genesis.ts +2 -1
  32. package/src/framework/home.ts +20 -45
  33. package/src/framework/workspaces.ts +62 -29
  34. package/src/idof_lint.ts +65 -51
  35. package/src/index.ts +21 -1
  36. package/src/intent.ts +11 -1
  37. package/src/law_graph.ts +81 -11
  38. package/src/law_graph_usda.ts +45 -54
  39. package/src/relation_schema.ts +18 -0
  40. package/src/shape_compat.ts +3 -0
  41. package/src/stable_ids_for_module.ts +7 -1
  42. package/src/value_object.ts +40 -0
@@ -41,7 +41,8 @@ import 'package:nomos_types/src/versioned_nomos_ref.dart'
41
41
  import 'package:nomos_types/src/foreign_ref.dart'
42
42
  show NomosForeignReader, NomosForeignWorkspaceReader;
43
43
 
44
- import 'session_bound.dart' show DomainNotInstalledForSession;
44
+ import 'session_bound.dart'
45
+ show DomainClientModelAdoptionRequired, DomainNotInstalledForSession;
45
46
 
46
47
  /// THE ENGINE (architecture/session_bound_clients.md): the multi-workspace RUNTIME — mount/connect,
47
48
  /// the session registry, sync, custody + recovery, the home ceremony, and [NomosBridge.follow]
@@ -61,6 +62,143 @@ final class NomosPage<T> {
61
62
  final bool done;
62
63
  }
63
64
 
65
+ /// Typed authority evidence carried by an offer that plans exactly one child birth.
66
+ /// It is offer metadata, never a tenant directive field. The bridge converts it to the
67
+ /// generated protocol's JSON-shaped transport only at the WebView boundary.
68
+ final class NomosBirthCertificate {
69
+ const NomosBirthCertificate({
70
+ required this.child,
71
+ required this.founder,
72
+ required this.verified,
73
+ required this.parentKey,
74
+ required this.parentId,
75
+ required this.epoch,
76
+ required this.signature,
77
+ this.verifiedVia,
78
+ this.idpToken,
79
+ this.capturedClock,
80
+ this.authProviderIssuer,
81
+ this.authProviderJwks,
82
+ this.authProviderAudience,
83
+ });
84
+
85
+ final String child;
86
+ final String founder;
87
+ final bool verified;
88
+ final List<int> parentKey;
89
+ final String parentId;
90
+ final int epoch;
91
+ final List<int> signature;
92
+ final String? verifiedVia;
93
+ final String? idpToken;
94
+ final int? capturedClock;
95
+ final String? authProviderIssuer;
96
+ final String? authProviderJwks;
97
+ final String? authProviderAudience;
98
+
99
+ static String _string(
100
+ Map<String, Object?> wire, String camel, String snake) =>
101
+ (wire[camel] ?? wire[snake] ?? '').toString();
102
+
103
+ static List<int> _bytes(
104
+ Map<String, Object?> wire, String camel, String snake) =>
105
+ ((wire[camel] ?? wire[snake]) as List? ?? const <Object?>[])
106
+ .map((value) => (value as num).toInt())
107
+ .toList(growable: false);
108
+
109
+ factory NomosBirthCertificate.fromWire(Map<String, Object?> wire) =>
110
+ NomosBirthCertificate(
111
+ child: _string(wire, 'child', 'child'),
112
+ founder: _string(wire, 'founder', 'founder'),
113
+ verified: wire['verified'] == true,
114
+ parentKey: _bytes(wire, 'parentKey', 'parent_key'),
115
+ parentId: _string(wire, 'parentId', 'parent_id'),
116
+ epoch: (wire['epoch'] as num?)?.toInt() ?? 0,
117
+ signature: _bytes(wire, 'signature', 'signature'),
118
+ verifiedVia: (wire['verifiedVia'] ?? wire['verified_via'])?.toString(),
119
+ idpToken: (wire['idpToken'] ?? wire['idp_token'])?.toString(),
120
+ capturedClock:
121
+ ((wire['capturedClock'] ?? wire['captured_clock']) as num?)
122
+ ?.toInt(),
123
+ authProviderIssuer:
124
+ (wire['authProviderIssuer'] ?? wire['auth_provider_issuer'])
125
+ ?.toString(),
126
+ authProviderJwks:
127
+ (wire['authProviderJwks'] ?? wire['auth_provider_jwks'])
128
+ ?.toString(),
129
+ authProviderAudience:
130
+ (wire['authProviderAudience'] ?? wire['auth_provider_audience'])
131
+ ?.toString(),
132
+ );
133
+
134
+ Map<String, Object?> toWire() => <String, Object?>{
135
+ 'child': child,
136
+ 'founder': founder,
137
+ 'verified': verified,
138
+ 'parentKey': parentKey,
139
+ 'parentId': parentId,
140
+ 'epoch': epoch,
141
+ 'signature': signature,
142
+ if (verifiedVia != null) 'verifiedVia': verifiedVia,
143
+ if (idpToken != null) 'idpToken': idpToken,
144
+ if (capturedClock != null) 'capturedClock': capturedClock,
145
+ if (authProviderIssuer != null)
146
+ 'authProviderIssuer': authProviderIssuer,
147
+ if (authProviderJwks != null) 'authProviderJwks': authProviderJwks,
148
+ if (authProviderAudience != null)
149
+ 'authProviderAudience': authProviderAudience,
150
+ };
151
+ }
152
+
153
+ /// Facts signed by [NomosBridge.signBirthCert]. The secret remains a separate argument and never
154
+ /// enters this value, so callers cannot accidentally serialize private key material with the cert.
155
+ final class NomosBirthCertificateSpec {
156
+ const NomosBirthCertificateSpec({
157
+ required this.child,
158
+ required this.founder,
159
+ required this.verified,
160
+ required this.parentKey,
161
+ required this.parentId,
162
+ required this.epoch,
163
+ this.verifiedVia,
164
+ this.idpToken,
165
+ this.capturedClock,
166
+ this.authProviderIssuer,
167
+ this.authProviderJwks,
168
+ this.authProviderAudience,
169
+ });
170
+
171
+ final String child;
172
+ final String founder;
173
+ final bool verified;
174
+ final String parentKey;
175
+ final String parentId;
176
+ final int epoch;
177
+ final String? verifiedVia;
178
+ final String? idpToken;
179
+ final int? capturedClock;
180
+ final String? authProviderIssuer;
181
+ final String? authProviderJwks;
182
+ final String? authProviderAudience;
183
+
184
+ Map<String, Object?> toWire() => <String, Object?>{
185
+ 'child': child,
186
+ 'founder': founder,
187
+ 'verified': verified,
188
+ 'parentKey': parentKey,
189
+ 'parentId': parentId,
190
+ 'epoch': epoch,
191
+ if (verifiedVia != null) 'verifiedVia': verifiedVia,
192
+ if (idpToken != null) 'idpToken': idpToken,
193
+ if (capturedClock != null) 'capturedClock': capturedClock,
194
+ if (authProviderIssuer != null)
195
+ 'authProviderIssuer': authProviderIssuer,
196
+ if (authProviderJwks != null) 'authProviderJwks': authProviderJwks,
197
+ if (authProviderAudience != null)
198
+ 'authProviderAudience': authProviderAudience,
199
+ };
200
+ }
201
+
64
202
  /// The protocol version the runner and the bridge agree on (`webview-runner.mjs` `WEBVIEW_BRIDGE_VERSION`).
65
203
  const int kWebviewBridgeVersion = 1;
66
204
 
@@ -116,12 +254,14 @@ class TransferProgress {
116
254
  bool get done => total != null && received >= total!;
117
255
  factory TransferProgress.fromMessage(Map<String, Object?> m) =>
118
256
  TransferProgress(
119
- received: (m['received'] as num?)?.toInt() ?? 0,
120
- total: (m['total'] as num?)?.toInt(),
257
+ received: _wireInteger(m['received']) ?? 0,
258
+ total: _wireInteger(m['total']),
121
259
  kind: m['kind']?.toString() ?? 'delta',
122
260
  );
123
261
  }
124
262
 
263
+ int? _wireInteger(Object? value) => value is num ? value.toInt() : null;
264
+
125
265
  /// One opaque point in this workspace's verified history. Applications use it as the handle passed to a
126
266
  /// generated client's `past.at(moment)`; [id] is framework identity, not a business field and need never be
127
267
  /// parsed. The remaining fields make a timeline useful without exposing commit or event-sourcing ceremony.
@@ -856,8 +996,59 @@ class NomosEncryption {
856
996
 
857
997
  /// The result of [NomosBridge.offerCreates]: the new local [head], every aggregate the plan minted, and —
858
998
  /// for a `.births()` directive — the child workspace(s) the offer brought into being on this local plane.
999
+ enum NomosOperationPhase { locallyAccepted, admittedToRemoteCustody, converged }
1000
+
1001
+ /// Product-facing progress for one accepted write. Transport topology and custody identifiers remain private.
1002
+ final class NomosOperationStatus {
1003
+ const NomosOperationStatus(this.phase, {this.updatedAt});
1004
+
1005
+ final NomosOperationPhase phase;
1006
+ final DateTime? updatedAt;
1007
+
1008
+ bool get isAdmitted => phase != NomosOperationPhase.locallyAccepted;
1009
+ bool get isConverged => phase == NomosOperationPhase.converged;
1010
+ }
1011
+
1012
+ /// An awaitable witness for one generated write.
1013
+ ///
1014
+ /// Creation means the write is already accepted into durable local custody. [admitted] completes when the
1015
+ /// receiving group accepts it into shared custody; [converged] completes only after this application has
1016
+ /// adopted that shared position. Callers never inspect workspaces, URLs, credentials, headers or Git heads.
1017
+ final class NomosOperationReceipt {
1018
+ NomosOperationReceipt._()
1019
+ : _status =
1020
+ const NomosOperationStatus(NomosOperationPhase.locallyAccepted),
1021
+ _changes = StreamController<NomosOperationStatus>.broadcast();
1022
+
1023
+ NomosOperationStatus _status;
1024
+ final StreamController<NomosOperationStatus> _changes;
1025
+
1026
+ NomosOperationStatus get current => _status;
1027
+
1028
+ Stream<NomosOperationStatus> watch() async* {
1029
+ yield _status;
1030
+ yield* _changes.stream;
1031
+ }
1032
+
1033
+ Future<void> get admitted => _status.isAdmitted
1034
+ ? Future<void>.value()
1035
+ : watch().firstWhere((status) => status.isAdmitted).then((_) {});
1036
+
1037
+ Future<void> get converged => _status.isConverged
1038
+ ? Future<void>.value()
1039
+ : watch().firstWhere((status) => status.isConverged).then((_) {});
1040
+
1041
+ void _advance(NomosOperationPhase phase) {
1042
+ if (phase.index <= _status.phase.index) return;
1043
+ _status = NomosOperationStatus(phase, updatedAt: DateTime.now());
1044
+ if (!_changes.isClosed) _changes.add(_status);
1045
+ if (phase == NomosOperationPhase.converged) _changes.close();
1046
+ }
1047
+ }
1048
+
859
1049
  class NomosOfferOutcome {
860
1050
  final String head;
1051
+ final String intentId;
861
1052
  final List<NomosCreated> created;
862
1053
 
863
1054
  /// Every business aggregate touched by the sealed intent, including creates,
@@ -882,15 +1073,25 @@ class NomosOfferOutcome {
882
1073
  /// local offer lane.
883
1074
  final Map<String, Object?>? gatewayVerdict;
884
1075
 
1076
+ final NomosOperationReceipt? _receipt;
1077
+
1078
+ /// Awaitable remote-custody witness for this exact write.
1079
+ NomosOperationReceipt get receipt =>
1080
+ _receipt ??
1081
+ (throw StateError(
1082
+ 'This legacy offer outcome has no operation receipt; regenerate the application client.'));
1083
+
885
1084
  const NomosOfferOutcome({
886
1085
  required this.head,
1086
+ this.intentId = '',
887
1087
  required this.created,
888
1088
  this.affected = const [],
889
1089
  this.workspace = '',
890
1090
  this.born = const [],
891
1091
  this.birthOutcomes = const [],
892
1092
  this.gatewayVerdict,
893
- });
1093
+ NomosOperationReceipt? receipt,
1094
+ }) : _receipt = receipt;
894
1095
 
895
1096
  /// TRUE when the offer COMMITTED — it advanced the ledger to a new head (a write landed and/or a child
896
1097
  /// was born). A REFUSED offer never yields an outcome (it throws / surfaces its typed law error), so a
@@ -1013,12 +1214,14 @@ class NomosBridge {
1013
1214
  late final StreamController<SyncStatus> _syncStatusCtrl =
1014
1215
  StreamController<SyncStatus>.broadcast(
1015
1216
  onListen: _startStatusPoll,
1016
- onCancel: () {
1017
- if (!_syncStatusCtrl.hasListener) _stopStatusPoll();
1018
- },
1217
+ onCancel: _stopStatusPollIfUnused,
1019
1218
  );
1020
1219
  SyncStatus _status = const SyncStatus();
1220
+ final Map<String, SyncStatus> _statusBySession = {};
1221
+ final Map<String, StreamController<SyncStatus>> _syncStatusBySession = {};
1021
1222
  Timer? _metricsTimer;
1223
+ Future<void>? _metricsPoll;
1224
+ bool _metricsPollAgain = false;
1022
1225
  final Completer<void> _ready = Completer<void>();
1023
1226
  // THE LOCAL PLANE'S WORKSPACES. A NomosBridge is a device's local holarchy plane; each connected
1024
1227
  // workspace is a live session on it. [_sessionNames] tracks what [connect] has opened; [_sessionViews]
@@ -1036,6 +1239,11 @@ class NomosBridge {
1036
1239
  // without asking tenant code for cloud URLs, tokens, sessions or signing keys. Kept per source
1037
1240
  // session so one local plane may safely host applications from different clouds/identities.
1038
1241
  final Map<String, _NomosRetainedCloudAccess> _sessionCloudAccess = {};
1242
+ // One model-decision policy follows the generated application through its
1243
+ // child sessions. The callback receives only the typed review object; child
1244
+ // addresses, archives, credentials and lifecycle directives remain private.
1245
+ final Map<String, Future<void> Function(DomainClientModelAdoptionRequired)>
1246
+ _sessionModelChangeHandlers = {};
1039
1247
  // [acquireSession]'s per-workspace single-flight: workspace → the in-flight open every concurrent
1040
1248
  // acquirer joins (removed when it completes; a live session then hits the fast path instead).
1041
1249
  final Map<String, Future<NomosSession>> _acquiring = {};
@@ -1044,12 +1252,28 @@ class NomosBridge {
1044
1252
  // several references, but the runner gets one autoSync registration for that session.
1045
1253
  final Set<String> _autoSyncSessions = {};
1046
1254
  final Map<String, Future<void>> _autoSyncStarting = {};
1255
+ final Map<String, Future<void>> _credentialRefreshStarting = {};
1256
+ // Exact-write custody receipts. The stable intent identity is framework-private; generated application
1257
+ // methods expose only the typed receipt and its product-language lifecycle.
1258
+ final Map<String, Map<String, NomosOperationReceipt>> _operationReceipts = {};
1259
+ final Map<String, NomosOperationReceipt> _birthReceipts = {};
1047
1260
  int _seq = 0;
1048
1261
  final String _traceId = _newTraceId();
1049
1262
  late final StreamSubscription<Map<String, Object?>> _sub;
1050
1263
 
1051
1264
  NomosBridge(this._transport, {this.allowStaleRunnerBundle = false}) {
1052
- _sub = _transport.messages.listen(_onMessage);
1265
+ _sub = _transport.messages.listen(_onMessage, onError: _onTransportError);
1266
+ }
1267
+
1268
+ void _onTransportError(Object error, StackTrace stackTrace) {
1269
+ if (!_ready.isCompleted) _ready.completeError(error, stackTrace);
1270
+ final pending = _pending.values.toList(growable: false);
1271
+ _pending.clear();
1272
+ _pendingOps.clear();
1273
+ _pendingTraces.clear();
1274
+ for (final completer in pending) {
1275
+ if (!completer.isCompleted) completer.completeError(error, stackTrace);
1276
+ }
1053
1277
  }
1054
1278
 
1055
1279
  /// Escape hatch for DELIBERATE mixed-version setups (framework dev, A/B probes): accept a runner
@@ -1189,29 +1413,59 @@ class NomosBridge {
1189
1413
  if (!_syncEvents.isClosed) _syncEvents.add(m);
1190
1414
  if (event == 'sync') {
1191
1415
  final result = (m['result'] as Map?)?.cast<String, Object?>();
1416
+ final session = m['session']?.toString();
1417
+ if (session != null && result != null) {
1418
+ _advanceOperationReceipts(session, result);
1419
+ if (_syncNeedsCredentialRefresh(m, result)) {
1420
+ unawaited(_refreshSessionCredential(session));
1421
+ }
1422
+ }
1192
1423
  if (m['ok'] == false) {
1193
1424
  // A rejected/failed round is not a successful sync timestamp. Keep the stable typed envelope on
1194
1425
  // telemetry; the app-ready status needs only one legible error and an honest error phase.
1195
1426
  final detail = m['error']?.toString() ?? 'automatic sync failed';
1196
- _pushStatus(_status.copyWith(
1197
- phase: SyncPhase.error, clearTransfer: true, lastError: detail));
1427
+ _updateScopedStatus(
1428
+ session,
1429
+ (current) => current.copyWith(
1430
+ phase: SyncPhase.error,
1431
+ clearTransfer: true,
1432
+ lastError: detail));
1198
1433
  } else if (result?['offline'] == true) {
1199
1434
  // Offline is expected transport debt. The durable local branch remains ahead and autoSync owns the
1200
1435
  // retry; do not claim a completed sync or overwrite the previous successful timestamp.
1201
- _pushStatus(_status.copyWith(
1202
- phase: SyncPhase.offline,
1203
- clearTransfer: true,
1204
- lastError: (result?['pushError'] as Map?)?['error']?.toString()));
1436
+ _updateScopedStatus(
1437
+ session,
1438
+ (current) => current.copyWith(
1439
+ phase: SyncPhase.offline,
1440
+ clearTransfer: true,
1441
+ lastError:
1442
+ (result?['pushError'] as Map?)?['error']?.toString()));
1443
+ } else if (result?['pushFailed'] == true) {
1444
+ // The sync round itself completed, but shared custody refused the
1445
+ // push. `stillAhead` is durable debt, not success: surface the
1446
+ // cloud's typed refusal on the owning workspace so generated
1447
+ // `whenCurrent()` calls fail with the real cause instead of timing
1448
+ // out behind a generic saving state.
1449
+ final detail = _syncPushFailureDetail(result!);
1450
+ _updateScopedStatus(
1451
+ session,
1452
+ (current) => current.copyWith(
1453
+ phase: SyncPhase.error,
1454
+ clearTransfer: true,
1455
+ lastError: detail));
1205
1456
  } else {
1206
1457
  // A completed round → idle (the next metrics poll folds in residual ahead/behind), stamp the time.
1207
- _pushStatus(_status.copyWith(
1208
- phase: SyncPhase.idle,
1209
- clearTransfer: true,
1210
- clearError: true,
1211
- lastSyncedAt: DateTime.now()));
1458
+ _updateScopedStatus(
1459
+ session,
1460
+ (current) => current.copyWith(
1461
+ phase: SyncPhase.idle,
1462
+ clearTransfer: true,
1463
+ clearError: true,
1464
+ lastSyncedAt: DateTime.now()));
1212
1465
  }
1213
1466
  } else if (m['status'] == 'closed' || m['status'] == 'offline') {
1214
- _pushStatus(_status.copyWith(phase: SyncPhase.offline));
1467
+ _updateScopedStatus(m['session']?.toString(),
1468
+ (current) => current.copyWith(phase: SyncPhase.offline));
1215
1469
  }
1216
1470
  return;
1217
1471
  }
@@ -1226,10 +1480,12 @@ class NomosBridge {
1226
1480
  // LIVE pack byte-progress { received, total, kind } — the real loading bar (host Content-Length).
1227
1481
  final tp = TransferProgress.fromMessage(m);
1228
1482
  if (!_transferEvents.isClosed) _transferEvents.add(tp);
1229
- _pushStatus(_status.copyWith(
1230
- phase: tp.done ? SyncPhase.syncing : SyncPhase.transferring,
1231
- transfer: tp,
1232
- clearTransfer: tp.done));
1483
+ _updateScopedStatus(
1484
+ m['session']?.toString(),
1485
+ (current) => current.copyWith(
1486
+ phase: tp.done ? SyncPhase.syncing : SyncPhase.transferring,
1487
+ transfer: tp,
1488
+ clearTransfer: tp.done));
1233
1489
  return;
1234
1490
  }
1235
1491
  if (event == 'trace') {
@@ -1241,8 +1497,11 @@ class NomosBridge {
1241
1497
  // signal holon projection: ephemeral coordination such as peer frontiers / lag.
1242
1498
  if (!_signalEvents.isClosed) _signalEvents.add(m);
1243
1499
  // the doorbell says canonical main advanced → we're behind until the next pull lands.
1244
- if (_status.phase == SyncPhase.idle)
1245
- _pushStatus(_status.copyWith(phase: SyncPhase.behind));
1500
+ _updateScopedStatus(
1501
+ m['session']?.toString(),
1502
+ (current) => current.phase == SyncPhase.idle
1503
+ ? current.copyWith(phase: SyncPhase.behind)
1504
+ : current);
1246
1505
  return;
1247
1506
  }
1248
1507
  if (event == 'span') {
@@ -1330,6 +1589,102 @@ class NomosBridge {
1330
1589
  }
1331
1590
  }
1332
1591
 
1592
+ NomosOperationReceipt _registerOperationReceipt(
1593
+ String workspace, String intentId) {
1594
+ if (intentId.isEmpty) {
1595
+ throw StateError(
1596
+ 'Nomos accepted a local write without returning its stable intent identity. '
1597
+ 'The runtime and kernel release are incompatible.');
1598
+ }
1599
+ final plane = _eventPlane;
1600
+ // A locally accepted write is not current until custody convergence
1601
+ // advances its receipt. Mark its workspace immediately so a same-tick
1602
+ // `whenCurrent()` cannot return using the previous metrics snapshot.
1603
+ final current = plane._currentSessionStatus(workspace);
1604
+ plane._pushSessionStatus(
1605
+ workspace,
1606
+ current.copyWith(
1607
+ phase: SyncPhase.syncing,
1608
+ ahead: current.ahead + 1,
1609
+ clearError: true,
1610
+ ),
1611
+ );
1612
+ plane._foldApplicationStatusFromSessions();
1613
+ return plane._operationReceipts
1614
+ .putIfAbsent(workspace, () => {})
1615
+ .putIfAbsent(intentId, NomosOperationReceipt._);
1616
+ }
1617
+
1618
+ void _advanceOperationReceipts(
1619
+ String workspace, Map<String, Object?> result) {
1620
+ final pending = _eventPlane._operationReceipts[workspace];
1621
+ if (pending == null || pending.isEmpty) return;
1622
+
1623
+ Iterable<String> ids(String key) => ((result[key] as List?) ?? const [])
1624
+ .whereType<String>()
1625
+ .where((value) => value.isNotEmpty);
1626
+
1627
+ for (final intentId in ids('admittedIntentIds')) {
1628
+ pending[intentId]?._advance(NomosOperationPhase.admittedToRemoteCustody);
1629
+ }
1630
+ for (final intentId in ids('convergedIntentIds')) {
1631
+ final receipt = pending.remove(intentId);
1632
+ receipt?._advance(NomosOperationPhase.converged);
1633
+ }
1634
+ if (pending.isEmpty) _eventPlane._operationReceipts.remove(workspace);
1635
+ }
1636
+
1637
+ bool _syncNeedsCredentialRefresh(
1638
+ Map<String, Object?> message, Map<String, Object?> result) {
1639
+ final push = (result['pushError'] as Map?)?.cast<String, Object?>();
1640
+ final status = push?['status'];
1641
+ if (status == 401 || status == 403) return true;
1642
+ final text =
1643
+ '${message['error'] ?? ''} ${push?['error'] ?? ''}'.toLowerCase();
1644
+ return text.contains('unauthenticated') ||
1645
+ text.contains('token expired') ||
1646
+ text.contains('invalid token');
1647
+ }
1648
+
1649
+ String _syncPushFailureDetail(Map<String, Object?> result) {
1650
+ final push = (result['pushError'] as Map?)?.cast<String, Object?>();
1651
+ final status = push?['status'];
1652
+ final reason = push?['error'] ?? push?['body'];
1653
+ final prefix = status == null
1654
+ ? 'Shared custody refused this workspace change'
1655
+ : 'Shared custody refused this workspace change (HTTP $status)';
1656
+ return reason == null || reason.toString().trim().isEmpty
1657
+ ? prefix
1658
+ : '$prefix: $reason';
1659
+ }
1660
+
1661
+ Future<void> _refreshSessionCredential(String workspace) {
1662
+ final plane = _eventPlane;
1663
+ return plane._credentialRefreshStarting.putIfAbsent(workspace, () async {
1664
+ try {
1665
+ final retained = plane._sessionCloudAccess[workspace];
1666
+ if (retained == null) return;
1667
+ final token = (await retained.signIn())?.trim() ?? '';
1668
+ if (token.isEmpty) {
1669
+ throw StateError(
1670
+ 'Nomos could not refresh the signed-in identity for background sync.');
1671
+ }
1672
+ await plane.session(workspace)._call('refreshCredentials', {
1673
+ 'authToken': token,
1674
+ });
1675
+ } finally {
1676
+ plane._credentialRefreshStarting.remove(workspace);
1677
+ }
1678
+ });
1679
+ }
1680
+
1681
+ /// The parent-write receipt which brought this child workspace into being, when it was born in this
1682
+ /// mounted application. Ordinary opens of pre-existing workspaces correctly have no creation receipt.
1683
+ NomosOperationReceipt? nomosInternalWorkspaceCreationReceipt() {
1684
+ final workspace = _sessionName;
1685
+ return workspace == null ? null : _eventPlane._birthReceipts[workspace];
1686
+ }
1687
+
1333
1688
  // ── the runtime-aware surface (the boundary the generated Dart client wraps) ──
1334
1689
 
1335
1690
  /// THE PRIMARY MULTI-WORKSPACE ENTRY. Return a [NomosSession] handle pinned to [workspace] — the object a
@@ -1438,6 +1793,15 @@ class NomosBridge {
1438
1793
  }));
1439
1794
  _eventPlane._sessionNames
1440
1795
  .add(ws); // the open succeeded — the session is real now
1796
+ // Opening succeeded, but scoped metrics have not yet proved that this
1797
+ // workspace is current. Unknown readiness must fail closed: otherwise a
1798
+ // generated `whenCurrent()` can observe the default idle value and return
1799
+ // before the first measurement arrives.
1800
+ _eventPlane._statusBySession.putIfAbsent(
1801
+ ws,
1802
+ () => const SyncStatus(phase: SyncPhase.syncing),
1803
+ );
1804
+ _eventPlane._foldApplicationStatusFromSessions();
1441
1805
  final boundPrincipal = _principalFromToken(authToken);
1442
1806
  if (boundPrincipal != null)
1443
1807
  _eventPlane._sessionPrincipals[ws] = boundPrincipal;
@@ -1446,6 +1810,9 @@ class NomosBridge {
1446
1810
  }
1447
1811
  _eventPlane._lastRecovery =
1448
1812
  (r['recovery'] as Map?)?.cast<String, Object?>();
1813
+ if (_eventPlane._syncStatusCtrl.hasListener) {
1814
+ unawaited(_eventPlane._pollMetricsOnce());
1815
+ }
1449
1816
  }
1450
1817
 
1451
1818
  /// The custody lifecycle stream (rebirth_and_custody_lifecycle.md) — a rebirth/divergence surfaces here as a
@@ -1580,6 +1947,7 @@ class NomosBridge {
1580
1947
  // Per-offer signing key (base64 device secret): sign THIS offer with a specific device key instead of
1581
1948
  // the connect-time one (the runner maps it onto `opts.signWith`). Same contract as [offerOutcome].
1582
1949
  String? authorSecret,
1950
+ NomosBirthCertificate? birthCertificate,
1583
1951
  }) async {
1584
1952
  final r = _asMap(await _call('offerDirective', {
1585
1953
  'domain': domain,
@@ -1592,6 +1960,8 @@ class NomosBridge {
1592
1960
  if (force != null) 'force': force,
1593
1961
  if (session != null) 'session': session,
1594
1962
  if (authorSecret != null) 'authorSecret': authorSecret,
1963
+ if (birthCertificate != null)
1964
+ 'birthCertificate': birthCertificate.toWire(),
1595
1965
  }));
1596
1966
  return r['head'].toString();
1597
1967
  }
@@ -1614,6 +1984,7 @@ class NomosBridge {
1614
1984
  bool? force,
1615
1985
  String? session,
1616
1986
  String? authorSecret,
1987
+ NomosBirthCertificate? birthCertificate,
1617
1988
  }) =>
1618
1989
  offerOutcome(
1619
1990
  domain: domain,
@@ -1625,6 +1996,7 @@ class NomosBridge {
1625
1996
  force: force,
1626
1997
  session: session,
1627
1998
  authorSecret: authorSecret,
1999
+ birthCertificate: birthCertificate,
1628
2000
  );
1629
2001
 
1630
2002
  /// THE ONE OUTCOME-RETURNING OFFER LANE — [offerDirective] answering the full typed [NomosOfferOutcome]
@@ -1644,6 +2016,7 @@ class NomosBridge {
1644
2016
  // Per-offer signing key (base64 device secret): sign THIS offer with a specific device key instead
1645
2017
  // of the connect-time one — the parent-directed lanes (sibling births after a signer repair) use it.
1646
2018
  String? authorSecret,
2019
+ NomosBirthCertificate? birthCertificate,
1647
2020
  }) async {
1648
2021
  final Map<String, Object?> r;
1649
2022
  // OFFER SPAN TAXONOMY (bridge legs): stamp the Dart-side send/receive around the op; the runner
@@ -1665,6 +2038,8 @@ class NomosBridge {
1665
2038
  if (force != null) 'force': force,
1666
2039
  if (session != null) 'session': session,
1667
2040
  if (authorSecret != null) 'authorSecret': authorSecret,
2041
+ if (birthCertificate != null)
2042
+ 'birthCertificate': birthCertificate.toWire(),
1668
2043
  }));
1669
2044
  } catch (e) {
1670
2045
  throw await _wrapDomainNotInstalled(e,
@@ -1737,9 +2112,16 @@ class NomosBridge {
1737
2112
  }
1738
2113
  }
1739
2114
  // The offer ran on this workspace (NomosRef) — stamp every minted ref with it so it is a complete locator.
1740
- final ws = (r['workspace'] ?? '').toString();
2115
+ final ws = (r['workspace'] ?? sourceSession ?? '').toString();
2116
+ final intentId = (r['intentId'] ?? '').toString();
2117
+ final receipt = _registerOperationReceipt(ws, intentId);
2118
+ for (final child in bornNames) {
2119
+ _eventPlane._birthReceipts[child] = receipt;
2120
+ }
1741
2121
  return NomosOfferOutcome(
1742
2122
  head: r['head'].toString(),
2123
+ intentId: intentId,
2124
+ receipt: receipt,
1743
2125
  workspace: ws,
1744
2126
  created: ((r['created'] as List?) ?? const [])
1745
2127
  .whereType<Map>()
@@ -1878,6 +2260,16 @@ class NomosBridge {
1878
2260
  'acquireSession("$workspace"): the workspace is already bound to a different signed-in person');
1879
2261
  }
1880
2262
  plane._sessionPrincipals[workspace] = sourcePrincipal;
2263
+ final source = _sessionName;
2264
+ if (source != null) {
2265
+ final handler = plane._sessionModelChangeHandlers[source];
2266
+ if (handler != null) {
2267
+ plane._sessionModelChangeHandlers.putIfAbsent(
2268
+ workspace,
2269
+ () => handler,
2270
+ );
2271
+ }
2272
+ }
1881
2273
  }
1882
2274
 
1883
2275
  Future<NomosSession> ready(NomosSession opened) async {
@@ -1909,7 +2301,9 @@ class NomosBridge {
1909
2301
  return ready(session(workspace));
1910
2302
  }
1911
2303
  // Consult the plane's TRUTHFUL registry (mounted ∪ parked ∪ resident custody — the runner's
1912
- // realm.list()): anything the device already holds opens locally, no cloud, no credentials.
2304
+ // realm.list()). A resident child needs no clone, but a generated application still binds its
2305
+ // retained cloud/session proof here so continuous sync owns that child too. With no retained
2306
+ // credentials (an explicitly local tool/test), preserve the local-only open.
1913
2307
  var resident = false;
1914
2308
  try {
1915
2309
  resident = (await listWorkspaces())
@@ -1918,11 +2312,26 @@ class NomosBridge {
1918
2312
  // a pre-connect plane has no registry yet — fall through to the credentials decision below.
1919
2313
  }
1920
2314
  if (resident) {
1921
- await connect(
2315
+ if (ifAbsentCredentials == null) {
2316
+ await connect(
2317
+ workspace: workspace,
2318
+ session: workspace,
2319
+ allowUnborn: true,
2320
+ authorSecret: authorSecret);
2321
+ } else {
2322
+ final c = await ifAbsentCredentials();
2323
+ await connect(
2324
+ cloud: c.cloud,
1922
2325
  workspace: workspace,
1923
2326
  session: workspace,
2327
+ clientId: c.clientId,
2328
+ authToken: c.authToken,
2329
+ authorSecret: c.authorSecret ?? authorSecret,
2330
+ // The local birth may not have reached remote custody yet. Binding the cloud posture must
2331
+ // not turn that expected parent-first convergence window into an open failure.
1924
2332
  allowUnborn: true,
1925
- authorSecret: authorSecret);
2333
+ );
2334
+ }
1926
2335
  return ready(session(workspace));
1927
2336
  }
1928
2337
  if (ifAbsentCredentials == null) {
@@ -2171,6 +2580,21 @@ class NomosBridge {
2171
2580
  .cast<String, Object?>();
2172
2581
  }
2173
2582
 
2583
+ /// Framework-private generated-model staging seam. The generated client owns
2584
+ /// the packaged USDA candidate and the bound session authors the ordinary
2585
+ /// lifecycle offer; tenant code never handles transport bytes or routing.
2586
+ Future<void> nomosInternalStageGeneratedModel({
2587
+ required String domain,
2588
+ required String candidateHash,
2589
+ required List<int> lawUsdz,
2590
+ }) async {
2591
+ await _call('stageGeneratedModel', {
2592
+ 'domain': domain,
2593
+ 'candidateHash': candidateHash,
2594
+ 'lawUsdz': base64Encode(lawUsdz),
2595
+ });
2596
+ }
2597
+
2174
2598
  /// Framework-private exact-history restoration seam. Generated clients expose
2175
2599
  /// `model.restorePrevious()` and a chained `decision.accept(reason)`; predecessor
2176
2600
  /// identity, derived dispositions, heads and receipts stay below this bridge.
@@ -2249,14 +2673,11 @@ class NomosBridge {
2249
2673
  /// VA ROOT-OF-TRUST: sign a BirthCert through the kernel `cert_sign` op (the SINGLE source of the cert
2250
2674
  /// byte-layout — no Dart copy). `fields` = `{child, founder, verified, parentKey (this device's base64
2251
2675
  /// ed25519 PUBLIC half), parentId, epoch}`; `deviceSecret` is that key's base64 SECRET half, handed to the
2252
- /// LOCAL kernel only (it never reaches a server). Returns the snake_case cert JSON `verify_to_root` parses.
2253
- Future<Map<String, Object?>> signBirthCert(
2254
- Map<String, Object?> fields, String deviceSecret) async =>
2255
- _asMap(await _call('signBirthCert', {
2256
- 'fields': fields,
2257
- 'deviceSecret': deviceSecret
2258
- }))['cert'] as Map<String, Object?>? ??
2259
- const {};
2676
+ /// LOCAL kernel only (it never reaches a server). Returns typed authority evidence suitable for an offer.
2677
+ Future<NomosBirthCertificate> signBirthCert(
2678
+ NomosBirthCertificateSpec spec, String deviceSecret) async =>
2679
+ NomosBirthCertificate.fromWire(_asMap(_asMap(await _call('signBirthCert',
2680
+ {'fields': spec.toWire(), 'deviceSecret': deviceSecret}))['cert']));
2260
2681
 
2261
2682
  /// MINT THE KEYLESS ENROLL-ONCE DELEGATION (`enrollFirstDevice.delegationCert`, the verified-first-device
2262
2683
  /// lane). The device SELF-signs it; its authority is the carried `idpToken` (the kernel re-verifies that to
@@ -2851,14 +3272,113 @@ class NomosBridge {
2851
3272
  /// THE APP-READY SYNC STATE — a truthful aggregation of sync rounds, the doorbell, transfer progress, and a
2852
3273
  /// periodic metrics poll. Map straight to a sync-state page. Starts a lightweight 4s metrics poll while
2853
3274
  /// listened to (stops on cancel). [currentStatus] is the latest snapshot for a non-stream read.
2854
- Stream<SyncStatus> get syncStatus => _eventPlane._syncStatusCtrl.stream;
2855
- SyncStatus get currentStatus => _eventPlane._status;
3275
+ Stream<SyncStatus> get syncStatus {
3276
+ final session = _sessionName;
3277
+ return session == null
3278
+ ? _eventPlane._syncStatusCtrl.stream
3279
+ : _eventPlane._sessionSyncStatus(session);
3280
+ }
3281
+
3282
+ SyncStatus get currentStatus {
3283
+ final session = _sessionName;
3284
+ return session == null
3285
+ ? _eventPlane._status
3286
+ : _eventPlane._currentSessionStatus(session);
3287
+ }
3288
+
3289
+ Stream<SyncStatus> _sessionSyncStatus(String session) =>
3290
+ _eventPlane._syncStatusBySession
3291
+ .putIfAbsent(
3292
+ session,
3293
+ () => StreamController<SyncStatus>.broadcast(
3294
+ onListen: () => _eventPlane._startSessionStatusPoll(session),
3295
+ onCancel: _eventPlane._stopStatusPollIfUnused,
3296
+ ),
3297
+ )
3298
+ .stream;
3299
+
3300
+ SyncStatus _currentSessionStatus(String session) =>
3301
+ _eventPlane._statusBySession[session] ?? const SyncStatus();
2856
3302
 
2857
3303
  void _pushStatus(SyncStatus s) {
2858
3304
  _status = s;
2859
3305
  if (!_syncStatusCtrl.isClosed) _syncStatusCtrl.add(s);
2860
3306
  }
2861
3307
 
3308
+ void _pushSessionStatus(String session, SyncStatus status) {
3309
+ _statusBySession[session] = status;
3310
+ final controller = _syncStatusBySession[session];
3311
+ if (controller != null && !controller.isClosed) controller.add(status);
3312
+ }
3313
+
3314
+ void _updateScopedStatus(
3315
+ String? session,
3316
+ SyncStatus Function(SyncStatus current) update,
3317
+ ) {
3318
+ if (session == null || session.isEmpty) {
3319
+ _pushStatus(update(_status));
3320
+ return;
3321
+ }
3322
+ _pushSessionStatus(session, update(_currentSessionStatus(session)));
3323
+ _foldApplicationStatusFromSessions();
3324
+ }
3325
+
3326
+ void _foldApplicationStatusFromSessions() {
3327
+ final statuses = _sessionNames
3328
+ .map((session) => _statusBySession[session])
3329
+ .whereType<SyncStatus>()
3330
+ .toList(growable: false);
3331
+ if (statuses.isEmpty) return;
3332
+
3333
+ SyncPhase phase;
3334
+ if (statuses.any((status) => status.phase == SyncPhase.error)) {
3335
+ phase = SyncPhase.error;
3336
+ } else if (statuses.any((status) => status.phase == SyncPhase.offline)) {
3337
+ phase = SyncPhase.offline;
3338
+ } else if (statuses
3339
+ .any((status) => status.phase == SyncPhase.transferring)) {
3340
+ phase = SyncPhase.transferring;
3341
+ } else if (statuses.any(
3342
+ (status) => status.phase == SyncPhase.behind || status.behind > 0)) {
3343
+ phase = SyncPhase.behind;
3344
+ } else if (statuses.any(
3345
+ (status) => status.phase == SyncPhase.syncing || status.ahead > 0)) {
3346
+ phase = SyncPhase.syncing;
3347
+ } else {
3348
+ phase = SyncPhase.idle;
3349
+ }
3350
+
3351
+ int total(int Function(SyncStatus status) read) =>
3352
+ statuses.fold(0, (sum, status) => sum + read(status));
3353
+ final lastSynced = statuses
3354
+ .map((status) => status.lastSyncedAt)
3355
+ .whereType<DateTime>()
3356
+ .fold<DateTime?>(
3357
+ null,
3358
+ (oldest, value) =>
3359
+ oldest == null || value.isBefore(oldest) ? value : oldest);
3360
+ final error = statuses
3361
+ .map((status) => status.lastError)
3362
+ .whereType<String>()
3363
+ .firstOrNull;
3364
+ final transfer = statuses
3365
+ .map((status) => status.transfer)
3366
+ .whereType<TransferProgress>()
3367
+ .firstOrNull;
3368
+
3369
+ _pushStatus(SyncStatus(
3370
+ phase: phase,
3371
+ ahead: total((status) => status.ahead),
3372
+ behind: total((status) => status.behind),
3373
+ saved: total((status) => status.saved),
3374
+ onMain: total((status) => status.onMain),
3375
+ deadLetters: total((status) => status.deadLetters),
3376
+ transfer: transfer,
3377
+ lastSyncedAt: lastSynced,
3378
+ lastError: error,
3379
+ ));
3380
+ }
3381
+
2862
3382
  void _startStatusPoll() {
2863
3383
  _pushStatus(_status); // seed the new listener with the current snapshot
2864
3384
  _pollMetricsOnce();
@@ -2866,33 +3386,109 @@ class NomosBridge {
2866
3386
  Timer.periodic(const Duration(seconds: 4), (_) => _pollMetricsOnce());
2867
3387
  }
2868
3388
 
3389
+ void _startSessionStatusPoll(String session) {
3390
+ _pushSessionStatus(session, _currentSessionStatus(session));
3391
+ _pollMetricsOnce();
3392
+ _metricsTimer ??=
3393
+ Timer.periodic(const Duration(seconds: 4), (_) => _pollMetricsOnce());
3394
+ }
3395
+
3396
+ void _stopStatusPollIfUnused() {
3397
+ if (_syncStatusCtrl.hasListener ||
3398
+ _syncStatusBySession.values
3399
+ .any((controller) => controller.hasListener)) {
3400
+ return;
3401
+ }
3402
+ _stopStatusPoll();
3403
+ }
3404
+
2869
3405
  void _stopStatusPoll() {
2870
3406
  _metricsTimer?.cancel();
2871
3407
  _metricsTimer = null;
2872
3408
  }
2873
3409
 
2874
3410
  Future<void> _pollMetricsOnce() async {
3411
+ final plane = _eventPlane;
3412
+ final inFlight = plane._metricsPoll;
3413
+ if (inFlight != null) {
3414
+ plane._metricsPollAgain = true;
3415
+ return inFlight;
3416
+ }
3417
+
3418
+ final poll = plane._pollMetricsForOpenSessions();
3419
+ plane._metricsPoll = poll;
2875
3420
  try {
2876
- final m = await metrics();
2877
- int? n(String k) =>
2878
- m.containsKey(k) ? ((m[k] as num?)?.toInt() ?? 0) : null;
2879
- final ahead = n('ahead') ?? _status.ahead;
2880
- final behind = n('behind') ?? _status.behind;
2881
- // Don't stomp an in-flight transfer's phase; otherwise derive idle/behind/syncing from the counts.
2882
- final phase = _status.transfer != null
2883
- ? _status.phase
2884
- : (behind > 0
2885
- ? SyncPhase.behind
2886
- : (ahead > 0 ? SyncPhase.syncing : SyncPhase.idle));
2887
- _pushStatus(_status.copyWith(
2888
- ahead: ahead,
2889
- behind: behind,
2890
- saved: n('saved'),
2891
- onMain: n('onMain'),
2892
- deadLetters: n('deadLetters'),
2893
- phase: phase,
2894
- clearError: true,
2895
- ));
3421
+ await poll;
3422
+ } finally {
3423
+ if (identical(plane._metricsPoll, poll)) plane._metricsPoll = null;
3424
+ if (plane._metricsPollAgain) {
3425
+ plane._metricsPollAgain = false;
3426
+ unawaited(plane._pollMetricsOnce());
3427
+ }
3428
+ }
3429
+ }
3430
+
3431
+ Future<void> _pollMetricsForOpenSessions() async {
3432
+ try {
3433
+ // SyncStatus belongs to the local application plane, not whichever
3434
+ // workspace happened to connect first. Once that plane has more than
3435
+ // one open workspace the raw bridge deliberately refuses an unscoped
3436
+ // `metrics` call, so ask every live session explicitly and fold their
3437
+ // counters into one application status.
3438
+ final sessions = _eventPlane._sessionNames.toList(growable: false);
3439
+ // A listener can attach just before the first connect finishes. Never
3440
+ // issue an unscoped diagnostic request in that gap: by the time the
3441
+ // runner handles it, several generated children may legitimately be
3442
+ // open and the plane must then reject the ambiguous request. The next
3443
+ // periodic tick (or sync event) will measure the now-open sessions.
3444
+ if (sessions.isEmpty) return;
3445
+ final measurements = await Future.wait(
3446
+ sessions.map((session) => metrics(session)),
3447
+ );
3448
+
3449
+ int? metric(Map<String, Object?> measurement, String key) {
3450
+ if (!measurement.containsKey(key)) return null;
3451
+ final value = measurement[key];
3452
+ if (value is num) return value.toInt();
3453
+ // The runtime's `behind` metric is deliberately a head-comparison
3454
+ // verdict, whereas SyncStatus exposes the same fact as a count. Keep
3455
+ // that typed boundary explicit instead of casting the boolean to num.
3456
+ if (value is bool) return value ? 1 : 0;
3457
+ throw FormatException(
3458
+ "Nomos sync metric '$key' must be numeric or boolean, got ${value.runtimeType}",
3459
+ );
3460
+ }
3461
+
3462
+ for (var index = 0; index < sessions.length; index += 1) {
3463
+ final session = sessions[index];
3464
+ final measurement = measurements[index];
3465
+ final current = _currentSessionStatus(session);
3466
+ final ahead = metric(measurement, 'ahead') ?? current.ahead;
3467
+ final behind = metric(measurement, 'behind') ?? current.behind;
3468
+ // Counters prove custody debt, but they do not prove that a failed or
3469
+ // offline transport recovered. Only a subsequent successful sync/link
3470
+ // event may clear those lifecycle states.
3471
+ final phase = current.transfer != null ||
3472
+ current.phase == SyncPhase.error ||
3473
+ current.phase == SyncPhase.offline
3474
+ ? current.phase
3475
+ : (behind > 0
3476
+ ? SyncPhase.behind
3477
+ : (ahead > 0 ? SyncPhase.syncing : SyncPhase.idle));
3478
+ _pushSessionStatus(
3479
+ session,
3480
+ current.copyWith(
3481
+ ahead: ahead,
3482
+ behind: behind,
3483
+ saved: metric(measurement, 'saved'),
3484
+ onMain: metric(measurement, 'onMain'),
3485
+ deadLetters: metric(measurement, 'deadLetters'),
3486
+ phase: phase,
3487
+ clearError: phase != SyncPhase.error && phase != SyncPhase.offline,
3488
+ ),
3489
+ );
3490
+ }
3491
+ _foldApplicationStatusFromSessions();
2896
3492
  } catch (e) {
2897
3493
  _pushStatus(
2898
3494
  _status.copyWith(phase: SyncPhase.error, lastError: e.toString()));
@@ -3183,8 +3779,18 @@ class NomosBridge {
3183
3779
  return ctrl.stream;
3184
3780
  }
3185
3781
 
3186
- /// Tear down: stop the transport subscription + fail any in-flight calls.
3782
+ /// Tear down the application realm, then stop the transport subscription and
3783
+ /// fail any remaining in-flight calls. The native kernel plane may remain
3784
+ /// resident for custody/cache reuse, but its application-owned sessions,
3785
+ /// watches and continuous-sync loops must not survive this client.
3187
3786
  Future<void> dispose() async {
3787
+ try {
3788
+ await _call('disposeRealm').timeout(const Duration(seconds: 10));
3789
+ } catch (_) {
3790
+ // A dead/unstarted embedding has no reachable runner to release. The
3791
+ // enclosing engine host is still disposed below by NomosScope; never let
3792
+ // teardown strand the Flutter lifecycle on an unavailable transport.
3793
+ }
3188
3794
  await _sub.cancel();
3189
3795
  final pending = _pending.entries
3190
3796
  .map((entry) => (
@@ -3228,6 +3834,11 @@ class NomosBridge {
3228
3834
  if (!_flameEvents.isClosed) await _flameEvents.close();
3229
3835
  if (!_spanEvents.isClosed) await _spanEvents.close();
3230
3836
  if (!_transferEvents.isClosed) await _transferEvents.close();
3837
+ final sessionStatuses = _syncStatusBySession.values.toList(growable: false);
3838
+ _syncStatusBySession.clear();
3839
+ for (final status in sessionStatuses) {
3840
+ if (!status.isClosed) unawaited(status.close());
3841
+ }
3231
3842
  if (!_syncStatusCtrl.isClosed) await _syncStatusCtrl.close();
3232
3843
  }
3233
3844
  }
@@ -3300,6 +3911,7 @@ class NomosSession extends NomosBridge {
3300
3911
  String? authorSecret,
3301
3912
  String? inheritedPrincipal}) {
3302
3913
  final retained = parent._sessionCloudAccess[this.workspace];
3914
+ final modelChange = parent._sessionModelChangeHandlers[this.workspace];
3303
3915
  final opened = parent.acquireSession(
3304
3916
  workspace,
3305
3917
  ifAbsentCredentials: ifAbsentCredentials ??
@@ -3314,10 +3926,63 @@ class NomosSession extends NomosBridge {
3314
3926
  // credential posture (home -> shared home -> child workspace remains one stateful application).
3315
3927
  if (retained != null)
3316
3928
  parent._sessionCloudAccess.putIfAbsent(workspace, () => retained);
3929
+ if (modelChange != null) {
3930
+ parent._sessionModelChangeHandlers.putIfAbsent(
3931
+ workspace,
3932
+ () => modelChange,
3933
+ );
3934
+ }
3317
3935
  return session;
3318
3936
  });
3319
3937
  }
3320
3938
 
3939
+ /// Generator-only parent-to-child model inheritance. The compiled USDZ is
3940
+ /// opaque transport; the target child's kernel re-derives its identity and
3941
+ /// judges the ordinary signed stageDomain offer before custody changes.
3942
+ Future<void> nomosInternalEnsureGeneratedModelInChild({
3943
+ required String childWorkspace,
3944
+ required String domain,
3945
+ required String candidateHash,
3946
+ required List<int> lawUsdz,
3947
+ }) async {
3948
+ final source = _sessionName;
3949
+ if (source == null || source.isEmpty) {
3950
+ throw StateError(
3951
+ 'Generated child model inheritance needs a bound parent session.');
3952
+ }
3953
+ await _call('stageGeneratedModelFromParent', {
3954
+ 'sourceSession': source,
3955
+ 'session': childWorkspace,
3956
+ 'domain': domain,
3957
+ 'candidateHash': candidateHash,
3958
+ 'lawUsdz': base64Encode(lawUsdz),
3959
+ });
3960
+ }
3961
+
3962
+ /// Retain the application's typed model-decision policy for generated child
3963
+ /// workspaces. This is not a deployment callback and carries no protocol data.
3964
+ void nomosInternalRetainModelChangeHandler(
3965
+ Future<void> Function(DomainClientModelAdoptionRequired) handler,
3966
+ ) {
3967
+ final workspace = _sessionName;
3968
+ if (workspace == null || workspace.isEmpty) {
3969
+ throw StateError(
3970
+ 'Model change handling needs a bound application session.');
3971
+ }
3972
+ _eventPlane._sessionModelChangeHandlers[workspace] = handler;
3973
+ }
3974
+
3975
+ Future<void> nomosInternalHandleModelChange(
3976
+ DomainClientModelAdoptionRequired change,
3977
+ ) async {
3978
+ final workspace = _sessionName;
3979
+ final handler = workspace == null
3980
+ ? null
3981
+ : _eventPlane._sessionModelChangeHandlers[workspace];
3982
+ if (handler == null) throw change;
3983
+ await handler(change);
3984
+ }
3985
+
3321
3986
  /// Generator/runtime-host seam: retain this application's refreshable cloud access privately so
3322
3987
  /// generated reference reads can mount another authorized workspace after a cold start.
3323
3988
  ///