@githolon/dsl 0.86.1 → 0.87.0
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 +13 -0
- package/dart/lib/nomos_headless.dart +1 -0
- package/dart/lib/src/dev_cloud.dart +199 -0
- package/dart/lib/src/session_bound.dart +15 -1
- package/dart/lib/src/test_harness.dart +134 -2
- package/dart/lib/testing.dart +1 -0
- package/dart/pubspec.yaml +1 -1
- package/package.json +1 -1
- package/src/codegen_proof.ts +194 -19
- package/src/dart_barrel_guard.ts +4 -1
- package/src/framework/home.ts +32 -2
package/dart/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
## 0.56.0
|
|
2
|
+
|
|
3
|
+
- **NomosTestCloud (co2 #4)** — a headless, cloud-BOUND real-sync test harness: start/stop a local
|
|
4
|
+
`githolon cloud dev` (the same engine plane production runs), an online/offline PARTITION proxy
|
|
5
|
+
(`setOnline`) for deterministic link sever/heal, and remote head/commit inspection (`remoteHead`).
|
|
6
|
+
Sessions connect cloud-bound to `cloud.origin`; sync/convergence uses the existing `bridge.sync`.
|
|
7
|
+
- **Typed snapshot lifecycle on NomosTestHarness (co2 #5)** — `snapshotWorkspace`/`snapshotPlane` and
|
|
8
|
+
`restoreWorkspace`/`restorePlane` over the per-workspace `export()`/`connect(restoreFrom:)`
|
|
9
|
+
primitives, with typed `WorkspaceSnapshot`/`PlaneSnapshot` (JSON round-trip, session/workspace
|
|
10
|
+
metadata). Migrate staged AppSim snapshots mechanically — no reaching into custody internals.
|
|
11
|
+
- **`assertCompatible` surfaces a projection read failure (co2 #2)** as a distinct `projectionReadFailed`
|
|
12
|
+
cause (carrying the failed query) instead of reclassifying an evicted/failed session as `trulyNotDeployed`.
|
|
13
|
+
|
|
1
14
|
## 0.55.2
|
|
2
15
|
|
|
3
16
|
- Session-state DX (co2): explicit workspace lifecycle `state` on `listWorkspaces()` —
|
|
@@ -39,6 +39,7 @@ library;
|
|
|
39
39
|
|
|
40
40
|
export 'src/node_runner_transport.dart';
|
|
41
41
|
export 'src/test_harness.dart';
|
|
42
|
+
export 'src/dev_cloud.dart'; // NomosTestCloud (co2 #4 real-sync harness)
|
|
42
43
|
// THE FULL SESSION SURFACE (task #86 gap 1): a headless test types everything the harness hands out —
|
|
43
44
|
// [NomosSessionCredentials] (the `acquireSession` ifAbsentCredentials callback's return type),
|
|
44
45
|
// [NomosBridge]/[NomosSession]/[NomosBbox]/[NomosCompat], the E2E sealing client
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// NOMOS TEST CLOUD (co2 #4) — a headless, real-sync test cloud for AppSim: start/stop a local
|
|
2
|
+
// `githolon cloud dev` (the SAME engine plane production runs), front it with a PARTITION PROXY so a test can
|
|
3
|
+
// go online/offline deterministically, and inspect the remote head/sequence — all sharing the production
|
|
4
|
+
// bridge/session clients (the sessions connect to [origin] exactly like they connect to the real cloud).
|
|
5
|
+
//
|
|
6
|
+
// ```dart
|
|
7
|
+
// final cloud = await NomosTestCloud.start(serverEntry: devServerPath); // spawns the dev cloud + proxy
|
|
8
|
+
// final harness = await NomosTestHarness.cluster(cloud: cloud.origin, ...); // cloud-BOUND sessions
|
|
9
|
+
// // ... author offline ...
|
|
10
|
+
// cloud.setOnline(false); // PARTITION — the up leg can't reach custody
|
|
11
|
+
// await harness.session('estate').sync(admit: true); // stays local; nothing lands remotely
|
|
12
|
+
// cloud.setOnline(true); // heal the partition
|
|
13
|
+
// await harness.session('estate').sync(admit: true); // converges; the write lands on remote main
|
|
14
|
+
// final remote = await cloud.remoteHead('estate'); // inspect remote head + commit count
|
|
15
|
+
// await cloud.stop();
|
|
16
|
+
// ```
|
|
17
|
+
import 'dart:async';
|
|
18
|
+
import 'dart:convert';
|
|
19
|
+
import 'dart:io';
|
|
20
|
+
|
|
21
|
+
/// The remote custody state of one workspace on the test cloud (fork/custody inspection, co2 #4): the ledger
|
|
22
|
+
/// tip the cloud serves + its commit count, read off `GET /v2/workspaces/<ws>`.
|
|
23
|
+
class RemoteWorkspaceState {
|
|
24
|
+
final String workspace;
|
|
25
|
+
final bool initialized;
|
|
26
|
+
final String head;
|
|
27
|
+
final int commits;
|
|
28
|
+
const RemoteWorkspaceState({
|
|
29
|
+
required this.workspace,
|
|
30
|
+
required this.initialized,
|
|
31
|
+
required this.head,
|
|
32
|
+
required this.commits,
|
|
33
|
+
});
|
|
34
|
+
@override
|
|
35
|
+
String toString() =>
|
|
36
|
+
'RemoteWorkspaceState($workspace, ${initialized ? "born" : "unborn"}, head=${head.isEmpty ? "∅" : head.substring(0, head.length.clamp(0, 10))}, $commits commit(s))';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
class NomosTestCloudException implements Exception {
|
|
40
|
+
final String message;
|
|
41
|
+
NomosTestCloudException(this.message);
|
|
42
|
+
@override
|
|
43
|
+
String toString() => 'NomosTestCloudException: $message';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
class NomosTestCloud {
|
|
47
|
+
final Process _server;
|
|
48
|
+
final HttpServer _proxy;
|
|
49
|
+
final Uri _upstream; // the real dev-cloud origin the proxy forwards to
|
|
50
|
+
final HttpClient _client;
|
|
51
|
+
final void Function(String line)? _onDiagnostic;
|
|
52
|
+
bool _online = true;
|
|
53
|
+
bool _stopped = false;
|
|
54
|
+
|
|
55
|
+
NomosTestCloud._(this._server, this._proxy, this._upstream, this._client, this._onDiagnostic);
|
|
56
|
+
|
|
57
|
+
/// The base URL a cloud-BOUND session connects to (`connect(cloud: cloud.origin)`). Points at the PARTITION
|
|
58
|
+
/// PROXY, so [setOnline] deterministically severs/heals the whole plane's link to custody.
|
|
59
|
+
String get origin => 'http://${_proxy.address.host}:${_proxy.port}';
|
|
60
|
+
|
|
61
|
+
/// PARTITION control (co2 #4). `false` = OFFLINE — every request through the proxy fails fast (a severed
|
|
62
|
+
/// link), so writes stay LOCAL and syncs can't reach custody. `true` = ONLINE — traffic flows to the dev
|
|
63
|
+
/// cloud and peers converge. Deterministic: no timing races, the test decides exactly when the link exists.
|
|
64
|
+
void setOnline(bool online) {
|
|
65
|
+
_online = online;
|
|
66
|
+
_log('partition: ${online ? "ONLINE (link up)" : "OFFLINE (link severed)"}');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
bool get online => _online;
|
|
70
|
+
|
|
71
|
+
/// Inspect a workspace's REMOTE head + commit count on the test cloud (custody/fork inspection). Reads the
|
|
72
|
+
/// live cloud state DIRECTLY (bypassing the partition proxy) — a test assertion about custody must see the
|
|
73
|
+
/// truth even while the app's link is partitioned OFFLINE.
|
|
74
|
+
Future<RemoteWorkspaceState> remoteHead(String workspace) async {
|
|
75
|
+
final body = await _getJson(_upstream.replace(path: '/v2/workspaces/$workspace'));
|
|
76
|
+
final ledger = (body['ledgerState'] as Map?)?.cast<String, Object?>() ?? const {};
|
|
77
|
+
return RemoteWorkspaceState(
|
|
78
|
+
workspace: workspace,
|
|
79
|
+
initialized: body['initialized'] == true,
|
|
80
|
+
head: (ledger['head'] ?? '').toString(),
|
|
81
|
+
commits: ((ledger['commits'] as num?) ?? 0).toInt(),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/// Start the test cloud: spawn `node <serverEntry> --port <n> [--ephemeral] [--data <dir>]`, wait until its
|
|
86
|
+
/// `/health` answers, then bring up the partition proxy in front of it. [serverEntry] is the dev-server
|
|
87
|
+
/// entry (`cloud/dev-server/server.mjs` in-repo, or `dist/dev-server.mjs` from a published `githolon`) — the
|
|
88
|
+
/// caller resolves it (the same seam the offline harness uses for its install helper). [node] defaults to
|
|
89
|
+
/// `node` on PATH.
|
|
90
|
+
static Future<NomosTestCloud> start({
|
|
91
|
+
required String serverEntry,
|
|
92
|
+
int port = 0,
|
|
93
|
+
bool ephemeral = true,
|
|
94
|
+
String? dataDir,
|
|
95
|
+
String node = 'node',
|
|
96
|
+
Duration readyTimeout = const Duration(seconds: 30),
|
|
97
|
+
void Function(String line)? onDiagnostic,
|
|
98
|
+
}) async {
|
|
99
|
+
final chosenPort = port == 0 ? await _freePort() : port;
|
|
100
|
+
final args = <String>[
|
|
101
|
+
serverEntry,
|
|
102
|
+
'--port', '$chosenPort',
|
|
103
|
+
if (ephemeral) '--ephemeral',
|
|
104
|
+
if (dataDir != null) ...['--data', dataDir],
|
|
105
|
+
];
|
|
106
|
+
void log(String m) => onDiagnostic?.call('[nomos-test-cloud] $m');
|
|
107
|
+
log('spawning: $node ${args.join(' ')}');
|
|
108
|
+
final proc = await Process.start(node, args, mode: ProcessStartMode.normal);
|
|
109
|
+
proc.stdout.transform(utf8.decoder).transform(const LineSplitter()).listen((l) => log('server: $l'));
|
|
110
|
+
proc.stderr.transform(utf8.decoder).transform(const LineSplitter()).listen((l) => log('server!: $l'));
|
|
111
|
+
|
|
112
|
+
final upstream = Uri.parse('http://127.0.0.1:$chosenPort');
|
|
113
|
+
final client = HttpClient();
|
|
114
|
+
// Wait for /health.
|
|
115
|
+
final deadline = DateTime.now().add(readyTimeout);
|
|
116
|
+
var ready = false;
|
|
117
|
+
while (DateTime.now().isBefore(deadline)) {
|
|
118
|
+
try {
|
|
119
|
+
final req = await client.getUrl(upstream.replace(path: '/health'));
|
|
120
|
+
final resp = await req.close();
|
|
121
|
+
if (resp.statusCode == 200) { ready = true; await resp.drain<void>(); break; }
|
|
122
|
+
await resp.drain<void>();
|
|
123
|
+
} catch (_) {/* not up yet */}
|
|
124
|
+
await Future<void>.delayed(const Duration(milliseconds: 200));
|
|
125
|
+
}
|
|
126
|
+
if (!ready) {
|
|
127
|
+
proc.kill(ProcessSignal.sigkill);
|
|
128
|
+
throw NomosTestCloudException('the dev cloud did not answer /health within ${readyTimeout.inSeconds}s (serverEntry: $serverEntry)');
|
|
129
|
+
}
|
|
130
|
+
log('dev cloud healthy at $upstream');
|
|
131
|
+
|
|
132
|
+
// The partition proxy — forwards to the dev cloud when ONLINE, fails fast when OFFLINE.
|
|
133
|
+
final proxy = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
|
134
|
+
final cloud = NomosTestCloud._(proc, proxy, upstream, client, onDiagnostic);
|
|
135
|
+
proxy.listen(cloud._handleProxy);
|
|
136
|
+
log('partition proxy at ${cloud.origin} → $upstream');
|
|
137
|
+
return cloud;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
Future<void> _handleProxy(HttpRequest req) async {
|
|
141
|
+
if (!_online) {
|
|
142
|
+
// OFFLINE — sever the link. Fail fast (503) so the client sees a dead-cloud, writes stay local, and a
|
|
143
|
+
// sync can't reach custody. (A 503 is the cleanest deterministic "link down" the Dart proxy can present.)
|
|
144
|
+
try {
|
|
145
|
+
req.response.statusCode = 503;
|
|
146
|
+
await req.drain<void>();
|
|
147
|
+
await req.response.close();
|
|
148
|
+
} catch (_) {}
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
final target = _upstream.replace(path: req.uri.path, query: req.uri.query.isEmpty ? null : req.uri.query);
|
|
153
|
+
final fwd = await _client.openUrl(req.method, target);
|
|
154
|
+
req.headers.forEach((name, values) { if (name.toLowerCase() != 'host') for (final v in values) fwd.headers.add(name, v); });
|
|
155
|
+
await fwd.addStream(req); // forward the request body
|
|
156
|
+
final resp = await fwd.close();
|
|
157
|
+
req.response.statusCode = resp.statusCode;
|
|
158
|
+
resp.headers.forEach((name, values) { for (final v in values) req.response.headers.add(name, v); });
|
|
159
|
+
await resp.pipe(req.response);
|
|
160
|
+
} catch (e) {
|
|
161
|
+
try { req.response.statusCode = 502; await req.response.close(); } catch (_) {}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
Future<Map<String, Object?>> _getJson(Uri url) async {
|
|
166
|
+
final req = await _client.getUrl(url);
|
|
167
|
+
final resp = await req.close();
|
|
168
|
+
final text = await resp.transform(utf8.decoder).join();
|
|
169
|
+
if (resp.statusCode != 200) {
|
|
170
|
+
throw NomosTestCloudException('GET $url → ${resp.statusCode}: ${text.substring(0, text.length.clamp(0, 200))}');
|
|
171
|
+
}
|
|
172
|
+
return (jsonDecode(text) as Map).cast<String, Object?>();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
void _log(String m) => _onDiagnostic?.call('[nomos-test-cloud] $m');
|
|
176
|
+
|
|
177
|
+
/// Deterministic teardown: close the proxy + kill the dev-server subprocess. Safe to call twice.
|
|
178
|
+
Future<void> stop() async {
|
|
179
|
+
if (_stopped) return;
|
|
180
|
+
_stopped = true;
|
|
181
|
+
_log('stopping');
|
|
182
|
+
try { await _proxy.close(force: true); } catch (_) {}
|
|
183
|
+
_server.kill(ProcessSignal.sigterm);
|
|
184
|
+
// Give it a moment, then SIGKILL if still alive.
|
|
185
|
+
try {
|
|
186
|
+
await _server.exitCode.timeout(const Duration(seconds: 5));
|
|
187
|
+
} on TimeoutException {
|
|
188
|
+
_server.kill(ProcessSignal.sigkill);
|
|
189
|
+
}
|
|
190
|
+
_client.close(force: true);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
static Future<int> _freePort() async {
|
|
194
|
+
final s = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
|
|
195
|
+
final p = s.port;
|
|
196
|
+
await s.close();
|
|
197
|
+
return p;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -79,6 +79,12 @@ enum SessionMismatchCause {
|
|
|
79
79
|
/// The domain IS installed but its shape is older than what this client requires — the client
|
|
80
80
|
/// expects a newer interface (recompile against the deployed law, or evolve the law).
|
|
81
81
|
clientExpectsNewerInterface,
|
|
82
|
+
|
|
83
|
+
/// The session's PROJECTION READ itself failed terminally (an unmountable/failing query shape —
|
|
84
|
+
/// e.g. co2's compound-key mount error) — the workspace's law could NOT be read, so this is NOT
|
|
85
|
+
/// "no law deployed". The hint carries the failed query + workspace so the real fault surfaces
|
|
86
|
+
/// (co2 blocker #2 — never reclassify a projection-refresh terminal error as trulyNotDeployed).
|
|
87
|
+
projectionReadFailed,
|
|
82
88
|
}
|
|
83
89
|
|
|
84
90
|
/// Thrown by `XClient.bind(session)` (and by the lazy first-dispatch check of the synchronous
|
|
@@ -212,13 +218,21 @@ extension NomosSessionCompat on NomosSession {
|
|
|
212
218
|
final offer = await interfaceOffer();
|
|
213
219
|
if (offer != null) activeKeys = offer.keys.toList()..sort();
|
|
214
220
|
} catch (e) {
|
|
221
|
+
// co2 blocker #2: a PROJECTION READ FAILURE (an unmountable/failing query shape — the runner surfaces
|
|
222
|
+
// `nomos.projection.read_failed` carrying the failed query) is NOT "not reachable" and MUST NOT be
|
|
223
|
+
// reclassified as trulyNotDeployed downstream — surface it as its own cause with the real fault text.
|
|
224
|
+
final isProjectionFault = e.toString().contains('nomos.projection.read_failed') ||
|
|
225
|
+
e.toString().contains('projection read failed');
|
|
215
226
|
throw DomainClientSessionMismatch(
|
|
216
227
|
expectedDomainKey: iface.domain,
|
|
217
228
|
expectedDomainHash: iface.expectedDomainHash,
|
|
218
229
|
expectedInterfaceHash: iface.expectedInterfaceHash,
|
|
219
230
|
sessionId: workspace,
|
|
220
231
|
sessionWorkspace: workspace,
|
|
221
|
-
|
|
232
|
+
cause: isProjectionFault ? SessionMismatchCause.projectionReadFailed : null,
|
|
233
|
+
hint: isProjectionFault
|
|
234
|
+
? 'the workspace law could NOT be READ — a projection/mount failure (NOT undeployed law): $e'
|
|
235
|
+
: 'the session workspace is not reachable/mounted on this plane: $e',
|
|
222
236
|
);
|
|
223
237
|
}
|
|
224
238
|
|
|
@@ -148,14 +148,15 @@ class NomosTestHarness {
|
|
|
148
148
|
final Duration? _phaseTimeout;
|
|
149
149
|
|
|
150
150
|
NomosTestHarness._(this.bridge, this.workspace, this._transport, this._workdir,
|
|
151
|
-
this._onDiagnostic,
|
|
151
|
+
this._onDiagnostic, List<String> sessionNames,
|
|
152
152
|
{Map<String, String> devicePublics = const {},
|
|
153
153
|
this.frameworkHash,
|
|
154
154
|
Map<String, List<String>> lawHashes = const {},
|
|
155
155
|
Map<String, String> snapshots = const {},
|
|
156
156
|
_InstallLane? installLane,
|
|
157
157
|
Duration? phaseTimeout})
|
|
158
|
-
:
|
|
158
|
+
: _sessionNames = List.of(sessionNames), // GROWABLE — restoreWorkspace may add a fresh session
|
|
159
|
+
_devicePublics = devicePublics,
|
|
159
160
|
_lawHashes = Map.of(lawHashes),
|
|
160
161
|
_snapshots = Map.of(snapshots),
|
|
161
162
|
_installLane = installLane,
|
|
@@ -722,6 +723,75 @@ class NomosTestHarness {
|
|
|
722
723
|
}
|
|
723
724
|
}
|
|
724
725
|
|
|
726
|
+
// ── SNAPSHOT LIFECYCLE (co2 #5) — a typed async snapshot API over the per-workspace export()/restoreFrom
|
|
727
|
+
// primitives, so staged AppSim snapshots migrate mechanically without reaching into custody internals. ──
|
|
728
|
+
|
|
729
|
+
/// Capture a typed [WorkspaceSnapshot] of one workspace's whole local holon (the bare nomos.git incl.
|
|
730
|
+
/// un-synced writes + the DLQ), with its head + law hashes + capture time. The bytes are the SAME
|
|
731
|
+
/// base64 `connect(restoreFrom:)` consumes — [restoreWorkspace] round-trips it. O(1)-ish (a tree serialize).
|
|
732
|
+
Future<WorkspaceSnapshot> snapshotWorkspace(String workspace) async {
|
|
733
|
+
final s = session(workspace); // throws NomosTestHarnessException if not a connected session
|
|
734
|
+
return _phased(
|
|
735
|
+
phase: 'snapshotWorkspace',
|
|
736
|
+
workspace: workspace,
|
|
737
|
+
awaiting: 'export',
|
|
738
|
+
timeout: _phaseTimeout ?? _kOtherDeadline,
|
|
739
|
+
onDiagnostic: _onDiagnostic,
|
|
740
|
+
run: () async {
|
|
741
|
+
final b64 = await s.export();
|
|
742
|
+
final head = await s.head();
|
|
743
|
+
return WorkspaceSnapshot(
|
|
744
|
+
workspace: workspace,
|
|
745
|
+
snapshotB64: b64,
|
|
746
|
+
head: head,
|
|
747
|
+
lawHashes: lawHashes(workspace),
|
|
748
|
+
capturedAt: DateTime.now().toUtc(),
|
|
749
|
+
);
|
|
750
|
+
},
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/// Snapshot EVERY connected workspace on this plane into a typed [PlaneSnapshot] (session/workspace metadata
|
|
755
|
+
/// carried per entry). The plane-level twin of [snapshotWorkspace] — migrate a whole staged AppSim plane.
|
|
756
|
+
Future<PlaneSnapshot> snapshotPlane() async {
|
|
757
|
+
final out = <String, WorkspaceSnapshot>{};
|
|
758
|
+
for (final ws in _sessionNames) {
|
|
759
|
+
out[ws] = await snapshotWorkspace(ws);
|
|
760
|
+
}
|
|
761
|
+
return PlaneSnapshot(workspaces: Map.unmodifiable(out), capturedAt: DateTime.now().toUtc());
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/// Restore a workspace FROM a [WorkspaceSnapshot] — re-open its session on this plane from the snapshot's
|
|
765
|
+
/// LOCAL CUSTODY (no network clone), replacing the resident holon. The workspace need not currently be a
|
|
766
|
+
/// session (a fresh restore is allowed); its name is taken from [snapshot] unless [into] overrides it.
|
|
767
|
+
Future<NomosSession> restoreWorkspace(WorkspaceSnapshot snapshot, {String? into}) async {
|
|
768
|
+
final ws = into ?? snapshot.workspace;
|
|
769
|
+
await _phased(
|
|
770
|
+
phase: 'restoreWorkspace',
|
|
771
|
+
workspace: ws,
|
|
772
|
+
awaiting: 'connect(restoreFrom:)',
|
|
773
|
+
timeout: _phaseTimeout ?? const Duration(seconds: 60),
|
|
774
|
+
onDiagnostic: _onDiagnostic,
|
|
775
|
+
run: () => _plane.connect(
|
|
776
|
+
workspace: ws,
|
|
777
|
+
session: ws,
|
|
778
|
+
clientId: '${_installLane?.clientId ?? 'test-runner'}-$ws',
|
|
779
|
+
restoreFrom: snapshot.snapshotB64,
|
|
780
|
+
allowUnborn: true,
|
|
781
|
+
),
|
|
782
|
+
);
|
|
783
|
+
if (!_sessionNames.contains(ws)) _sessionNames.add(ws);
|
|
784
|
+
return bridge.session(ws);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/// Restore an entire [PlaneSnapshot] — every workspace re-opened from its snapshot. Migrates a staged plane
|
|
788
|
+
/// wholesale (the mechanical AppSim-snapshot migration co2 #5 asks for).
|
|
789
|
+
Future<void> restorePlane(PlaneSnapshot plane) async {
|
|
790
|
+
for (final entry in plane.workspaces.values) {
|
|
791
|
+
await restoreWorkspace(entry);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
725
795
|
/// Deterministic, idempotent, total-ordered teardown: stop the runner (SIGTERM → 5s deadline → SIGKILL,
|
|
726
796
|
/// via [NodeRunnerTransport.dispose]), then delete the temp dir. Safe to call twice; safe to call after a
|
|
727
797
|
/// failed setup. Nothing left running, nothing left on disk.
|
|
@@ -841,3 +911,65 @@ class NomosTestHarness {
|
|
|
841
911
|
return out;
|
|
842
912
|
}
|
|
843
913
|
}
|
|
914
|
+
|
|
915
|
+
/// A typed, self-describing snapshot of ONE workspace's local holon (co2 #5). [snapshotB64] is the exact
|
|
916
|
+
/// base64 `connect(restoreFrom:)` consumes — the whole nomos.git incl. un-synced writes + the DLQ — plus the
|
|
917
|
+
/// metadata that makes a staged snapshot mechanically migratable: the [workspace], its ledger [head] at
|
|
918
|
+
/// capture, its installed [lawHashes], and [capturedAt] (UTC).
|
|
919
|
+
class WorkspaceSnapshot {
|
|
920
|
+
final String workspace;
|
|
921
|
+
final String snapshotB64;
|
|
922
|
+
final String head;
|
|
923
|
+
final List<String> lawHashes;
|
|
924
|
+
final DateTime capturedAt;
|
|
925
|
+
|
|
926
|
+
const WorkspaceSnapshot({
|
|
927
|
+
required this.workspace,
|
|
928
|
+
required this.snapshotB64,
|
|
929
|
+
required this.head,
|
|
930
|
+
required this.lawHashes,
|
|
931
|
+
required this.capturedAt,
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
/// JSON round-trip so a staged snapshot persists to disk verbatim (the bytes + provenance).
|
|
935
|
+
Map<String, Object?> toJson() => {
|
|
936
|
+
'workspace': workspace,
|
|
937
|
+
'snapshotB64': snapshotB64,
|
|
938
|
+
'head': head,
|
|
939
|
+
'lawHashes': lawHashes,
|
|
940
|
+
'capturedAt': capturedAt.toUtc().toIso8601String(),
|
|
941
|
+
};
|
|
942
|
+
|
|
943
|
+
factory WorkspaceSnapshot.fromJson(Map<String, Object?> j) => WorkspaceSnapshot(
|
|
944
|
+
workspace: j['workspace'] as String,
|
|
945
|
+
snapshotB64: j['snapshotB64'] as String,
|
|
946
|
+
head: (j['head'] ?? '') as String,
|
|
947
|
+
lawHashes: ((j['lawHashes'] as List?) ?? const []).map((e) => '$e').toList(),
|
|
948
|
+
capturedAt: DateTime.parse((j['capturedAt'] ?? DateTime.now().toUtc().toIso8601String()) as String),
|
|
949
|
+
);
|
|
950
|
+
|
|
951
|
+
@override
|
|
952
|
+
String toString() =>
|
|
953
|
+
'WorkspaceSnapshot($workspace @ ${head.isEmpty ? "∅" : head.substring(0, head.length.clamp(0, 10))}, '
|
|
954
|
+
'${lawHashes.length} law(s), ${capturedAt.toIso8601String()})';
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/// A typed snapshot of a WHOLE plane — every connected workspace keyed by name (co2 #5). Restore it with
|
|
958
|
+
/// [NomosTestHarness.restorePlane] to migrate a staged multi-workspace AppSim scene wholesale.
|
|
959
|
+
class PlaneSnapshot {
|
|
960
|
+
final Map<String, WorkspaceSnapshot> workspaces;
|
|
961
|
+
final DateTime capturedAt;
|
|
962
|
+
|
|
963
|
+
const PlaneSnapshot({required this.workspaces, required this.capturedAt});
|
|
964
|
+
|
|
965
|
+
Map<String, Object?> toJson() => {
|
|
966
|
+
'workspaces': workspaces.map((k, v) => MapEntry(k, v.toJson())),
|
|
967
|
+
'capturedAt': capturedAt.toUtc().toIso8601String(),
|
|
968
|
+
};
|
|
969
|
+
|
|
970
|
+
factory PlaneSnapshot.fromJson(Map<String, Object?> j) => PlaneSnapshot(
|
|
971
|
+
workspaces: ((j['workspaces'] as Map?) ?? const {}).map(
|
|
972
|
+
(k, v) => MapEntry('$k', WorkspaceSnapshot.fromJson((v as Map).cast<String, Object?>()))),
|
|
973
|
+
capturedAt: DateTime.parse((j['capturedAt'] ?? DateTime.now().toUtc().toIso8601String()) as String),
|
|
974
|
+
);
|
|
975
|
+
}
|
package/dart/lib/testing.dart
CHANGED
package/dart/pubspec.yaml
CHANGED
|
@@ -11,7 +11,7 @@ description: >-
|
|
|
11
11
|
Nomos 2 Dart frontend types — the hand-written kernel wire types plus the
|
|
12
12
|
code-generated per-domain aggregate + directive-payload types. (De)serializes
|
|
13
13
|
the EXACT serde JSON the Rust kernel + projection produce/consume.
|
|
14
|
-
version: 0.
|
|
14
|
+
version: 0.56.0
|
|
15
15
|
|
|
16
16
|
environment:
|
|
17
17
|
sdk: ^3.5.0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@githolon/dsl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.87.0",
|
|
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_proof.ts
CHANGED
|
@@ -491,6 +491,20 @@ function authorityConformanceLegLines(
|
|
|
491
491
|
* actor and assert it SUCCEEDS — proving that once relation R is granted, an actor holding R authors the gated
|
|
492
492
|
* write (the exact loop the negative leg can't cover). Emits nothing when no granter is discoverable.
|
|
493
493
|
*/
|
|
494
|
+
/** The canonical set of relation-bootstrap legs for a domain (co2 #2 multi-target): the `relationBootstraps[]`
|
|
495
|
+
* array when present, else the singular `relationBootstrap` as a one-element list, else empty. */
|
|
496
|
+
function allRelationBootstrapLegs(pl: ProofLegs): readonly RelationBootstrapLeg[] {
|
|
497
|
+
return pl.relationBootstraps ?? (pl.relationBootstrap !== undefined ? [pl.relationBootstrap] : []);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** Emit the seed-then-prove lines for EVERY discovered gated-create authority leg (co2 #2), or — when the
|
|
501
|
+
* domain has a gated create but NO seedable path — the single loud gap diagnostic. */
|
|
502
|
+
function relationSeedLegsLines(pl: ProofLegs, factory: string, n: () => number, gap: string | undefined): string[] {
|
|
503
|
+
const legs = allRelationBootstrapLegs(pl);
|
|
504
|
+
if (legs.length === 0) return relationSeedLegLines(undefined, factory, n, gap);
|
|
505
|
+
return legs.flatMap((leg) => relationSeedLegLines(leg, factory, n, undefined));
|
|
506
|
+
}
|
|
507
|
+
|
|
494
508
|
function relationSeedLegLines(
|
|
495
509
|
leg: RelationBootstrapLeg | undefined,
|
|
496
510
|
factory: string,
|
|
@@ -605,6 +619,19 @@ function relationSeedLegLines(
|
|
|
605
619
|
const prereqNote = (leg.prerequisiteSeeds ?? []).length > 0
|
|
606
620
|
? ` The grant needs prerequisite seed(s) [${(leg.prerequisiteSeeds ?? []).map((s) => s.directiveId).join(", ")}] run first (as the actor = founder).`
|
|
607
621
|
: ``;
|
|
622
|
+
// TARGET PRECONDITION SETUPS (co2 #3): the gated establisher(s) for the target's `.requiresExisting`/`.where`
|
|
623
|
+
// parent — dispatched AFTER the grant (the actor now holds the relation), BEFORE the target, so its declared
|
|
624
|
+
// predicate (e.g. `lifecycle == "active"`) holds against the REAL parent, not a shape-only shell.
|
|
625
|
+
const precondLines = (leg.preconditionSetups ?? []).flatMap((s) => {
|
|
626
|
+
const lit = s.autoStamped.length > 0
|
|
627
|
+
? `(() => { const p: Record<string, unknown> = { ...${JSON.stringify(s.payload)} }; ${s.autoStamped.map((f) => `p[${JSON.stringify(f)}] = new Date().toISOString();`).join(" ")} return p; })()`
|
|
628
|
+
: JSON.stringify(s.payload);
|
|
629
|
+
return [
|
|
630
|
+
` // precondition: dispatch the REAL ${leg.domain}/${s.directiveId} (gated by "${s.requiresRelation ?? leg.relation}", already seated) so the target's declared predicate holds against real state.`,
|
|
631
|
+
` await ${factory}(holon, { actor: ACTOR }).${s.directiveId}(${lit});`,
|
|
632
|
+
` ok(\`relation bootstrap (precondition) — ${leg.domain}/${s.directiveId} established before ${leg.gatedDirectiveId} (real parent state, not a shell)\`);`,
|
|
633
|
+
];
|
|
634
|
+
});
|
|
608
635
|
return [
|
|
609
636
|
`// ${n()}. RELATION BOOTSTRAP (seed-then-prove the POSITIVE): \`${leg.domain}/${leg.gatedDirectiveId}\` is GATED`,
|
|
610
637
|
`// (\`.requires("${leg.relation}")\`). \`${leg.domain}/${leg.grantDirectiveId}\` GRANTS "${leg.grantedRelation}" (${leg.via})${computedNote}.${prereqNote}`,
|
|
@@ -619,6 +646,7 @@ function relationSeedLegLines(
|
|
|
619
646
|
` // (1) grant "${leg.grantedRelation}" to ACTOR (the grant folds a writeTuple; subject seated on ACTOR).`,
|
|
620
647
|
` await ${factory}(holon, { actor: ACTOR }).${leg.grantDirectiveId}(${grantPayloadLit});`,
|
|
621
648
|
` ok(\`relation bootstrap (grant) — ${leg.domain}/${leg.grantDirectiveId} granted "${leg.grantedRelation}" to \${ACTOR}${leg.grantedRelation !== leg.relation ? ` (⇒ satisfies "${leg.relation}" via rewrite)` : ``}\`);`,
|
|
649
|
+
...precondLines,
|
|
622
650
|
` // (2) the GATED write now SUCCEEDS under the seeded actor (holds "${leg.relation}"${leg.grantedRelation !== leg.relation ? ` via "${leg.grantedRelation}"` : ``}).`,
|
|
623
651
|
` await ${factory}(holon, { actor: ACTOR }).${leg.gatedDirectiveId}(${gatedPayloadLit});`,
|
|
624
652
|
` ok(\`relation bootstrap (prove) — ${leg.domain}/${leg.gatedDirectiveId} authored under the seeded holder of "${leg.relation}" (gated write SUCCEEDS once granted)\`);`,
|
|
@@ -712,6 +740,29 @@ export interface RelationBootstrapLeg {
|
|
|
712
740
|
* (each as the proof actor = founder, so their own write-once/author-binding invariants hold) BEFORE the
|
|
713
741
|
* grant — then the grant + the gated write succeed. A non-birth analog of {@link BornContextAuthority}. */
|
|
714
742
|
readonly prerequisiteSeeds?: readonly PrerequisiteSeed[];
|
|
743
|
+
/** THE TARGET'S OWN PRECONDITION SETUPS (co2 #3). The gated create carries a `.requiresExisting`/`.where`
|
|
744
|
+
* precondition reading ANOTHER aggregate by a payload-id field with a predicate (e.g. `createClassification
|
|
745
|
+
* Draft` needs a `ClassificationScheme` at `p.schemeId` with `lifecycle == "active"`). The establishing
|
|
746
|
+
* create (`createClassificationScheme`) is itself GATED by the SAME relation this leg already seats, and —
|
|
747
|
+
* because the id field synthesizes deterministically to `"sample-<field>"` in BOTH — it writes the parent
|
|
748
|
+
* at the exact id the target reads, WITH the real lifecycle its plan sets. The runner dispatches these AFTER
|
|
749
|
+
* the grant (the actor holds the relation) and BEFORE the target, satisfying its declared predicate — not a
|
|
750
|
+
* shape-only shell. Dependency-ordered. */
|
|
751
|
+
readonly preconditionSetups?: readonly PreconditionSetup[];
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/** One target-precondition setup (co2 #3): a REAL create of a parent aggregate the gated target's precondition
|
|
755
|
+
* reads, dispatched (as the actor, who holds the seated relation) BEFORE the target so its predicate holds. */
|
|
756
|
+
export interface PreconditionSetup {
|
|
757
|
+
/** The establishing create directive id (e.g. `createClassificationScheme`). */
|
|
758
|
+
readonly directiveId: string;
|
|
759
|
+
/** Its synthesized payload — the id field (`.withId`) is RETAINED and matches the target's (both
|
|
760
|
+
* `"sample-<field>"`), so it writes the parent at the exact instance the target's precondition reads. */
|
|
761
|
+
readonly payload: Record<string, unknown>;
|
|
762
|
+
readonly autoStamped: readonly string[];
|
|
763
|
+
/** The relation the establisher is gated by (informational; the runner dispatches it as the actor already
|
|
764
|
+
* holding this leg's seated relation — the common case is the SAME relation as the target). */
|
|
765
|
+
readonly requiresRelation?: string;
|
|
715
766
|
}
|
|
716
767
|
|
|
717
768
|
/** One prerequisite seed the runner dispatches (as the proof actor = founder) BEFORE the grant — it
|
|
@@ -1148,11 +1199,19 @@ export function discoverBornContextAuthority(
|
|
|
1148
1199
|
return undefined;
|
|
1149
1200
|
}
|
|
1150
1201
|
|
|
1151
|
-
/** Does any workspaceInvariant fire ON `directiveId`
|
|
1152
|
-
*
|
|
1153
|
-
*
|
|
1202
|
+
/** Does any workspaceInvariant fire ON `directiveId` with a REAL cross-step PREREQUISITE — a declared read of
|
|
1203
|
+
* state ANOTHER step must establish first? A standalone replay of such a directive can FALSE-fail (the prior
|
|
1204
|
+
* state is absent), so a born domain must prove via the genesis path. co2 #2: a write-once SEED invariant
|
|
1205
|
+
* whose only reads are `pre:`-self-checks OR the directive's OWN output aggregate is NOT a cross-step
|
|
1206
|
+
* prerequisite (it's a self-absence check) — such a granter stays CLEAN and is a valid backtrack target. */
|
|
1154
1207
|
function directiveHasPrerequisiteInvariant(mod: DomainModule, directiveId: string): boolean {
|
|
1155
|
-
|
|
1208
|
+
const invs = (mod.workspaceInvariants ?? []).filter((inv) => inv.on === directiveId);
|
|
1209
|
+
if (invs.length === 0) return false;
|
|
1210
|
+
const dir = (mod.directives as Directive<unknown>[]).find((d) => d.id === directiveId);
|
|
1211
|
+
const selfWritten = dir !== undefined ? probeWritesAggregateTypes(dir, `${mod.domain ?? mod.name}/${directiveId}`) : new Set<string>();
|
|
1212
|
+
// A real prerequisite reads ≥1 aggregate this directive does NOT itself establish (pre:-self-checks already
|
|
1213
|
+
// excluded in invariantReadAggregateTypes). If every read is self-written, it's a self-absence check → clean.
|
|
1214
|
+
return invs.some((inv) => [...invariantReadAggregateTypes(inv)].some((agg) => !selfWritten.has(agg)));
|
|
1156
1215
|
}
|
|
1157
1216
|
|
|
1158
1217
|
/** The AGGREGATE TYPES a directive's plan writes (set/ensure/create ops) — used to find the sibling seed that
|
|
@@ -1244,6 +1303,63 @@ export function discoverPrerequisiteChain(mod: DomainModule, granter: Directive<
|
|
|
1244
1303
|
return chain.length > 0 ? chain : undefined;
|
|
1245
1304
|
}
|
|
1246
1305
|
|
|
1306
|
+
/**
|
|
1307
|
+
* TARGET-PRECONDITION SETUPS (co2 #3). A gated create may carry a `.requiresExisting`/`.where` precondition
|
|
1308
|
+
* reading a PARENT aggregate by a payload-id field with a predicate (e.g. `createClassificationDraft` needs a
|
|
1309
|
+
* `ClassificationScheme` at `p.schemeId` with `lifecycle == "active"`). The proof must ESTABLISH that parent
|
|
1310
|
+
* with REAL state — a shape-only shell fails the predicate (`classification-scheme-retired`). For each such
|
|
1311
|
+
* precondition we find the parent's own create directive (`createClassificationScheme`, itself gated) and
|
|
1312
|
+
* record it as a setup dispatched AFTER the grant / BEFORE the target: because the parent's id field
|
|
1313
|
+
* synthesizes to the SAME `"sample-<field>"` the target reads, it writes the parent at the exact instance,
|
|
1314
|
+
* WITH the lifecycle its real plan sets. Skips a self-precondition (the target's own aggregate) and any parent
|
|
1315
|
+
* with no discoverable create (the predicate then arbitrates at the real gate — never a false green).
|
|
1316
|
+
*/
|
|
1317
|
+
function discoverPreconditionSetups(mod: DomainModule, gated: Directive<unknown>): PreconditionSetup[] {
|
|
1318
|
+
const domain = mod.domain ?? mod.name;
|
|
1319
|
+
const invariants = (mod.workspaceInvariants ?? []) as Array<WorkspaceInvariantDecl>;
|
|
1320
|
+
const directives = mod.directives as Directive<unknown>[];
|
|
1321
|
+
const gatedWrites = probeWritesAggregateTypes(gated, `${domain}/${gated.id}`);
|
|
1322
|
+
const setups: PreconditionSetup[] = [];
|
|
1323
|
+
const seen = new Set<string>();
|
|
1324
|
+
for (const inv of invariants) {
|
|
1325
|
+
if (inv.on !== gated.id) continue; // preconditions on THIS target only
|
|
1326
|
+
for (const aggType of invariantReadAggregateTypes(inv)) {
|
|
1327
|
+
if (gatedWrites.has(aggType)) continue; // self-precondition — the target establishes it in its own apply
|
|
1328
|
+
// The parent's own ESTABLISHER — a directive that WRITES `aggType` at a DETERMINISTIC (dev-chosen) key:
|
|
1329
|
+
// an `.ensures(<aggType>)` keyed upsert (the kernel's sanctioned non-minted-id lane), or a create-once
|
|
1330
|
+
// (`.withId`). Its id field synthesizes to the SAME `"sample-<field>"` the target's precondition reads,
|
|
1331
|
+
// so it writes the parent at the exact instance WITH the state its real plan sets. A truly MINTED
|
|
1332
|
+
// `.creates` (kernel `<Type>_<uuid>`) has NO dev-chosen id to match the target's synthesized id — its
|
|
1333
|
+
// raw-id create is refused by the mint gate anyway — so it is NOT a usable establisher here (the real
|
|
1334
|
+
// gate then arbitrates the target, never a false green).
|
|
1335
|
+
const creator = directives.find((d) => {
|
|
1336
|
+
if (d === gated || typeof d?.payloadSchema !== "object" || d.birthsChild === true) return false;
|
|
1337
|
+
if (!probeWritesAggregateTypes(d, `${domain}/${d.id}`).has(aggType)) return false;
|
|
1338
|
+
const isEnsures = d.marker === "ensures";
|
|
1339
|
+
const isCreateOnce = d.marker === "creates" && (d as { createOnceIdField?: unknown }).createOnceIdField !== undefined;
|
|
1340
|
+
return isEnsures || isCreateOnce;
|
|
1341
|
+
});
|
|
1342
|
+
if (creator === undefined) continue;
|
|
1343
|
+
if (seen.has(creator.id)) continue;
|
|
1344
|
+
let synth: SynthesizedPayload;
|
|
1345
|
+
try {
|
|
1346
|
+
synth = synthesizePayload(creator.payloadSchema as z.ZodTypeAny, `${domain}/${creator.id}`);
|
|
1347
|
+
} catch {
|
|
1348
|
+
continue;
|
|
1349
|
+
}
|
|
1350
|
+
const payload: Record<string, unknown> = { ...synth.value };
|
|
1351
|
+
seen.add(creator.id);
|
|
1352
|
+
setups.push({
|
|
1353
|
+
directiveId: creator.id,
|
|
1354
|
+
payload,
|
|
1355
|
+
autoStamped: synth.autoStamped,
|
|
1356
|
+
...(creator.requiresRelationGate !== undefined ? { requiresRelation: creator.requiresRelationGate.relation } : {}),
|
|
1357
|
+
});
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
return setups;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1247
1363
|
/** Discover the RELATION-BOOTSTRAP leg for a module: the first gated (`.requires(R)`) directive plus the
|
|
1248
1364
|
* sibling that ESTABLISHES the actor's authority for R. In priority order: (1) a DIRECT granter of R (a
|
|
1249
1365
|
* `.grants(R)` marker or a plan-probed `writeTuple(R)`) — the original lane; (2) REWRITE-AWARE — when R is
|
|
@@ -1293,20 +1409,39 @@ export function discoverRelationBootstrapLeg(mod: DomainModule, gatedOverride?:
|
|
|
1293
1409
|
// relations B (e.g. gate `estateMember` → `estateOwner`/`admin`), and find a granter for a base —
|
|
1294
1410
|
// granting B makes the actor satisfy R through the computed chain (what estate's seed does).
|
|
1295
1411
|
const candidates = directives.filter((d) => d !== gated && typeof d?.payloadSchema === "object" && d.birthsChild !== true);
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
const
|
|
1300
|
-
|
|
1412
|
+
// ALL granters of `rel`, ordered: `.grants(R)` markers first (unambiguous), then plan-probed `writeTuple(R)`.
|
|
1413
|
+
const findGrantersFor = (rel: string): { granter: Directive<unknown>; via: "marker" | "inferred" }[] => {
|
|
1414
|
+
const out: { granter: Directive<unknown>; via: "marker" | "inferred" }[] = [];
|
|
1415
|
+
for (const d of candidates) if ((d.grantsRelation ?? []).includes(rel)) out.push({ granter: d, via: "marker" });
|
|
1416
|
+
for (const d of candidates) if (!out.some((o) => o.granter === d) && probeGrantsRelation(d, rel, `${domain}/${d.id}`)) out.push({ granter: d, via: "inferred" });
|
|
1417
|
+
return out;
|
|
1301
1418
|
};
|
|
1419
|
+
const findGranterFor = (rel: string): { granter: Directive<unknown>; via: "marker" | "inferred" } | undefined => findGrantersFor(rel)[0];
|
|
1302
1420
|
|
|
1303
|
-
// (1) direct grant of R.
|
|
1421
|
+
// (1) direct grant of R — WITH BACKTRACKING (co2 #2 "standards"): several directives may grant R (e.g. a
|
|
1422
|
+
// workspace seed AND a generated administrator seed both write `admin`). PREFER a CLEAN granter (no
|
|
1423
|
+
// prerequisite invariant → a standalone re-seat just works); else the FIRST granter whose prerequisite chain
|
|
1424
|
+
// is satisfiable; else the first (the runner then names the gap). This backtracks instead of accepting the
|
|
1425
|
+
// first `writeTuple(R)` match, which could be an unsuitable seed (the co2 symptom).
|
|
1304
1426
|
let grantedRelation = relation;
|
|
1305
1427
|
let via: "marker" | "inferred" | "computed-base" | "seed-path" = "marker";
|
|
1306
1428
|
let seedSpeculative = false;
|
|
1307
1429
|
let seedWritesRelations: string[] | undefined;
|
|
1308
1430
|
let prerequisiteSeeds: PrerequisiteSeed[] | undefined;
|
|
1309
|
-
|
|
1431
|
+
const directGranters = findGrantersFor(relation);
|
|
1432
|
+
let found: { granter: Directive<unknown>; via: "marker" | "inferred" } | undefined;
|
|
1433
|
+
{
|
|
1434
|
+
const clean = directGranters.find((g) => !directiveHasPrerequisiteInvariant(mod, g.granter.id));
|
|
1435
|
+
if (clean !== undefined) {
|
|
1436
|
+
found = clean;
|
|
1437
|
+
} else {
|
|
1438
|
+
for (const g of directGranters) {
|
|
1439
|
+
const chain = discoverPrerequisiteChain(mod, g.granter);
|
|
1440
|
+
if (chain !== undefined) { found = g; prerequisiteSeeds = chain; break; }
|
|
1441
|
+
}
|
|
1442
|
+
if (found === undefined) found = directGranters[0]; // none clean/satisfiable — keep the first (gap named at runtime)
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1310
1445
|
if (found !== undefined) {
|
|
1311
1446
|
via = found.via;
|
|
1312
1447
|
} else {
|
|
@@ -1429,9 +1564,44 @@ export function discoverRelationBootstrapLeg(mod: DomainModule, gatedOverride?:
|
|
|
1429
1564
|
...(seedWritesRelations !== undefined ? { seedWritesRelations } : {}),
|
|
1430
1565
|
...(prerequisiteSeeds !== undefined ? { prerequisiteSeeds } : {}),
|
|
1431
1566
|
...(granterAuthority !== undefined ? { granterAuthority } : {}),
|
|
1567
|
+
...((() => { const ps = discoverPreconditionSetups(mod, gated); return ps.length > 0 ? { preconditionSetups: ps } : {}; })()),
|
|
1432
1568
|
};
|
|
1433
1569
|
}
|
|
1434
1570
|
|
|
1571
|
+
/**
|
|
1572
|
+
* MULTI-TARGET relation-bootstrap discovery (co2 ask #2). The singular {@link discoverRelationBootstrapLeg}
|
|
1573
|
+
* proves ONE gated create per domain; this runs TARGET-SPECIFIC discovery for EVERY gated create/write, so a
|
|
1574
|
+
* domain with (say) owner-, editor-, and publisher-gated creates emits ONE positive authority leg PER gated
|
|
1575
|
+
* create instead of proving one and falsely labelling the rest `no seedable authority path found`. Each
|
|
1576
|
+
* target reuses the same granter/seed/prerequisite discovery via the `gatedOverride` lane. A target with no
|
|
1577
|
+
* discoverable path is simply absent from the result (the taxonomy audit then names ONLY the real gaps).
|
|
1578
|
+
* De-duplicated by `gatedDirectiveId` (a target proven once is not re-emitted).
|
|
1579
|
+
*/
|
|
1580
|
+
export function discoverRelationBootstrapLegs(mod: DomainModule): RelationBootstrapLeg[] {
|
|
1581
|
+
const directives = mod.directives as Directive<unknown>[];
|
|
1582
|
+
const isGatedProvable = (d: Directive<unknown> | undefined): boolean =>
|
|
1583
|
+
d?.requiresRelationGate !== undefined &&
|
|
1584
|
+
typeof d.payloadSchema === "object" &&
|
|
1585
|
+
d.birthsChild !== true &&
|
|
1586
|
+
d.attestationAuthorizedGate === undefined;
|
|
1587
|
+
const legs: RelationBootstrapLeg[] = [];
|
|
1588
|
+
const seen = new Set<string>();
|
|
1589
|
+
for (const target of directives.filter(isGatedProvable)) {
|
|
1590
|
+
if (seen.has(target.id)) continue;
|
|
1591
|
+
let leg: RelationBootstrapLeg | undefined;
|
|
1592
|
+
try {
|
|
1593
|
+
leg = discoverRelationBootstrapLeg(mod, target);
|
|
1594
|
+
} catch {
|
|
1595
|
+
leg = undefined;
|
|
1596
|
+
}
|
|
1597
|
+
if (leg !== undefined) {
|
|
1598
|
+
legs.push(leg);
|
|
1599
|
+
seen.add(leg.gatedDirectiveId);
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
return legs;
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1435
1605
|
/** A discovered layer-3 SEED directive: a sibling (non-gated, non-birth) whose plan writes relation tuples,
|
|
1436
1606
|
* so replaying it SEATS relation authority the gate's rewrite may consume. Ranked by how many relations it
|
|
1437
1607
|
* writes (a broad genesis/owner seed outranks a single-relation grant). `undefined` ⇒ no such directive. */
|
|
@@ -1978,6 +2148,10 @@ export interface ProofLegs {
|
|
|
1978
2148
|
* discoverable granter of R (a `.grants(R)` marker, or a plan-probed granting `writeTuple`). Proves the
|
|
1979
2149
|
* gated write SUCCEEDS once R is granted (the seed-then-prove positive). Additive: absent ⇒ older/ungated. */
|
|
1980
2150
|
readonly relationBootstrap?: RelationBootstrapLeg;
|
|
2151
|
+
/** MULTI-TARGET (co2 #2) — one authority leg PER gated create/write when the domain has several. Present
|
|
2152
|
+
* iff >1 was discovered; `relationBootstrap` is `relationBootstraps[0]`. Readers should treat
|
|
2153
|
+
* `relationBootstraps ?? (relationBootstrap ? [relationBootstrap] : [])` as the canonical set. */
|
|
2154
|
+
readonly relationBootstraps?: readonly RelationBootstrapLeg[];
|
|
1981
2155
|
}
|
|
1982
2156
|
|
|
1983
2157
|
/** Synthesize the standalone-create offline legs for one domain module, or `null` when it has no
|
|
@@ -2161,12 +2335,14 @@ export function legsForDomain(mod: DomainModule, modules: readonly DomainModule[
|
|
|
2161
2335
|
|
|
2162
2336
|
// The relation-bootstrap leg (when the domain has a `.requires(R)`-gated directive AND a discoverable
|
|
2163
2337
|
// granter of R). FAIL SOFT: no granter → no leg (the negative conformance leg still covers the gate).
|
|
2164
|
-
|
|
2338
|
+
// MULTI-TARGET (co2 #2): discover one authority leg per gated create/write, not just the first.
|
|
2339
|
+
let relationBootstraps: RelationBootstrapLeg[] = [];
|
|
2165
2340
|
try {
|
|
2166
|
-
|
|
2341
|
+
relationBootstraps = discoverRelationBootstrapLegs(mod);
|
|
2167
2342
|
} catch {
|
|
2168
|
-
|
|
2343
|
+
relationBootstraps = [];
|
|
2169
2344
|
}
|
|
2345
|
+
const relationBootstrap: RelationBootstrapLeg | undefined = relationBootstraps[0];
|
|
2170
2346
|
|
|
2171
2347
|
const legs: ProofKind[] = ["compile-valid"];
|
|
2172
2348
|
if (standalone !== null) legs.push("standalone-provable");
|
|
@@ -2192,7 +2368,7 @@ export function legsForDomain(mod: DomainModule, modules: readonly DomainModule[
|
|
|
2192
2368
|
if (standalone.directiveId !== undefined) provenCreateIds.add(standalone.directiveId);
|
|
2193
2369
|
for (const ec of standalone.extraCreates ?? []) provenCreateIds.add(ec.directiveId);
|
|
2194
2370
|
}
|
|
2195
|
-
|
|
2371
|
+
for (const rb of relationBootstraps) provenCreateIds.add(rb.gatedDirectiveId);
|
|
2196
2372
|
for (const d of mod.directives as Directive<unknown>[]) {
|
|
2197
2373
|
if (d?.marker !== "creates" || typeof d.payloadSchema !== "object" || d.birthsChild === true) continue;
|
|
2198
2374
|
if (provenCreateIds.has(d.id)) continue; // proven by some leg — nothing to name
|
|
@@ -2242,6 +2418,7 @@ export function legsForDomain(mod: DomainModule, modules: readonly DomainModule[
|
|
|
2242
2418
|
...(childBirthWithSetup !== undefined ? { childBirth: childBirthWithSetup } : {}),
|
|
2243
2419
|
...(childBirths.length > 1 ? { childBirths } : {}),
|
|
2244
2420
|
...(relationBootstrap !== undefined ? { relationBootstrap } : {}),
|
|
2421
|
+
...(relationBootstraps.length > 1 ? { relationBootstraps } : {}),
|
|
2245
2422
|
};
|
|
2246
2423
|
}
|
|
2247
2424
|
|
|
@@ -2579,7 +2756,7 @@ function generateBirthOnlyProof(
|
|
|
2579
2756
|
// non-birth directive whose authority is established by the birth genesis (co2 home's updateHomeProfile
|
|
2580
2757
|
// via birthHome→seedHomeCreator). Emit it so the birth-only proof names it (the born-context proof itself
|
|
2581
2758
|
// runs in the offline `githolon proof` lane). No-op when the domain has no gated directive.
|
|
2582
|
-
...
|
|
2759
|
+
...relationSeedLegsLines(primaryLegs, factory, n, diagnoseRelationBootstrapGap(mod)),
|
|
2583
2760
|
`console.log(\`\\nALL GREEN — generated proof for "${opts.packageName}": deploy → child birth (\${WS} → the born child) → verified\`);`,
|
|
2584
2761
|
``,
|
|
2585
2762
|
...v2CatchFinally(),
|
|
@@ -2749,8 +2926,6 @@ export function generateTsProof(modules: readonly DomainModule[], opts: TsProofO
|
|
|
2749
2926
|
// THE RELATION-BOOTSTRAP LEG (the seed-then-prove positive): grant relation R to the proof actor, then prove
|
|
2750
2927
|
// a `.requires(R)`-gated write SUCCEEDS under that actor. `undefined` ⇒ no gated directive or no discoverable
|
|
2751
2928
|
// granter (the negative conformance leg still runs). Comes from the SAME `legsForDomain` synthesis (marker).
|
|
2752
|
-
const relationBootstrap = primaryLegs.relationBootstrap;
|
|
2753
|
-
|
|
2754
2929
|
// Workspace-name slug: any package name → a safe, recognizable ws prefix.
|
|
2755
2930
|
const slug =
|
|
2756
2931
|
opts.packageName
|
|
@@ -3015,7 +3190,7 @@ export function generateTsProof(modules: readonly DomainModule[], opts: TsProofO
|
|
|
3015
3190
|
// the proof actor, then prove the gated write SUCCEEDS under that actor. Runs on the LOCAL holon (fetch
|
|
3016
3191
|
// re-trapped). When no granter is discoverable but a gated directive exists, emits a CLEAR diagnostic naming
|
|
3017
3192
|
// exactly what's missing (the negative conformance leg still covers the gate).
|
|
3018
|
-
out.push(...
|
|
3193
|
+
out.push(...relationSeedLegsLines(primaryLegs, factory, n, diagnoseRelationBootstrapGap(mod)));
|
|
3019
3194
|
|
|
3020
3195
|
// THE CHILD-BIRTH LEG (G3, gap 2): when the primary domain has a `.births()` directive, prove the birth
|
|
3021
3196
|
// END-TO-END against the cloud — drive the parent birth offer, then read the BORN child workspace it spawns.
|
package/src/dart_barrel_guard.ts
CHANGED
|
@@ -31,8 +31,11 @@ export interface BarrelExportedFile {
|
|
|
31
31
|
* Private (`_`-prefixed) names never leak through an `export`, so they cannot collide — excluded. */
|
|
32
32
|
export function publicDartDeclarations(src: string): Set<string> {
|
|
33
33
|
const names = new Set<string>();
|
|
34
|
+
// `extension type const <Id>(…)` is legal Dart — the optional `const` modifier sits between `extension
|
|
35
|
+
// type` and the identifier. Skip it so the captured name is the TYPE, not the keyword `const` (co2: the
|
|
36
|
+
// generated `extension type const CatalogueListingFieldValueId(String value)` was misread as a type `const`).
|
|
34
37
|
const re =
|
|
35
|
-
/^(?:abstract\s+|base\s+|final\s+|sealed\s+|interface\s+|mixin\s+)*(?:class|enum|mixin|extension type|typedef)\s+([A-Za-z][A-Za-z0-9_]*)/gm;
|
|
38
|
+
/^(?:abstract\s+|base\s+|final\s+|sealed\s+|interface\s+|mixin\s+)*(?:class|enum|mixin|extension type|typedef)\s+(?:const\s+)?([A-Za-z][A-Za-z0-9_]*)/gm;
|
|
36
39
|
for (let m = re.exec(src); m !== null; m = re.exec(src)) {
|
|
37
40
|
if (!m[1]!.startsWith("_")) names.add(m[1]!);
|
|
38
41
|
}
|
package/src/framework/home.ts
CHANGED
|
@@ -35,6 +35,8 @@ import { query } from "../query.js";
|
|
|
35
35
|
import { birth } from "../births.js";
|
|
36
36
|
import { workspaceInvariant, refAs } from "./workspace_invariant.js";
|
|
37
37
|
import { identityRelations } from "./signing_identity.js";
|
|
38
|
+
import { writeTuple } from "./relations.js";
|
|
39
|
+
import { relationType, self, user, selfObject } from "../relation_schema.js";
|
|
38
40
|
import { requireParentAttestedBirth, type ParentAttestedBirthMarker } from "../births.js";
|
|
39
41
|
import { GenesisWarrant, seedGenesis } from "./genesis.js";
|
|
40
42
|
|
|
@@ -77,10 +79,38 @@ export const HomeOwner = aggregate("HomeOwner", {
|
|
|
77
79
|
bornAt: t.string().merge(Lww),
|
|
78
80
|
});
|
|
79
81
|
|
|
80
|
-
/**
|
|
82
|
+
/**
|
|
83
|
+
* THE HOME-OWNER RELATION (the ReBAC authority spine, co2 ask #1). The {@link HomeOwner} aggregate is the
|
|
84
|
+
* ownership RECORD; this relation is the AUTHORIZATION spine a tenant can compute from. `seedHomeOwner` seats
|
|
85
|
+
* `(home:self, homeOwner, user:<owner>)` ATOMICALLY inside the same child genesis (before the warrant closes
|
|
86
|
+
* bootstrap), so a canonical `birthHome` yields a home whose owner passes role gates from intent 0 — a tenant
|
|
87
|
+
* declares `estateCreator = computed(homeOwner)` WITHOUT copying birth mechanics. Self-bound (object `home:
|
|
88
|
+
* self`, subject the owner) — the {@link homeNameBindsOwner} squat invariant already proves the owner is the
|
|
89
|
+
* rightful one, so no separate grant is needed.
|
|
90
|
+
*/
|
|
91
|
+
export const homeRelations = relationType("home").relation("homeOwner", {
|
|
92
|
+
rewrite: self(),
|
|
93
|
+
allow: [user()],
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
/** The framework PRIMITIVE plan: stamp the home's owner + its self-committed name (singleton at the fixed id),
|
|
97
|
+
* AND atomically seat the canonical `homeOwner` relation tuple for the same self-bound principal (co2 #1). */
|
|
81
98
|
export function writeHomeOwner(p: { principal: string; homeName: string; bornAt: string }): PlannedOp[] {
|
|
82
99
|
const h = instance(HomeOwner, HOME_OWNER_ID);
|
|
83
|
-
return [
|
|
100
|
+
return [
|
|
101
|
+
set(h, "principal", p.principal),
|
|
102
|
+
set(h, "homeName", p.homeName),
|
|
103
|
+
set(h, "bornAt", p.bornAt),
|
|
104
|
+
// ATOMIC ReBAC seat — the owner subject is the canonical `user:<uid>` on HomeOwner.principal; grantedBy is
|
|
105
|
+
// the owner itself (self-bound, no external granter) and grantedAt rides the caller-stamped bornAt.
|
|
106
|
+
...writeTuple({
|
|
107
|
+
object: selfObject(homeRelations),
|
|
108
|
+
relation: "homeOwner",
|
|
109
|
+
subject: p.principal,
|
|
110
|
+
grantedBy: p.principal,
|
|
111
|
+
grantedAt: p.bornAt,
|
|
112
|
+
}),
|
|
113
|
+
];
|
|
84
114
|
}
|
|
85
115
|
|
|
86
116
|
/**
|