@githolon/dsl 0.103.4 → 0.103.6

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 (40) hide show
  1. package/dart/CHANGELOG.md +8 -0
  2. package/dart/lib/src/runner/node-entry.mjs +333 -117
  3. package/dart/lib/src/runner/offline-install.mjs +173 -67
  4. package/dart/lib/src/session_bound.dart +111 -2
  5. package/dart/lib/src/test_harness.dart +51 -41
  6. package/dart/lib/src/webview_bridge.dart +714 -60
  7. package/dart/pubspec.lock +1 -1
  8. package/dart/pubspec.yaml +2 -2
  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 +326 -7
  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 +347 -113
  19. package/src/births.ts +16 -1
  20. package/src/business_birth.ts +55 -34
  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 +159 -43
  28. package/src/fields.ts +43 -0
  29. package/src/framework/domain_lifecycle.ts +108 -88
  30. package/src/framework/genesis.ts +2 -1
  31. package/src/framework/home.ts +6 -43
  32. package/src/framework/workspaces.ts +42 -25
  33. package/src/idof_lint.ts +65 -51
  34. package/src/index.ts +21 -1
  35. package/src/intent.ts +11 -1
  36. package/src/law_graph.ts +81 -11
  37. package/src/relation_schema.ts +18 -0
  38. package/src/shape_compat.ts +3 -0
  39. package/src/stable_ids_for_module.ts +7 -1
  40. 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,6 +1252,11 @@ 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;
@@ -1200,29 +1413,59 @@ class NomosBridge {
1200
1413
  if (!_syncEvents.isClosed) _syncEvents.add(m);
1201
1414
  if (event == 'sync') {
1202
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
+ }
1203
1423
  if (m['ok'] == false) {
1204
1424
  // A rejected/failed round is not a successful sync timestamp. Keep the stable typed envelope on
1205
1425
  // telemetry; the app-ready status needs only one legible error and an honest error phase.
1206
1426
  final detail = m['error']?.toString() ?? 'automatic sync failed';
1207
- _pushStatus(_status.copyWith(
1208
- 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));
1209
1433
  } else if (result?['offline'] == true) {
1210
1434
  // Offline is expected transport debt. The durable local branch remains ahead and autoSync owns the
1211
1435
  // retry; do not claim a completed sync or overwrite the previous successful timestamp.
1212
- _pushStatus(_status.copyWith(
1213
- phase: SyncPhase.offline,
1214
- clearTransfer: true,
1215
- 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));
1216
1456
  } else {
1217
1457
  // A completed round → idle (the next metrics poll folds in residual ahead/behind), stamp the time.
1218
- _pushStatus(_status.copyWith(
1219
- phase: SyncPhase.idle,
1220
- clearTransfer: true,
1221
- clearError: true,
1222
- 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()));
1223
1465
  }
1224
1466
  } else if (m['status'] == 'closed' || m['status'] == 'offline') {
1225
- _pushStatus(_status.copyWith(phase: SyncPhase.offline));
1467
+ _updateScopedStatus(m['session']?.toString(),
1468
+ (current) => current.copyWith(phase: SyncPhase.offline));
1226
1469
  }
1227
1470
  return;
1228
1471
  }
@@ -1237,10 +1480,12 @@ class NomosBridge {
1237
1480
  // LIVE pack byte-progress { received, total, kind } — the real loading bar (host Content-Length).
1238
1481
  final tp = TransferProgress.fromMessage(m);
1239
1482
  if (!_transferEvents.isClosed) _transferEvents.add(tp);
1240
- _pushStatus(_status.copyWith(
1241
- phase: tp.done ? SyncPhase.syncing : SyncPhase.transferring,
1242
- transfer: tp,
1243
- 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));
1244
1489
  return;
1245
1490
  }
1246
1491
  if (event == 'trace') {
@@ -1252,8 +1497,11 @@ class NomosBridge {
1252
1497
  // signal holon projection: ephemeral coordination such as peer frontiers / lag.
1253
1498
  if (!_signalEvents.isClosed) _signalEvents.add(m);
1254
1499
  // the doorbell says canonical main advanced → we're behind until the next pull lands.
1255
- if (_status.phase == SyncPhase.idle)
1256
- _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);
1257
1505
  return;
1258
1506
  }
1259
1507
  if (event == 'span') {
@@ -1341,6 +1589,102 @@ class NomosBridge {
1341
1589
  }
1342
1590
  }
1343
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
+
1344
1688
  // ── the runtime-aware surface (the boundary the generated Dart client wraps) ──
1345
1689
 
1346
1690
  /// THE PRIMARY MULTI-WORKSPACE ENTRY. Return a [NomosSession] handle pinned to [workspace] — the object a
@@ -1449,6 +1793,15 @@ class NomosBridge {
1449
1793
  }));
1450
1794
  _eventPlane._sessionNames
1451
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();
1452
1805
  final boundPrincipal = _principalFromToken(authToken);
1453
1806
  if (boundPrincipal != null)
1454
1807
  _eventPlane._sessionPrincipals[ws] = boundPrincipal;
@@ -1457,6 +1810,9 @@ class NomosBridge {
1457
1810
  }
1458
1811
  _eventPlane._lastRecovery =
1459
1812
  (r['recovery'] as Map?)?.cast<String, Object?>();
1813
+ if (_eventPlane._syncStatusCtrl.hasListener) {
1814
+ unawaited(_eventPlane._pollMetricsOnce());
1815
+ }
1460
1816
  }
1461
1817
 
1462
1818
  /// The custody lifecycle stream (rebirth_and_custody_lifecycle.md) — a rebirth/divergence surfaces here as a
@@ -1591,6 +1947,7 @@ class NomosBridge {
1591
1947
  // Per-offer signing key (base64 device secret): sign THIS offer with a specific device key instead of
1592
1948
  // the connect-time one (the runner maps it onto `opts.signWith`). Same contract as [offerOutcome].
1593
1949
  String? authorSecret,
1950
+ NomosBirthCertificate? birthCertificate,
1594
1951
  }) async {
1595
1952
  final r = _asMap(await _call('offerDirective', {
1596
1953
  'domain': domain,
@@ -1603,6 +1960,8 @@ class NomosBridge {
1603
1960
  if (force != null) 'force': force,
1604
1961
  if (session != null) 'session': session,
1605
1962
  if (authorSecret != null) 'authorSecret': authorSecret,
1963
+ if (birthCertificate != null)
1964
+ 'birthCertificate': birthCertificate.toWire(),
1606
1965
  }));
1607
1966
  return r['head'].toString();
1608
1967
  }
@@ -1625,6 +1984,7 @@ class NomosBridge {
1625
1984
  bool? force,
1626
1985
  String? session,
1627
1986
  String? authorSecret,
1987
+ NomosBirthCertificate? birthCertificate,
1628
1988
  }) =>
1629
1989
  offerOutcome(
1630
1990
  domain: domain,
@@ -1636,6 +1996,7 @@ class NomosBridge {
1636
1996
  force: force,
1637
1997
  session: session,
1638
1998
  authorSecret: authorSecret,
1999
+ birthCertificate: birthCertificate,
1639
2000
  );
1640
2001
 
1641
2002
  /// THE ONE OUTCOME-RETURNING OFFER LANE — [offerDirective] answering the full typed [NomosOfferOutcome]
@@ -1655,6 +2016,7 @@ class NomosBridge {
1655
2016
  // Per-offer signing key (base64 device secret): sign THIS offer with a specific device key instead
1656
2017
  // of the connect-time one — the parent-directed lanes (sibling births after a signer repair) use it.
1657
2018
  String? authorSecret,
2019
+ NomosBirthCertificate? birthCertificate,
1658
2020
  }) async {
1659
2021
  final Map<String, Object?> r;
1660
2022
  // OFFER SPAN TAXONOMY (bridge legs): stamp the Dart-side send/receive around the op; the runner
@@ -1676,6 +2038,8 @@ class NomosBridge {
1676
2038
  if (force != null) 'force': force,
1677
2039
  if (session != null) 'session': session,
1678
2040
  if (authorSecret != null) 'authorSecret': authorSecret,
2041
+ if (birthCertificate != null)
2042
+ 'birthCertificate': birthCertificate.toWire(),
1679
2043
  }));
1680
2044
  } catch (e) {
1681
2045
  throw await _wrapDomainNotInstalled(e,
@@ -1748,9 +2112,16 @@ class NomosBridge {
1748
2112
  }
1749
2113
  }
1750
2114
  // The offer ran on this workspace (NomosRef) — stamp every minted ref with it so it is a complete locator.
1751
- 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
+ }
1752
2121
  return NomosOfferOutcome(
1753
2122
  head: r['head'].toString(),
2123
+ intentId: intentId,
2124
+ receipt: receipt,
1754
2125
  workspace: ws,
1755
2126
  created: ((r['created'] as List?) ?? const [])
1756
2127
  .whereType<Map>()
@@ -1889,6 +2260,16 @@ class NomosBridge {
1889
2260
  'acquireSession("$workspace"): the workspace is already bound to a different signed-in person');
1890
2261
  }
1891
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
+ }
1892
2273
  }
1893
2274
 
1894
2275
  Future<NomosSession> ready(NomosSession opened) async {
@@ -1920,7 +2301,9 @@ class NomosBridge {
1920
2301
  return ready(session(workspace));
1921
2302
  }
1922
2303
  // Consult the plane's TRUTHFUL registry (mounted ∪ parked ∪ resident custody — the runner's
1923
- // 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.
1924
2307
  var resident = false;
1925
2308
  try {
1926
2309
  resident = (await listWorkspaces())
@@ -1929,11 +2312,26 @@ class NomosBridge {
1929
2312
  // a pre-connect plane has no registry yet — fall through to the credentials decision below.
1930
2313
  }
1931
2314
  if (resident) {
1932
- 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,
1933
2325
  workspace: workspace,
1934
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.
1935
2332
  allowUnborn: true,
1936
- authorSecret: authorSecret);
2333
+ );
2334
+ }
1937
2335
  return ready(session(workspace));
1938
2336
  }
1939
2337
  if (ifAbsentCredentials == null) {
@@ -2182,6 +2580,21 @@ class NomosBridge {
2182
2580
  .cast<String, Object?>();
2183
2581
  }
2184
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
+
2185
2598
  /// Framework-private exact-history restoration seam. Generated clients expose
2186
2599
  /// `model.restorePrevious()` and a chained `decision.accept(reason)`; predecessor
2187
2600
  /// identity, derived dispositions, heads and receipts stay below this bridge.
@@ -2260,14 +2673,11 @@ class NomosBridge {
2260
2673
  /// VA ROOT-OF-TRUST: sign a BirthCert through the kernel `cert_sign` op (the SINGLE source of the cert
2261
2674
  /// byte-layout — no Dart copy). `fields` = `{child, founder, verified, parentKey (this device's base64
2262
2675
  /// ed25519 PUBLIC half), parentId, epoch}`; `deviceSecret` is that key's base64 SECRET half, handed to the
2263
- /// LOCAL kernel only (it never reaches a server). Returns the snake_case cert JSON `verify_to_root` parses.
2264
- Future<Map<String, Object?>> signBirthCert(
2265
- Map<String, Object?> fields, String deviceSecret) async =>
2266
- _asMap(await _call('signBirthCert', {
2267
- 'fields': fields,
2268
- 'deviceSecret': deviceSecret
2269
- }))['cert'] as Map<String, Object?>? ??
2270
- 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']));
2271
2681
 
2272
2682
  /// MINT THE KEYLESS ENROLL-ONCE DELEGATION (`enrollFirstDevice.delegationCert`, the verified-first-device
2273
2683
  /// lane). The device SELF-signs it; its authority is the carried `idpToken` (the kernel re-verifies that to
@@ -2862,14 +3272,113 @@ class NomosBridge {
2862
3272
  /// THE APP-READY SYNC STATE — a truthful aggregation of sync rounds, the doorbell, transfer progress, and a
2863
3273
  /// periodic metrics poll. Map straight to a sync-state page. Starts a lightweight 4s metrics poll while
2864
3274
  /// listened to (stops on cancel). [currentStatus] is the latest snapshot for a non-stream read.
2865
- Stream<SyncStatus> get syncStatus => _eventPlane._syncStatusCtrl.stream;
2866
- 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();
2867
3302
 
2868
3303
  void _pushStatus(SyncStatus s) {
2869
3304
  _status = s;
2870
3305
  if (!_syncStatusCtrl.isClosed) _syncStatusCtrl.add(s);
2871
3306
  }
2872
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
+
2873
3382
  void _startStatusPoll() {
2874
3383
  _pushStatus(_status); // seed the new listener with the current snapshot
2875
3384
  _pollMetricsOnce();
@@ -2877,33 +3386,109 @@ class NomosBridge {
2877
3386
  Timer.periodic(const Duration(seconds: 4), (_) => _pollMetricsOnce());
2878
3387
  }
2879
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
+
2880
3405
  void _stopStatusPoll() {
2881
3406
  _metricsTimer?.cancel();
2882
3407
  _metricsTimer = null;
2883
3408
  }
2884
3409
 
2885
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;
2886
3420
  try {
2887
- final m = await metrics();
2888
- int? n(String k) =>
2889
- m.containsKey(k) ? ((m[k] as num?)?.toInt() ?? 0) : null;
2890
- final ahead = n('ahead') ?? _status.ahead;
2891
- final behind = n('behind') ?? _status.behind;
2892
- // Don't stomp an in-flight transfer's phase; otherwise derive idle/behind/syncing from the counts.
2893
- final phase = _status.transfer != null
2894
- ? _status.phase
2895
- : (behind > 0
2896
- ? SyncPhase.behind
2897
- : (ahead > 0 ? SyncPhase.syncing : SyncPhase.idle));
2898
- _pushStatus(_status.copyWith(
2899
- ahead: ahead,
2900
- behind: behind,
2901
- saved: n('saved'),
2902
- onMain: n('onMain'),
2903
- deadLetters: n('deadLetters'),
2904
- phase: phase,
2905
- clearError: true,
2906
- ));
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();
2907
3492
  } catch (e) {
2908
3493
  _pushStatus(
2909
3494
  _status.copyWith(phase: SyncPhase.error, lastError: e.toString()));
@@ -3194,8 +3779,18 @@ class NomosBridge {
3194
3779
  return ctrl.stream;
3195
3780
  }
3196
3781
 
3197
- /// 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.
3198
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
+ }
3199
3794
  await _sub.cancel();
3200
3795
  final pending = _pending.entries
3201
3796
  .map((entry) => (
@@ -3239,6 +3834,11 @@ class NomosBridge {
3239
3834
  if (!_flameEvents.isClosed) await _flameEvents.close();
3240
3835
  if (!_spanEvents.isClosed) await _spanEvents.close();
3241
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
+ }
3242
3842
  if (!_syncStatusCtrl.isClosed) await _syncStatusCtrl.close();
3243
3843
  }
3244
3844
  }
@@ -3311,6 +3911,7 @@ class NomosSession extends NomosBridge {
3311
3911
  String? authorSecret,
3312
3912
  String? inheritedPrincipal}) {
3313
3913
  final retained = parent._sessionCloudAccess[this.workspace];
3914
+ final modelChange = parent._sessionModelChangeHandlers[this.workspace];
3314
3915
  final opened = parent.acquireSession(
3315
3916
  workspace,
3316
3917
  ifAbsentCredentials: ifAbsentCredentials ??
@@ -3325,10 +3926,63 @@ class NomosSession extends NomosBridge {
3325
3926
  // credential posture (home -> shared home -> child workspace remains one stateful application).
3326
3927
  if (retained != null)
3327
3928
  parent._sessionCloudAccess.putIfAbsent(workspace, () => retained);
3929
+ if (modelChange != null) {
3930
+ parent._sessionModelChangeHandlers.putIfAbsent(
3931
+ workspace,
3932
+ () => modelChange,
3933
+ );
3934
+ }
3328
3935
  return session;
3329
3936
  });
3330
3937
  }
3331
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
+
3332
3986
  /// Generator/runtime-host seam: retain this application's refreshable cloud access privately so
3333
3987
  /// generated reference reads can mount another authorized workspace after a cold start.
3334
3988
  ///