@githolon/dsl 0.64.0 → 0.65.1

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,11 @@
1
+ ## 0.43.0
2
+
3
+ - `NomosBridge.foreignReader({cloud})` — a `NomosForeignReader` backed by the device's multi-workspace bridge
4
+ plane, so a generated `ForeignRef` (`t.foreignRef`) resolves against real data: it mounts the foreign
5
+ workspace on demand (`connect(session: ws, allowUnborn: true)`, idempotent) and reads it
6
+ (`session(ws).queryById(id)`); `watch` re-reads on each sync tick. A locator resolver, not a foreign key —
7
+ the foreign holon is mounted only when you resolve. Requires `nomos_types >= 0.1.1` (the `ForeignRef` types).
8
+
1
9
  ## 0.41.0
2
10
  - `NomosTestHarness` emits named phase-progress diagnostics (install → spawn runner → await ready → connect, plus per-session progress in `cluster`) and each `connect()` carries a `connectTimeout` (default 120s) that throws a NAMED remedy instead of hanging — so a multi-test AppSim run can tell install vs runner-wait vs session-block vs deadlock (co2 feedback).
3
11
 
@@ -19,7 +19,13 @@ import 'dart:convert';
19
19
  import 'dart:math';
20
20
 
21
21
  import 'package:nomos_types/nomos_types.dart'
22
- show nomosErrorFromInfo, NomosTraceContext, NomosErrorInfo;
22
+ show
23
+ nomosErrorFromInfo,
24
+ NomosTraceContext,
25
+ NomosErrorInfo,
26
+ NomosForeignReader,
27
+ NomosForeignWorkspaceReader,
28
+ ForeignAttestation;
23
29
 
24
30
  /// The protocol version the runner and the bridge agree on (`webview-runner.mjs` `WEBVIEW_BRIDGE_VERSION`).
25
31
  const int kWebviewBridgeVersion = 1;
@@ -840,6 +846,15 @@ class NomosBridge {
840
846
  NomosSession session(String workspace) =>
841
847
  _sessionViews.putIfAbsent(workspace, () => NomosSession(this, workspace));
842
848
 
849
+ /// A [NomosForeignReader] backed by THIS bridge's multi-workspace plane — what a generated `ForeignRef`
850
+ /// (a `t.foreignRef` cross-workspace locator) resolves against: `row.data.listing.resolve(bridge.foreignReader())`.
851
+ /// `foreignWorkspace(ws).queryById(id)` MOUNTS the foreign workspace on this device's plane ON DEMAND
852
+ /// ([connect] is idempotent — "first connect wins", and `allowUnborn` so a missing workspace reads empty →
853
+ /// `ForeignNotFound`, not a throw) and reads it. [cloud] is the cloud the foreign workspaces sync from
854
+ /// (usually the same cloud your primary workspace uses); omit for a purely local plane. This is a LOCATOR
855
+ /// resolver, not a foreign key — the foreign holon is sovereign, mounted only when you choose to resolve.
856
+ NomosForeignReader foreignReader({String? cloud}) => _BridgeForeignReader(this, cloud);
857
+
843
858
  /// The workspace names [connect] has opened on this plane (the live sessions).
844
859
  List<String> get connectedSessions => List.unmodifiable(_sessionNames);
845
860
 
@@ -1728,3 +1743,74 @@ class NomosSession extends NomosBridge {
1728
1743
  @override
1729
1744
  List<Map<String, Object?>> get recentSpans => parent.recentSpans;
1730
1745
  }
1746
+
1747
+ /// THE BRIDGE-BACKED FOREIGN READER — a [NomosForeignReader] over a device's multi-workspace [NomosBridge]
1748
+ /// plane. Constructed via `bridge.foreignReader({cloud})`; a generated `ForeignRef` resolves against it.
1749
+ /// Mounting a foreign workspace has real cost (it opens + syncs that holon on demand) — which is the honest
1750
+ /// price of a cross-workspace read, paid only when you resolve, never up front.
1751
+ class _BridgeForeignReader implements NomosForeignReader {
1752
+ _BridgeForeignReader(this._bridge, this._cloud);
1753
+ final NomosBridge _bridge;
1754
+ final String? _cloud;
1755
+
1756
+ @override
1757
+ NomosForeignWorkspaceReader foreignWorkspace(String workspace) =>
1758
+ _BridgeForeignWorkspaceReader(_bridge, workspace, _cloud);
1759
+
1760
+ // No attestation hook by default — `ForeignRef.attest` throws unless the app wires the attested-read lane
1761
+ // (trustSource + .readsFrom). That gate is the security model (a locator is not a foreign key), not a
1762
+ // limitation: unverifiable "evidence" would be the real footgun.
1763
+ @override
1764
+ Future<ForeignAttestation> Function(String workspace, String id, String type)? get attestForeign => null;
1765
+ }
1766
+
1767
+ /// A per-workspace read handle on the bridge plane — mounts [_workspace] lazily on the first read.
1768
+ class _BridgeForeignWorkspaceReader implements NomosForeignWorkspaceReader {
1769
+ _BridgeForeignWorkspaceReader(this._bridge, this._workspace, this._cloud);
1770
+ final NomosBridge _bridge;
1771
+ final String _workspace;
1772
+ final String? _cloud;
1773
+ Future<void>? _mounting;
1774
+
1775
+ // Mount ONCE (idempotent on the plane — "first connect wins"). `allowUnborn` so a not-yet-born foreign
1776
+ // workspace reads empty (→ ForeignNotFound) rather than throwing. A genuinely unreachable cloud throws,
1777
+ // which resolveResult classifies as ForeignUnreachable.
1778
+ Future<void> _ensureMounted() =>
1779
+ _mounting ??= _bridge.connect(
1780
+ cloud: _cloud,
1781
+ workspace: _workspace,
1782
+ session: _workspace,
1783
+ allowUnborn: true,
1784
+ );
1785
+
1786
+ @override
1787
+ Future<List<Map<String, dynamic>>> queryById(String id) async {
1788
+ await _ensureMounted();
1789
+ return _bridge.session(_workspace).queryById(id);
1790
+ }
1791
+
1792
+ // Reactive: emit the current value, then re-read on every plane sync tick (the foreign workspace's folds
1793
+ // arrive as sync events). Chatty but correct — a foreignRef watch is a convenience, not a hot path.
1794
+ @override
1795
+ Stream<List<Map<String, dynamic>>>? watchById(String id) {
1796
+ late final StreamController<List<Map<String, dynamic>>> ctrl;
1797
+ StreamSubscription<Object?>? sub;
1798
+ Future<void> emit() async {
1799
+ try {
1800
+ if (!ctrl.isClosed) ctrl.add(await queryById(id));
1801
+ } catch (_) {
1802
+ // a transient read failure is not fatal to the subscription — the next sync tick retries.
1803
+ }
1804
+ }
1805
+ ctrl = StreamController<List<Map<String, dynamic>>>(
1806
+ onListen: () {
1807
+ unawaited(emit());
1808
+ sub = _bridge.session(_workspace).syncEvents.listen((_) => unawaited(emit()));
1809
+ },
1810
+ onCancel: () async {
1811
+ await sub?.cancel();
1812
+ },
1813
+ );
1814
+ return ctrl.stream;
1815
+ }
1816
+ }
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.41.0
14
+ version: 0.43.0
15
15
 
16
16
  environment:
17
17
  sdk: ^3.5.0
@@ -20,7 +20,7 @@ dependencies:
20
20
  # The pure-data core (wire types, offer codec, NomosReads, schema validation, error
21
21
  # info). nomos_client depends on and re-exports it, so existing imports are unchanged.
22
22
  # For local monorepo dev, pubspec_overrides.yaml points this at ../dart_types.
23
- nomos_types: ^0.1.0
23
+ nomos_types: ^0.1.1
24
24
  json_schema: ^5.2.2
25
25
  crypto: ^3.0.0
26
26
 
@@ -1,3 +1,11 @@
1
+ ## 0.1.1
2
+
3
+ - Add `t.foreignRef` runtime types: `ForeignRef<T>` (a typed cross-workspace locator), `NomosForeignReader` /
4
+ `NomosForeignWorkspaceReader`, the sealed `ForeignResolveResult<T>` (`ForeignFound`/`ForeignNotFound`/
5
+ `ForeignDenied`/`ForeignUnreachable`), and `ForeignAttestation`. Generated read models type a `foreignRef`
6
+ field as `ForeignRef<Target>`; `.resolve/.resolveResult/.watch/.exists` are the informational lookup,
7
+ `.attest` is gated on the attested-read lane. Purely additive.
8
+
1
9
  ## 0.1.0
2
10
  - Initial release. The pure-data core split out of `nomos_client`: kernel wire types,
3
11
  the offer codec, `NomosReads`, the tri-state `NomosSlot`, aggregate metadata, schema
@@ -27,3 +27,7 @@ export 'src/schema_validation.dart';
27
27
  export 'src/aggregate_meta.dart';
28
28
  export 'src/error_info.dart';
29
29
  export 'src/errors.dart';
30
+ // The FOREIGN-REF locator runtime (t.foreignRef): the typed cross-workspace `ForeignRef<T>` + the structural
31
+ // `NomosForeignReader`/`NomosForeignWorkspaceReader` read surfaces + the named resolve outcomes. Pure data +
32
+ // abstract interfaces (no bridge) — so both the layered TYPES package and nomos_client resolve them.
33
+ export 'src/foreign_ref.dart';
@@ -0,0 +1,214 @@
1
+ // NOMOS — the FOREIGN-REF runtime (t.foreignRef): a TYPED CROSS-WORKSPACE LOCATOR, the Dart twin of the
2
+ // TS client's `ForeignRef`/`NomosForeignReader` (codegen_ts.ts). A `t.foreignRef({ workspace, target })`
3
+ // field stores the foreign aggregate's id, alongside a SIBLING field on the same aggregate holding the
4
+ // foreign WORKSPACE id (so the target workspace is dynamic per row). The generated read model surfaces the
5
+ // field as a `ForeignRef<T>` — NOT a raw string — so the app can `resolve`/`watch`/`attest` the target on
6
+ // demand WITHOUT cloning the other holon: it opens the foreign workspace's read surface lazily.
7
+ //
8
+ // A ForeignRef is a LOCATOR, not an atomic foreign key: reading it is informational (resolve/exists/watch);
9
+ // only `attest` is law-grade (signed evidence the reference resolves) and it works ONLY when the law
10
+ // DECLARES the dependency (`trustSource` + `.readsFrom`) — otherwise it throws, by design.
11
+ //
12
+ // These types live in the pure `nomos_types` core (no bridge, no lifecycle) so BOTH the layered TYPES
13
+ // package (frontend-safe) AND the runtime `nomos_client` package resolve them; `nomos_client` re-exports
14
+ // this barrel, and its `NomosBridge` implements [NomosForeignReader] over the device's multi-workspace plane.
15
+ library;
16
+
17
+ /// Reads a FOREIGN workspace on demand — the surface a [ForeignRef] resolves against. It is STRUCTURAL: any
18
+ /// object with this shape works. `nomos_client`'s `NomosBridge` implements it over the device's local
19
+ /// multi-workspace plane (`bridge.session(ws).queryById(id)`, mounting the workspace lazily); the TS twin is
20
+ /// the `openRealm()` reader. A plain single-workspace holon is NOT one — pass the multi-workspace reader.
21
+ abstract class NomosForeignReader {
22
+ /// A read handle for [workspace] (mounted on the local plane on demand, never cloned).
23
+ NomosForeignWorkspaceReader foreignWorkspace(String workspace);
24
+
25
+ /// The LAW-GRADE attestation hook — non-`null` ONLY when the app wires the attested-read lane
26
+ /// (`trustSource` + `.readsFrom`). `null` here ⇒ [ForeignRef.attest] throws, by design (a locator is not an
27
+ /// atomic foreign key). Override in a reader that carries the attested-read lane.
28
+ Future<ForeignAttestation> Function(String workspace, String id, String type)?
29
+ get attestForeign => null;
30
+ }
31
+
32
+ /// One foreign workspace's by-id read surface (used by [ForeignRef.resolve]/[ForeignRef.watch]). Returns the
33
+ /// RAW projection rows (`[{type,id,data}, …]`) — the ForeignRef decodes the matching row's flat `data` map
34
+ /// via the target `fromJson` it captured at construction (the same shape [NomosReads.decodeRows] consumes).
35
+ abstract class NomosForeignWorkspaceReader {
36
+ /// The projection rows for kernel aggregate [id] (a by-id read). Each row is a `{type, id, data}` map, its
37
+ /// `data` the flat already-decoded field map. An EMPTY list ⇒ the target has not been folded / is absent.
38
+ Future<List<Map<String, dynamic>>> queryById(String id);
39
+
40
+ /// OPTIONAL reactive by-id read — the target's rows re-emitted on each projection tick. `null` when this
41
+ /// reader offers no watch lane (then [ForeignRef.watch] calls back once with `null` and no-ops).
42
+ Stream<List<Map<String, dynamic>>>? watchById(String id) => null;
43
+ }
44
+
45
+ /// The OUTCOME of a [ForeignRef.resolveResult], with the reason NAMED — a foreign read can come back empty
46
+ /// because the target does not exist, the reader lacks access, or the holon is unreachable, and those are
47
+ /// very different. [ForeignRef.resolve] collapses them all to `null`; use this when the WHY matters.
48
+ sealed class ForeignResolveResult<T> {
49
+ const ForeignResolveResult();
50
+ }
51
+
52
+ /// The target resolved — its typed [row] is present.
53
+ class ForeignFound<T> extends ForeignResolveResult<T> {
54
+ final T row;
55
+ const ForeignFound(this.row);
56
+ }
57
+
58
+ /// The reference does not resolve to a folded aggregate (no such id in the target workspace).
59
+ class ForeignNotFound<T> extends ForeignResolveResult<T> {
60
+ const ForeignNotFound();
61
+ }
62
+
63
+ /// The read was REFUSED — the reader's error looked like an auth/permission denial (best-effort classified).
64
+ class ForeignDenied<T> extends ForeignResolveResult<T> {
65
+ final Object error;
66
+ const ForeignDenied(this.error);
67
+ }
68
+
69
+ /// The foreign holon could not be reached (any error that did not look like a denial).
70
+ class ForeignUnreachable<T> extends ForeignResolveResult<T> {
71
+ final Object error;
72
+ const ForeignUnreachable(this.error);
73
+ }
74
+
75
+ /// LAW-GRADE signed evidence that a [ForeignRef] resolves — returned by [ForeignRef.attest] on the
76
+ /// attested-read lane (`trustSource` + `.readsFrom`). Pure data.
77
+ class ForeignAttestation {
78
+ /// The foreign aggregate's id the attestation is over.
79
+ final String id;
80
+
81
+ /// The foreign workspace the attestation is over.
82
+ final String workspace;
83
+
84
+ /// Whether the target existed at attestation time.
85
+ final bool exists;
86
+
87
+ /// The opaque signed attestation blob (the law-grade evidence).
88
+ final String attestation;
89
+
90
+ const ForeignAttestation({
91
+ required this.id,
92
+ required this.workspace,
93
+ required this.exists,
94
+ required this.attestation,
95
+ });
96
+ }
97
+
98
+ /// A TYPED CROSS-WORKSPACE LOCATOR (`t.foreignRef`) — the foreign aggregate's [id] + the foreign [workspace]
99
+ /// (from the declared sibling workspace field) + the target aggregate [type], plus the target `fromJson`
100
+ /// captured at construction so [resolve]/[watch] can DECODE it. NOT an atomic foreign key: [resolve]/[watch]
101
+ /// open the foreign holon ON DEMAND (no clone). [exists] is the cheap "is it still there?" check; [attest] is
102
+ /// the law-grade one (see [ForeignAttestation]). The generated read model builds these from a projection row
103
+ /// (the raw id field + the sibling workspace field); [id] stays the raw stored id.
104
+ class ForeignRef<T> {
105
+ /// The foreign aggregate's id (this field's stored value).
106
+ final String id;
107
+
108
+ /// The foreign workspace id (from the declared sibling workspace field).
109
+ final String workspace;
110
+
111
+ /// The foreign aggregate type this locator points at.
112
+ final String type;
113
+
114
+ /// The target's decoder (the aggregate `fromJson` factory), applied to a resolved row's flat `data` map.
115
+ final T Function(Map<String, dynamic> data, [String id]) _decode;
116
+
117
+ const ForeignRef({
118
+ required this.id,
119
+ required this.workspace,
120
+ required this.type,
121
+ required T Function(Map<String, dynamic> data, [String id]) fromJson,
122
+ }) : _decode = fromJson;
123
+
124
+ /// Pick the matching row (by [id], else the first) from a raw `{type,id,data}` row list and decode its flat
125
+ /// `data` via the captured `fromJson` (threading the row `id`, mirroring [NomosReads.decodeRows]). `null`
126
+ /// when the list is empty or the matched row carries no object `data`.
127
+ T? _decodeRows(List<Map<String, dynamic>> rows) {
128
+ if (rows.isEmpty) return null;
129
+ Map<String, dynamic>? match;
130
+ for (final r in rows) {
131
+ if (r['id'] == id) {
132
+ match = r;
133
+ break;
134
+ }
135
+ }
136
+ match ??= rows.first;
137
+ final data = match['data'];
138
+ if (data is! Map<String, dynamic>) return null;
139
+ final rawId = match['id'];
140
+ final rowId = rawId is String ? rawId : (rawId?.toString() ?? id);
141
+ return _decode(data, rowId);
142
+ }
143
+
144
+ /// THE LOOKUP WITH THE OUTCOME NAMED — found / not-found / denied / unreachable. Opens the foreign
145
+ /// workspace's read surface, reads the target by [id], and classifies an empty/errored read (a thrown
146
+ /// auth/permission-looking error ⇒ denied; any other throw ⇒ unreachable; an empty read ⇒ not-found).
147
+ Future<ForeignResolveResult<T>> resolveResult(NomosForeignReader nomos) async {
148
+ if (id.isEmpty || workspace.isEmpty) return ForeignNotFound<T>();
149
+ try {
150
+ final rows = await nomos.foreignWorkspace(workspace).queryById(id);
151
+ final row = _decodeRows(rows);
152
+ return row != null ? ForeignFound<T>(row) : ForeignNotFound<T>();
153
+ } catch (error) {
154
+ final m = error.toString().toLowerCase();
155
+ final denied = RegExp(r'forbid|denied|permission|unauthor|not permitted|403|401').hasMatch(m);
156
+ return denied ? ForeignDenied<T>(error) : ForeignUnreachable<T>(error);
157
+ }
158
+ }
159
+
160
+ /// THE LOOKUP (the 99% case): open the foreign workspace + read the target by [id] — on demand, no clone.
161
+ /// Returns `null` for ANY empty outcome (not-found / denied / unreachable); use [resolveResult] for the WHY.
162
+ Future<T?> resolve(NomosForeignReader nomos) async {
163
+ final r = await resolveResult(nomos);
164
+ return r is ForeignFound<T> ? r.row : null;
165
+ }
166
+
167
+ /// Subscribe to the target's read model across the workspace boundary. Returns an unsubscribe fn. Calls
168
+ /// [cb] with `null` once (and no-ops) when the id/workspace is empty or the reader offers no watch lane.
169
+ void Function() watch(NomosForeignReader nomos, void Function(T? row) cb) {
170
+ if (id.isEmpty || workspace.isEmpty) {
171
+ cb(null);
172
+ return () {};
173
+ }
174
+ final stream = nomos.foreignWorkspace(workspace).watchById(id);
175
+ if (stream == null) {
176
+ cb(null);
177
+ return () {};
178
+ }
179
+ final sub = stream.listen(
180
+ (rows) => cb(_decodeRows(rows)),
181
+ onError: (Object _) => cb(null),
182
+ );
183
+ return sub.cancel;
184
+ }
185
+
186
+ /// Cheap existence check — does the referenced target still exist? (found ⇒ `true`; anything else ⇒ `false`).
187
+ Future<bool> exists(NomosForeignReader nomos) async =>
188
+ (await resolveResult(nomos)) is ForeignFound<T>;
189
+
190
+ /// LAW-GRADE: signed evidence that the reference resolves, for when your law DEPENDS on the foreign fact.
191
+ /// Requires the attested-read lane — declare `trustSource(workspace, …)` + `.readsFrom(…)` on the directive;
192
+ /// without it (no `attestForeign` on the reader) this throws, by design (a locator is not an atomic FK).
193
+ Future<ForeignAttestation> attest(NomosForeignReader nomos) {
194
+ final hook = nomos.attestForeign;
195
+ if (hook == null) {
196
+ throw StateError(
197
+ 'attest() needs the attested-read lane — declare trustSource("$workspace", …) + '
198
+ '.readsFrom(…) on the directive whose law depends on this reference '
199
+ '(a foreignRef is a locator, not an atomic foreign key)',
200
+ );
201
+ }
202
+ return hook(workspace, id, type);
203
+ }
204
+
205
+ @override
206
+ bool operator ==(Object other) =>
207
+ other is ForeignRef<T> &&
208
+ other.id == id &&
209
+ other.workspace == workspace &&
210
+ other.type == type;
211
+
212
+ @override
213
+ int get hashCode => Object.hash(id, workspace, type);
214
+ }
@@ -11,7 +11,7 @@ description: >-
11
11
  Pure kernel wire/data types for Nomos Dart frontends — no transport, no bridge,
12
12
  no runtime. The hand-written serde-JSON-compatible types plus the offer codec,
13
13
  reactive read engine, schema validation, and data-only error/trace types.
14
- version: 0.1.0
14
+ version: 0.1.1
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.64.0",
3
+ "version": "0.65.1",
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",
@@ -426,6 +426,19 @@ interface AggFieldSpec {
426
426
  * non-ref field (or a ref whose target type is unknown — falls back to `String`).
427
427
  */
428
428
  refIdClass?: string;
429
+ /**
430
+ * For a `t.foreignRef` field (a TYPED CROSS-WORKSPACE LOCATOR, not an atomic FK): the target
431
+ * aggregate's Dart class (e.g. `CatalogueListing`). The field's Dart type is `ForeignRef<Target>`
432
+ * (from the shared `nomos_types` runtime), NOT the target's typed-id class — so the app can
433
+ * `resolve`/`watch`/`attest` the foreign target on demand. `undefined` for a plain same-workspace
434
+ * `ref` field (which stays its target's typed id class via {@link refIdClass}).
435
+ */
436
+ foreignRefTarget?: string;
437
+ /**
438
+ * For a `t.foreignRef` field: the wire name of the SIBLING field on THIS aggregate holding the foreign
439
+ * WORKSPACE id (the locator's `workspace`, dynamic per row). `undefined` for a non-foreignRef field.
440
+ */
441
+ foreignRefWorkspaceField?: string;
429
442
  /**
430
443
  * Whether the field is OPTIONAL on a folded aggregate — derived from the DSL field
431
444
  * schema (GAP 1). A `set`/`map` is a COLLECTION: it is ALWAYS empty-defaultable
@@ -726,7 +739,23 @@ function aggFieldSpec(
726
739
  ...(field.jsonShape !== undefined ? { jsonShape: field.jsonShape } : {}),
727
740
  };
728
741
  case "ref": {
729
- // A `ref` field carries ANOTHER aggregate's id — type it as that target's
742
+ // A `t.foreignRef` field is a TYPED CROSS-WORKSPACE LOCATOR (not an atomic FK): surface it as
743
+ // `ForeignRef<Target>` (the shared `nomos_types` runtime type) — the raw stored id + the sibling
744
+ // workspace field give the app `resolve`/`watch`/`attest` on demand, without cloning the other holon.
745
+ // This takes precedence over the same-workspace typed-id path below.
746
+ if (field.foreignRef !== undefined) {
747
+ const target = field.foreignRef.targetAggregateId;
748
+ return {
749
+ name,
750
+ wireName,
751
+ kind: field.kind,
752
+ dartType: `ForeignRef<${target}>`,
753
+ optional,
754
+ foreignRefTarget: target,
755
+ foreignRefWorkspaceField: field.foreignRef.workspaceField,
756
+ };
757
+ }
758
+ // A plain same-workspace `ref` field carries ANOTHER aggregate's id — type it as that target's
730
759
  // TYPED id class (cross-aggregate ref typing), so e.g. a `Building.siteId`
731
760
  // ref is a `SiteId`, never a bare String. The target wire id is recorded on
732
761
  // the field (`refAggregateId`); fall back to `String` only if it is absent.
@@ -1910,6 +1939,26 @@ function collectionDefaultParam(s: AggFieldSpec): string {
1910
1939
  * spike path may emit); an `enum` is its bare wire string; a `json` field is the
1911
1940
  * stored JSON which may have re-parsed into a container (re-stringified to keep the
1912
1941
  * aggregate's `String` type). */
1942
+ /** The decode expression for a `t.foreignRef` field: build the shared `ForeignRef<Target>` locator from the
1943
+ * row's raw id (this field's stored string) + the sibling workspace field (the foreign workspace id) +
1944
+ * the target aggregate's `fromJson` decoder. `id` stays the raw stored id. An optional/absent field decodes
1945
+ * `null`. Shared by the aggregate class and the read model (both surface the field as `ForeignRef<Target>`). */
1946
+ function foreignRefExpr(s: AggFieldSpec, aggregate: string): string {
1947
+ const target = s.foreignRefTarget as string;
1948
+ const wsField = s.foreignRefWorkspaceField as string;
1949
+ const idReader = s.optional
1950
+ ? `readStrOrNull(data, '${s.wireName}')`
1951
+ : `readStr(data, '${aggregate}', '${s.wireName}')`;
1952
+ // The sibling workspace field may not have folded yet — default it to '' (a locator with an empty
1953
+ // workspace resolves to not-found, never throws).
1954
+ const wsExpr = `(readStrOrNull(data, '${wsField}') ?? '')`;
1955
+ const build = (idVar: string): string =>
1956
+ `ForeignRef<${target}>(id: ${idVar}, workspace: ${wsExpr}, type: '${target}', fromJson: ${target}.fromJson)`;
1957
+ return s.optional
1958
+ ? `() { final v = ${idReader}; return v == null ? null : ${build("v")}; }()`
1959
+ : build(idReader);
1960
+ }
1961
+
1913
1962
  function parseField(s: AggFieldSpec, aggregate: string): string {
1914
1963
  // A `.optional()` scalar reads through the `…OrNull` tolerant reader (returns null
1915
1964
  // when the flat `data` lacks it — a freshly-folded aggregate need not have folded
@@ -1918,6 +1967,9 @@ function parseField(s: AggFieldSpec, aggregate: string): string {
1918
1967
  const opt = s.optional;
1919
1968
  switch (s.kind) {
1920
1969
  case "ref": {
1970
+ // A `t.foreignRef` field decodes to the shared `ForeignRef<Target>` locator (raw id + sibling
1971
+ // workspace field + the target `fromJson`), not a typed-id class.
1972
+ if (s.foreignRefTarget !== undefined) return foreignRefExpr(s, aggregate);
1921
1973
  // A ref reads its id STRING, then wraps it via `.fromRow` (NOT the public
1922
1974
  // `.fromMinted`, which asserts the type tag). A ref may
1923
1975
  // legitimately point at a not-yet-minted aggregate whose id carries no
@@ -2003,6 +2055,13 @@ function parseField(s: AggFieldSpec, aggregate: string): string {
2003
2055
  function slotDecodeCallback(s: AggFieldSpec, mode: "aggregate" | "readModel"): string {
2004
2056
  switch (s.kind) {
2005
2057
  case "ref":
2058
+ // A null-admitting `t.foreignRef` (exotic): the sibling workspace field is not reachable from this
2059
+ // single leaf, so the locator carries the raw id with an empty workspace (resolves to not-found until
2060
+ // read through the normal field path). A plain ref keeps its typed-id decode.
2061
+ if (s.foreignRefTarget !== undefined) {
2062
+ const t = s.foreignRefTarget;
2063
+ return `(v) => ForeignRef<${t}>(id: v as String, workspace: '', type: '${t}', fromJson: ${t}.fromJson)`;
2064
+ }
2006
2065
  return s.refIdClass !== undefined
2007
2066
  ? `(v) => ${s.refIdClass}.fromRow(v as String)`
2008
2067
  : `(v) => v as String`;
@@ -2066,6 +2125,9 @@ export function readModelClassName(aggregateType: string): string {
2066
2125
  function readModelDartType(s: AggFieldSpec): string {
2067
2126
  switch (s.kind) {
2068
2127
  case "ref":
2128
+ // A `t.foreignRef` field surfaces as the typed cross-workspace `ForeignRef<Target>` locator; a plain
2129
+ // same-workspace ref stays its target's typed-id class (or a bare String when the target is unknown).
2130
+ if (s.foreignRefTarget !== undefined) return `ForeignRef<${s.foreignRefTarget}>`;
2069
2131
  return s.refIdClass ?? "String";
2070
2132
  case "json":
2071
2133
  // A `t.jsonObject()` leaf (`jsonShape:"object"`, e.g. `location`) is the DECODED
@@ -2128,6 +2190,9 @@ function parseReadModelField(s: AggFieldSpec, aggregate: string): string {
2128
2190
  const opt = s.optional;
2129
2191
  switch (s.kind) {
2130
2192
  case "ref": {
2193
+ // A `t.foreignRef` field decodes to the shared `ForeignRef<Target>` locator (same as the aggregate
2194
+ // class); a plain same-workspace ref stays its target's typed-id class.
2195
+ if (s.foreignRefTarget !== undefined) return foreignRefExpr(s, aggregate);
2131
2196
  const reader = opt
2132
2197
  ? `readStrOrNull(data, '${s.wireName}')`
2133
2198
  : `readStr(data, '${aggregate}', '${s.wireName}')`;
@@ -423,6 +423,9 @@ const DART_SUPPORT_CORE_SRC = [
423
423
  // `<name>.client.dart` reference these through the barrel. No external pub deps.
424
424
  "error_info.dart",
425
425
  "errors.dart",
426
+ // The foreign-ref locator runtime (ForeignRef<T>/NomosForeignReader/…, t.foreignRef) — a generated read
427
+ // model with a `t.foreignRef` field references `ForeignRef<Target>` through the barrel, so it must vendor.
428
+ "foreign_ref.dart",
426
429
  ] as const;
427
430
  // The RUNTIME support files, vendored from `nomos_client` (dsl/dart/lib/src): the async WebView
428
431
  // bridge the generated `<name>.client.dart` drives, and the provider surface. Their
@@ -671,6 +674,15 @@ function emitDart(
671
674
  writeGenerated(FRAMEWORK_FILE, generateDartDomain(shared.frameworkMod));
672
675
  }
673
676
 
677
+ // CROSS-DOMAIN `t.foreignRef` (auto import). A foreignRef field typed `ForeignRef<CatalogueListing>` +
678
+ // `CatalogueListing.fromJson` needs the TARGET aggregate's Dart class — which lives in the target DOMAIN's
679
+ // generated file. Within ONE package the compiler knows which domain owns each aggregate, so we auto-emit
680
+ // the import (no manual `dartImports` config). Map every aggregate id → its domain's generated file base.
681
+ const aggIdToDartFile = new Map<string, string>();
682
+ for (const m of modules) {
683
+ for (const agg of (m.aggregates as { id: string }[])) aggIdToDartFile.set(agg.id, m.name);
684
+ }
685
+
674
686
  // 2b. one generated file per composed domain — the TYPES (`<name>.dart`) + the async bridge
675
687
  // CLIENT (`<name>.client.dart`, the Flutter sibling of `<name>.client.ts`).
676
688
  for (const mod of modules) {
@@ -681,7 +693,28 @@ function emitDart(
681
693
  // framework import. Only pass the import when this domain actually folds a framework symbol.
682
694
  const { modForDart, skips, foldsFramework } = domainDedupOpts(mod, shared);
683
695
  const domainFrameworkImport = foldsFramework ? frameworkImportUri : undefined;
684
- const src = generateDartDomain(modForDart, {
696
+ // AUTO cross-domain foreignRef imports: for each foreignRef whose target aggregate lives in ANOTHER
697
+ // tenant domain, import that domain's generated file so `ForeignRef<Target>` + `Target.fromJson` resolve.
698
+ // A target that is framework-shared resolves via the frameworkImportUri (not in the tenant map), so it is
699
+ // skipped here. Merged with any manual `dartImports`; deduped by uri.
700
+ const foreignRefImportUris = new Set<string>();
701
+ for (const agg of (modForDart.aggregates as { fields: Record<string, { foreignRef?: { targetAggregateId: string } }> }[])) {
702
+ for (const f of Object.values(agg.fields)) {
703
+ const tid = f.foreignRef?.targetAggregateId;
704
+ if (tid === undefined) continue;
705
+ const targetFile = aggIdToDartFile.get(tid);
706
+ if (targetFile !== undefined && targetFile !== modForDart.name) foreignRefImportUris.add(`${targetFile}.dart`);
707
+ }
708
+ }
709
+ const mergedDartImports = [
710
+ ...(modForDart.dartImports ?? []),
711
+ ...[...foreignRefImportUris]
712
+ .filter((uri) => !(modForDart.dartImports ?? []).some((i) => i.uri === uri))
713
+ .sort()
714
+ .map((uri) => ({ uri })),
715
+ ];
716
+ const modForDartWithImports = mergedDartImports.length > 0 ? { ...modForDart, dartImports: mergedDartImports } : modForDart;
717
+ const src = generateDartDomain(modForDartWithImports, {
685
718
  ...skips,
686
719
  ...(domainFrameworkImport !== undefined ? { frameworkImportUri: domainFrameworkImport } : {}),
687
720
  });
@@ -854,18 +887,29 @@ function emitDartPackages(
854
887
  // ── the TYPES package — pure data, `nomos_types` only ──
855
888
  const typesGen = freshGenDir(packages.types);
856
889
  const typesSources = new Map<string, string>(); // fileBase → src (for alias validation + purity)
890
+ // The SIBLING generated type files a types-package file MAY import (a cross-domain `t.foreignRef` types its
891
+ // field as the target aggregate's class, which lives in the target domain's file). A sibling types file is
892
+ // still PURE (no runtime/bridge surface — the purity gate exists to keep nomos_client out, not to isolate
893
+ // domain files from each other; Dart allows type-import cycles for mutual refs).
894
+ const siblingTypeFiles = new Set<string>([...modules.map((m) => `${m.name}.dart`), `${FRAMEWORK_FILE}.dart`]);
857
895
  const writeTypes = (fileBase: string, src: string): void => {
858
896
  if (!src.includes(DART_PACKAGE_IMPORT)) {
859
897
  fail(`generated Dart for '${fileBase}' does not carry the expected support import — codegen_dart header drifted`);
860
898
  }
861
899
  const pure = src.replace(DART_PACKAGE_IMPORT, DART_TYPES_IMPORT);
862
- // PURITY, fail-closed: only the nomos_types barrel + the relative framework file may be imported.
900
+ // PURITY, fail-closed: only the nomos_types barrel, the framework file, and SIBLING generated type files
901
+ // (cross-domain foreignRef targets) may be imported. Runtime/bridge surfaces (nomos_client) stay out.
863
902
  for (const line of pure.split("\n")) {
864
903
  const m = /^import\s+'([^']+)'/.exec(line.trim());
865
- if (m !== null && m[1] !== "package:nomos_types/nomos_types.dart" && m[1] !== `${FRAMEWORK_FILE}.dart`) {
904
+ if (
905
+ m !== null &&
906
+ m[1] !== "package:nomos_types/nomos_types.dart" &&
907
+ m[1] !== `${FRAMEWORK_FILE}.dart` &&
908
+ !siblingTypeFiles.has(m[1]!)
909
+ ) {
866
910
  fail(
867
911
  `types-package purity: generated '${fileBase}.dart' imports '${m[1]}' — a types file may only ` +
868
- `import the nomos_types core (runtime/bridge surfaces belong in the client package)`,
912
+ `import the nomos_types core, the framework file, or a sibling generated domain file`,
869
913
  );
870
914
  }
871
915
  }
@@ -876,11 +920,28 @@ function emitDartPackages(
876
920
  };
877
921
 
878
922
  if (shared.frameworkMod !== undefined) writeTypes(FRAMEWORK_FILE, generateDartDomain(shared.frameworkMod));
923
+ // CROSS-DOMAIN foreignRef (layered): map aggregate id → its domain's type file, so a foreignRef field's
924
+ // target class resolves via a sibling import (the purity gate now permits sibling type files).
925
+ const aggIdToTypeFile = new Map<string, string>();
926
+ for (const m of modules) for (const agg of (m.aggregates as { id: string }[])) aggIdToTypeFile.set(agg.id, m.name);
879
927
  const perDomain = modules.map((mod) => ({ mod, dd: domainDedupOpts(mod, shared) }));
880
928
  for (const { mod, dd } of perDomain) {
929
+ const frImports = new Set<string>();
930
+ for (const agg of (dd.modForDart.aggregates as { fields: Record<string, { foreignRef?: { targetAggregateId: string } }> }[])) {
931
+ for (const f of Object.values(agg.fields)) {
932
+ const tid = f.foreignRef?.targetAggregateId;
933
+ const tf = tid !== undefined ? aggIdToTypeFile.get(tid) : undefined;
934
+ if (tf !== undefined && tf !== dd.modForDart.name) frImports.add(`${tf}.dart`);
935
+ }
936
+ }
937
+ const merged = [
938
+ ...(dd.modForDart.dartImports ?? []),
939
+ ...[...frImports].filter((uri) => !(dd.modForDart.dartImports ?? []).some((i) => i.uri === uri)).sort().map((uri) => ({ uri })),
940
+ ];
941
+ const modForTypes = merged.length > 0 ? { ...dd.modForDart, dartImports: merged } : dd.modForDart;
881
942
  writeTypes(
882
943
  mod.name,
883
- generateDartDomain(dd.modForDart, {
944
+ generateDartDomain(modForTypes, {
884
945
  ...dd.skips,
885
946
  ...(dd.foldsFramework ? { frameworkImportUri: `${FRAMEWORK_FILE}.dart` } : {}),
886
947
  }),