@githolon/dsl 0.62.0 → 0.64.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.62.0",
3
+ "version": "0.64.0",
4
4
  "type": "module",
5
5
  "description": "Nomos 2 domain-authoring DSL: aggregates + directives in TS, executed and encoded to the Rust kernel's wire shapes.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
package/src/aggregate.ts CHANGED
@@ -243,6 +243,27 @@ export function aggregate<const Id extends string, F extends Record<string, Fiel
243
243
  opts?: { invariant?: AggregateInvariantFn; invariantDeclassify?: string; cap?: number; visibility?: ReadVisibility; global?: boolean },
244
244
  ): AggregateHandle<Id, F> & AudienceMethods<Id, F> {
245
245
  const { stored, hasMany } = partitionFields(fields);
246
+ // FOREIGN-REF SIBLING CHECK (pain point #4): a `t.foreignRef({ workspace, target })` field names a
247
+ // SIBLING string field (`workspaceField`) that holds the foreign workspace id — the target workspace is
248
+ // DYNAMIC, read from that sibling at resolve time. That link is STRINGLY-TYPED (a bare field name), so a
249
+ // typo would silently produce a locator pointing at a non-existent workspace field. Catch it HERE, at
250
+ // compile: the named sibling must exist on THIS aggregate and be a plain string-kind field (it holds a
251
+ // workspace id). It must be a real string field — not itself a foreignRef/ref — since it carries the raw id.
252
+ for (const [name, field] of Object.entries(stored)) {
253
+ const fref = (field as Field).foreignRef;
254
+ if (fref === undefined) continue;
255
+ const sibling = stored[fref.workspaceField] as Field | undefined;
256
+ if (sibling === undefined) {
257
+ throw new Error(
258
+ `aggregate '${id}': field '${name}' is a t.foreignRef whose workspace field '${fref.workspaceField}' does not exist on aggregate '${id}' — add a \`${fref.workspaceField}: t.string()\` field to aggregate '${id}', or fix the \`workspace:\` name in the foreignRef`,
259
+ );
260
+ }
261
+ if (sibling.kind !== "string") {
262
+ throw new Error(
263
+ `aggregate '${id}': field '${name}' is a t.foreignRef whose workspace field '${fref.workspaceField}' is kind '${sibling.kind}', but it must be a plain string-kind field (it holds the foreign workspace id) — declare \`${fref.workspaceField}: t.string()\``,
264
+ );
265
+ }
266
+ }
246
267
  if (opts?.cap !== undefined && (!Number.isInteger(opts.cap) || opts.cap < 1)) {
247
268
  throw new Error(`aggregate '${id}': cap must be a positive integer (got ${opts.cap})`);
248
269
  }
package/src/codegen_ts.ts CHANGED
@@ -496,8 +496,12 @@ function tsBaseTypeOfField(field: Field): string {
496
496
  } catch {
497
497
  return "string";
498
498
  }
499
- case "ref":
500
- return "string"; // the related aggregate's id
499
+ case "ref": {
500
+ // A `t.foreignRef` field surfaces as a TYPED cross-workspace locator (hydrated by the accessors from
501
+ // the raw id + the declared workspace field); a plain same-workspace `t.ref` stays the id string.
502
+ const fr = (field as { foreignRef?: { targetAggregateId: string } }).foreignRef;
503
+ return fr !== undefined ? `ForeignRef<${fr.targetAggregateId}Data>` : "string";
504
+ }
501
505
  case "json":
502
506
  // A NAMED VALUE OBJECT field reads as its generated interface (`value_object.ts`);
503
507
  // a bare `t.jsonObject()` stays the anonymous object; a plain `t.json()` is unknown.
@@ -720,6 +724,76 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
720
724
  : []),
721
725
  `/** One projection row: the aggregate's wire type, its id, and the folded fields. */`,
722
726
  `export interface Row<T> { type: string; id: string; data: T }`,
727
+ // MULTI-WORKSPACE LOCATORS (t.foreignRef): a TYPED cross-workspace edge — the foreign aggregate's id +
728
+ // the foreign workspace id (from a sibling field). NOT an atomic FK: it does not clone/open the other
729
+ // holon; `resolve`/`watch` open it ON DEMAND over a realm (openRealm's `.workspace(ws)` surface).
730
+ // The reader `resolve`/`watch`/`attest` need: the multi-workspace `.workspace(ws)` surface `openRealm()`
731
+ // returns (a plain `connect()` holon is single-workspace — pass a Realm). `attestForeign` is the attested-
732
+ // read extension (present only when your law declares `trustSource` + `.readsFrom`). ANY object with this
733
+ // shape works — the generated client is import-free and binds structurally to your @githolon/client.
734
+ `export interface NomosForeignReader { workspace(ws: string): { queryById(id: string): Promise<{ type: string; id: string; data: unknown }[]>; watchById?(id: string, cb: (rows: { type: string; id: string; data: unknown }[]) => void, opts?: { intervalMs?: number }): () => void }; attestForeign?(workspace: string, id: string, type: string): Promise<{ id: string; workspace: string; exists: boolean; attestation: string }>; }`,
735
+ // resolve() collapses "not there" to undefined for the 99% lookup; resolveResult() names WHY — a foreign
736
+ // read can come back empty because the target does not exist, you lack read access, or the holon is
737
+ // unreachable, and those are very different. `denied`/`unreachable` are best-effort from the reader's
738
+ // error (an error whose message mentions auth/permission/forbidden ⇒ denied; else unreachable).
739
+ `export type ForeignResolveResult<TData> = { status: "found"; row: Row<TData> } | { status: "not-found" } | { status: "denied"; error: unknown } | { status: "unreachable"; error: unknown };`,
740
+ // A ForeignRef is a TYPED CROSS-WORKSPACE LOCATOR, not an atomic FK. In ~99% of cases you use it as a
741
+ // lookup — `resolve`/`watch` open the foreign holon on demand so you can do more reads. `exists` is the
742
+ // cheap "is the target still there?" check. `attest` is the LAW-GRADE one — signed evidence that the
743
+ // reference resolves — and only works when your law DECLARES the dependency (`trustSource` + a
744
+ // `.readsFrom(...)` on the directive); it is deliberately NOT a free method, because attesting has cost
745
+ // and law meaning. Resolving/existence are informational; attestation is what you reach for when the
746
+ // current workspace's LAW depends on the foreign fact.
747
+ `export interface ForeignRef<TData> {`,
748
+ ` /** The foreign aggregate's id (this field's stored value). */`,
749
+ ` readonly id: string;`,
750
+ ` /** The foreign workspace id (from the declared sibling workspace field). */`,
751
+ ` readonly workspace: string;`,
752
+ ` /** The foreign aggregate type this locator points at. */`,
753
+ ` readonly type: string;`,
754
+ ` /** THE LOOKUP (the 99% case): open the foreign workspace + read the target by id — on demand, no clone.`,
755
+ ` * Returns undefined for ANY empty outcome; use resolveResult() when you need to know WHY. */`,
756
+ ` resolve(nomos: NomosForeignReader): Promise<Row<TData> | undefined>;`,
757
+ ` /** The lookup with the OUTCOME named — found / not-found / denied / unreachable (a foreign read can be`,
758
+ ` * empty for very different reasons; do not collapse them when it matters). */`,
759
+ ` resolveResult(nomos: NomosForeignReader): Promise<ForeignResolveResult<TData>>;`,
760
+ ` /** Subscribe to the target's read model across the workspace boundary (returns an unsubscribe fn). */`,
761
+ ` watch(nomos: NomosForeignReader, cb: (row: Row<TData> | undefined) => void, opts?: { intervalMs?: number }): () => void;`,
762
+ ` /** Cheap existence check — does the referenced target still exist? (found ⇒ true; anything else ⇒ false). */`,
763
+ ` exists(nomos: NomosForeignReader): Promise<boolean>;`,
764
+ ` /** LAW-GRADE: signed evidence that the reference resolves, for when your law DEPENDS on the foreign fact.`,
765
+ ` * Requires the attested-read lane — declare \`trustSource(workspace, ...)\` + \`.readsFrom(...)\` on the`,
766
+ ` * directive; without it (no \`attestForeign\` on the reader) this throws, by design (a locator is not an FK). */`,
767
+ ` attest(nomos: NomosForeignReader): Promise<{ id: string; workspace: string; exists: boolean; attestation: string }>;`,
768
+ `}`,
769
+ `/** Build a typed foreign-ref locator from a row's id + workspace-id values. The generated \`<domain>ForeignRefs\` factory calls this with the declared fields. */`,
770
+ `export function foreignRef<TData>(type: string, id: string, workspace: string): ForeignRef<TData> {`,
771
+ ` return {`,
772
+ ` id, workspace, type,`,
773
+ ` async resolveResult(nomos) {`,
774
+ ` if (!id || !workspace) return { status: "not-found" };`,
775
+ ` try {`,
776
+ ` const rows = await nomos.workspace(workspace).queryById(id);`,
777
+ ` const row = (rows.find((r) => r.id === id) ?? rows[0]) as Row<TData> | undefined;`,
778
+ ` return row !== undefined ? { status: "found", row } : { status: "not-found" };`,
779
+ ` } catch (error) {`,
780
+ ` const m = String((error as { message?: unknown })?.message ?? error).toLowerCase();`,
781
+ ` return { status: /forbid|denied|permission|unauthor|not permitted|403|401/.test(m) ? "denied" : "unreachable", error };`,
782
+ ` }`,
783
+ ` },`,
784
+ ` async resolve(nomos) { const r = await this.resolveResult(nomos); return r.status === "found" ? r.row : undefined; },`,
785
+ ` watch(nomos, cb, opts) {`,
786
+ ` const w = nomos.workspace(workspace).watchById;`,
787
+ ` if (!w || !id || !workspace) { cb(undefined); return () => {}; }`,
788
+ ` return w(id, (rows) => cb((rows.find((r) => r.id === id) ?? rows[0]) as Row<TData> | undefined), opts);`,
789
+ ` },`,
790
+ ` async exists(nomos) { return (await this.resolveResult(nomos)).status === "found"; },`,
791
+ ` async attest(nomos) {`,
792
+ ` if (!nomos.attestForeign) throw new Error("attest() needs the attested-read lane — declare trustSource(" + JSON.stringify(workspace) + ", …) + .readsFrom(…) on the directive whose law depends on this reference (a foreignRef is a locator, not an atomic foreign key)");`,
793
+ ` return nomos.attestForeign(workspace, id, type);`,
794
+ ` },`,
795
+ ` };`,
796
+ `}`,
723
797
  ``,
724
798
  `/** Arbitrary JSON — the type a RECURSIVE \`z.lazy\` payload subtree degrades to (a self-`,
725
799
  ` * referencing tree/JSON schema can't be a finite named literal, so the engine validates it`,
@@ -1145,6 +1219,11 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
1145
1219
  }
1146
1220
 
1147
1221
  out.push(`// ── read models (projection \`data\` shapes; partial-fold optional, EXCEPT .fromBirth()`, `// fields — their presence is gate law, so the type is non-optional) ──`, ``);
1222
+ const foreignRefFields: { agg: string; field: string; workspaceField: string; target: string }[] = [];
1223
+ // wrap a read accessor return expression through the foreignRef row-hydrator (identity + byte-identical
1224
+ // when the package declares no foreignRef, so non-foreignRef packages are unchanged).
1225
+ const wrapP = (expr: string): string => (foreignRefFields.length > 0 ? `_hydrateP(${expr})` : expr);
1226
+ const wrapRows = (expr: string): string => (foreignRefFields.length > 0 ? `_hydrateRows(${expr})` : expr);
1148
1227
  for (const [typeId, agg] of [...aggByType].sort(([a], [b]) => a.localeCompare(b))) {
1149
1228
  out.push(`export interface ${typeId}Data {`);
1150
1229
  for (const [name, field] of Object.entries(agg.fields)) {
@@ -1152,10 +1231,54 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
1152
1231
  const doc = nullDocLine(field);
1153
1232
  if (doc !== undefined) out.push(` /** ${doc} */`);
1154
1233
  out.push(` ${name}${required ? "" : "?"}: ${tsTypeOfField(field as Field)};`);
1234
+ const fr = (field as { foreignRef?: { workspaceField: string; targetAggregateId: string } }).foreignRef;
1235
+ if (fr !== undefined) foreignRefFields.push({ agg: typeId, field: name, workspaceField: fr.workspaceField, target: fr.targetAggregateId });
1155
1236
  }
1156
1237
  for (const extra of extrasByType.get(typeId) ?? []) out.push(extra);
1157
1238
  out.push(`}`, ``);
1158
1239
  }
1240
+ // MULTI-WORKSPACE LOCATOR FACTORY (t.foreignRef): per aggregate, a typed builder that turns a projection
1241
+ // row into a `ForeignRef<TargetData>` — reading the foreign id (the field) + the foreign workspace id (the
1242
+ // declared sibling field). The read model stays honest (the field is the raw stored id string); this gives
1243
+ // the TYPED locator with resolve/watch on top, without cloning the other holon.
1244
+ if (foreignRefFields.length > 0) {
1245
+ const byAgg = new Map<string, typeof foreignRefFields>();
1246
+ for (const f of foreignRefFields) { const l = byAgg.get(f.agg) ?? []; l.push(f); byAgg.set(f.agg, l); }
1247
+ out.push(`// ── foreign-ref locators (t.foreignRef): typed cross-workspace edges (resolve/watch on demand) ──`);
1248
+ out.push(`export const ${camel(opts.packageName)}ForeignRefs = {`);
1249
+ for (const [agg, fields] of [...byAgg].sort(([a], [b]) => a.localeCompare(b))) {
1250
+ out.push(` ${agg}: {`);
1251
+ for (const f of fields) {
1252
+ out.push(
1253
+ ` /** \`${agg}.${f.field}\` → \`${f.target}\` in the workspace named by \`${f.workspaceField}\`. */`,
1254
+ ` ${f.field}: (row: ${agg}Data): ForeignRef<${f.target}Data> => foreignRef(${JSON.stringify(f.target)}, String(row.${f.field} ?? ""), String((row as Record<string, unknown>)[${JSON.stringify(f.workspaceField)}] ?? "")),`,
1255
+ );
1256
+ }
1257
+ out.push(` },`);
1258
+ }
1259
+ out.push(`} as const;`, ``);
1260
+ // ROW HYDRATION: the read model surfaces a foreignRef field as a typed `ForeignRef` locator (so
1261
+ // `row.data.<field>.resolve(nomos)` works directly). The projection stores the raw id string; the read
1262
+ // accessors pipe rows through `_hydrateRows`, which — for aggregates that declare foreignRefs — replaces
1263
+ // each foreignRef field with a locator built from its id + the declared sibling workspace field.
1264
+ // `row.data.<field>.id` is still the raw id. Identity for any aggregate without foreignRefs.
1265
+ const hydrateSpecs: Record<string, { field: string; wsField: string; target: string }[]> = {};
1266
+ for (const f of foreignRefFields) { (hydrateSpecs[f.agg] ??= []).push({ field: f.field, wsField: f.workspaceField, target: f.target }); }
1267
+ out.push(
1268
+ `const _FOREIGN_REFS: Record<string, { field: string; wsField: string; target: string }[]> = ${JSON.stringify(hydrateSpecs)};`,
1269
+ `function _hydrateRows<T>(rows: Row<T>[]): Row<T>[] {`,
1270
+ ` return rows.map((row) => {`,
1271
+ ` const specs = _FOREIGN_REFS[row.type];`,
1272
+ ` if (specs === undefined || row.data == null) return row;`,
1273
+ ` const data: Record<string, unknown> = { ...(row.data as Record<string, unknown>) };`,
1274
+ ` for (const s of specs) { const v = data[s.field]; if (typeof v === "string") data[s.field] = foreignRef(s.target, v, String(data[s.wsField] ?? "")); }`,
1275
+ ` return { ...row, data: data as T };`,
1276
+ ` });`,
1277
+ `}`,
1278
+ `const _hydrateP = <T>(p: Promise<Row<T>[]>): Promise<Row<T>[]> => p.then(_hydrateRows);`,
1279
+ ``,
1280
+ );
1281
+ }
1159
1282
 
1160
1283
  // ── DERIVED BIRTH LANES (sharding §10 RATIFIED, slice 1) ──
1161
1284
  // A `workspaceType(...).hasMany(...)` taxonomy DERIVES typed birth helpers over
@@ -1494,7 +1617,7 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
1494
1617
  for (const sp of (mod.spatials ?? []) as { id: string; of: string; on: string }[]) {
1495
1618
  methods.push(
1496
1619
  ` /** Declared spatial index \`${sp.id}\` — rows of \`${sp.of}\` whose \`${sp.on}\` geometry intersects the bbox (R*Tree probe). */`,
1497
- ` ${lcFirst(camel(sp.id))}: (bbox: { minX: number; minY: number; maxX: number; maxY: number } | { minLng: number; minLat: number; maxLng: number; maxLat: number }) => (holon.spatialWithin ?? (() => { throw new Error("this holon predates spatial reads — upgrade @githolon/client"); }))(${JSON.stringify(sp.id)}, bbox) as Promise<Row<${sp.of}Data>[]>,`,
1620
+ ` ${lcFirst(camel(sp.id))}: (bbox: { minX: number; minY: number; maxX: number; maxY: number } | { minLng: number; minLat: number; maxLng: number; maxLat: number }) => ${wrapP(`(holon.spatialWithin ?? (() => { throw new Error("this holon predates spatial reads — upgrade @githolon/client"); }))(${JSON.stringify(sp.id)}, bbox) as Promise<Row<${sp.of}Data>[]>`)},`,
1498
1621
  );
1499
1622
  }
1500
1623
  for (const q of (mod.queries ?? []) as QueryDecl[]) {
@@ -1511,8 +1634,8 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
1511
1634
  methods.push(
1512
1635
  ` /** Declared query \`${q.id}\` (indexed probe — routed by the read manifest).${qScopes.length ? " Encrypted field(s) decrypt when the bound { keyStore } holds the scope key; else they stay opaque envelopes." : ""} */`,
1513
1636
  qScopes.length
1514
- ? ` ${lcFirst(camel(q.id))}: async (params: { ${params} }) => holon.query(${JSON.stringify(q.id)}, params, await _readKeys(keyStore, ${JSON.stringify(qScopes)})) as Promise<Row<${q.returns}Data>[]>,`
1515
- : ` ${lcFirst(camel(q.id))}: (params: { ${params} }) => holon.query(${JSON.stringify(q.id)}, params) as Promise<Row<${q.returns}Data>[]>,`,
1637
+ ? ` ${lcFirst(camel(q.id))}: async (params: { ${params} }) => ${wrapP(`holon.query(${JSON.stringify(q.id)}, params, await _readKeys(keyStore, ${JSON.stringify(qScopes)})) as Promise<Row<${q.returns}Data>[]>`)},`
1638
+ : ` ${lcFirst(camel(q.id))}: (params: { ${params} }) => ${wrapP(`holon.query(${JSON.stringify(q.id)}, params) as Promise<Row<${q.returns}Data>[]>`)},`,
1516
1639
  );
1517
1640
  // THE LOGICAL PAGED ROW SURFACE (sharding §5.3, ruling 2 — slice 4): emitted
1518
1641
  // ONLY for packed-taxonomy domains (taxonomy-free clients stay byte-identical).
@@ -1553,15 +1676,17 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
1553
1676
  methods.push(
1554
1677
  ` /** Read one ${agg.id} by aggregate id.${aScopes.length ? " Encrypted field(s) decrypt when the bound { keyStore } holds the scope key." : ""} */`,
1555
1678
  aScopes.length
1556
- ? ` ${lcFirst(camel(agg.id))}ById: async (id: string) => holon.queryById(id, await _readKeys(keyStore, ${JSON.stringify(aScopes)})) as Promise<Row<${agg.id}Data>[]>,`
1557
- : ` ${lcFirst(camel(agg.id))}ById: (id: string) => holon.queryById(id) as Promise<Row<${agg.id}Data>[]>,`,
1679
+ ? ` ${lcFirst(camel(agg.id))}ById: async (id: string) => ${wrapP(`holon.queryById(id, await _readKeys(keyStore, ${JSON.stringify(aScopes)})) as Promise<Row<${agg.id}Data>[]>`)},`
1680
+ : ` ${lcFirst(camel(agg.id))}ById: (id: string) => ${wrapP(`holon.queryById(id) as Promise<Row<${agg.id}Data>[]>`)},`,
1558
1681
  ` /** Every ${agg.id} (the type-index probe — O(result), not a scan). */`,
1559
1682
  aScopes.length
1560
- ? ` list${agg.id}s: async () => holon.query("aggregatesByType", { __type: ${JSON.stringify(agg.id)} }, await _readKeys(keyStore, ${JSON.stringify(aScopes)})) as Promise<Row<${agg.id}Data>[]>,`
1561
- : ` list${agg.id}s: () => holon.query("aggregatesByType", { __type: ${JSON.stringify(agg.id)} }) as Promise<Row<${agg.id}Data>[]>,`,
1683
+ ? ` list${agg.id}s: async () => ${wrapP(`holon.query("aggregatesByType", { __type: ${JSON.stringify(agg.id)} }, await _readKeys(keyStore, ${JSON.stringify(aScopes)})) as Promise<Row<${agg.id}Data>[]>`)},`
1684
+ : ` list${agg.id}s: () => ${wrapP(`holon.query("aggregatesByType", { __type: ${JSON.stringify(agg.id)} }) as Promise<Row<${agg.id}Data>[]>`)},`,
1562
1685
  ` /** Watch one ${agg.id} by aggregate id (local reactive read; returns stop()). */`,
1563
1686
  ` watch${agg.id}ById: (id: string, cb: (rows: Row<${agg.id}Data>[]) => void, opts?: { intervalMs?: number }) =>`,
1564
- ` holon.watchById(id, cb as (rows: unknown) => void, opts),`,
1687
+ foreignRefFields.length > 0
1688
+ ? ` holon.watchById(id, ((rows: Row<${agg.id}Data>[]) => cb(_hydrateRows(rows))) as (rows: unknown) => void, opts),`
1689
+ : ` holon.watchById(id, cb as (rows: unknown) => void, opts),`,
1565
1690
  );
1566
1691
  }
1567
1692
 
package/src/fields.ts CHANGED
@@ -268,6 +268,21 @@ export interface Field<T = unknown, K extends FieldKind = FieldKind> {
268
268
  readonly clearRequires?: string;
269
269
  /** For `t.ref` cross-workspace edges: the foreign workspace id, if any. */
270
270
  readonly refWorkspace?: string;
271
+ /**
272
+ * `t.foreignRef({ workspace, target })` — a TYPED CROSS-WORKSPACE LOCATOR (not an atomic FK). Stored as
273
+ * the foreign aggregate's id STRING (kind `"ref"`, zero kernel change); this meta names WHERE the foreign
274
+ * workspace id lives (`workspaceField` — a sibling field on THIS aggregate, so the target workspace is
275
+ * DYNAMIC) + the TARGET aggregate/domain (typed codegen). The read model surfaces it as a typed locator
276
+ * (`ForeignRef<TargetData>` = `{ id, workspace }`), NEVER auto-cloning the other holon; the generated
277
+ * client's `resolve(nomos)`/`watch(nomos)` open the foreign workspace on demand. Law that DEPENDS on the
278
+ * foreign fact opts into attested evidence separately (`trustSource`/`.readsFrom`). Present ⇒ codegen emits
279
+ * the locator; absent ⇒ a plain `t.ref`. Additive IR (`nomosForeignRefs`), era-safe.
280
+ */
281
+ readonly foreignRef?: {
282
+ readonly workspaceField: string;
283
+ readonly targetAggregateId: string;
284
+ readonly targetDomain?: string;
285
+ };
271
286
  /** For `t.ref` (the target) AND `t.hasMany` (the child type): the related aggregate's wire id. */
272
287
  readonly refAggregateId?: string;
273
288
  /** For `t.hasMany(Child).via(backref)`: the back-reference field NAME on the child. Virtual. */
@@ -396,7 +411,14 @@ function makeField<T, K extends FieldKind = FieldKind>(
396
411
  driver: Driver,
397
412
  extra: {
398
413
  refWorkspace?: string;
414
+ foreignRef?: { workspaceField: string; targetAggregateId: string; targetDomain?: string };
399
415
  refAggregateId?: string;
416
+ /** LAZY target resolution — `t.ref(() => Agg)` / `t.hasMany(() => Agg)`. Breaks a BIDIRECTIONAL
417
+ * declaration cycle (a Parent's `hasMany(Child)` ⇄ a Child's `ref(Parent)`): the thunk is stored and
418
+ * resolved on FIRST read of `refAggregateId` (at manifest build — after all module consts init), so
419
+ * neither side needs the other to already exist at its own `const`. Consumers read `refAggregateId`
420
+ * transparently (a memoized getter). */
421
+ refAggregateIdThunk?: () => string;
400
422
  viaField?: string;
401
423
  isOptional?: boolean;
402
424
  fromBirth?: true;
@@ -422,6 +444,7 @@ function makeField<T, K extends FieldKind = FieldKind>(
422
444
  ...(extra.facet !== undefined ? { facet: extra.facet } : {}),
423
445
  ...(extra.refWorkspace !== undefined ? { refWorkspace: extra.refWorkspace } : {}),
424
446
  ...(extra.refAggregateId !== undefined ? { refAggregateId: extra.refAggregateId } : {}),
447
+ ...(extra.foreignRef !== undefined ? { foreignRef: extra.foreignRef } : {}),
425
448
  ...(extra.viaField !== undefined ? { viaField: extra.viaField } : {}),
426
449
  ...(extra.mapValueKind !== undefined ? { mapValueKind: extra.mapValueKind } : {}),
427
450
  ...(extra.jsonShape !== undefined ? { jsonShape: extra.jsonShape } : {}),
@@ -496,6 +519,21 @@ function makeField<T, K extends FieldKind = FieldKind>(
496
519
  return makeField<T, K>(kind, zod, driver, { ...extra, facet }) as Field<T, K> & { readonly facet: Fa };
497
520
  },
498
521
  };
522
+ // LAZY REF TARGET: when the target was given as a thunk (`t.ref(() => Agg)` / `t.hasMany(() => Agg)` — to
523
+ // break a bidirectional declaration cycle), expose `refAggregateId` as a MEMOIZED getter that resolves the
524
+ // thunk on first read (build time, after all module consts init). Consumers read `refAggregateId` as usual.
525
+ if (extra.refAggregateIdThunk !== undefined) {
526
+ const thunk = extra.refAggregateIdThunk;
527
+ let resolved: string | undefined;
528
+ Object.defineProperty(f, "refAggregateId", {
529
+ configurable: true,
530
+ enumerable: true,
531
+ get() {
532
+ if (resolved === undefined) resolved = thunk();
533
+ return resolved;
534
+ },
535
+ });
536
+ }
499
537
  return f;
500
538
  }
501
539
 
@@ -732,6 +770,30 @@ export const t = {
732
770
  * routing is the kernel's job.
733
771
  */
734
772
  ref: refImpl as RefFn,
773
+ /**
774
+ * `t.foreignRef({ workspace, target })` — a TYPED CROSS-WORKSPACE LOCATOR. Use for an edge into ANOTHER
775
+ * holon (a catalogue listing shared into an estate, a media/blob ref, a home's "accessible estate" card):
776
+ * the field STORES the foreign aggregate's id, `workspace` names a SIBLING field holding the foreign
777
+ * workspace id (so the target workspace is dynamic), and `target` types it to the foreign aggregate. The
778
+ * read model surfaces a typed `ForeignRef` locator ({ id, workspace }); it does NOT clone/open the other
779
+ * holon — the generated client's `resolve(nomos)`/`watch(nomos)` do that on demand. It is a LOCATOR, not a
780
+ * database join: law that depends on the foreign FACT opts into attested evidence via `trustSource` +
781
+ * `.readsFrom(...)`. Same-workspace edges stay `t.ref`/`t.hasMany`; this is the honest cross-workspace twin.
782
+ */
783
+ foreignRef(opts: {
784
+ workspace: string;
785
+ target: AggregateHandle<string, Record<string, Field>>;
786
+ domain?: string;
787
+ }): Field<string, "ref"> {
788
+ return makeField<string, "ref">("ref", z.string(), Conflict, {
789
+ refAggregateId: opts.target.id,
790
+ foreignRef: {
791
+ workspaceField: opts.workspace,
792
+ targetAggregateId: opts.target.id,
793
+ ...(opts.domain !== undefined ? { targetDomain: opts.domain } : {}),
794
+ },
795
+ });
796
+ },
735
797
  /**
736
798
  * A 1:N INVERSE relation — the read side of a `t.ref` on the child (`aggregate_lifecycle_and_relations.md`).
737
799
  * `t.hasMany(Child).via("parent")` on a Parent declares "the Children whose `parent` ref points here".
@@ -740,17 +802,26 @@ export const t = {
740
802
  * (one event on the CHILD, parent untouched), and the read engine serves `parent.children` as the
741
803
  * managed `Child by parent` index. The dev never builds a write-amplifying parent collection.
742
804
  */
743
- hasMany(child: AggregateHandle<string, Record<string, Field>>): {
744
- via(backref: string): Field<string[], "hasMany">;
745
- } {
805
+ hasMany: (function hasMany(
806
+ child: AggregateHandle<string, Record<string, Field>> | (() => AggregateHandle<string, Record<string, Field>>),
807
+ ): { via(backref: string): Field<string[], "hasMany"> } {
808
+ // THUNK form (`t.hasMany(() => Child)`) — the common bidirectional case (a Parent's `hasMany(Child)` ⇄ a
809
+ // Child's `ref(Parent)`): pass a thunk so the Parent needn't wait for the Child's `const` to exist.
810
+ const isThunk = typeof child === "function";
746
811
  return {
747
812
  via(backref: string): Field<string[], "hasMany"> {
748
813
  return makeField<string[], "hasMany">("hasMany", z.array(z.string()), Conflict, {
749
- refAggregateId: child.id,
814
+ ...(isThunk ? { refAggregateIdThunk: () => child().id } : { refAggregateId: child.id }),
750
815
  viaField: backref,
751
816
  });
752
817
  },
753
818
  };
819
+ }) as {
820
+ /** 1:N inverse by aggregate HANDLE. */
821
+ (child: AggregateHandle<string, Record<string, Field>>): { via(backref: string): Field<string[], "hasMany"> };
822
+ /** 1:N inverse by a THUNK (`t.hasMany(() => Child)`) — a DISTINCT overload so the arrow's return is
823
+ * contextually the WIDE handle, breaking the bidirectional `const` cycle (no circular-`any`). */
824
+ (child: () => AggregateHandle<string, Record<string, Field>>): { via(backref: string): Field<string[], "hasMany"> };
754
825
  },
755
826
  /**
756
827
  * A content-addressed EVIDENCE reference (`evidence.md` §6/§9.2). The authored value is
@@ -768,8 +839,15 @@ export const t = {
768
839
  },
769
840
  };
770
841
 
842
+ type RefTarget = AggregateHandle<string, Record<string, Field>> | (() => AggregateHandle<string, Record<string, Field>>);
843
+
771
844
  interface RefFn {
845
+ /** Same-workspace reference by aggregate HANDLE. */
772
846
  (target: AggregateHandle<string, Record<string, Field>>): Field<string, "ref">;
847
+ /** Same-workspace reference by a THUNK (`t.ref(() => Agg)`) — breaks a bidirectional declaration cycle.
848
+ * A DISTINCT overload (not a union arm) so TS contextually types the arrow's return as the WIDE handle,
849
+ * which is what keeps `const A = ...ref(() => B)` / `const B = ...ref(() => A)` from a circular-`any`. */
850
+ (target: () => AggregateHandle<string, Record<string, Field>>): Field<string, "ref">;
773
851
  (
774
852
  workspaceId: string,
775
853
  target: AggregateHandle<string, Record<string, Field>>,
@@ -777,7 +855,7 @@ interface RefFn {
777
855
  }
778
856
 
779
857
  function refImpl(
780
- a: string | AggregateHandle<string, Record<string, Field>>,
858
+ a: string | RefTarget,
781
859
  b?: AggregateHandle<string, Record<string, Field>>,
782
860
  ): Field<string, "ref"> {
783
861
  if (typeof a === "string") {
@@ -787,6 +865,10 @@ function refImpl(
787
865
  refAggregateId: target.id,
788
866
  });
789
867
  }
868
+ // THUNK form (`t.ref(() => Agg)`) — resolve lazily to break a bidirectional declaration cycle.
869
+ if (typeof a === "function") {
870
+ return makeField<string, "ref">("ref", z.string(), Conflict, { refAggregateIdThunk: () => a().id });
871
+ }
790
872
  return makeField<string, "ref">("ref", z.string(), Conflict, { refAggregateId: a.id });
791
873
  }
792
874
 
package/src/usd.ts CHANGED
@@ -585,6 +585,24 @@ export interface UsdLayer {
585
585
  * (byte-identical hash — the era rule); declaring a VO moves the PACKAGE hash only
586
586
  * (the canonical manifest/domainHash is untouched — the VO is not wire law).
587
587
  */
588
+ /**
589
+ * MULTI-WORKSPACE LOCATORS (`t.foreignRef`) — the self-describing cross-workspace edges this domain
590
+ * declares: `{aggregate, field, workspaceField, target, targetDomain?}` sorted by (aggregate, field).
591
+ * Descriptive law metadata (codegen emits a typed `ForeignRef` locator + resolve/watch; proof knows the
592
+ * edge shape without proving the concrete foreign workspace). A LOCATOR, not an atomic FK — law that
593
+ * depends on the foreign fact opts into attested evidence separately (`nomosAttestedReadGate`). OMITTED
594
+ * when the domain declares no foreignRef (era-safe — hash-identical to before this key existed).
595
+ */
596
+ readonly nomosForeignRefs?: {
597
+ readonly v: 1;
598
+ readonly refs: readonly {
599
+ readonly aggregate: string;
600
+ readonly field: string;
601
+ readonly workspaceField: string;
602
+ readonly target: string;
603
+ readonly targetDomain?: string;
604
+ }[];
605
+ };
588
606
  readonly nomosValueObjects?: {
589
607
  readonly v: 1;
590
608
  readonly valueObjects: readonly import("./fields.js").ValueObjectDescriptor[];
@@ -1329,6 +1347,17 @@ export function emitUsd(
1329
1347
  const voUsages = collectValueObjectUsages(
1330
1348
  l.module.aggregates as unknown as readonly { id: string; fields: Record<string, import("./fields.js").Field> }[],
1331
1349
  );
1350
+ // MULTI-WORKSPACE LOCATORS: collect every `t.foreignRef` field across the module's aggregates.
1351
+ const foreignRefList: { aggregate: string; field: string; workspaceField: string; target: string; targetDomain?: string }[] = [];
1352
+ for (const agg of l.module.aggregates) {
1353
+ for (const [fname, f] of Object.entries((agg as { fields: Record<string, { foreignRef?: { workspaceField: string; targetAggregateId: string; targetDomain?: string } }> }).fields)) {
1354
+ const fr = f.foreignRef;
1355
+ if (fr === undefined) continue;
1356
+ foreignRefList.push({ aggregate: agg.id, field: fname, workspaceField: fr.workspaceField, target: fr.targetAggregateId, ...(fr.targetDomain !== undefined ? { targetDomain: fr.targetDomain } : {}) });
1357
+ }
1358
+ }
1359
+ foreignRefList.sort((a, b) => (a.aggregate !== b.aggregate ? (a.aggregate < b.aggregate ? -1 : 1) : a.field < b.field ? -1 : a.field > b.field ? 1 : 0));
1360
+ const nomosForeignRefs = foreignRefList.length > 0 ? { v: 1 as const, refs: foreignRefList } : undefined;
1332
1361
  const nomosValueObjects =
1333
1362
  moduleValueObjects !== undefined && moduleValueObjects.length > 0
1334
1363
  ? {
@@ -1388,6 +1417,7 @@ export function emitUsd(
1388
1417
  ...(nomosParentAttestedBirth !== undefined ? { nomosParentAttestedBirth } : {}),
1389
1418
  ...(nomosVerifiedFirstEnroll !== undefined ? { nomosVerifiedFirstEnroll } : {}),
1390
1419
  ...(eraCertGate !== undefined ? { nomosEraCertGate: eraCertGate } : {}),
1420
+ ...(nomosForeignRefs !== undefined ? { nomosForeignRefs } : {}),
1391
1421
  ...(nomosValueObjects !== undefined ? { nomosValueObjects } : {}),
1392
1422
  ...(Object.keys(visAggregates).length > 0
1393
1423
  ? { nomosReadVisibility: { v: 1 as const, aggregates: visAggregates } }
package/src/usd_layers.ts CHANGED
@@ -206,6 +206,9 @@ function domainLayerAttrLines(domain: string, layer: UsdLayer, indent: string):
206
206
  ...(layer.nomosValueObjects !== undefined
207
207
  ? [`${indent}custom string nomos:valueObjectsHex = ${usdaString(hexUtf8(JSON.stringify(layer.nomosValueObjects)))}`]
208
208
  : []),
209
+ ...(layer.nomosForeignRefs !== undefined
210
+ ? [`${indent}custom string nomos:foreignRefsHex = ${usdaString(hexUtf8(JSON.stringify(layer.nomosForeignRefs)))}`]
211
+ : []),
209
212
  ];
210
213
  }
211
214
 
@@ -948,7 +951,9 @@ export function flattenLayeredUsd(layers: readonly UsdLayer[]): UsdDocument {
948
951
  valueObjects = { v: 1, valueObjects: descs, ...(uses.length > 0 ? { usages: uses } : {}) };
949
952
  }
950
953
  }
951
- return {
954
+ let foreignRefs: UsdLayer["nomosForeignRefs"];
955
+ for (const l of stack) { if (l.nomosForeignRefs !== undefined) foreignRefs = l.nomosForeignRefs; }
956
+ return {
952
957
  path,
953
958
  prims: [...prims.keys()].sort().map((p) => prims.get(p)!),
954
959
  ...(identityManifest !== undefined ? { nomosIdentityManifest: identityManifest } : {}),
@@ -973,6 +978,7 @@ export function flattenLayeredUsd(layers: readonly UsdLayer[]): UsdDocument {
973
978
  ...(verifiedFirstEnroll !== undefined ? { nomosVerifiedFirstEnroll: verifiedFirstEnroll } : {}),
974
979
  ...(eraCertGate !== undefined ? { nomosEraCertGate: eraCertGate } : {}),
975
980
  ...(valueObjects !== undefined ? { nomosValueObjects: valueObjects } : {}),
981
+ ...(foreignRefs !== undefined ? { nomosForeignRefs: foreignRefs } : {}),
976
982
  };
977
983
  });
978
984
 
@@ -1112,6 +1118,7 @@ export function parseUsdaDocument(text: string): UsdDocument {
1112
1118
  const verifiedFirstEnrollByDomain = new Map<string, UsdLayer["nomosVerifiedFirstEnroll"]>();
1113
1119
  const eraCertGateByDomain = new Map<string, UsdLayer["nomosEraCertGate"]>();
1114
1120
  const valueObjectsByDomain = new Map<string, UsdLayer["nomosValueObjects"]>();
1121
+ const foreignRefsByDomain = new Map<string, UsdLayer["nomosForeignRefs"]>();
1115
1122
  for (const p of prims) {
1116
1123
  if (p.attrs.get("nomos:kind") === "domain") {
1117
1124
  const domain = attrString(p, "nomos:domain");
@@ -1207,6 +1214,10 @@ export function parseUsdaDocument(text: string): UsdDocument {
1207
1214
  if (typeof voHex === "string") {
1208
1215
  valueObjectsByDomain.set(domain, JSON.parse(unhexUtf8(voHex)) as UsdLayer["nomosValueObjects"]);
1209
1216
  }
1217
+ const frHex = p.attrs.get("nomos:foreignRefsHex");
1218
+ if (typeof frHex === "string") {
1219
+ foreignRefsByDomain.set(domain, JSON.parse(unhexUtf8(frHex)) as UsdLayer["nomosForeignRefs"]);
1220
+ }
1210
1221
  }
1211
1222
  }
1212
1223
 
@@ -1317,6 +1328,7 @@ export function parseUsdaDocument(text: string): UsdDocument {
1317
1328
  const verifiedFirstEnroll = verifiedFirstEnrollByDomain.get(domain);
1318
1329
  const eraCertGate = eraCertGateByDomain.get(domain);
1319
1330
  const valueObjects = valueObjectsByDomain.get(domain);
1331
+ const foreignRefs2 = foreignRefsByDomain.get(domain);
1320
1332
  return {
1321
1333
  path: `/Nomos/${domain}`,
1322
1334
  prims: [...b.prims].sort(byPath),
@@ -1342,6 +1354,7 @@ export function parseUsdaDocument(text: string): UsdDocument {
1342
1354
  ...(verifiedFirstEnroll !== undefined ? { nomosVerifiedFirstEnroll: verifiedFirstEnroll } : {}),
1343
1355
  ...(eraCertGate !== undefined ? { nomosEraCertGate: eraCertGate } : {}),
1344
1356
  ...(valueObjects !== undefined ? { nomosValueObjects: valueObjects } : {}),
1357
+ ...(foreignRefs2 !== undefined ? { nomosForeignRefs: foreignRefs2 } : {}),
1345
1358
  };
1346
1359
  })
1347
1360
  .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));