@githolon/dsl 0.88.0 → 0.90.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.
@@ -5,7 +5,7 @@
5
5
  //
6
6
  // ```dart
7
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
8
+ // final harness = await NomosTestHarness.cloudBound(cloud: cloud, deployJsonPaths: [...], platformWorkspace: 'co2-platform', subject: 'user:test');
9
9
  // // ... author offline ...
10
10
  // cloud.setOnline(false); // PARTITION — the up leg can't reach custody
11
11
  // await harness.session('estate').sync(admit: true); // stays local; nothing lands remotely
@@ -33,6 +33,9 @@
33
33
  import 'dart:convert';
34
34
  import 'dart:io';
35
35
 
36
+ import 'package:crypto/crypto.dart' show sha256;
37
+
38
+ import 'dev_cloud.dart';
36
39
  import 'node_runner_transport.dart';
37
40
  import 'webview_bridge.dart' show NomosBridge, NomosSession;
38
41
 
@@ -147,6 +150,25 @@ class NomosTestHarness {
147
150
  /// When set, overrides EVERY phase's default deadline (see [HarnessPhaseTimeout]).
148
151
  final Duration? _phaseTimeout;
149
152
 
153
+ /// The test cloud a [cloudBound] harness runs against (null for the offline constructors). Partition it
154
+ /// with [NomosTestCloud.setOnline]; assert remote custody with [NomosTestCloud.remoteHead].
155
+ final NomosTestCloud? testCloud;
156
+
157
+ /// The HOME workspace a [cloudBound] harness mounted (born remotely by the platform ceremony). Null for
158
+ /// the offline constructors.
159
+ final String? homeWorkspace;
160
+
161
+ /// The mounted home as a [NomosSession] (the [cloudBound] app-shaped entry point — bind the generated
162
+ /// `HomeClient` to it). Throws for an offline harness (no home ceremony ran).
163
+ NomosSession get homeSession {
164
+ final ws = homeWorkspace;
165
+ if (ws == null) {
166
+ throw NomosTestHarnessException(
167
+ 'homeSession is only available on a cloudBound harness (this one was built by ephemeral/cluster)');
168
+ }
169
+ return session(ws);
170
+ }
171
+
150
172
  NomosTestHarness._(this.bridge, this.workspace, this._transport, this._workdir,
151
173
  this._onDiagnostic, List<String> sessionNames,
152
174
  {Map<String, String> devicePublics = const {},
@@ -154,7 +176,9 @@ class NomosTestHarness {
154
176
  Map<String, List<String>> lawHashes = const {},
155
177
  Map<String, String> snapshots = const {},
156
178
  _InstallLane? installLane,
157
- Duration? phaseTimeout})
179
+ Duration? phaseTimeout,
180
+ this.testCloud,
181
+ this.homeWorkspace})
158
182
  : _sessionNames = List.of(sessionNames), // GROWABLE — restoreWorkspace may add a fresh session
159
183
  _devicePublics = devicePublics,
160
184
  _lawHashes = Map.of(lawHashes),
@@ -613,6 +637,226 @@ class NomosTestHarness {
613
637
  }
614
638
  }
615
639
 
640
+ /// CLOUD-BOUND HARNESS (co2's real-app topology): a [NomosTestCloud] plays the remote control plane, the
641
+ /// REAL authenticated home-birth ceremony runs REMOTELY on [platformWorkspace], and the born home is
642
+ /// mounted into ONE local engine plane as a normal cloud-bound [NomosSession] ([homeSession]). From there
643
+ /// the app shape holds exactly: generated `.births()` directives run LOCALLY (the app never clones the
644
+ /// platform), children open as sibling sessions (`outcome.bornRefs.single.acquire(harness.bridge)`),
645
+ /// writes/projections work offline, and [converge] establishes remote custody parent-first when
646
+ /// connectivity returns ([NomosTestCloud.setOnline]). Entirely inside the test cloud's trust universe.
647
+ ///
648
+ /// ```dart
649
+ /// final cloud = await NomosTestCloud.start(serverEntry: devServer);
650
+ /// final h = await NomosTestHarness.cloudBound(
651
+ /// cloud: cloud, deployJsonPaths: ['build/co2.deploy.json'],
652
+ /// platformWorkspace: 'co2-platform', subject: 'user:test');
653
+ /// final home = h.homeSession; // the app's entry point
654
+ /// cloud.setOnline(false); // PARTITION
655
+ /// // ... local births + writes ...
656
+ /// cloud.setOnline(true);
657
+ /// await h.converge(); // home first, then locally-born children
658
+ /// expect((await cloud.remoteHead(home.workspace)).initialized, isTrue);
659
+ /// ```
660
+ static Future<NomosTestHarness> cloudBound({
661
+ required NomosTestCloud cloud,
662
+ required List<String> deployJsonPaths,
663
+ String platformWorkspace = 'test-platform',
664
+ required String subject,
665
+ String? authToken,
666
+ String clientId = 'test-runner',
667
+ String? nodeEntry,
668
+ String? resolveFrom,
669
+ String node = 'node',
670
+ bool autoTearDown = true,
671
+ void Function(Future<void> Function())? registerTearDown,
672
+ void Function(String line)? onDiagnostic,
673
+ Duration readyTimeout = const Duration(seconds: 30),
674
+ Duration connectTimeout = const Duration(seconds: 120),
675
+ Duration? phaseTimeout,
676
+ bool decoySession = true,
677
+ }) async {
678
+ if (deployJsonPaths.isEmpty) {
679
+ throw NomosTestHarnessException(
680
+ 'no deployJsonPaths — pass the build/<name>.deploy.json `npx githolon compile` emits');
681
+ }
682
+ final entry = nodeEntry ??
683
+ await _resolveEntry(node, resolveFrom ?? Directory.current.path);
684
+ final bareUid = subject.startsWith('user:') ? subject.substring(5) : subject;
685
+ final http = HttpClient();
686
+ Future<Map<String, Object?>> post(String path, Object? body,
687
+ {Map<String, String> headers = const {}}) async {
688
+ final req = await http.postUrl(Uri.parse('${cloud.origin}$path'));
689
+ req.headers.set('content-type', 'application/json');
690
+ req.headers.set('x-nomos-principal', bareUid);
691
+ if (authToken != null) req.headers.set('x-nomos-auth', authToken);
692
+ headers.forEach(req.headers.set);
693
+ if (body != null) req.add(utf8.encode(body is String ? body : jsonEncode(body)));
694
+ final resp = await req.close();
695
+ final text = await resp.transform(utf8.decoder).join();
696
+ final decoded = (jsonDecode(text) as Map).cast<String, Object?>();
697
+ if (resp.statusCode >= 300 || decoded['ok'] == false) {
698
+ throw NomosTestHarnessException(
699
+ 'cloudBound setup: POST $path → ${resp.statusCode}: ${text.substring(0, text.length.clamp(0, 400))}');
700
+ }
701
+ return decoded;
702
+ }
703
+
704
+ final workdir = await Directory.systemTemp.createTemp('nomos-harness-');
705
+ NodeRunnerTransport? transport;
706
+ try {
707
+ // 1/5 — the remote control plane: create the platform workspace + deploy the law under the test
708
+ // cloud's own trust universe (its K_root; nothing here touches production).
709
+ String? frameworkHash;
710
+ final lawHashes = <String>[];
711
+ await _phased(
712
+ phase: '1/5: create platform + deploy law on the test cloud',
713
+ workspace: platformWorkspace,
714
+ awaiting: 'POST /v2/workspaces + /domains',
715
+ timeout: phaseTimeout ?? _kInstallDeadline,
716
+ onDiagnostic: onDiagnostic,
717
+ run: () async {
718
+ final created = await post('/v2/workspaces/$platformWorkspace', null);
719
+ frameworkHash =
720
+ ((created['controller'] as Map?)?['domainHash'])?.toString();
721
+ for (final p in deployJsonPaths) {
722
+ final body = await File(p).readAsString();
723
+ await post('/v2/workspaces/$platformWorkspace/domains', body);
724
+ final summary = ((jsonDecode(body) as Map)['summary'] as Map?);
725
+ final h = summary?['domainHash']?.toString();
726
+ if (h != null && h.isNotEmpty) lawHashes.add(h);
727
+ }
728
+ });
729
+ if (frameworkHash == null || frameworkHash!.isEmpty) {
730
+ throw NomosTestHarnessException(
731
+ 'cloudBound: the test cloud returned no controller.domainHash — cannot run the birth ceremony (frameworkHash unknown)');
732
+ }
733
+ if (lawHashes.isEmpty) {
734
+ throw NomosTestHarnessException(
735
+ 'cloudBound: no deploy.json carried a summary.domainHash — cannot run the birth ceremony (lawHash unknown)');
736
+ }
737
+ // 2/5 — THE HOME CEREMONY, remotely: the platform's own gateway offer births the home (the ONE
738
+ // control-plane contact; the app lane below never touches the platform again).
739
+ String? home;
740
+ await _phased(
741
+ phase: '2/5: remote home-birth ceremony',
742
+ workspace: platformWorkspace,
743
+ awaiting: 'POST /v2/workspaces/$platformWorkspace/birthHome',
744
+ timeout: phaseTimeout ?? _kInstallDeadline,
745
+ onDiagnostic: onDiagnostic,
746
+ run: () async {
747
+ final out = await post('/v2/workspaces/$platformWorkspace/birthHome', {
748
+ 'owner': subject,
749
+ 'frameworkHash': frameworkHash,
750
+ 'lawHash': lawHashes.first,
751
+ 'bornAt': DateTime.now().toUtc().toIso8601String(),
752
+ });
753
+ final born = (out['born'] as List?) ?? const [];
754
+ final bornNames = born.map((b) => (b as Map)['workspace']?.toString()).whereType<String>().where((w) => w.isNotEmpty).toList();
755
+ home = bornNames.isEmpty ? null : bornNames.first;
756
+ home ??= _homeNameOf(subject); // deterministic fallback (home-<sha256(subject)[..40]>)
757
+ });
758
+ // 3/5 — the LOCAL engine plane, CLOUD-BOUND (no offline env: the runner pulls the runtime + the
759
+ // home's custody from the test cloud like a real device).
760
+ transport = await _phased(
761
+ phase: '3/5: spawn the node runner (cloud-bound)',
762
+ awaiting: 'Process.start($node)',
763
+ timeout: phaseTimeout ?? _kSpawnDeadline,
764
+ onDiagnostic: onDiagnostic,
765
+ run: () => NodeRunnerTransport.start(
766
+ entry: entry, resolveFrom: resolveFrom, node: node, onDiagnostic: onDiagnostic));
767
+ final plane = NomosBridge(transport!);
768
+ await _phased(
769
+ phase: '4/5: runner ready',
770
+ awaiting: "event:'ready'",
771
+ timeout: phaseTimeout ?? readyTimeout,
772
+ onDiagnostic: onDiagnostic,
773
+ run: () => plane.ready);
774
+ // 5/5 — mount the born home as a normal cloud-bound session (clone from the test cloud).
775
+ await _phased(
776
+ phase: '5/5: mount the born home',
777
+ workspace: home,
778
+ awaiting: 'connect(cloud:, workspace: $home)',
779
+ timeout: phaseTimeout ?? connectTimeout,
780
+ onDiagnostic: onDiagnostic,
781
+ run: () => plane.connect(
782
+ cloud: cloud.origin,
783
+ workspace: home!,
784
+ clientId: clientId,
785
+ authToken: authToken,
786
+ ));
787
+ // NO DECOY on a cloudBound harness: the app is genuinely multi-workspace (the home plus every
788
+ // locally-born child), so the plane is never sole-session — the decoy the offline harness
789
+ // needs (to break single-session inference) would just be a spurious local-only workspace here.
790
+ final _ = decoySession; // (accepted for signature parity; a cloudBound plane needs no decoy)
791
+ final harness = NomosTestHarness._(
792
+ plane.session(home!),
793
+ home!,
794
+ transport,
795
+ workdir,
796
+ onDiagnostic,
797
+ [home!],
798
+ frameworkHash: frameworkHash,
799
+ lawHashes: {home!: lawHashes},
800
+ phaseTimeout: phaseTimeout,
801
+ testCloud: cloud,
802
+ homeWorkspace: home,
803
+ );
804
+ if (autoTearDown && registerTearDown != null) {
805
+ registerTearDown(() => harness.dispose());
806
+ }
807
+ return harness;
808
+ } catch (e) {
809
+ try { transport?.dispose(); } catch (_) {}
810
+ try { await workdir.delete(recursive: true); } catch (_) {}
811
+ rethrow;
812
+ } finally {
813
+ http.close(force: true);
814
+ }
815
+ }
816
+
817
+ /// The framework home-slot derivation (`home-<sha256(subject)[0..40]>`) — the fallback when the birth
818
+ /// offer response carries no `born` surface (an older dev cloud).
819
+ static String _homeNameOf(String subject) =>
820
+ 'home-${sha256.convert(utf8.encode(subject)).toString().substring(0, 40)}';
821
+
822
+ /// CONVERGE local custody with the test cloud (co2 topology step 8): sync every connected session —
823
+ /// PARENT-FIRST by connect order, so the home's chain lands remotely and its birth offer-effects create
824
+ /// each locally-born child's remote custody BEFORE that child's own sync converges against it. Repeats
825
+ /// until every session reports neither `pushFailed` nor `stillAhead`, or [timeout] (then throws with the
826
+ /// per-workspace last verdicts). Returns the final sync result per workspace.
827
+ Future<Map<String, Map<String, Object?>>> converge(
828
+ {Duration timeout = const Duration(seconds: 120), List<String>? workspaces}) async {
829
+ // Every LIVE session on the plane (not the harness's construction-time list — a locally-born child
830
+ // acquired after construction must converge too), PARENT-FIRST: the home syncs before its children, so
831
+ // its birth offer-effect creates each child's remote custody BEFORE that child pushes its own writes.
832
+ final live = bridge.connectedSessions.where((w) => w != _decoyWorkspace).toList();
833
+ final hw = homeWorkspace;
834
+ if (hw != null && live.remove(hw)) live.insert(0, hw); // the parent leads
835
+ final targets = workspaces ?? live;
836
+ final last = <String, Map<String, Object?>>{};
837
+ final pending = List.of(targets);
838
+ final deadline = DateTime.now().add(timeout);
839
+ while (pending.isNotEmpty) {
840
+ for (final ws in List.of(pending)) {
841
+ try {
842
+ final r = (await bridge.session(ws).sync(admit: true)).cast<String, Object?>();
843
+ last[ws] = r;
844
+ if (r['pushFailed'] != true && r['stillAhead'] != true) pending.remove(ws);
845
+ } catch (e) {
846
+ last[ws] = {'error': e.toString()};
847
+ }
848
+ }
849
+ if (pending.isEmpty) break;
850
+ if (DateTime.now().isAfter(deadline)) {
851
+ throw NomosTestHarnessException(
852
+ 'converge: ${pending.length} workspace(s) did not converge within ${timeout.inSeconds}s — '
853
+ '${pending.map((w) => '$w: ${jsonEncode(last[w] ?? {})}').join('; ')}');
854
+ }
855
+ await Future<void>.delayed(const Duration(milliseconds: 500));
856
+ }
857
+ return last;
858
+ }
859
+
616
860
  // ── CUSTODY DIAGNOSTIC HELPERS (architecture/session_bound_clients.md ask 9) ──
617
861
 
618
862
  /// The raw multi-workspace plane under [bridge] (the session handle a single-workspace 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.56.0
14
+ version: 0.57.0
15
15
 
16
16
  environment:
17
17
  sdk: ^3.5.0
@@ -0,0 +1,140 @@
1
+ // THE CLOUD-BOUND TOPOLOGY ACCEPTANCE (co2's exact 10-step ask) — the real-app shape end-to-end:
2
+ //
3
+ // 1. the remote platform births the home (the ONE control-plane contact — the authenticated ceremony);
4
+ // 2. the app mounts the home locally (a normal cloud-bound NomosSession; co2-platform is never cloned);
5
+ // 3. the network PARTITIONS (NomosTestCloud.setOnline(false));
6
+ // 4. the home births the estate LOCALLY (`birthEstateWorkspace` — a `.births()` offer on this device);
7
+ // 5. the estate opens as a sibling session and ACCEPTS LOCAL WRITES while offline;
8
+ // 6. UI-facing queries return those writes immediately (no sync awaited, ever);
9
+ // 7. the network HEALS;
10
+ // 8. `harness.converge()` establishes remote custody PARENT-FIRST (the home's synced chain folds the
11
+ // birth offer-effect remotely, creating the estate's custody; then the estate converges against it);
12
+ // 9. a FRESH observer (the dev cloud's own remote projection) sees the estate writes;
13
+ // 10. custody is REAL on both: remote heads born with commits (the dev cloud adopted the chains through
14
+ // the same gate everything else uses — replay IS validation).
15
+ //
16
+ // Entirely inside the test cloud's trust universe: nothing here touches production K_root or custody.
17
+ import 'dart:convert';
18
+ import 'dart:io';
19
+
20
+ import 'package:nomos_client/nomos_headless.dart';
21
+ import 'package:test/test.dart';
22
+
23
+ String get _root => '${Directory.current.path}/../..';
24
+ String get _repoEntry => '$_root/cloud/web-client/runner/node-entry.mjs';
25
+ String get _devServer => '$_root/cloud/dev-server/server.mjs';
26
+ String get _deploy =>
27
+ '$_root/bench/scale/fixtures/cloud-topology/build/cloud-topology.deploy.json';
28
+ const _subject = 'user:topology-test';
29
+
30
+ String? _missingPrereq() {
31
+ if (!File(_repoEntry).existsSync()) return 'in-repo runner not found at $_repoEntry';
32
+ if (!File(_devServer).existsSync()) return 'in-repo dev server not found at $_devServer';
33
+ if (!File(_deploy).existsSync()) {
34
+ return 'compile the fixture: cd bench/scale/fixtures/cloud-topology && node ../../../../dsl/compile_package.mjs — missing $_deploy';
35
+ }
36
+ return null;
37
+ }
38
+
39
+ void main() {
40
+ test('the co2 topology: remote home ceremony → local estate birth offline → converge → remote custody',
41
+ () async {
42
+ final skip = _missingPrereq();
43
+ if (skip != null) {
44
+ markTestSkipped(skip);
45
+ return;
46
+ }
47
+ final diag = Platform.environment['NOMOS_HARNESS_DIAG'] == '1'
48
+ ? (String l) => stderr.writeln(l)
49
+ : null;
50
+ final cloud = await NomosTestCloud.start(serverEntry: _devServer, onDiagnostic: diag);
51
+ NomosTestHarness? harness;
52
+ try {
53
+ // 1+2 — the remote ceremony + the local mount, in one constructor (the app's boot path).
54
+ harness = await NomosTestHarness.cloudBound(
55
+ cloud: cloud,
56
+ deployJsonPaths: [_deploy],
57
+ platformWorkspace: 'topology-platform',
58
+ subject: _subject,
59
+ nodeEntry: _repoEntry,
60
+ onDiagnostic: diag,
61
+ );
62
+ final home = harness.homeSession;
63
+ expect(home.workspace, startsWith('home-'),
64
+ reason: 'the remote ceremony born the deterministic home slot');
65
+ final remoteHome = await cloud.remoteHead(home.workspace);
66
+ expect(remoteHome.initialized, isTrue,
67
+ reason: 'the home was born REMOTELY (custody exists before the app ever mounted it)');
68
+
69
+ // 3 — PARTITION.
70
+ cloud.setOnline(false);
71
+
72
+ // 4 — the home births the estate LOCALLY (the app lane never needs the network).
73
+ final outcome = await harness.bridge.session(home.workspace).offerCreates(
74
+ domain: 'home',
75
+ directiveId: 'birthEstateWorkspace',
76
+ payload: {
77
+ 'owner': _subject,
78
+ 'frameworkHash': harness.frameworkHash,
79
+ 'lawHash': harness.lawHashes(home.workspace).first,
80
+ 'bornAt': '2026-07-14T00:00:00Z',
81
+ },
82
+ actor: _subject,
83
+ );
84
+ expect(outcome.born, isNotEmpty, reason: 'the local .births() offer spawned the estate');
85
+ final estateWs = outcome.born.single;
86
+ expect(estateWs, startsWith('estate-'));
87
+
88
+ // 5 — the estate opens as a sibling session and accepts a LOCAL WRITE while offline.
89
+ final estate = await harness.bridge.acquireSession(estateWs);
90
+ await estate.offerCreates(
91
+ domain: 'estate',
92
+ directiveId: 'writeEstateNote',
93
+ payload: {'author': _subject, 'body': 'written offline', 'writtenAt': '2026-07-14T00:00:01Z'},
94
+ actor: _subject,
95
+ );
96
+
97
+ // 6 — the UI-facing query returns the write IMMEDIATELY (local projection; nothing awaited sync).
98
+ final localRows = await estate.query('notesByAuthor', {'author': _subject});
99
+ expect(localRows, hasLength(1));
100
+ expect((localRows.single['data'] as Map)['body'], 'written offline');
101
+
102
+ // …and the estate has NO remote custody yet — the partition is real.
103
+ final remoteEstateOffline = await cloud.remoteHead(estateWs);
104
+ expect(remoteEstateOffline.initialized, isFalse,
105
+ reason: 'while partitioned, the locally-born estate must have NO remote custody');
106
+
107
+ // 7+8 — HEAL, then converge parent-first (home's chain lands; its birth offer-effect creates the
108
+ // estate's remote custody; the estate then converges against it).
109
+ cloud.setOnline(true);
110
+ await harness.converge(timeout: const Duration(seconds: 180));
111
+
112
+ // 9 — a FRESH observer: the dev cloud's own remote projection serves the estate write.
113
+ final http = HttpClient();
114
+ try {
115
+ final req = await http.getUrl(Uri.parse(
116
+ '${cloud.origin}/v2/workspaces/$estateWs/notesByAuthor?author=${Uri.encodeQueryComponent(_subject)}'));
117
+ final resp = await req.close();
118
+ final body = jsonDecode(await resp.transform(utf8.decoder).join());
119
+ final rows = ((body as Map)['rows'] as List?) ?? const [];
120
+ expect(rows, hasLength(1),
121
+ reason: 'the remote projection (a fresh observer) serves the offline-written note after converge');
122
+ expect(((rows.single as Map)['data'] as Map)['body'], 'written offline');
123
+ } finally {
124
+ http.close(force: true);
125
+ }
126
+
127
+ // 10 — custody is REAL on both (the dev cloud adopted the chains through the one gate).
128
+ final remoteHome2 = await cloud.remoteHead(home.workspace);
129
+ final remoteEstate2 = await cloud.remoteHead(estateWs);
130
+ expect(remoteHome2.initialized, isTrue);
131
+ expect(remoteHome2.commits, greaterThan(0));
132
+ expect(remoteEstate2.initialized, isTrue,
133
+ reason: 'converge established the estate\'s remote custody (parent birth folded remotely)');
134
+ expect(remoteEstate2.commits, greaterThan(0));
135
+ } finally {
136
+ try { await harness?.dispose(); } catch (_) {}
137
+ await cloud.stop();
138
+ }
139
+ }, timeout: const Timeout(Duration(minutes: 6)));
140
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.88.0",
3
+ "version": "0.90.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",
@@ -2139,8 +2139,32 @@ function emitDartContractClass(c: import("./contract.js").ContractHandle): strin
2139
2139
  * import header so `compile_package_main`'s uniform rewrite (→ vendored relative / → nomos_types) applies;
2140
2140
  * the classes themselves reference only nomos_types primitives (+ shared VO classes), so the file is PURE.
2141
2141
  */
2142
- export function generateDartContracts(contracts: readonly import("./contract.js").ContractHandle[]): string {
2142
+ export function generateDartContracts(
2143
+ contracts: readonly import("./contract.js").ContractHandle[],
2144
+ opts: {
2145
+ /** The shared `nomos_framework.dart` import URI when that file exists — contract-used VOs (e.g. a
2146
+ * `ReferenceCode.field()`) render inside the DTOs, and their classes are HOISTED there (layer-zero),
2147
+ * so contracts.dart must import it (co2 #117b). */
2148
+ readonly frameworkImportUri?: string;
2149
+ /** Local aggregates' typed-id class names — a contract named after a local aggregate must NOT
2150
+ * re-declare its `<X>Id` (the aggregate's own class, with `.fromRow`, already exists). */
2151
+ readonly aggregateIdClassNames?: ReadonlySet<string>;
2152
+ } = {},
2153
+ ): string {
2143
2154
  const sorted = [...contracts].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
2155
+ // CONTRACT TYPED IDS (co2 #117a): `idOf(SomeContract)` in a payload types the field `<ContractId>Id` —
2156
+ // exactly like an aggregate's typed id — but a contract has no aggregate emitter to declare the class.
2157
+ // Emit each contract's id as a ZERO-COST String extension type here (the shared layer every domain
2158
+ // imports): a const ctor from the wire string + `.value` — the exact surface the generated payloads use.
2159
+ const idTypes = sorted
2160
+ .map((c) => typedIdClassForAggregateType(c.id))
2161
+ .filter((cls) => !(opts.aggregateIdClassNames?.has(cls) ?? false))
2162
+ .map((cls) => [
2163
+ `/// TYPED CONTRACT ID \`${cls}\` — a ZERO-COST nominal String (Dart extension type): the id of the`,
2164
+ `/// foreign aggregate behind the contract, as \`idOf(<Contract>)\` payload fields carry it. Construct`,
2165
+ `/// it from a \`ForeignRef.id\` / a queried row id: \`${cls}(ref.id)\`; \`.value\` is the wire string.`,
2166
+ `extension type const ${cls}(String value) {}`,
2167
+ ].join("\n"));
2144
2168
  return [
2145
2169
  `// GENERATED by nomos-compile — the package's shared domain CONTRACTS (public DTOs). DO NOT EDIT.`,
2146
2170
  `// Every domain depends on this shared layer; no domain imports another's concrete types (the acyclic graph).`,
@@ -2149,6 +2173,9 @@ export function generateDartContracts(contracts: readonly import("./contract.js"
2149
2173
  `library;`,
2150
2174
  ``,
2151
2175
  `import 'package:nomos_client/nomos_client.dart';`,
2176
+ ...(opts.frameworkImportUri !== undefined ? [`import '${opts.frameworkImportUri}';`] : []),
2177
+ ``,
2178
+ ...idTypes,
2152
2179
  ``,
2153
2180
  ...sorted.map(emitDartContractClass),
2154
2181
  ``,
@@ -4250,7 +4277,9 @@ export function generateDartDomain(mod: DomainModule, opts: GenerateDartDomainOp
4250
4277
  const brandNames = new Set<string>();
4251
4278
  const walkUnitField = (f: ValueObjectFieldDescriptor): void => {
4252
4279
  if (f.type === "number" && f.unit !== undefined) unitNames.add(f.unit);
4253
- else if (f.type === "string" && f.brand !== undefined) brandNames.add(f.brand);
4280
+ // A standalone brandedId declares its own extension type; an idOf REFERENCE does NOT — the aggregate
4281
+ // emits the canonical `<Agg>Id` class (with `.fromRow`), so re-declaring it here dup'd the symbol (co2).
4282
+ else if (f.type === "string" && f.brand !== undefined && f.idOf === undefined) brandNames.add(f.brand);
4254
4283
  else if (f.type === "array") walkUnitField(f.items);
4255
4284
  else if (f.type === "map") walkUnitField(f.values);
4256
4285
  };
@@ -48,7 +48,7 @@ import { clearDirectiveId } from "./clear_directive.js";
48
48
  import { voMetaOf, voSample } from "./value_object.js";
49
49
  import { mintedCreateField } from "./workspace_routing.js";
50
50
  import { deterministicPorts } from "./ctx.js";
51
- import { __setDispatchDomain } from "./framework/relations.js";
51
+ import { __setDispatchDomain, __setDispatchContext } from "./framework/relations.js";
52
52
  import { __resetBirths, __drainBirths } from "./births.js";
53
53
  import { __resetAuthoring, __drainAuthoring } from "./authoring.js";
54
54
  import { __resetReads, __drainReads } from "./read.js";
@@ -127,10 +127,20 @@ function uniquifyBirthPayload(
127
127
  disc: string,
128
128
  schema: z.ZodTypeAny | undefined,
129
129
  ): Record<string, unknown> {
130
+ // Deterministic 12-hex discriminator for FORMAT-CONSTRAINED samples (a uuid-shaped subject can't take a
131
+ // `-<disc>` suffix — the field schema would refuse it; vary the uuid TAIL instead, format-preserving).
132
+ const discHex = [...disc].reduce((h, ch) => (Math.imul(h, 31) + ch.charCodeAt(0)) >>> 0, 7).toString(16).padStart(8, "0").repeat(2).slice(0, 12);
130
133
  const suffixed: Record<string, unknown> = {};
131
134
  let changed = false;
132
135
  for (const [k, v] of Object.entries(payload)) {
133
136
  if (typeof v === "string" && v.startsWith("sample-")) { suffixed[k] = `${v}-${disc}`; changed = true; }
137
+ else if (typeof v === "string" && v.includes(ZERO_UUID)) {
138
+ // The synthesized subject/uuid sentinel — uniquify the tail so sibling birth legs derive DISTINCT
139
+ // deterministic child names (co2 #118 follow-on: six gated births each parentSetup-birthed the SAME
140
+ // home — the second one found it already born, `born=[]`, and the leg died).
141
+ suffixed[k] = v.replace(ZERO_UUID, `00000000-0000-0000-0000-${discHex}`);
142
+ changed = true;
143
+ }
134
144
  else suffixed[k] = v;
135
145
  }
136
146
  if (!changed) return payload;
@@ -151,11 +161,28 @@ function uniquifyBirthPayload(
151
161
  return suffixed;
152
162
  }
153
163
 
164
+ // Constrained-string candidates probed against a field's OWN schema when the readable `sample-<field>`
165
+ // fails it (a `.regex()/.length()/.email()/.url()` bound — co2: a locale-pattern `defaultLocale` was
166
+ // handed `sample-defaultLocale`, violating its own schema and killing the whole leg downstream). Ordered
167
+ // common-first; the READABLE sample stays the first try so unconstrained fields are byte-stable.
168
+ const STRING_CANDIDATES: readonly string[] = [
169
+ "sample", "en", "en-GB", "AA", "A1", "a", "1", "2026-01-01", "2026-01-01T00:00:00Z",
170
+ "a@example.com", "https://example.com", "#000000", "00000000-0000-0000-0000-000000000000",
171
+ "0000000000000000000000000000000000000000000000000000000000000000", // a 64-hex digest
172
+ ];
154
173
  function sampleString(zt: z.ZodTypeAny, field: string): string {
155
174
  const readable = `sample-${field}`;
156
175
  if (zt.safeParse(readable).success) return readable;
157
176
  for (const cand of SUBJECT_CANDIDATES) if (zt.safeParse(cand).success) return cand;
158
- return readable;
177
+ for (const cand of STRING_CANDIDATES) if (zt.safeParse(cand).success) return cand;
178
+ // NO INVALID SAMPLE (co2): returning a schema-violating string poisons every consumer downstream (the
179
+ // recipe probe, the proof leg, the generated .mts) with a mystery refusal — throw NAMED instead (the
180
+ // callers' try/catch degrades to the same honest skip an unsynthesizable shape gets).
181
+ throw new Error(
182
+ `codegen-proof: field '${field}': no sample satisfies its own string schema (a .regex()/.length() ` +
183
+ `bound none of the ${1 + SUBJECT_CANDIDATES.length + STRING_CANDIDATES.length} candidates fit) — ` +
184
+ `loosen the pattern, or prove that directive by hand in test/e2e.mts`,
185
+ );
159
186
  }
160
187
 
161
188
  // Candidate numbers probed against a field's own schema (in a sensible order) so a `.min()/.max()/
@@ -1408,7 +1435,17 @@ export function discoverRelationBootstrapLeg(mod: DomainModule, gatedOverride?:
1408
1435
  // (2) COMPUTED-BASE: when R is computed, walk the relation-schema rewrite for the directly-grantable BASE
1409
1436
  // relations B (e.g. gate `estateMember` → `estateOwner`/`admin`), and find a granter for a base —
1410
1437
  // granting B makes the actor satisfy R through the computed chain (what estate's seed does).
1411
- const candidates = directives.filter((d) => d !== gated && typeof d?.payloadSchema === "object" && d.birthsChild !== true);
1438
+ // A `.delegatedRole(...)` granter verifies a SIGNED delegation cert no synthesizer can produce — it can
1439
+ // never be seated by a proof (the cert gate refuses the sample), so it is NOT a candidate (the computed-
1440
+ // base/seed lanes then find the law's seatable path instead — co2 #118: acceptDelegatedCatalogueCreator
1441
+ // shadowed seedHomeCreator and the leg died on the cert gate).
1442
+ const candidates = directives.filter(
1443
+ (d) =>
1444
+ d !== gated &&
1445
+ typeof d?.payloadSchema === "object" &&
1446
+ d.birthsChild !== true &&
1447
+ (d as { delegatedRoleGate?: unknown }).delegatedRoleGate === undefined,
1448
+ );
1412
1449
  // ALL granters of `rel`, ordered: `.grants(R)` markers first (unambiguous), then plan-probed `writeTuple(R)`.
1413
1450
  const findGrantersFor = (rel: string): { granter: Directive<unknown>; via: "marker" | "inferred" }[] => {
1414
1451
  const out: { granter: Directive<unknown>; via: "marker" | "inferred" }[] = [];
@@ -1948,7 +1985,7 @@ export function discoverChildBirthLegOrReason(
1948
1985
  const seedDir = (childMod.directives as Directive<unknown>[]).find((d) => d?.id === childSeedDirectiveId);
1949
1986
  if (seedDir !== undefined) {
1950
1987
  childSeedAggregateId = seedDir.aggregateId;
1951
- childSeedInstanceId = discoverSingletonInstanceId(seedDir);
1988
+ childSeedInstanceId = discoverSingletonInstanceId(seedDir, childMod);
1952
1989
  const q = ((childMod.queries ?? []) as QueryDecl[]).find((qq) => qq.returns === seedDir.aggregateId && qq.key.length > 0);
1953
1990
  if (q !== undefined) childQuery = { id: q.id, key: q.key };
1954
1991
  }
@@ -2038,7 +2075,19 @@ function attachParentSetup(leg: ChildBirthLeg, allBirths: readonly ChildBirthLeg
2038
2075
  /** Discover the SINGLETON instance id a seed directive writes (a fixed `<Agg>:self`-shaped id), by running
2039
2076
  * its plan with a probe payload + birth-probe globals and reading the first written op's instance id when
2040
2077
  * it is a constant (the same id across two distinct probe payloads). `undefined` ⇒ not a fixed singleton. */
2041
- function discoverSingletonInstanceId(seedDir: Directive<unknown>): string | undefined {
2078
+ function discoverSingletonInstanceId(seedDir: Directive<unknown>, ownerMod?: DomainModule): string | undefined {
2079
+ // RELATION-NAMESPACING PARITY (co2 #118 follow-on): the ENGINE qualifies a `writeTuple` object token /
2080
+ // bare `<type>:<id>` by the dispatching domain (`<domain>__<type>`) for the domain's local non-global
2081
+ // relationTypes — so the FOLDED tuple's instance id is the QUALIFIED one. Probe under the same dispatch
2082
+ // context (the same classification the engine builds), else the discovered singleton id is the bare
2083
+ // variant and the runner's read-back misses the row the genesis actually seeded.
2084
+ const qualifiable = new Set<string>();
2085
+ for (const rt of (ownerMod?.relationTypes ?? []) as unknown as Array<{ type: string; global?: boolean; relations: Map<string, unknown> | Record<string, unknown> }>) {
2086
+ if (rt.global === true) continue;
2087
+ const rels = rt.relations instanceof Map ? [...rt.relations.keys()] : Object.keys(rt.relations ?? {});
2088
+ for (const rel of rels) qualifiable.add(`${rt.type}#${rel}`);
2089
+ }
2090
+ const domain = ownerMod !== undefined ? (ownerMod.domain ?? ownerMod.name) : undefined;
2042
2091
  const idsFor = (seed: number): string | undefined => {
2043
2092
  let synth: SynthesizedPayload;
2044
2093
  try {
@@ -2056,6 +2105,9 @@ function discoverSingletonInstanceId(seedDir: Directive<unknown>): string | unde
2056
2105
  __resetAuthoring();
2057
2106
  __resetReads();
2058
2107
  __resetBirths();
2108
+ // Probe under the OWNER DOMAIN's dispatch context so a relation-tuple singleton id qualifies exactly
2109
+ // as the engine folds it (`<domain>__<type>:…`) — see the qualifiable set built above.
2110
+ if (domain !== undefined) __setDispatchContext({ domain, qualifiable });
2059
2111
  try {
2060
2112
  const returned = seedDir.plan(payload as never, deterministicPorts({ physical: seed, replica: 1 }));
2061
2113
  const ops = [...__drainAuthoring(), ...(Array.isArray(returned) ? returned : [])];
@@ -2064,6 +2116,7 @@ function discoverSingletonInstanceId(seedDir: Directive<unknown>): string | unde
2064
2116
  } catch {
2065
2117
  id = undefined;
2066
2118
  } finally {
2119
+ if (domain !== undefined) __setDispatchContext(undefined);
2067
2120
  try { __drainReads(); __drainBirths(); } catch { /* drained */ }
2068
2121
  }
2069
2122
  });
@@ -2320,10 +2373,13 @@ export function legsForDomain(mod: DomainModule, modules: readonly DomainModule[
2320
2373
  const uniq = uniquifyBirthPayload(withParent.birthPayload, b.birthDirectiveId, schema);
2321
2374
  if (uniq !== withParent.birthPayload) withParent = { ...withParent, birthPayload: uniq };
2322
2375
  }
2323
- // A GATED birth with no parent-birth authority path: seat its relation via the SAME granter/seed
2324
- // discovery the relation-bootstrap leg uses (the law's own authority path, e.g. co2's
2325
- // seedPlatformFounder → seedPlatformAdmin → grantCo2EstateCreator → birthEstateWorkspace).
2326
- if (withParent.birthRequiresRelation !== undefined && withParent.parentSetup === undefined) {
2376
+ // A GATED birth: seat its relation via the SAME granter/seed discovery the relation-bootstrap leg uses
2377
+ // (the law's own authority path, e.g. co2's seedPlatformFounder → seedPlatformAdmin →
2378
+ // grantCo2EstateCreator → birthEstateWorkspace). ALSO when a parentSetup exists (co2 #118): a parent
2379
+ // birth's genesis does NOT necessarily seat the gated relation (co2's homeCreator is claimed POST-birth
2380
+ // by the owner via seedHomeCreator), so the runner seats the discovered path INSIDE the born parent —
2381
+ // as the birth actor — before the gated birth.
2382
+ if (withParent.birthRequiresRelation !== undefined) {
2327
2383
  const birthDir = (mod.directives as Directive<unknown>[]).find((d) => d?.id === b.birthDirectiveId);
2328
2384
  const rb = birthDir !== undefined ? discoverRelationBootstrapLeg(mod, birthDir) : undefined;
2329
2385
  // a born-context path proves INSIDE a born workspace — not a seatable pre-grant; leave it to the gap diagnostic.
@@ -47,7 +47,7 @@
47
47
  */
48
48
  import { execFileSync } from "node:child_process";
49
49
  import { expandCapabilityExports, isCapabilityDecl } from "./capability_exports.js";
50
- import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
50
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
51
51
  import { createRequire } from "node:module";
52
52
  import path from "node:path";
53
53
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -59,6 +59,7 @@ import {
59
59
  generateDartActivityRegistry,
60
60
  collectActivityDescriptors,
61
61
  collectPayloadIdOfTargets,
62
+ typedIdClassForAggregateType,
62
63
  type DartImport,
63
64
  type DartRefImport,
64
65
  type DomainModule,
@@ -503,6 +504,14 @@ function collectPackageContracts(modules: readonly DomainModule[]): ContractHand
503
504
  return [...byId.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
504
505
  }
505
506
 
507
+ /** Every aggregate's generated typed-id class name across the package — the collision guard for the
508
+ * contract typed-id emission (a contract named after a local aggregate must not re-declare `<X>Id`). */
509
+ function packageAggregateIdClassNames(modules: readonly DomainModule[]): Set<string> {
510
+ const out = new Set<string>();
511
+ for (const m of modules) for (const a of m.aggregates) out.add(typedIdClassForAggregateType(a.id));
512
+ return out;
513
+ }
514
+
506
515
  /**
507
516
  * CROSS-DOMAIN REF TRAVERSAL map (dart_codegen_parity.md §2a follow-on): every aggregate id in the package
508
517
  * → its owning domain (`domain` for the generated client class name; `name` for the sibling
@@ -578,7 +587,10 @@ interface DartSharedSymbols {
578
587
  /** The synthetic module whose emit IS `nomos_framework.dart` — undefined when nothing is shared. */
579
588
  readonly frameworkMod: DomainModule | undefined;
580
589
  }
581
- function collectDartSharedSymbols(modules: readonly DomainModule[]): DartSharedSymbols {
590
+ function collectDartSharedSymbols(
591
+ modules: readonly DomainModule[],
592
+ packageContracts: readonly ContractHandle[] = [],
593
+ ): DartSharedSymbols {
582
594
  type ModDirective = DomainModule["directives"][number];
583
595
  type ModQuery = NonNullable<DomainModule["queries"]>[number];
584
596
  const frameworkAggById = new Map<string, AggregateHandle>();
@@ -600,9 +612,28 @@ function collectDartSharedSymbols(modules: readonly DomainModule[]): DartSharedS
600
612
  voDescriptorByName.set(d.name, d);
601
613
  }
602
614
  }
603
- const sharedVoNames: ReadonlySet<string> = new Set(
604
- [...voDomainsByName.entries()].filter(([, n]) => n > 1).map(([name]) => name),
605
- );
615
+ // CONTRACT-USED VOs ARE LAYER-ZERO (co2 #117): a VO referenced by a contract FIELD (e.g. a
616
+ // `ReferenceCode.field()` inside `contract(...)`) is rendered inside `contracts.dart` — the shared layer
617
+ // every domain imports and which may import NO domain file (the acyclic rule). So its class must live in
618
+ // `nomos_framework.dart` REGARDLESS of how many domains use it (a single-domain VO would otherwise land in
619
+ // that domain's file, unreachable from contracts.dart). Transitive deps included (a nested VO renders too).
620
+ const contractVoNames = new Set<string>();
621
+ for (const c of packageContracts) {
622
+ for (const f of Object.values(c.fields)) {
623
+ const meta = (f as { valueObject?: { descriptor: { name: string }; dependencies: readonly { name: string }[] } }).valueObject;
624
+ if (meta === undefined) continue;
625
+ contractVoNames.add(meta.descriptor.name);
626
+ voDescriptorByName.set(meta.descriptor.name, meta.descriptor as NonNullable<DomainModule["valueObjects"]>[number]);
627
+ for (const dep of meta.dependencies) {
628
+ contractVoNames.add(dep.name);
629
+ voDescriptorByName.set(dep.name, dep as NonNullable<DomainModule["valueObjects"]>[number]);
630
+ }
631
+ }
632
+ }
633
+ const sharedVoNames: ReadonlySet<string> = new Set([
634
+ ...[...voDomainsByName.entries()].filter(([, n]) => n > 1).map(([name]) => name),
635
+ ...contractVoNames,
636
+ ]);
606
637
  const sharedVos = [...sharedVoNames].sort().map((name) => voDescriptorByName.get(name)!);
607
638
  const hasFrameworkSymbols =
608
639
  frameworkAggs.length > 0 || frameworkDirs.length > 0 || frameworkQueries.length > 0 || sharedVos.length > 0;
@@ -814,7 +845,8 @@ function emitDart(
814
845
  // • QUERIES — the top-level `watch<Q>`/`read<Q>` accessors.
815
846
  // Per-domain files SKIP each and IMPORT the shared file; clients RE-EXPORT it (Dart de-dups the
816
847
  // same-library re-export). So a barrel over all domains needs NO `hide` clause.
817
- const shared = collectDartSharedSymbols(modules);
848
+ const packageContracts = collectPackageContracts(modules);
849
+ const shared = collectDartSharedSymbols(modules, packageContracts);
818
850
  const { frameworkAggIds, frameworkDirIds, frameworkQueryIds, sharedVoNames, hasFrameworkSymbols } = shared;
819
851
  const frameworkImportUri = hasFrameworkSymbols ? `${FRAMEWORK_FILE}.dart` : undefined;
820
852
 
@@ -845,10 +877,12 @@ function emitDart(
845
877
  }
846
878
  // 2a-bis. the shared DOMAIN CONTRACTS file (`contracts.dart`) — every public DTO class, ONCE. Pure
847
879
  // (nomos_types + VOs). A foreignRef targeting a contract imports THIS, never the concrete domain.
848
- const packageContracts = collectPackageContracts(modules);
849
880
  const contractsImportUri = packageContracts.length > 0 ? `${CONTRACTS_FILE}.dart` : undefined;
850
881
  if (packageContracts.length > 0) {
851
- writeGenerated(CONTRACTS_FILE, generateDartContracts(packageContracts));
882
+ writeGenerated(CONTRACTS_FILE, generateDartContracts(packageContracts, {
883
+ ...(frameworkImportUri !== undefined ? { frameworkImportUri } : {}),
884
+ aggregateIdClassNames: packageAggregateIdClassNames(modules),
885
+ }));
852
886
  }
853
887
  // 2a-ter. the shared ACTIVITY REGISTRY (`activity_registry.dart`) — the pure `renderActivity`, ONCE. Emitted
854
888
  // iff a directive opts in via `.activity(...)` (activity-free packages stay byte-identical). Pure
@@ -1016,7 +1050,7 @@ const NOMOS_TYPES_DEP = "^0.4.1";
1016
1050
  // 0.48.x: session-bound clients (XClient.bind/NomosClientInterface/DomainClientSessionMismatch/
1017
1051
  // DomainNotInstalledForSession, engine.follow, pendingConflicts lanes) — the generated client
1018
1052
  // REQUIRES this surface. 0.48.1 fixes NomosSession.watchPendingConflicts delegation.
1019
- const NOMOS_CLIENT_DEP = "^0.56.0";
1053
+ const NOMOS_CLIENT_DEP = "^0.57.0";
1020
1054
  // DRIFT TRIPWIRE: pubspec_pins.test.ts asserts these equal the CURRENT versions in dsl/dart/pubspec.yaml
1021
1055
  // and dsl/dart_types/pubspec.yaml — a Dart release without this bump fails the suite, so tenants never
1022
1056
  // land back in dependency_overrides (the exact co2 report this closes, third occurrence).
@@ -1120,7 +1154,8 @@ function emitDartPackages(
1120
1154
  ];
1121
1155
  if (new Set(roleNames).size !== roleNames.length) fail(`dart.packages: types/client/test/compat names must be distinct`);
1122
1156
 
1123
- const shared = collectDartSharedSymbols(modules);
1157
+ const packageContracts = collectPackageContracts(modules);
1158
+ const shared = collectDartSharedSymbols(modules, packageContracts);
1124
1159
  const written: string[] = [];
1125
1160
  const genRel = DART_GENERATED_SUBDIR.split(path.sep).join("/");
1126
1161
 
@@ -1175,9 +1210,13 @@ function emitDartPackages(
1175
1210
  if (shared.frameworkMod !== undefined) writeTypes(FRAMEWORK_FILE, generateDartDomain(shared.frameworkMod));
1176
1211
  // DOMAIN CONTRACTS (layered): the shared `contracts.dart` DTO file in the TYPES package (pure —
1177
1212
  // nomos_types + VOs). A contract-target foreignRef imports THIS sibling, never the concrete domain.
1178
- const packageContracts = collectPackageContracts(modules);
1179
1213
  const contractsImportUri = packageContracts.length > 0 ? `${CONTRACTS_FILE}.dart` : undefined;
1180
- if (packageContracts.length > 0) writeTypes(CONTRACTS_FILE, generateDartContracts(packageContracts));
1214
+ if (packageContracts.length > 0) {
1215
+ writeTypes(CONTRACTS_FILE, generateDartContracts(packageContracts, {
1216
+ ...(shared.frameworkMod !== undefined ? { frameworkImportUri: `${FRAMEWORK_FILE}.dart` } : {}),
1217
+ aggregateIdClassNames: packageAggregateIdClassNames(modules),
1218
+ }));
1219
+ }
1181
1220
  // ACTIVITY REGISTRY (layered): the pure `renderActivity` in the TYPES package (nomos_types only). The
1182
1221
  // client resolves it through the types-package barrel (which re-exports every types file). Opt-in only.
1183
1222
  const packageActivity = collectActivityDescriptors(modules);
@@ -1476,6 +1515,26 @@ async function main(): Promise<void> {
1476
1515
  if (!existsSync(configPath)) fail(`config not found: ${configPath}`);
1477
1516
  const cfgDir = path.dirname(configPath);
1478
1517
 
1518
+ // DUAL-INSTANCE GUARD (DX): the law's `import "@githolon/dsl"` resolves from the LAW's own node_modules,
1519
+ // while this compiler runs from wherever it was invoked. Two DIFFERENT dsl instances silently split every
1520
+ // module-level registry (the fluent-authoring drain, brands, markers) — plans probe empty, lints
1521
+ // false-fire, ids drift. Detect the skew and WARN LOUDLY with the two paths (never fail — a
1522
+ // deliberately-vendored setup may be intentional, and same-version split instances still work for
1523
+ // compilation itself, which imports the law through the compiler's own module graph).
1524
+ try {
1525
+ const lawReq = createRequire(pathToFileURL(path.join(cfgDir, "noop.js")));
1526
+ const lawDslPkg = lawReq.resolve("@githolon/dsl/package.json");
1527
+ const ownDslPkg = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json");
1528
+ if (path.resolve(lawDslPkg) !== path.resolve(ownDslPkg)) {
1529
+ const vOf = (p: string): string => { try { return (JSON.parse(readFileSync(p, "utf8")) as { version?: string }).version ?? "?"; } catch { return "?"; } };
1530
+ console.error(
1531
+ `⚠ nomos-compile: TWO @githolon/dsl instances — the law resolves ${vOf(lawDslPkg)} at ${path.dirname(lawDslPkg)}, but this compiler runs ${vOf(path.resolve(ownDslPkg))} at ${path.dirname(path.resolve(ownDslPkg))}. ` +
1532
+ `Split instances split the DSL's module registries (phantom lint errors, empty plan probes, id drift) — even at the same version string when the code differs. ` +
1533
+ `Run the law package's OWN \`npx githolon compile\`, or align the two installs.`,
1534
+ );
1535
+ }
1536
+ } catch { /* no dsl resolvable from the law dir (vendored/workspace setups) — nothing to compare */ }
1537
+
1479
1538
  const cfgModule = (await import(pathToFileURL(configPath).href)) as { default?: PackageConfig };
1480
1539
  const cfg = cfgModule.default;
1481
1540
  if (!cfg || typeof cfg.name !== "string" || !Array.isArray(cfg.domains) || cfg.domains.length === 0) {
@@ -2683,20 +2742,67 @@ async function main(): Promise<void> {
2683
2742
  // false red) when `dart` is absent or the package's deps aren't resolved yet (needs `pub get`). Opt out
2684
2743
  // with NOMOS_SKIP_DART_ANALYZE=1.
2685
2744
  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()}`);
2745
+ // The emitted PACKAGE dirs (each has a pubspec.yaml): the layered emit produces several under `dartOut`;
2746
+ // a flat emit puts one at `dartOut`. Analyze each — but RESOLVE its deps first (`dart pub get`), else
2747
+ // the analyzer reports unresolved `package:` imports as errors and the gate can't tell a codegen bug
2748
+ // from an un-got package (the co2 "analyze skipped → false success" trap).
2749
+ const dOut: string = dartOut;
2750
+ // Analyze exactly THE FILES THIS COMPILE EMITTED (`dartFiles`) — never the whole dir, which would
2751
+ // sweep in the transactional `.nomos-bak` backup and any stale legacy-layout leftovers and judge the
2752
+ // compile on files it did not write. First `dart pub get` each emitted PACKAGE (a dir up-chain with a
2753
+ // pubspec) so `package:` imports resolve; a self-contained vendored emit has no pubspec (no get needed).
2754
+ const emittedDart = dartFiles.filter((f) => f.endsWith(".dart"));
2755
+ const pkgDirOf = (file: string): string | undefined => {
2756
+ let dir = path.dirname(file);
2757
+ for (let hops = 0; hops < 8; hops++) {
2758
+ if (existsSync(path.join(dir, "pubspec.yaml"))) return dir;
2759
+ if (path.resolve(dir) === path.resolve(dOut) || dir === path.dirname(dir)) return undefined;
2760
+ dir = path.dirname(dir);
2699
2761
  }
2762
+ return undefined;
2763
+ };
2764
+ // Only files under a PUB PACKAGE (a pubspec up-chain) are verifiable — a VENDORED no-pubspec emit's
2765
+ // runtime `package:nomos_client` imports cannot resolve without a package config, so judging those
2766
+ // files would false-fail every vendored fixture. Analyze the in-package set; note the loose remainder.
2767
+ const inPackage = emittedDart.filter((f) => pkgDirOf(f) !== undefined);
2768
+ const pkgDirs = [...new Set(inPackage.map(pkgDirOf).filter((d): d is string => d !== undefined))];
2769
+ let dartMissing = false;
2770
+ let unresolved: string | undefined;
2771
+ for (const pkg of pkgDirs) {
2772
+ try {
2773
+ execFileSync("dart", ["pub", "get"], { cwd: pkg, stdio: "pipe", encoding: "utf8" });
2774
+ } catch (e) {
2775
+ const err = e as { code?: string; stdout?: string; stderr?: string };
2776
+ if (err.code === "ENOENT") { dartMissing = true; break; }
2777
+ unresolved = `${path.relative(process.cwd(), pkg)}: ${`${err.stdout ?? ""}${err.stderr ?? ""}`.trim().split("\n").slice(-3).join(" ")}`;
2778
+ }
2779
+ }
2780
+ // Only analyze when EVERY package's deps resolved: `dart analyze` against an unresolved package reports
2781
+ // every framework symbol (NomosRef, …) as undefined — a meaningless flood, not a real defect. An
2782
+ // unresolved dep is already surfaced as UNVERIFIED below (e.g. a fixture pinning an as-yet-unpublished
2783
+ // nomos_client — it verifies for real the moment that version lands).
2784
+ if (!dartMissing && unresolved === undefined && inPackage.length > 0) {
2785
+ try {
2786
+ execFileSync("dart", ["analyze", "--no-fatal-warnings", ...inPackage], { stdio: "pipe", encoding: "utf8" });
2787
+ } catch (e) {
2788
+ const err = e as { code?: string; stdout?: string; stderr?: string };
2789
+ if (err.code === "ENOENT") { dartMissing = true; }
2790
+ else {
2791
+ const out = `${err.stdout ?? ""}${err.stderr ?? ""}`.trim();
2792
+ fail(`the generated Dart 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}`);
2793
+ }
2794
+ }
2795
+ }
2796
+ if (dartMissing) {
2797
+ // No `dart` on PATH — we genuinely cannot verify. Say so LOUDLY (never a `✓`), so a broken client is
2798
+ // NOT reported as a clean compile; installing dart turns this into a real gate.
2799
+ console.log(` dart ⚠ UNVERIFIED — \`dart\` is not on PATH, so the generated Dart could NOT be analyzed (install dart to gate it)`);
2800
+ } else if (unresolved !== undefined) {
2801
+ console.log(` dart ⚠ UNVERIFIED — \`dart pub get\` failed, so analysis is incomplete (${unresolved})`);
2802
+ } else if (inPackage.length > 0) {
2803
+ console.log(` dart ✓ generated Dart analyzes clean (compile-valid ⇒ compilable)`);
2804
+ } else if (emittedDart.length > 0) {
2805
+ console.log(` dart — vendored (no pub package) emit — runtime package imports are unverifiable without a pubspec; analysis skipped (use dart.packages for a fully-gated pub package)`);
2700
2806
  }
2701
2807
  }
2702
2808
  }
package/src/fields.ts CHANGED
@@ -170,7 +170,7 @@ export type ValueObjectFieldDescriptor =
170
170
  /** A plain string, OR a BRANDED nominal string when `brand` is present (a `brandedId(...)`
171
171
  * — the generated TS branded alias / Dart extension-type wrapper's name). Wire-identical
172
172
  * to a plain string; the brand is a compile-time nominal tag only. */
173
- | { readonly type: "string"; readonly brand?: string; readonly optional?: true }
173
+ | { readonly type: "string"; readonly brand?: string; readonly idOf?: string; readonly optional?: true }
174
174
  /** `int: true` when the zod carried `.int()` (Dart `int`, TS `number`); `unit` when the field
175
175
  * was declared through `unit(...)` — the generated Dart extension-type wrapper's name. */
176
176
  | { readonly type: "number"; readonly int?: true; readonly unit?: string; readonly optional?: true }
@@ -362,16 +362,16 @@ function describeZod(
362
362
  if (ownBrand !== undefined) return takeVoField(ownBrand as ValueObjectFieldMeta, deps);
363
363
  switch (zodKind(schema)) {
364
364
  case "string": {
365
- const brand = (schema as unknown as Record<symbol, unknown>)[NOMOS_BRANDED_ID_BRAND]
366
- ?? (schema as unknown as Record<symbol, unknown>)[NOMOS_IDOF_BRAND];
367
- // idOf's brand is the aggregate type name; a payload idOf surfaces as `<Agg>Id`.
368
- const brandName =
369
- typeof (schema as unknown as Record<symbol, unknown>)[NOMOS_BRANDED_ID_BRAND] === "string"
370
- ? (brand as string)
371
- : typeof brand === "string"
372
- ? `${brand}Id`
373
- : undefined;
374
- return brandName !== undefined ? { type: "string", brand: brandName } : { type: "string" };
365
+ const brandedBrand = (schema as unknown as Record<symbol, unknown>)[NOMOS_BRANDED_ID_BRAND];
366
+ const idOfAgg = (schema as unknown as Record<symbol, unknown>)[NOMOS_IDOF_BRAND];
367
+ // idOf(Agg) REFERENCES the aggregate's canonical typed-id class `<Agg>Id` (emitted ONCE by the
368
+ // aggregate) — it must NOT re-declare a standalone extension type (that duplicated the id symbol and
369
+ // stripped its `.fromRow`, co2). Carry `idOf` so the emitter references + imports it and skips the
370
+ // brand-extension-type emission. A standalone `brandedId("X")` still declares its own extension type.
371
+ if (typeof idOfAgg === "string") {
372
+ return { type: "string", brand: `${idOfAgg}Id`, idOf: idOfAgg };
373
+ }
374
+ return typeof brandedBrand === "string" ? { type: "string", brand: brandedBrand } : { type: "string" };
375
375
  }
376
376
  case "record": {
377
377
  if (zodRecordKeyKind(schema) !== "string") {