@githolon/dsl 0.86.1 → 0.88.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 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
- hint: 'the session workspace is not reachable/mounted on this plane: $e',
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, this._sessionNames,
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
- : _devicePublics = devicePublics,
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
+ }
@@ -11,3 +11,4 @@ library;
11
11
 
12
12
  export 'src/node_runner_transport.dart';
13
13
  export 'src/test_harness.dart';
14
+ export 'src/dev_cloud.dart'; // NomosTestCloud (co2 #4 real-sync harness)
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.55.2
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.86.1",
3
+ "version": "0.88.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",
@@ -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` — i.e. does that directive carry a PREREQUISITE (a
1152
- * declared invariant reading state another step must establish first)? A standalone replay of such a
1153
- * directive can FALSE-fail (the prior state is absent), so a born domain must prove via the genesis path. */
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
- return (mod.workspaceInvariants ?? []).some((inv) => inv.on === directiveId);
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
- const findGranterFor = (rel: string): { granter: Directive<unknown>; via: "marker" | "inferred" } | undefined => {
1297
- const marked = candidates.find((d) => (d.grantsRelation ?? []).includes(rel));
1298
- if (marked !== undefined) return { granter: marked, via: "marker" };
1299
- const inferred = candidates.find((d) => probeGrantsRelation(d, rel, `${domain}/${d.id}`));
1300
- return inferred !== undefined ? { granter: inferred, via: "inferred" } : undefined;
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
- let found = findGranterFor(relation);
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
- let relationBootstrap: RelationBootstrapLeg | undefined;
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
- relationBootstrap = discoverRelationBootstrapLeg(mod);
2341
+ relationBootstraps = discoverRelationBootstrapLegs(mod);
2167
2342
  } catch {
2168
- relationBootstrap = undefined;
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
- if (relationBootstrap !== undefined) provenCreateIds.add(relationBootstrap.gatedDirectiveId);
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
- ...relationSeedLegLines(primaryLegs.relationBootstrap, factory, n, primaryLegs.relationBootstrap === undefined ? diagnoseRelationBootstrapGap(mod) : undefined),
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(...relationSeedLegLines(relationBootstrap, factory, n, relationBootstrap === undefined ? diagnoseRelationBootstrapGap(mod) : undefined));
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.
@@ -1016,7 +1016,7 @@ const NOMOS_TYPES_DEP = "^0.4.1";
1016
1016
  // 0.48.x: session-bound clients (XClient.bind/NomosClientInterface/DomainClientSessionMismatch/
1017
1017
  // DomainNotInstalledForSession, engine.follow, pendingConflicts lanes) — the generated client
1018
1018
  // REQUIRES this surface. 0.48.1 fixes NomosSession.watchPendingConflicts delegation.
1019
- const NOMOS_CLIENT_DEP = "^0.55.0";
1019
+ const NOMOS_CLIENT_DEP = "^0.56.0";
1020
1020
  // DRIFT TRIPWIRE: pubspec_pins.test.ts asserts these equal the CURRENT versions in dsl/dart/pubspec.yaml
1021
1021
  // and dsl/dart_types/pubspec.yaml — a Dart release without this bump fails the suite, so tenants never
1022
1022
  // land back in dependency_overrides (the exact co2 report this closes, third occurrence).
@@ -2676,6 +2676,29 @@ async function main(): Promise<void> {
2676
2676
  typeof dartCfg === "object" && dartCfg.barrel !== undefined ? dartCfg.barrel : defaultBarrel;
2677
2677
  dartFiles = emitDart(domainModules, dartOut, domainHash, packageInterface, dartSupportImport, dartBarrel, nomosCompat, dartNames);
2678
2678
  }
2679
+ // COMPILE-VALID MUST MEAN COMPILABLE (co2): `githolon compile` reported success while the generated Dart
2680
+ // had hard compiler errors (a duplicate id symbol, missing contract-id defs). Run `dart analyze` over the
2681
+ // emitted Dart as a SYMBOL-RESOLUTION GATE — a real analysis ERROR fails the compile (with the analyzer's
2682
+ // own output), so a broken package never passes `mountable ✓`. Best-effort: skipped (with a note, never a
2683
+ // false red) when `dart` is absent or the package's deps aren't resolved yet (needs `pub get`). Opt out
2684
+ // with NOMOS_SKIP_DART_ANALYZE=1.
2685
+ if (dartOut !== undefined && process.env.NOMOS_SKIP_DART_ANALYZE !== "1") {
2686
+ try {
2687
+ execFileSync("dart", ["analyze", "--no-fatal-warnings", dartOut], { stdio: "pipe", encoding: "utf8" });
2688
+ console.log(` dart ✓ generated Dart analyzes clean (compile-valid ⇒ compilable)`);
2689
+ } catch (e) {
2690
+ const err = e as { status?: number; stdout?: string; stderr?: string; code?: string };
2691
+ const out = `${err.stdout ?? ""}${err.stderr ?? ""}`;
2692
+ if (err.code === "ENOENT") {
2693
+ console.log(` dart — analyze skipped (dart not on PATH); the generated Dart is UNVERIFIED`);
2694
+ } else if (/isn't a package dependency|Target of URI doesn't exist: 'package:|run .?(dart |flutter )?pub get|No pubspec\.yaml/i.test(out) && !/\berror •/.test(out)) {
2695
+ // Deps not resolved (pub get not run) rather than a codegen bug — skip, don't false-fail.
2696
+ console.log(` dart — analyze skipped (package deps not resolved — run \`dart pub get\` in ${path.relative(process.cwd(), dartOut)}); the generated Dart is UNVERIFIED`);
2697
+ } else {
2698
+ fail(`the generated Dart package does NOT compile — \`dart analyze\` found errors (compile-valid must mean compilable). Fix the law/codegen or set NOMOS_SKIP_DART_ANALYZE=1 to emit anyway:\n${out.trim()}`);
2699
+ }
2700
+ }
2701
+ }
2679
2702
  }
2680
2703
 
2681
2704
  // ── summary ──
@@ -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
  }
@@ -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
- /** The framework PRIMITIVE plan: stamp the home's owner + its self-committed name (singleton at the fixed id). */
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 [set(h, "principal", p.principal), set(h, "homeName", p.homeName), set(h, "bornAt", p.bornAt)];
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
  /**