@githolon/dsl 0.101.0 → 0.101.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dart/CHANGELOG.md CHANGED
@@ -1,3 +1,13 @@
1
+ ## 0.63.1
2
+
3
+ - Make automatic sync a truthful client-owned lifecycle: local acceptance is durable, offline/restored
4
+ sessions remain usable, reconnect converges parent before child, and failed/offline sync never reports a
5
+ successful timestamp.
6
+ - Make home bootstrap and designated recovery resumable across interruption, exposing one typed next action
7
+ while keeping identity proofs, signers, custody wrapping and retry state private.
8
+ - Expand the generated CO2 gauntlet through application, home, Estate, Site and Asset with two offline
9
+ devices, process restart, conflict merge, automatic convergence and cold replay.
10
+
1
11
  ## 0.63.0
2
12
 
3
13
  - Generated clients now expose business-first conflict policy, schema-aware merge continuation and recursive
@@ -107,17 +107,39 @@ class HarnessPhaseTimeout extends NomosTestHarnessException {
107
107
  /// so the plane is ALWAYS multi-session in tests (sole-session inference never silently passes).
108
108
  const String _decoyWorkspace = '__nomos-decoy';
109
109
 
110
+ class _CloudRestartLane {
111
+ const _CloudRestartLane({
112
+ required this.entry,
113
+ required this.node,
114
+ required this.cloud,
115
+ required this.authToken,
116
+ required this.authorSecret,
117
+ required this.clientId,
118
+ this.resolveFrom,
119
+ this.environment,
120
+ });
121
+
122
+ final String entry;
123
+ final String node;
124
+ final String cloud;
125
+ final String authToken;
126
+ final String authorSecret;
127
+ final String clientId;
128
+ final String? resolveFrom;
129
+ final Map<String, String>? environment;
130
+ }
131
+
110
132
  class NomosTestHarness {
111
133
  /// The connected bridge — hand it to the generated typed clients (the same surface the app UI uses).
112
134
  /// For [ephemeral] this is a [NomosSession] HANDLE pinned to [workspace] (the plane underneath also
113
135
  /// holds a decoy session, so unpinned plane-level calls refuse the way they would in a real
114
136
  /// multi-workspace app); for [cluster] it is the shared plane — name sessions via [session].
115
- final NomosBridge bridge;
137
+ NomosBridge bridge;
116
138
 
117
139
  /// The local workspace name the law was installed into.
118
140
  final String workspace;
119
141
 
120
- final NodeRunnerTransport _transport;
142
+ NodeRunnerTransport _transport;
121
143
  final Directory _workdir;
122
144
  final void Function(String line)? _onDiagnostic;
123
145
  bool _disposed = false;
@@ -154,6 +176,10 @@ class NomosTestHarness {
154
176
  /// When set, overrides EVERY phase's default deadline (see [HarnessPhaseTimeout]).
155
177
  final Duration? _phaseTimeout;
156
178
 
179
+ /// Private process-restart inputs for a cloud-bound test device. Tests use [restart]; credentials,
180
+ /// snapshots, runner setup and parent-first session reopening remain framework-owned.
181
+ final _CloudRestartLane? _cloudRestartLane;
182
+
157
183
  /// The test cloud a [cloudBound] harness runs against (null for the offline constructors). Partition it
158
184
  /// with [NomosTestCloud.setOnline]; assert remote custody with [NomosTestCloud.remoteHead].
159
185
  final NomosTestCloud? testCloud;
@@ -182,6 +208,7 @@ class NomosTestHarness {
182
208
  Map<String, String> snapshots = const {},
183
209
  _InstallLane? installLane,
184
210
  Duration? phaseTimeout,
211
+ _CloudRestartLane? cloudRestartLane,
185
212
  this.testCloud,
186
213
  this.homeWorkspace})
187
214
  : _sessionNames = List.of(
@@ -191,7 +218,8 @@ class NomosTestHarness {
191
218
  _lawHashes = Map.of(lawHashes),
192
219
  _snapshots = Map.of(snapshots),
193
220
  _installLane = installLane,
194
- _phaseTimeout = phaseTimeout;
221
+ _phaseTimeout = phaseTimeout,
222
+ _cloudRestartLane = cloudRestartLane;
195
223
 
196
224
  // ── THE "NO SILENT HANGS" MACHINERY — every lifecycle await runs as a named, deadlined PHASE ──
197
225
 
@@ -875,6 +903,43 @@ class NomosTestHarness {
875
903
  final enrollFirstDevicePath = enrollmentRoutes.single.path;
876
904
  // 2/6 — the LOCAL engine plane, CLOUD-BOUND, spawned BEFORE the birth: the runner's local kernel is
877
905
  // what mints the device key + signs the birth cert (era-0: a birth is ATTESTED or not at all).
906
+ // The executable is device custody too. A process restart while offline must not fetch the kernel
907
+ // before it can replay the persisted ledger. Release CI supplies the exact candidate runtime. An
908
+ // ordinary local test pins the exact kernel advertised by its ephemeral cloud while still online;
909
+ // never fall back to a possibly stale global cache.
910
+ final candidateRuntimeDir = Platform.environment['NOMOS_DART_RUNTIME'] ??
911
+ Platform.environment['NOMOS_OFFLINE_RUNTIME'];
912
+ var localWasm = candidateRuntimeDir == null
913
+ ? ''
914
+ : '$candidateRuntimeDir/${cloud.testKRootPublic == null ? 'holon.wasm' : 'holon-testmint.wasm'}';
915
+ if (localWasm.isEmpty || !File(localWasm).existsSync()) {
916
+ localWasm = '${workdir.path}/client-kernel.wasm';
917
+ await _phased(
918
+ phase: '2/6: pin the client kernel for offline restart',
919
+ awaiting: 'GET /v1/runtime/holon.wasm',
920
+ timeout: phaseTimeout ?? _kInstallDeadline,
921
+ onDiagnostic: onDiagnostic,
922
+ run: () async {
923
+ final request = await http
924
+ .getUrl(Uri.parse('${cloud.origin}/v1/runtime/holon.wasm'));
925
+ final response = await request.close();
926
+ final bytes = await response
927
+ .fold<List<int>>(<int>[], (all, chunk) => all..addAll(chunk));
928
+ if (response.statusCode != HttpStatus.ok || bytes.length < 1024) {
929
+ throw NomosTestHarnessException(
930
+ 'cloudBound: could not pin the client kernel before going offline '
931
+ '(status ${response.statusCode}, ${bytes.length} bytes)');
932
+ }
933
+ await File(localWasm).writeAsBytes(bytes, flush: true);
934
+ },
935
+ );
936
+ }
937
+ final runnerEnvironment = <String, String>{
938
+ ...Platform.environment,
939
+ if (cloud.testKRootPublic != null)
940
+ 'NOMOS_TEST_K_ROOT_PUBLIC': cloud.testKRootPublic!,
941
+ 'NOMOS_LOCAL_WASM': localWasm,
942
+ };
878
943
  transport = await _phased(
879
944
  phase: '2/6: spawn the node runner (cloud-bound)',
880
945
  awaiting: 'Process.start($node)',
@@ -884,12 +949,7 @@ class NomosTestHarness {
884
949
  entry: entry,
885
950
  resolveFrom: resolveFrom,
886
951
  node: node,
887
- environment: cloud.testKRootPublic == null
888
- ? null
889
- : {
890
- ...Platform.environment,
891
- 'NOMOS_TEST_K_ROOT_PUBLIC': cloud.testKRootPublic!,
892
- },
952
+ environment: runnerEnvironment,
893
953
  onDiagnostic: onDiagnostic));
894
954
  final plane = NomosBridge(transport!);
895
955
  await _phased(
@@ -1064,6 +1124,16 @@ class NomosTestHarness {
1064
1124
  frameworkHash: frameworkHash,
1065
1125
  lawHashes: {home!: lawHashes},
1066
1126
  phaseTimeout: phaseTimeout,
1127
+ cloudRestartLane: _CloudRestartLane(
1128
+ entry: entry,
1129
+ resolveFrom: resolveFrom,
1130
+ node: node,
1131
+ environment: runnerEnvironment,
1132
+ cloud: cloud.origin,
1133
+ authToken: authToken ?? device.idpToken,
1134
+ authorSecret: device.secret,
1135
+ clientId: clientId,
1136
+ ),
1067
1137
  testCloud: cloud,
1068
1138
  homeWorkspace: home,
1069
1139
  );
@@ -1250,8 +1320,12 @@ class NomosTestHarness {
1250
1320
  /// un-synced writes + the DLQ), with its head + law hashes + capture time. The bytes are the SAME
1251
1321
  /// base64 `connect(restoreFrom:)` consumes — [restoreWorkspace] round-trips it. O(1)-ish (a tree serialize).
1252
1322
  Future<WorkspaceSnapshot> snapshotWorkspace(String workspace) async {
1253
- final s = session(
1254
- workspace); // throws NomosTestHarnessException if not a connected session
1323
+ final connected = _plane.connectedSessions;
1324
+ if (!connected.contains(workspace)) {
1325
+ throw NomosTestHarnessException(
1326
+ 'snapshotWorkspace("$workspace"): no connected session; connected workspaces: $connected');
1327
+ }
1328
+ final s = _plane.session(workspace);
1255
1329
  return _phased(
1256
1330
  phase: 'snapshotWorkspace',
1257
1331
  workspace: workspace,
@@ -1276,7 +1350,9 @@ class NomosTestHarness {
1276
1350
  /// carried per entry). The plane-level twin of [snapshotWorkspace] — migrate a whole staged AppSim plane.
1277
1351
  Future<PlaneSnapshot> snapshotPlane() async {
1278
1352
  final out = <String, WorkspaceSnapshot>{};
1279
- for (final ws in _sessionNames) {
1353
+ final connected = _plane.connectedSessions.where(
1354
+ (ws) => ws != _decoyWorkspace && !ws.endsWith('-onboard-scratch'));
1355
+ for (final ws in connected) {
1280
1356
  out[ws] = await snapshotWorkspace(ws);
1281
1357
  }
1282
1358
  return PlaneSnapshot(
@@ -1315,6 +1391,105 @@ class NomosTestHarness {
1315
1391
  }
1316
1392
  }
1317
1393
 
1394
+ /// Simulate an app process restart from its durably persisted local custody. This is deliberately one
1395
+ /// harness verb: snapshots, runner replacement, credentials, workspace ordering and auto-sync registration
1396
+ /// remain Nomos concerns. The caller reacquires generated clients from [homeSession]/[bridge] afterward.
1397
+ Future<void> restart() async {
1398
+ if (_disposed) {
1399
+ throw NomosTestHarnessException(
1400
+ 'restart(): this test device has already been disposed');
1401
+ }
1402
+ final lane = _cloudRestartLane;
1403
+ final home = homeWorkspace;
1404
+ if (lane == null || home == null) {
1405
+ throw NomosTestHarnessException(
1406
+ 'restart() is available on a cloudBound harness');
1407
+ }
1408
+
1409
+ final oldPlane = _plane;
1410
+ final targets = oldPlane.connectedSessions
1411
+ .where(
1412
+ (ws) => ws != _decoyWorkspace && !ws.endsWith('-onboard-scratch'))
1413
+ .toList();
1414
+ if (targets.remove(home)) targets.insert(0, home);
1415
+ final snapshots = <String, String>{};
1416
+ for (final ws in targets) {
1417
+ final session = oldPlane.session(ws);
1418
+ await _phased(
1419
+ phase: 'restart: stop automatic sync',
1420
+ workspace: ws,
1421
+ awaiting: 'stopAutoSync',
1422
+ timeout: _phaseTimeout ?? _kOtherDeadline,
1423
+ onDiagnostic: _onDiagnostic,
1424
+ run: session.stopAutoSync,
1425
+ );
1426
+ snapshots[ws] = await _phased(
1427
+ phase: 'restart: persist local custody',
1428
+ workspace: ws,
1429
+ awaiting: 'export',
1430
+ timeout: _phaseTimeout ?? _kOtherDeadline,
1431
+ onDiagnostic: _onDiagnostic,
1432
+ run: session.export,
1433
+ );
1434
+ }
1435
+
1436
+ await _phased(
1437
+ phase: 'restart: terminate old runner',
1438
+ awaiting: 'NodeRunnerTransport.dispose',
1439
+ timeout: _phaseTimeout ?? _kOtherDeadline,
1440
+ onDiagnostic: _onDiagnostic,
1441
+ run: _transport.dispose,
1442
+ );
1443
+ _transport = await _phased(
1444
+ phase: 'restart: spawn runner',
1445
+ awaiting: 'Process.start(${lane.node})',
1446
+ timeout: _phaseTimeout ?? _kSpawnDeadline,
1447
+ onDiagnostic: _onDiagnostic,
1448
+ run: () => NodeRunnerTransport.start(
1449
+ entry: lane.entry,
1450
+ resolveFrom: lane.resolveFrom,
1451
+ node: lane.node,
1452
+ environment: lane.environment,
1453
+ onDiagnostic: _onDiagnostic,
1454
+ ),
1455
+ );
1456
+ final restarted = NomosBridge(_transport);
1457
+ await _phased(
1458
+ phase: 'restart: runner ready',
1459
+ awaiting: "event:'ready'",
1460
+ timeout: _phaseTimeout ?? _kOtherDeadline,
1461
+ onDiagnostic: _onDiagnostic,
1462
+ run: () => restarted.ready,
1463
+ );
1464
+ for (final ws in targets) {
1465
+ await _phased(
1466
+ phase: 'restart: reopen local custody',
1467
+ workspace: ws,
1468
+ awaiting: 'connect(restoreFrom:)',
1469
+ timeout: _phaseTimeout ?? const Duration(seconds: 60),
1470
+ onDiagnostic: _onDiagnostic,
1471
+ run: () => restarted.connect(
1472
+ cloud: lane.cloud,
1473
+ workspace: ws,
1474
+ session: ws,
1475
+ clientId: '${lane.clientId}-$ws',
1476
+ authToken: lane.authToken,
1477
+ authorSecret: lane.authorSecret,
1478
+ restoreFrom: snapshots[ws],
1479
+ allowUnborn: true,
1480
+ ),
1481
+ );
1482
+ }
1483
+ bridge = restarted.session(home);
1484
+ _sessionNames
1485
+ ..clear()
1486
+ ..addAll(targets);
1487
+ _snapshots.addAll(snapshots);
1488
+ for (final ws in targets) {
1489
+ await restarted.session(ws).autoSync(fallbackMs: 60000);
1490
+ }
1491
+ }
1492
+
1318
1493
  /// Deterministic, idempotent, total-ordered teardown: stop the runner (SIGTERM → 5s deadline → SIGKILL,
1319
1494
  /// via [NodeRunnerTransport.dispose]), then delete the temp dir. Safe to call twice; safe to call after a
1320
1495
  /// failed setup. Nothing left running, nothing left on disk.
@@ -399,7 +399,7 @@ class NomosDevices {
399
399
  }
400
400
  }
401
401
 
402
- /// The one business-level recovery policy surface. Escrow wraps, Orders, Receipts and retries stay inside Nomos.
402
+ /// The one business-level recovery policy surface. The handoff protocol stays inside Nomos.
403
403
  class NomosRecovery {
404
404
  final NomosBridge bridge;
405
405
  final String domain;
@@ -1127,11 +1127,28 @@ class NomosBridge {
1127
1127
  // autoSync telemetry: each round { event:'sync', reason, result } and socket state { event:'link', status }.
1128
1128
  if (!_syncEvents.isClosed) _syncEvents.add(m);
1129
1129
  if (event == 'sync') {
1130
- // a completed round → idle (the next metrics poll folds in residual ahead/behind), stamp the time.
1131
- _pushStatus(_status.copyWith(
1132
- phase: SyncPhase.idle,
1133
- clearTransfer: true,
1134
- lastSyncedAt: DateTime.now()));
1130
+ final result = (m['result'] as Map?)?.cast<String, Object?>();
1131
+ if (m['ok'] == false) {
1132
+ // A rejected/failed round is not a successful sync timestamp. Keep the stable typed envelope on
1133
+ // telemetry; the app-ready status needs only one legible error and an honest error phase.
1134
+ final detail = m['error']?.toString() ?? 'automatic sync failed';
1135
+ _pushStatus(_status.copyWith(
1136
+ phase: SyncPhase.error, clearTransfer: true, lastError: detail));
1137
+ } else if (result?['offline'] == true) {
1138
+ // Offline is expected transport debt. The durable local branch remains ahead and autoSync owns the
1139
+ // retry; do not claim a completed sync or overwrite the previous successful timestamp.
1140
+ _pushStatus(_status.copyWith(
1141
+ phase: SyncPhase.offline,
1142
+ clearTransfer: true,
1143
+ lastError: (result?['pushError'] as Map?)?['error']?.toString()));
1144
+ } else {
1145
+ // A completed round → idle (the next metrics poll folds in residual ahead/behind), stamp the time.
1146
+ _pushStatus(_status.copyWith(
1147
+ phase: SyncPhase.idle,
1148
+ clearTransfer: true,
1149
+ clearError: true,
1150
+ lastSyncedAt: DateTime.now()));
1151
+ }
1135
1152
  } else if (m['status'] == 'closed' || m['status'] == 'offline') {
1136
1153
  _pushStatus(_status.copyWith(phase: SyncPhase.offline));
1137
1154
  }
package/dart/pubspec.lock CHANGED
@@ -191,7 +191,7 @@ packages:
191
191
  path: "../dart_types"
192
192
  relative: true
193
193
  source: path
194
- version: "0.7.0"
194
+ version: "0.7.1"
195
195
  package_config:
196
196
  dependency: transitive
197
197
  description:
package/dart/pubspec.yaml CHANGED
@@ -1,9 +1,6 @@
1
1
  # NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
2
- # remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
3
- # wasm32-wasip1 artifact {kernel · projection · embedded
4
- # QuickJS engine} on V8 + WASI-shim, byte-identical everywhere. V8 = portability; the one
5
- # wasm = determinism. No native, no wasmtime, no 2nd artifact, no domain-JS on bare V8.
6
- # If a file isn't this / hosting this / authoring for this / proving this — it's gone.
2
+ # remotes are replaceable custody/transport, not truth. Supported kernel releases remain deterministic and
3
+ # capability-checked while old and new clients coexist during gradual rollout.
7
4
 
8
5
  name: nomos_client
9
6
  repository: https://github.com/Captain-App/nomos2
@@ -11,7 +8,7 @@ description: >-
11
8
  Nomos 2 Dart frontend types — the hand-written kernel wire types plus the
12
9
  code-generated per-domain aggregate + directive-payload types. (De)serializes
13
10
  the EXACT serde JSON the Rust kernel + projection produce/consume.
14
- version: 0.63.0
11
+ version: 0.63.1
15
12
 
16
13
  environment:
17
14
  sdk: ^3.5.0
@@ -20,7 +17,7 @@ dependencies:
20
17
  # The pure-data core (wire types, offer codec, NomosReads, schema validation, error
21
18
  # info). nomos_client depends on and re-exports it, so existing imports are unchanged.
22
19
  # For local monorepo dev, pubspec_overrides.yaml points this at ../dart_types.
23
- nomos_types: ^0.7.0
20
+ nomos_types: ^0.7.1
24
21
  json_schema: ^5.2.2
25
22
  crypto: ^3.0.0
26
23
 
@@ -105,7 +105,7 @@ void main() {
105
105
  onDiagnostic: diagnostic,
106
106
  phaseTimeout: const Duration(seconds: 180),
107
107
  );
108
- final home = harness.homeSession;
108
+ var home = harness.homeSession;
109
109
  expect((await cloud.remoteHead(home.workspace)).initialized, isTrue,
110
110
  reason:
111
111
  'the application born the identity home before business code ran');
@@ -126,7 +126,7 @@ void main() {
126
126
  country: 'GB',
127
127
  baselineTonnesCo2e: 4200,
128
128
  ));
129
- final estate = await harness.bridge.follow(
129
+ var estate = await harness.bridge.follow(
130
130
  estateRef,
131
131
  authorSecret: harness.deviceSecret(home.workspace),
132
132
  );
@@ -149,6 +149,22 @@ void main() {
149
149
  reason:
150
150
  'the Estate remained fully usable without remote custody during the partition');
151
151
 
152
+ // Kill the device process while both parent and child still carry unsynchronised local custody.
153
+ // Restart is one framework verb: the product test supplies no snapshots, credentials, routes, retry
154
+ // loop or topology ordering. Generated business reads must work before connectivity returns.
155
+ await harness.restart();
156
+ home = harness.homeSession;
157
+ estate = harness.session(estateRef.workspace);
158
+ final restartedClient = await Co2Client.bind(estate, actor: _subject);
159
+ final restartedAssets =
160
+ await restartedClient.assetsBySite(siteId: siteId);
161
+ expect(restartedAssets.single.name, 'Main electricity meter');
162
+ expect(restartedAssets.single.operationalStatus?.valueOrNull,
163
+ 'commissioned');
164
+ expect((await estate.verifyChain())['valid'], isTrue,
165
+ reason:
166
+ 'the unsynchronised Estate must replay locally before any network recovery');
167
+
152
168
  cloud.setOnline(true);
153
169
  // No tenant sync(), no harness.converge(), no topology ordering. The two stateful client sessions must
154
170
  // retain their local branches, retry after reconnect, establish the parent birth first as required by
@@ -24,7 +24,6 @@ void main() {
24
24
  workspace: 'home-ada',
25
25
  attrs: {
26
26
  'nomos.owned.stage': 'awaitingRecoveryPrincipal',
27
- 'nomos.owned.orderId': 'NomosOwnedRecoveryOrder_1',
28
27
  'nomos.owned.recoveryPrincipal': 'user:bob',
29
28
  'nomos.owned.recommendedAction':
30
29
  NomosRecommendedAction.openRecoveryPrincipalDevice,
@@ -34,7 +33,6 @@ void main() {
34
33
  final recovery = e as OwnedRecoveryPending;
35
34
  expect(recovery.workspace, 'home-ada');
36
35
  expect(recovery.stage, 'awaitingRecoveryPrincipal');
37
- expect(recovery.orderId, 'NomosOwnedRecoveryOrder_1');
38
36
  expect(recovery.recoveryPrincipal, 'user:bob');
39
37
  expect(recovery.recommendedAction,
40
38
  NomosRecommendedAction.openRecoveryPrincipalDevice);
@@ -19,8 +19,11 @@ class FakeRunner implements BridgeTransport {
19
19
 
20
20
  FakeRunner() {
21
21
  // the runner announces readiness at construction.
22
- scheduleMicrotask(
23
- () => _incoming.add({'v': kWebviewBridgeVersion, 'event': 'ready', 'bundleVersion': 'unbundled-dev'}));
22
+ scheduleMicrotask(() => _incoming.add({
23
+ 'v': kWebviewBridgeVersion,
24
+ 'event': 'ready',
25
+ 'bundleVersion': 'unbundled-dev'
26
+ }));
24
27
  }
25
28
 
26
29
  @override
@@ -255,6 +258,10 @@ class FakeRunner implements BridgeTransport {
255
258
  /// Simulate the runner forwarding a signal holon projection.
256
259
  void pushSignal(Map<String, Object?> signal) => _incoming
257
260
  .add({'v': kWebviewBridgeVersion, 'event': 'signal', 'signal': signal});
261
+
262
+ /// Simulate one background-sync event from the runner.
263
+ void pushSync(Map<String, Object?> event) =>
264
+ _incoming.add({'v': kWebviewBridgeVersion, 'event': 'sync', ...event});
258
265
  }
259
266
 
260
267
  /// A transport that answers EVERY request with one canned `result` (the golden corpus's reply) and records
@@ -264,8 +271,11 @@ class ReplayTransport implements BridgeTransport {
264
271
  final _incoming = StreamController<Map<String, Object?>>.broadcast();
265
272
  Map<String, Object?>? lastRequest;
266
273
  ReplayTransport(this.result) {
267
- scheduleMicrotask(
268
- () => _incoming.add({'v': kWebviewBridgeVersion, 'event': 'ready', 'bundleVersion': 'unbundled-dev'}));
274
+ scheduleMicrotask(() => _incoming.add({
275
+ 'v': kWebviewBridgeVersion,
276
+ 'event': 'ready',
277
+ 'bundleVersion': 'unbundled-dev'
278
+ }));
269
279
  }
270
280
  @override
271
281
  Stream<Map<String, Object?>> get messages => _incoming.stream;
@@ -404,7 +414,9 @@ void main() {
404
414
  final bridge = NomosBridge(transport);
405
415
  await bridge.ready;
406
416
  await bridge.offerOutcome(
407
- domain: 'guestbook', directiveId: 'signGuestbook', payload: {'author': 'ada'});
417
+ domain: 'guestbook',
418
+ directiveId: 'signGuestbook',
419
+ payload: {'author': 'ada'});
408
420
  final kinds = bridge.recentSpans
409
421
  .map((m) => ((m['span'] as Map?)?['kind'] ?? '').toString())
410
422
  .toSet();
@@ -415,13 +427,17 @@ void main() {
415
427
  await bridge.dispose();
416
428
  });
417
429
 
418
- test('a runner without timing (older bundle) emits no bridge-leg spans and still answers', () async {
419
- final transport =
420
- ReplayTransport({'head': 'H1', 'created': <Object?>[], 'workspace': 'ws'});
430
+ test(
431
+ 'a runner without timing (older bundle) emits no bridge-leg spans and still answers',
432
+ () async {
433
+ final transport = ReplayTransport(
434
+ {'head': 'H1', 'created': <Object?>[], 'workspace': 'ws'});
421
435
  final bridge = NomosBridge(transport);
422
436
  await bridge.ready;
423
437
  final out = await bridge.offerOutcome(
424
- domain: 'guestbook', directiveId: 'signGuestbook', payload: {'author': 'ada'});
438
+ domain: 'guestbook',
439
+ directiveId: 'signGuestbook',
440
+ payload: {'author': 'ada'});
425
441
  expect(out.head, 'H1');
426
442
  final kinds = bridge.recentSpans
427
443
  .map((m) => ((m['span'] as Map?)?['kind'] ?? '').toString())
@@ -435,8 +451,8 @@ void main() {
435
451
  // author path signs THIS offer with the intended enrolled key, not the connect-time one). Both offer lanes. ──
436
452
  group('per-offer authorSecret threads to the runner op (#9)', () {
437
453
  test('offerOutcome carries authorSecret to the runner op args', () async {
438
- final transport =
439
- ReplayTransport({'head': 'H1', 'created': <Object?>[], 'workspace': 'ws'});
454
+ final transport = ReplayTransport(
455
+ {'head': 'H1', 'created': <Object?>[], 'workspace': 'ws'});
440
456
  final bridge = NomosBridge(transport);
441
457
  await bridge.ready;
442
458
  await bridge.offerOutcome(
@@ -444,10 +460,12 @@ void main() {
444
460
  directiveId: 'writeNote',
445
461
  payload: {'author': 'ada'},
446
462
  authorSecret: 'ENROLLED-KEY-B64');
447
- final args = (transport.lastRequest!['args'] as Map).cast<String, Object?>();
463
+ final args =
464
+ (transport.lastRequest!['args'] as Map).cast<String, Object?>();
448
465
  expect(transport.lastRequest!['op'], 'offerDirective');
449
466
  expect(args['authorSecret'], 'ENROLLED-KEY-B64',
450
- reason: 'the per-offer signing key must reach the runner op (→ opts.signWith)');
467
+ reason:
468
+ 'the per-offer signing key must reach the runner op (→ opts.signWith)');
451
469
  await bridge.dispose();
452
470
  });
453
471
 
@@ -460,18 +478,23 @@ void main() {
460
478
  directiveId: 'writeNote',
461
479
  payload: {'author': 'ada'},
462
480
  authorSecret: 'ENROLLED-KEY-B64');
463
- final args = (transport.lastRequest!['args'] as Map).cast<String, Object?>();
481
+ final args =
482
+ (transport.lastRequest!['args'] as Map).cast<String, Object?>();
464
483
  expect(args['authorSecret'], 'ENROLLED-KEY-B64');
465
484
  await bridge.dispose();
466
485
  });
467
486
 
468
- test('no authorSecret ⇒ the op omits it (byte-identical to the pre-#9 contract)', () async {
469
- final transport = ReplayTransport({'head': 'H1', 'created': <Object?>[], 'workspace': 'ws'});
487
+ test(
488
+ 'no authorSecret ⇒ the op omits it (byte-identical to the pre-#9 contract)',
489
+ () async {
490
+ final transport = ReplayTransport(
491
+ {'head': 'H1', 'created': <Object?>[], 'workspace': 'ws'});
470
492
  final bridge = NomosBridge(transport);
471
493
  await bridge.ready;
472
494
  await bridge.offerOutcome(
473
495
  domain: 'home', directiveId: 'writeNote', payload: {'author': 'ada'});
474
- final args = (transport.lastRequest!['args'] as Map).cast<String, Object?>();
496
+ final args =
497
+ (transport.lastRequest!['args'] as Map).cast<String, Object?>();
475
498
  expect(args.containsKey('authorSecret'), isFalse);
476
499
  await bridge.dispose();
477
500
  });
@@ -534,7 +557,9 @@ void main() {
534
557
  expect((req['args'] as Map)['allowUnborn'], isTrue);
535
558
  });
536
559
 
537
- test('a session retains the authenticated token subject as its default principal', () async {
560
+ test(
561
+ 'a session retains the authenticated token subject as its default principal',
562
+ () async {
538
563
  await bridge.ready;
539
564
  final payload = base64Url
540
565
  .encode(utf8.encode(jsonEncode({'sub': 'ada'})))
@@ -553,8 +578,7 @@ void main() {
553
578
  'generated model restoration crosses one framework-private bridge operation',
554
579
  () async {
555
580
  await bridge.ready;
556
- final verdict =
557
- await bridge.nomosFrameworkRestorePreviousGeneratedModel(
581
+ final verdict = await bridge.nomosFrameworkRestorePreviousGeneratedModel(
558
582
  domain: 'evo',
559
583
  currentHash: 'current-law-hash',
560
584
  because: 'the previous operating policy is required',
@@ -562,8 +586,7 @@ void main() {
562
586
 
563
587
  expect(verdict['ok'], isFalse);
564
588
  expect(verdict['stage'], 'acceptance');
565
- expect(
566
- ((verdict['report'] as Map)['divergences'] as List).single,
589
+ expect(((verdict['report'] as Map)['divergences'] as List).single,
567
590
  containsPair('directiveId', 'setMood'));
568
591
  final request = runner.sent.firstWhere(
569
592
  (message) => message['op'] == 'restorePreviousGeneratedModel',
@@ -589,7 +612,8 @@ void main() {
589
612
  await bridge.lawStatus('abc'), {'installed': true, 'drifted': false});
590
613
  });
591
614
 
592
- test('moments and historic reads keep commit plumbing behind an opaque handle',
615
+ test(
616
+ 'moments and historic reads keep commit plumbing behind an opaque handle',
593
617
  () async {
594
618
  final momentsTransport = ReplayTransport({
595
619
  'commits': [
@@ -663,8 +687,8 @@ void main() {
663
687
  );
664
688
  final offerArgs = (offerCall['args'] as Map).cast<String, Object?>();
665
689
  expect(offerArgs['domain'], 'notes');
666
- expect(offerArgs['directiveId'],
667
- 'nomos_internal_approve_owned_device_7c1f5e');
690
+ expect(
691
+ offerArgs['directiveId'], 'nomos_internal_approve_owned_device_7c1f5e');
668
692
  expect(offerArgs['actor'], 'user:alice');
669
693
  expect(offerArgs['domainHash'], 'a' * 64);
670
694
  expect(offerArgs['payload'], {
@@ -676,7 +700,8 @@ void main() {
676
700
  });
677
701
  });
678
702
 
679
- test('record sharing keeps keys, identity and action payloads behind the bridge',
703
+ test(
704
+ 'record sharing keeps keys, identity and action payloads behind the bridge',
680
705
  () async {
681
706
  await bridge.ready;
682
707
  final keys = await bridge.nomosOwnedScopeKeys(
@@ -742,8 +767,7 @@ void main() {
742
767
 
743
768
  expect(verdict['ok'], isFalse);
744
769
  expect(verdict['stage'], 'acceptance');
745
- expect(
746
- ((verdict['report'] as Map)['divergences'] as List).single,
770
+ expect(((verdict['report'] as Map)['divergences'] as List).single,
747
771
  containsPair('directiveId', 'registerSite'));
748
772
  final request = runner.sent.firstWhere(
749
773
  (message) => message['op'] == 'adoptGeneratedModel',
@@ -808,6 +832,43 @@ void main() {
808
832
  expect((req['args'] as Map)['signalId'], 'signal:ws:list:main');
809
833
  });
810
834
 
835
+ test(
836
+ 'background sync status never calls an offline or failed round successful',
837
+ () async {
838
+ await bridge.ready;
839
+
840
+ runner.pushSync({
841
+ 'reason': 'reconnect',
842
+ 'result': {
843
+ 'offline': true,
844
+ 'pushError': {'error': 'network unavailable'}
845
+ }
846
+ });
847
+ await Future<void>.delayed(Duration.zero);
848
+ expect(bridge.currentStatus.phase, SyncPhase.offline);
849
+ expect(bridge.currentStatus.lastSyncedAt, isNull);
850
+ expect(bridge.currentStatus.lastError, 'network unavailable');
851
+
852
+ runner.pushSync({
853
+ 'reason': 'reconnect',
854
+ 'ok': false,
855
+ 'error': 'kernel capability skew',
856
+ });
857
+ await Future<void>.delayed(Duration.zero);
858
+ expect(bridge.currentStatus.phase, SyncPhase.error);
859
+ expect(bridge.currentStatus.lastSyncedAt, isNull);
860
+ expect(bridge.currentStatus.lastError, 'kernel capability skew');
861
+
862
+ runner.pushSync({
863
+ 'reason': 'reconnect',
864
+ 'result': {'stillAhead': false}
865
+ });
866
+ await Future<void>.delayed(Duration.zero);
867
+ expect(bridge.currentStatus.phase, SyncPhase.idle);
868
+ expect(bridge.currentStatus.lastSyncedAt, isNotNull);
869
+ expect(bridge.currentStatus.lastError, isNull);
870
+ });
871
+
811
872
  test('a law refusal throws NomosBridgeError with the verbatim message',
812
873
  () async {
813
874
  await bridge.ready;
@@ -871,7 +932,8 @@ void main() {
871
932
  expect(e.recoverable, isTrue);
872
933
  expect(e.recommendedAction, 'quarantineLocalSnapshotAndReclone');
873
934
  expect(e.shouldEvictLocalCustody, isTrue);
874
- expect(e.info.trace.traceId, isNotEmpty); // trace IDs ride as on every bridge error
935
+ expect(e.info.trace.traceId,
936
+ isNotEmpty); // trace IDs ride as on every bridge error
875
937
  expect(e.message, contains('shallow-insufficient'));
876
938
  });
877
939
 
@@ -1,3 +1,8 @@
1
+ ## 0.7.1
2
+
3
+ - Add typed framework recovery actions and lifecycle failure shapes without leaking signer, certificate,
4
+ escrow or retry protocol into generated business clients.
5
+
1
6
  ## 0.7.0
2
7
 
3
8
  - Add the public conflict and description shapes used by generated aggregate clients: merge observations,
@@ -128,12 +128,11 @@ class NomosRecommendedAction {
128
128
  }
129
129
 
130
130
  /// All-device-loss recovery is a normal, resumable lifecycle state. The app may render the one concrete
131
- /// [recommendedAction]; it never needs to inspect escrow wraps, Orders, Receipts, or key material.
131
+ /// [recommendedAction]; the private recovery protocol stays inside Nomos.
132
132
  class OwnedRecoveryPending extends NomosBridgeError {
133
133
  final String code;
134
134
  final String? workspace;
135
135
  final String stage;
136
- final String? orderId;
137
136
  final String? recoveryPrincipal;
138
137
  final String recommendedAction;
139
138
 
@@ -142,7 +141,6 @@ class OwnedRecoveryPending extends NomosBridgeError {
142
141
  required String message,
143
142
  this.workspace,
144
143
  this.stage = 'request',
145
- this.orderId,
146
144
  this.recoveryPrincipal,
147
145
  this.recommendedAction = NomosRecommendedAction.waitForRecoveryPrincipal,
148
146
  NomosErrorInfo? info,
@@ -160,7 +158,6 @@ class OwnedRecoveryPending extends NomosBridgeError {
160
158
  message: info.message,
161
159
  workspace: info.workspace,
162
160
  stage: attr('stage') ?? 'request',
163
- orderId: attr('orderId') ?? attr('order_id'),
164
161
  recoveryPrincipal:
165
162
  attr('recoveryPrincipal') ?? attr('recovery_principal'),
166
163
  recommendedAction: attr('recommendedAction') ??
@@ -171,8 +168,7 @@ class OwnedRecoveryPending extends NomosBridgeError {
171
168
  }
172
169
 
173
170
  @override
174
- String toString() =>
175
- 'OwnedRecoveryPending(stage: $stage${orderId != null ? ', order: $orderId' : ''}'
171
+ String toString() => 'OwnedRecoveryPending(stage: $stage'
176
172
  '${recoveryPrincipal != null ? ', recoveryPrincipal: $recoveryPrincipal' : ''}, '
177
173
  'recommendedAction: $recommendedAction): $message';
178
174
  }
@@ -700,6 +696,8 @@ class CustodyError extends NomosBridgeError {
700
696
  /// cloud answered 401 / "invalid token" on a lane that requires a verified principal (every birth does).
701
697
  /// REMEDY: re-acquire a fresh token from the auth provider and reconnect.
702
698
  class AuthTokenInvalid extends NomosBridgeError {
699
+ final String code = 'nomos.auth.token_invalid';
700
+ final String recommendedAction = NomosRecommendedAction.reauth;
703
701
  AuthTokenInvalid([String? message])
704
702
  : super(message ??
705
703
  'the auth-provider token was missing or rejected (the cloud refused 401) — a birth needs a verified principal');
@@ -709,6 +707,9 @@ class AuthTokenInvalid extends NomosBridgeError {
709
707
  /// own law commits to `home-<sha256(subject)>` (the squat invariant), so a mismatch means the cloud routed to
710
708
  /// the wrong slot — a fail-closed safety stop, never silently mount the wrong holon.
711
709
  class HomeNameMismatch extends NomosBridgeError {
710
+ final String code = 'nomos.home.name_mismatch';
711
+ final String recommendedAction = NomosRecommendedAction.reopen;
712
+
712
713
  /// What `homeNameOf(subject)` derived locally.
713
714
  final String expected;
714
715
 
@@ -725,6 +726,9 @@ class HomeNameMismatch extends NomosBridgeError {
725
726
  /// run the home law FAILS here rather than silently mounting a lawless workspace. REMEDY: the tenant's
726
727
  /// governance must install the home law (the birth offer should author it; if it didn't, the deploy is wrong).
727
728
  class HomeLawMissing extends NomosBridgeError {
729
+ final String code = 'nomos.home.law_missing';
730
+ final String recommendedAction = NomosRecommendedAction.redeployLaw;
731
+
728
732
  /// The workspace that is missing the home law.
729
733
  final String workspace;
730
734
  HomeLawMissing({required this.workspace, String? message})
@@ -736,6 +740,9 @@ class HomeLawMissing extends NomosBridgeError {
736
740
  /// principal lacks creation authority, the pool is exhausted, or the genesis recipe was rejected). Carries
737
741
  /// the cloud's verdict verbatim. REMEDY is tenant-specific (grant creation authority / raise the pool).
738
742
  class BirthRejected extends NomosBridgeError {
743
+ final String code = 'nomos.home.birth_refused';
744
+ final String recommendedAction = NomosRecommendedAction.redeployLaw;
745
+
739
746
  /// The birth-lane directive id that was offered (e.g. the tenant's `birthHome` / `seedHomeOwner`).
740
747
  final String? directiveId;
741
748
 
@@ -750,6 +757,9 @@ class BirthRejected extends NomosBridgeError {
750
757
  /// a git pack. This is a transport/custody integrity fault, not a law refusal. REMEDY: retry; if persistent,
751
758
  /// the custody bytes are corrupt.
752
759
  class PackMalformed extends NomosBridgeError {
760
+ final String code = 'nomos.custody.pack_malformed';
761
+ final String recommendedAction =
762
+ NomosRecommendedAction.refetchFullHistoryAndRetry;
753
763
  PackMalformed([String? message])
754
764
  : super(message ??
755
765
  'a returned pack was malformed and could not be applied (custody integrity fault)');
@@ -759,6 +769,9 @@ class PackMalformed extends NomosBridgeError {
759
769
  /// domain key, so the app cannot confirm WHICH law the home runs (and thus cannot baked-hash-pin or judge
760
770
  /// installed-ness). REMEDY: the home law must be deployed; if it was, retry once the deploy has folded.
761
771
  class LawHashUnavailable extends NomosBridgeError {
772
+ final String code = 'nomos.home.law_hash_unavailable';
773
+ final String recommendedAction = NomosRecommendedAction.redeployLaw;
774
+
762
775
  /// The workspace whose current law hash could not be resolved.
763
776
  final String workspace;
764
777
  LawHashUnavailable({required this.workspace, String? message})
@@ -1,7 +1,5 @@
1
1
  # NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
2
- # remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
3
- # wasm32-wasip1 artifact {kernel · projection · embedded QuickJS engine} —
4
- # byte-identical everywhere. This package is the PURE-DATA core: wire/data types
2
+ # remotes are replaceable custody/transport, not truth. This package is the PURE-DATA core: wire/data types
5
3
  # with no transport, no bridge, no runtime, so generated per-domain TYPES files
6
4
  # resolve against it alone.
7
5
 
@@ -11,7 +9,7 @@ description: >-
11
9
  Pure kernel wire/data types for Nomos Dart frontends — no transport, no bridge,
12
10
  no runtime. The hand-written serde-JSON-compatible types plus the offer codec,
13
11
  reactive read engine, schema validation, and data-only error/trace types.
14
- version: 0.7.0
12
+ version: 0.7.1
15
13
 
16
14
  environment:
17
15
  sdk: ^3.5.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.101.0",
3
+ "version": "0.101.2",
4
4
  "type": "module",
5
5
  "description": "Nomos 2 domain-authoring DSL: aggregates + directives in TS, executed and encoded to the Rust kernel's wire shapes.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
package/src/codegen_ts.ts CHANGED
@@ -1431,7 +1431,7 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
1431
1431
  ` __nomosOwnedScopeKeys?(args: { scope: string; record: { type: string; id: string } }): Promise<NomosScopeKey[]>;`,
1432
1432
  ` /** FRAMEWORK-INTERNAL: make this principal ready to receive generated record shares. */`,
1433
1433
  ` __nomosEnsureOwnedIdentity?(args: { scope: string; domain: string; domainHash?: string }): Promise<void>;`,
1434
- ` /** FRAMEWORK-INTERNAL: atomically record the opt-in recovery policy and escrow wrap. */`,
1434
+ ` /** FRAMEWORK-INTERNAL: atomically enable the opt-in recovery policy. */`,
1435
1435
  ` __nomosProtectOwnedIdentity?(args: { scope: string; domain: string; domainHash?: string; recoveryPrincipal: string }): Promise<{ status: "protected"; recoveryPrincipal: string; unchanged?: boolean }>;`,
1436
1436
  ` /** FRAMEWORK-INTERNAL: prepare the atomic law payload behind share/unshare/transfer. */`,
1437
1437
  ` __nomosPrepareOwnedRecordAction?(args: { action: "share" | "unshare" | "transfer"; scope: string; record: NomosRef; peer: string }): Promise<Record<string, unknown>>;`,
@@ -2996,7 +2996,7 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
2996
2996
  : []),
2997
2997
  ...(ownedScope !== undefined
2998
2998
  ? [
2999
- ` /** Opt into all-device-loss recovery with one explicitly designated person. Nomos owns the escrow and audit protocol. */`,
2999
+ ` /** Opt into all-device-loss recovery with one explicitly designated person. Nomos owns the handoff. */`,
3000
3000
  ` recovery: {`,
3001
3001
  ` protectWith: async (person: string): Promise<{ status: "protected"; recoveryPrincipal: string; unchanged?: boolean }> => {`,
3002
3002
  ` if (!person.trim()) throw new Error("recovery.protectWith() needs the person who will recover you");`,
@@ -3004,7 +3004,7 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
3004
3004
  ` return holon.__nomosProtectOwnedIdentity({ scope: ${JSON.stringify(ownedScope)}, domain: ${JSON.stringify(domain)}, domainHash, recoveryPrincipal: person });`,
3005
3005
  ` },`,
3006
3006
  ` },`,
3007
- ` /** Add another of this person's devices without exposing keys, certificates or signer law. */`,
3007
+ ` /** Add another of this person's devices through one opaque request and approval. */`,
3008
3008
  ` devices: {`,
3009
3009
  ` request: async (): Promise<NomosDeviceRequest> => {`,
3010
3010
  ` if (!holon.__nomosOwnedDeviceRequest) throw new Error("devices.request() needs the current @githolon/client");`,
@@ -1062,7 +1062,7 @@ const NOMOS_TYPES_DEP = "^0.7.0";
1062
1062
  // 0.48.x: session-bound clients (XClient.bind/NomosClientInterface/DomainClientSessionMismatch/
1063
1063
  // DomainNotInstalledForSession, engine.follow, pendingConflicts lanes) — the generated client
1064
1064
  // REQUIRES this surface. 0.48.1 fixes NomosSession.watchPendingConflicts delegation.
1065
- const NOMOS_CLIENT_DEP = "^0.63.0";
1065
+ const NOMOS_CLIENT_DEP = "^0.63.1";
1066
1066
  // DRIFT TRIPWIRE: pubspec_pins.test.ts asserts these equal the CURRENT versions in dsl/dart/pubspec.yaml
1067
1067
  // and dsl/dart_types/pubspec.yaml — a Dart release without this bump fails the suite, so tenants never
1068
1068
  // land back in dependency_overrides (the exact co2 report this closes, third occurrence).