@githolon/dsl 0.101.1 → 0.101.3
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 +10 -0
- package/dart/lib/src/test_harness.dart +187 -12
- package/dart/lib/src/webview_bridge.dart +23 -6
- package/dart/pubspec.lock +1 -1
- package/dart/pubspec.yaml +4 -7
- package/dart/test/co2_product_gauntlet_test.dart +18 -2
- package/dart/test/errors_test.dart +0 -2
- package/dart/test/webview_bridge_test.dart +91 -29
- package/dart_types/CHANGELOG.md +5 -0
- package/dart_types/lib/src/errors.dart +19 -6
- package/dart_types/pubspec.yaml +2 -4
- package/package.json +1 -1
- package/src/codegen_ts.ts +3 -3
- package/src/compile_package_main.ts +1 -1
- package/src/directive.ts +43 -0
- package/src/flow_gate.ts +9 -0
- package/src/framework/workspace_invariant.ts +2 -0
- package/src/law_check.ts +13 -2
- package/src/precondition.ts +2 -0
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
|
-
|
|
137
|
+
NomosBridge bridge;
|
|
116
138
|
|
|
117
139
|
/// The local workspace name the law was installed into.
|
|
118
140
|
final String workspace;
|
|
119
141
|
|
|
120
|
-
|
|
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:
|
|
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
|
|
1254
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
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
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.
|
|
3
|
-
#
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
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(
|
|
419
|
-
|
|
420
|
-
|
|
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',
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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 =
|
|
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(
|
|
469
|
-
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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,
|
|
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
|
|
package/dart_types/CHANGELOG.md
CHANGED
|
@@ -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];
|
|
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})
|
package/dart_types/pubspec.yaml
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
|
|
2
|
-
# remotes are replaceable custody/transport, not truth.
|
|
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.
|
|
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.
|
|
3
|
+
"version": "0.101.3",
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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).
|
package/src/directive.ts
CHANGED
|
@@ -64,6 +64,12 @@ export interface PreconditionDescriptor {
|
|
|
64
64
|
readonly predicate: (row: Record<string, unknown> | undefined) => boolean;
|
|
65
65
|
/** The typed refusal code emitted when the predicate is false. */
|
|
66
66
|
readonly rejectCode: string;
|
|
67
|
+
/**
|
|
68
|
+
* Why this precondition may intentionally make a fact about private state observable through its
|
|
69
|
+
* public refusal. Set by chaining `.declassify(reason)` directly after the precondition marker;
|
|
70
|
+
* carried onto the synthesized workspace invariant and into the replay-verified flow-gate law.
|
|
71
|
+
*/
|
|
72
|
+
readonly declassify?: string;
|
|
67
73
|
}
|
|
68
74
|
|
|
69
75
|
/**
|
|
@@ -662,6 +668,43 @@ class DirectivePlanStep<Id extends string, P> {
|
|
|
662
668
|
});
|
|
663
669
|
}
|
|
664
670
|
|
|
671
|
+
/**
|
|
672
|
+
* Acknowledge that the MOST RECENT write-path precondition may intentionally reveal one fact about
|
|
673
|
+
* private state through its typed refusal. This is business policy, so it stays beside the condition
|
|
674
|
+
* it explains; the compiler carries it onto the generated invariant without exposing that internal
|
|
675
|
+
* invariant to tenant code.
|
|
676
|
+
*/
|
|
677
|
+
declassify(reason: string): DirectivePlanStep<Id, P> {
|
|
678
|
+
const trimmed = reason.trim();
|
|
679
|
+
if (trimmed.length === 0) {
|
|
680
|
+
throw new Error(
|
|
681
|
+
`directive('${this.id}').declassify(reason) needs a non-empty human reason — it is the audit record for an intended private-state refusal.`,
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
if (this.preconditions.length === 0) {
|
|
685
|
+
throw new Error(
|
|
686
|
+
`directive('${this.id}').declassify(reason) must follow .requiresExisting(...), .where(...), or .withId(...).`,
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
const last = this.preconditions[this.preconditions.length - 1]!;
|
|
690
|
+
if (last.declassify !== undefined) {
|
|
691
|
+
throw new Error(
|
|
692
|
+
`directive('${this.id}') already declassifies its most recent precondition — declare each policy once, beside its condition.`,
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
return new DirectivePlanStep(
|
|
696
|
+
this.id,
|
|
697
|
+
this.marker,
|
|
698
|
+
this.aggregate,
|
|
699
|
+
this.payloadSchema,
|
|
700
|
+
this.createOnceIdField,
|
|
701
|
+
[
|
|
702
|
+
...this.preconditions.slice(0, -1),
|
|
703
|
+
{ ...last, declassify: trimmed },
|
|
704
|
+
],
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
|
|
665
708
|
plan(fn: (payload: P, ctx: Ports) => PlannedOp[]): RequirableDirective<P> {
|
|
666
709
|
return makeRequirable({
|
|
667
710
|
id: this.id,
|
package/src/flow_gate.ts
CHANGED
|
@@ -50,6 +50,7 @@ type FlowModule = {
|
|
|
50
50
|
readonly id: string;
|
|
51
51
|
readonly on: string;
|
|
52
52
|
readonly declassify?: string;
|
|
53
|
+
readonly __nomosGenerated?: "directive-precondition";
|
|
53
54
|
readonly reads: (ctx: { intent: Record<string, unknown>; principal?: string }) => readonly { readonly aggregate: string }[];
|
|
54
55
|
}[];
|
|
55
56
|
readonly impureCapabilities?: readonly {
|
|
@@ -190,6 +191,14 @@ export function assertFlowGate(m: CanonicalManifest, mod?: FlowModule): void {
|
|
|
190
191
|
for (const r of refs) {
|
|
191
192
|
const short = r.aggregate.includes(".") ? r.aggregate.slice(r.aggregate.lastIndexOf(".") + 1) : r.aggregate;
|
|
192
193
|
if (privateTypes.has(short)) {
|
|
194
|
+
if (wi.__nomosGenerated === "directive-precondition") {
|
|
195
|
+
throw new FlowGateError(
|
|
196
|
+
`[nomosFlowGate rule 4 — no secret-correlated refusal] directive '${wi.on}' checks the PRIVATE aggregate ` +
|
|
197
|
+
`'${r.aggregate}' before committing. Its refusal can reveal a fact about that record. If that is intended, ` +
|
|
198
|
+
`keep the policy beside the condition: chain .declassify("<why this refusal may reveal private state>") ` +
|
|
199
|
+
`immediately after .requiresExisting(...), .where(...), or .withId(...).`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
193
202
|
throw new FlowGateError(
|
|
194
203
|
`[nomosFlowGate rule 4 — no secret-correlated refusal] workspaceInvariant '${wi.id}' (on '${wi.on}') reads the ` +
|
|
195
204
|
`PRIVATE aggregate '${r.aggregate}' — its refusal is a public ref transition that can correlate with that secret. ` +
|
|
@@ -150,6 +150,8 @@ export interface WorkspaceInvariantDecl {
|
|
|
150
150
|
* a secret-correlated refusal.
|
|
151
151
|
*/
|
|
152
152
|
readonly declassify?: string;
|
|
153
|
+
/** Framework-only provenance used to keep compiler errors on the tenant-authored surface. */
|
|
154
|
+
readonly __nomosGenerated?: "directive-precondition";
|
|
153
155
|
/** Brand so a plain object is never mistaken for a declaration. */
|
|
154
156
|
readonly __isWorkspaceInvariant: true;
|
|
155
157
|
}
|
package/src/law_check.ts
CHANGED
|
@@ -907,6 +907,12 @@ function invariantFindings(mod: DomainModule, aggById: ReadonlyMap<string, Aggre
|
|
|
907
907
|
let intent: Record<string, unknown> = {};
|
|
908
908
|
if (dir !== undefined) {
|
|
909
909
|
const probe = probeDirective(dir, `${domain}/${wi.id}#intent`);
|
|
910
|
+
// A generated precondition's id selector is fully typed and only runs after payload validation.
|
|
911
|
+
// If Nomos cannot synthesize this directive's otherwise-valid exotic payload (for example a
|
|
912
|
+
// branded value object), invoking that selector with our invented `{}` would manufacture a
|
|
913
|
+
// framework false positive. Hand-authored invariants still get the empty-intent probe because
|
|
914
|
+
// their reads function may genuinely need to be total independently of a directive schema.
|
|
915
|
+
if (probe === undefined && wi.__nomosGenerated === "directive-precondition") continue;
|
|
910
916
|
if (probe !== undefined) intent = probe.probePayload;
|
|
911
917
|
}
|
|
912
918
|
const ctx = { intent, principal: "principal:probe", principalVerified: true } as const;
|
|
@@ -935,12 +941,17 @@ function invariantFindings(mod: DomainModule, aggById: ReadonlyMap<string, Aggre
|
|
|
935
941
|
}
|
|
936
942
|
});
|
|
937
943
|
if (threwAt !== undefined) {
|
|
944
|
+
const generatedPrecondition = wi.__nomosGenerated === "directive-precondition";
|
|
938
945
|
out.push({
|
|
939
946
|
domain,
|
|
940
947
|
directiveId: wi.on,
|
|
941
948
|
level: "warn",
|
|
942
|
-
what:
|
|
943
|
-
|
|
949
|
+
what: generatedPrecondition
|
|
950
|
+
? `directive '${wi.on}' precondition THREW on the synthesized payload (${(err as Error)?.message ?? String(err)}) — its id selector or predicate may not handle every valid payload`
|
|
951
|
+
: `workspace invariant '${wi.id}' ${threwAt === "reads" ? "reads()" : "assert()"} THREW on the synthesized sample (${(err as Error)?.message ?? String(err)}) — a predicate bug, or a payload-dependent path the sample hit`,
|
|
952
|
+
fix: generatedPrecondition
|
|
953
|
+
? `run the directive's .requiresExisting(...), .where(...), or .withId(...) condition on ${samplePayloadText(intent)} — if that payload is valid, make the condition total for it`
|
|
954
|
+
: `run it on intent=${samplePayloadText(intent)} snapshots=${samplePayloadText(snapshots)} — if the throw is real, the gate refuses on '${wi.on}'; if the sample's shape never occurs, guard the predicate`,
|
|
944
955
|
});
|
|
945
956
|
}
|
|
946
957
|
}
|
package/src/precondition.ts
CHANGED
|
@@ -86,6 +86,8 @@ export function synthesizePreconditionInvariants<P>(directive: Directive<P>): Wo
|
|
|
86
86
|
const row = snapshots[REF_NAME] as Record<string, unknown> | undefined;
|
|
87
87
|
return desc.predicate(row) ? { accept: true } : { reject: desc.rejectCode };
|
|
88
88
|
},
|
|
89
|
+
...(desc.declassify !== undefined ? { declassify: desc.declassify } : {}),
|
|
90
|
+
__nomosGenerated: "directive-precondition" as const,
|
|
89
91
|
__isWorkspaceInvariant: true as const,
|
|
90
92
|
};
|
|
91
93
|
});
|