@githolon/dsl 0.29.0 → 0.30.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.29.0",
3
+ "version": "0.30.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",
@@ -45,6 +45,7 @@ import type { RelationTypeDecl, Rewrite } from "./relation_schema.js";
45
45
  import { finishCount } from "./count.js";
46
46
  import { mintedCreateField } from "./workspace_routing.js";
47
47
  import { deterministicPorts } from "./ctx.js";
48
+ import { __setDispatchDomain } from "./framework/relations.js";
48
49
  import { __resetBirths, __drainBirths } from "./births.js";
49
50
  import { __resetAuthoring, __drainAuthoring } from "./authoring.js";
50
51
  import { __resetReads, __drainReads } from "./read.js";
@@ -696,13 +697,17 @@ export function resolveGrantableBaseRelations(
696
697
  export function discoverRelationBootstrapLeg(mod: DomainModule): RelationBootstrapLeg | undefined {
697
698
  const domain = mod.domain ?? mod.name;
698
699
  const directives = mod.directives as Directive<unknown>[];
699
- const gated = directives.find(
700
- (d) =>
701
- d?.requiresRelationGate !== undefined &&
702
- typeof d.payloadSchema === "object" &&
703
- d.birthsChild !== true &&
704
- d.attestationAuthorizedGate === undefined,
705
- );
700
+ const isGatedProvable = (d: Directive<unknown> | undefined): boolean =>
701
+ d?.requiresRelationGate !== undefined &&
702
+ typeof d.payloadSchema === "object" &&
703
+ d.birthsChild !== true &&
704
+ d.attestationAuthorizedGate === undefined;
705
+ // PREFER a gated `.creates` as the bootstrap target: a gated create is exactly the shape excluded from the
706
+ // naive standalone-create leg (isStandaloneCreate), so the bootstrap leg is its ONLY positive proof — seed
707
+ // authority, then prove the CREATE is admitted. When no gated create exists, fall back to any gated write
708
+ // (a gated `.mutates`/`.ensures`), so an un-created gated law is still proven.
709
+ const gated = directives.find((d) => isGatedProvable(d) && d.marker === "creates")
710
+ ?? directives.find((d) => isGatedProvable(d));
706
711
  if (gated === undefined) return undefined;
707
712
  const relation = gated.requiresRelationGate!.relation;
708
713
 
@@ -935,9 +940,15 @@ function withBirthProbeGlobals<T>(run: () => T): T {
935
940
  return h.toString(16).padStart(8, "0").repeat(8);
936
941
  },
937
942
  };
943
+ // RELATION-NAMESPACING: a probed seed plan may build a relation OBJECT TOKEN (`selfWorkspace()`),
944
+ // which qualifies its type by the dispatching domain. The proof generator's STATIC probe reads the
945
+ // relation name + subject entry key off the drained ops (never the object string), so a sentinel
946
+ // dispatch domain is enough to let the token resolve without throwing — no real domain is needed.
947
+ __setDispatchDomain("proof");
938
948
  try {
939
949
  return run();
940
950
  } finally {
951
+ __setDispatchDomain(undefined);
941
952
  if (had && prior !== undefined) g.nomos = prior; else delete g.nomos;
942
953
  }
943
954
  }
@@ -1185,13 +1196,17 @@ export interface ProofLegs {
1185
1196
  * The SINGLE source of the create-leg selection — `generateTsProof` (the .mts marker) and
1186
1197
  * `proofLegsIndex` (the per-domain index) both call this, so the offline lane proves any domain the
1187
1198
  * same way the generated proof proves its primary one. */
1188
- /** A `.creates` directive the proof can DRIVE from a synthesized sample offline: not a `.births()` (a birth
1189
- * is the child-birth lane), not ATTESTATION-AUTHORIZED (needs a signed §3 quote no synthesizer can produce —
1190
- * e.g. the home law's `recordShare`). Those are NOT standalone-provable offline; the domain proves via its
1191
- * child-birth leg / compile-valid instead. */
1199
+ /** A `.creates` directive the proof can DRIVE from a synthesized sample offline WITH NO SEEDED AUTHORITY: not
1200
+ * a `.births()` (a birth is the child-birth lane), not ATTESTATION-AUTHORIZED (needs a signed §3 quote no
1201
+ * synthesizer can produce — e.g. the home law's `recordShare`), and NOT AUTHZ-GATED (`.requires(R)` — a
1202
+ * gated create dispatched with no seeded authority is REFUSED at the gate; its metadata already says it
1203
+ * needs authority, so the naive standalone leg must not touch it — it is proven POSITIVELY by the
1204
+ * relation-bootstrap leg / the born-child genesis instead). Those are NOT standalone-provable offline; the
1205
+ * domain proves via its bootstrap / child-birth / compile-valid legs instead. */
1192
1206
  function isStandaloneCreate(d: Directive<unknown> | undefined): boolean {
1193
1207
  return d?.marker === "creates" && typeof d.payloadSchema === "object"
1194
- && d.birthsChild !== true && d.attestationAuthorizedGate === undefined;
1208
+ && d.birthsChild !== true && d.attestationAuthorizedGate === undefined
1209
+ && d.requiresRelationGate === undefined;
1195
1210
  }
1196
1211
 
1197
1212
  export function proofLegsForModule(mod: DomainModule): ProofLegs | null {
@@ -1545,19 +1560,94 @@ function generateBirthOnlyProof(
1545
1560
  return out.join("\n");
1546
1561
  }
1547
1562
 
1563
+ /**
1564
+ * THE BOOTSTRAP-ONLY PROOF (gated-create isolation): a package whose ONLY creating write is AUTHZ-GATED
1565
+ * (`.creates(...).requires(R)` — co2's estate `createSite`). It has no ungated standalone create to dispatch
1566
+ * naively (that would be REFUSED at the gate with no seeded authority) and no `.births()` genesis. So the
1567
+ * proof deploys the law, connects, and drives the RELATION-BOOTSTRAP leg as its primary write: seed authority
1568
+ * (layers 1-3), THEN prove the gated create is ADMITTED — the ordering (seed BEFORE the gated dispatch) is
1569
+ * correct by construction. If authority genuinely can't be seeded, the leg emits the loud diagnostic (never a
1570
+ * false green). The offline runner proves the same leg locally (`githolon proof`); this `.mts` is `--live`.
1571
+ */
1572
+ function generateBootstrapOnlyProof(
1573
+ mod: DomainModule,
1574
+ opts: TsProofOptions,
1575
+ primaryLegs: ProofLegs,
1576
+ ): string {
1577
+ const domain = mod.domain ?? mod.name;
1578
+ const factory = tsClientFactoryName(domain);
1579
+ const hashConst = tsHashConstName(opts.packageName);
1580
+ const rb = primaryLegs.relationBootstrap;
1581
+ if (rb === undefined) {
1582
+ // Only reached defensively — the caller gates on relationBootstrap !== undefined.
1583
+ throw new Error(`domain '${domain}' has a gated create but no relation-bootstrap leg to prove it (no seedable authority)`);
1584
+ }
1585
+ const slug =
1586
+ opts.packageName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 24) || "domain";
1587
+ const legsMarker = JSON.stringify(primaryLegs);
1588
+ let step = 0;
1589
+ const n = () => ++step;
1590
+ const out: string[] = [];
1591
+ out.push(
1592
+ `// AUTO-GENERATED by nomos-compile — the RUNNABLE PROOF for package "${opts.packageName}",`,
1593
+ `// synthesized from the domain's OWN law. This package's ONLY creating write is AUTHZ-GATED`,
1594
+ `// (\`${domain}/${rb.gatedDirectiveId}\` .requires("${rb.relation}")), so the proof SEEDS authority first,`,
1595
+ `// then proves the gated create is admitted — never a naive dispatch the gate would refuse. Reshape the`,
1596
+ `// law, recompile, and this proof reshapes itself — no test rewriting. Run it:`,
1597
+ `//`,
1598
+ `// npx githolon proof (offline legs on a local holon)`,
1599
+ `// npx githolon proof --live (or: npx tsx build/${opts.packageName}.proof.mts — the full cloud loop)`,
1600
+ `//`,
1601
+ `// Regenerated on every compile; do not edit.`,
1602
+ `// @nomos-proof-legs ${legsMarker}`,
1603
+ `import { readFileSync } from "node:fs";`,
1604
+ `import { connect, encodeIntentOfferBytes } from "@githolon/client";`,
1605
+ `import { ${factory}, ${hashConst} } from "./${opts.packageName}.client.ts";`,
1606
+ `void connect; void encodeIntentOfferBytes; void ${factory};`,
1607
+ ``,
1608
+ `const CLOUD = (process.env.NOMOS_CLOUD || "https://nomos.captainapp.co.uk").replace(/\\/+$/, "");`,
1609
+ `const WS = process.env.NOMOS_WS || ${JSON.stringify(slug + "-proof-")} + Math.random().toString(36).slice(2, 8);`,
1610
+ ``,
1611
+ ...v2IdentityAndCleanupPreamble(opts, hashConst, n),
1612
+ ``,
1613
+ `// ${n()}. connect + bind the GENERATED typed client (the relation-bootstrap leg drives it with an actor)`,
1614
+ `const holon = await connect({ cloud: CLOUD, workspace: WS, clientId: "proof-1" });`,
1615
+ `ok(\`web client connected — ${factory} bound (replica \${holon.replica.slice(0, 6)}…)\`);`,
1616
+ ``,
1617
+ // The SAME relation-bootstrap emitter the standalone proof uses — seed authority, then prove the GATED
1618
+ // CREATE is admitted (layers 1-3; a speculative seed-path degrades to a diagnostic, never a false green).
1619
+ ...relationSeedLegLines(rb, factory, n),
1620
+ `console.log(\`\\nALL GREEN — generated proof for "${opts.packageName}": deploy → seed authority → gated create ${rb.gatedDirectiveId} admitted (\${WS})\`);`,
1621
+ ``,
1622
+ ...v2CatchFinally(),
1623
+ ``,
1624
+ );
1625
+ return out.join("\n");
1626
+ }
1627
+
1548
1628
  export function generateTsProof(modules: readonly DomainModule[], opts: TsProofOptions): string {
1549
- // PRIMARY-DOMAIN SELECTION: the live `.mts` drives a STANDALONE create when the package has one (the full
1550
- // deploy → write → query → sync → cloud loop). A BIRTH-ONLY package (every `.creates` is also a `.births()`
1551
- // — the home/estate composable shape) has no standalone create to drive, so the primary becomes the first
1552
- // `.births()` domain and the live `.mts` drives the birth as its primary write. Either way, the marker =
1553
- // the primary domain's FULL legs (so the offline runner + the index never describe different directives).
1629
+ // PRIMARY-DOMAIN SELECTION: the live `.mts` drives a STANDALONE (ungated) create when the package has one
1630
+ // (the full deploy → write → query → sync → cloud loop). Else a BIRTH package (`.births()`) drives the
1631
+ // birth. Else a GATED-CREATE package — whose only creating write `.requires(R)` (co2's estate `createSite`)
1632
+ // — drives the RELATION-BOOTSTRAP leg as its primary write: seed authority, THEN the gated create is
1633
+ // admitted (never a naive first dispatch that the gate would refuse). Either way, the marker = the primary
1634
+ // domain's FULL legs (so the offline runner + the index never describe different directives).
1554
1635
  const hasStandaloneCreate = (m: DomainModule): boolean =>
1555
1636
  (m.directives as Directive<unknown>[]).some((d) => isStandaloneCreate(d));
1556
1637
  const hasBirth = (m: DomainModule): boolean => birthDirectiveOf(m) !== undefined;
1557
- const mod = modules.find(hasStandaloneCreate) ?? modules.find(hasBirth);
1638
+ const isBootstrapProvable = (m: DomainModule): boolean => {
1639
+ try { return discoverRelationBootstrapLeg(m) !== undefined; } catch { return false; }
1640
+ };
1641
+ const mod = modules.find(hasStandaloneCreate) ?? modules.find(hasBirth) ?? modules.find(isBootstrapProvable);
1558
1642
  if (mod === undefined) {
1643
+ // No provable creating write anywhere. If a GATED create exists but its authority can't be seeded, surface
1644
+ // the loud bootstrap diagnostic (never a false green); else the generic "add a .creates" remedy.
1645
+ const gapMod = modules.find((m) => diagnoseRelationBootstrapGap(m) !== undefined);
1646
+ const gap = gapMod !== undefined ? diagnoseRelationBootstrapGap(gapMod) : undefined;
1559
1647
  throw new Error(
1560
- `no .creates directive in any domain — a generated proof needs one creating write (or a .births() birth directive); add a directive(...).creates(...) (test/e2e.mts remains the hand-authored lane)`,
1648
+ gap !== undefined
1649
+ ? `no proof could be synthesized: ${gap}`
1650
+ : `no .creates directive in any domain — a generated proof needs one creating write (or a .births() birth directive); add a directive(...).creates(...) (test/e2e.mts remains the hand-authored lane)`,
1561
1651
  );
1562
1652
  }
1563
1653
  const domain = mod.domain ?? mod.name;
@@ -1565,18 +1655,24 @@ export function generateTsProof(modules: readonly DomainModule[], opts: TsProofO
1565
1655
  const hashConst = tsHashConstName(opts.packageName);
1566
1656
  // THE MARKER (since 0.5.0) + the per-domain index are ONE synthesis (`legsForDomain`) — the proof body, its
1567
1657
  // marker, and the index can never describe different directives. The primary domain's legs carry whichever
1568
- // of standalone / child-birth it has.
1658
+ // of standalone / child-birth / relation-bootstrap it has.
1569
1659
  const primaryLegs = legsForDomain(mod, modules);
1570
1660
  const childBirth = primaryLegs.childBirth;
1571
1661
 
1572
- // The standalone-create lane (when the primary has one). A birth-only primary skips this — its primary
1573
- // write is the child-birth leg below.
1574
- const createDir = (mod.directives as Directive<unknown>[]).find(
1575
- (d) => d?.marker === "creates" && typeof d.payloadSchema === "object" && d.birthsChild !== true,
1576
- );
1662
+ // The standalone-create lane needs an UNGATED create (isStandaloneCreate — never a gated/birth/attestation
1663
+ // create, which the gate would refuse dispatched with no seeded authority). Absent one, the primary write
1664
+ // is the birth leg (a `.births()` domain) or the relation-bootstrap leg (a gated-create domain).
1665
+ const createDir = (mod.directives as Directive<unknown>[]).find((d) => isStandaloneCreate(d));
1577
1666
  if (createDir === undefined) {
1578
- // BIRTH-ONLY PRIMARY: emit a birth-shaped proof (no standalone create/query/count/concurrency legs).
1579
- return generateBirthOnlyProof(mod, modules, opts, primaryLegs);
1667
+ // BIRTH-ONLY PRIMARY: emit a birth-shaped proof (the birth replays genesis, seating authority, then the
1668
+ // born child self-validates — a gated child-domain create is proven INSIDE the born child context).
1669
+ if (primaryLegs.childBirth !== undefined) return generateBirthOnlyProof(mod, modules, opts, primaryLegs);
1670
+ // BOOTSTRAP-ONLY PRIMARY: the only creating write is GATED — deploy, then seed authority + prove the
1671
+ // gated create is admitted (the relation-bootstrap leg is its ONLY positive proof).
1672
+ if (primaryLegs.relationBootstrap !== undefined) return generateBootstrapOnlyProof(mod, opts, primaryLegs);
1673
+ // A gated create with no seedable authority: loud diagnostic (compile skips the proof, law still ships).
1674
+ const gap = diagnoseRelationBootstrapGap(mod);
1675
+ throw new Error(gap !== undefined ? `no proof could be synthesized for '${domain}': ${gap}` : `domain '${domain}' has no provable creating write`);
1580
1676
  }
1581
1677
  const agg = (mod.aggregates as AggregateHandle[]).find((a) => a.id === createDir.aggregateId);
1582
1678
  if (agg === undefined) {
@@ -53,7 +53,8 @@
53
53
  */
54
54
  import { executeDirectiveToIntent } from "./wire_encode.js";
55
55
  import { expandCapabilityExports } from "./capability_exports.js";
56
- import { RelationTuple } from "./framework/relations.js";
56
+ import { RelationTuple, __setDispatchContext } from "./framework/relations.js";
57
+ import { isRelationTypeDecl } from "./relation_schema.js";
57
58
  import { synthesizePreconditionInvariants } from "./precondition.js";
58
59
  import { generateOpenApiFragment, mergeOpenApiFragments, type OpenApiDoc } from "./codegen_openapi.js";
59
60
  import type { QueryDecl } from "./query.js";
@@ -263,6 +264,12 @@ interface DomainSlice {
263
264
  aggInvariants: Map<string, AggregateInvariantFn>;
264
265
  /** workspace-invariant id → its declaration (#266 — `reads`/`assert` bodies ship HERE). */
265
266
  wsInvariants: Map<string, WorkspaceInvariantDecl>;
267
+ /**
268
+ * RELATION-NAMESPACING classification: `<type>#<relation>` for every LOCAL (non-`.asGlobal()`) authored
269
+ * relation this domain declares — the set a bare `writeTuple` object auto-qualifies against. A GLOBAL
270
+ * type's relations (governance/signer/read-visibility) are EXCLUDED so their bare objects stay bare.
271
+ */
272
+ qualifiableRelations: Set<string>;
266
273
  }
267
274
 
268
275
  /** Build one domain's slice from its merged module exports (the original scans). */
@@ -370,7 +377,18 @@ function buildDomainSlice(mod: DomainModuleExports): DomainSlice {
370
377
  for (const w of synthesizePreconditionInvariants(directive)) wsInvariants.set(w.id, w);
371
378
  }
372
379
 
373
- return { registry, deriveds, combineds, invariants, aggInvariants, wsInvariants };
380
+ // ── RELATION-NAMESPACING classification — the LOCAL non-global `(type,relation)` set (by shape) ──
381
+ // A bare `writeTuple({object:"<type>:<id>", relation})` auto-qualifies iff `<type>#<relation>` is here.
382
+ // Global types (`.asGlobal()` — governance `workspace`, signer `identity`) are excluded so their bare
383
+ // objects stay bare; the synthesized read-visibility `workspace` caps are never authored relationTypes,
384
+ // so they are absent here too (their bare grants stay bare — the read gate hardcodes `workspace:self`).
385
+ const qualifiableRelations = new Set<string>();
386
+ for (const v of Object.values(mod)) {
387
+ if (!isRelationTypeDecl(v) || v.global === true) continue;
388
+ for (const rel of v.relations.keys()) qualifiableRelations.add(`${v.type}#${rel}`);
389
+ }
390
+
391
+ return { registry, deriveds, combineds, invariants, aggInvariants, wsInvariants, qualifiableRelations };
374
392
  }
375
393
 
376
394
  /**
@@ -797,7 +815,20 @@ export function registerEngine(config: EngineEntryConfig): RegisteredEngine {
797
815
  throw new Error(`engine plan: no directive registered for (${domain}, ${directiveId})`);
798
816
  }
799
817
  const ctx = portsFromHost(job.capturedPorts?.principal);
800
- const wire = executeDirectiveToIntent(entry.directive, entry.agg, intent.payload as never, ctx);
818
+ // RELATION-NAMESPACING: publish the dispatching domain + its LOCAL non-global `(type,relation)`
819
+ // classification, so a plan's `writeTuple` object lands on the SAME qualified id the compiler wrote
820
+ // onto the `.requires(R)` gate — a bare `"workspace:self"` for a local relation AUTO-QUALIFIES to
821
+ // `<domain>__workspace:self`; a global/read-visibility/unknown relation's bare object stays bare; a
822
+ // token qualifies explicitly. (`module.domain` === `intent.domain`.) Synchronous plan execution ⇒
823
+ // cleared in the `finally`.
824
+ const slice = sliceOf(domain)!;
825
+ __setDispatchContext({ domain, qualifiable: slice.qualifiableRelations });
826
+ let wire: ReturnType<typeof executeDirectiveToIntent>;
827
+ try {
828
+ wire = executeDirectiveToIntent(entry.directive, entry.agg, intent.payload as never, ctx);
829
+ } finally {
830
+ __setDispatchContext(undefined);
831
+ }
801
832
  // Emit {events, strikes} ONLY when a strike is present, else the bare event array —
802
833
  // so every strikes-free directive canonicalizes byte-identically to the pre-strikeout contract.
803
834
  // The OBSERVED ENDORSE footprint (Slice B) rides the object form too — TRANSIENT (the gate reads
@@ -47,6 +47,73 @@ import { directive } from "../directive.js";
47
47
  import { set, setEntry, withMarker, type PlannedOp } from "../ops.js";
48
48
  import { query } from "../query.js";
49
49
  import { endorsementOf, type AttributionPrincipal, type EndorsedAuthority } from "../principal_evidence.js";
50
+ import { isRelationObjectToken, qualifyRelationType, type RelationObjectToken } from "../relation_schema.js";
51
+
52
+ // ─────────────────────────────── the dispatch context (relation-namespacing) ───────────────────────────────
53
+ //
54
+ // The engine entry publishes the DISPATCHING domain + its own relation-schema classification around each
55
+ // plan dispatch (`engine_entry.ts::plan`), so a `writeTuple` object lands on the SAME qualified id the
56
+ // compiler wrote onto the `.requires(R)` gate — WITHOUT the author naming a domain. Single-threaded
57
+ // synchronous dispatch ⇒ no re-entrancy; cleared in a `finally`.
58
+ //
59
+ // THE DISAMBIGUATION (the maintainer's rule — the natural bare-string code must be correct): a bare
60
+ // `writeTuple({ object:"workspace:self", relation:R })` is classified by the `(type, relation)` pair
61
+ // against the dispatching domain's OWN schema:
62
+ // - `(type, R)` is a LOCAL (non-`.asGlobal()`) authored relation of this domain → AUTO-QUALIFY the
63
+ // object type to `<domain>__<type>` (it matches the qualified gate object by construction).
64
+ // - `(type, R)` is a GLOBAL type's relation (governance/signer/read-visibility) → leave BARE.
65
+ // - `(type, R)` matches NOTHING in this domain's schema (unknown / per-record) → leave BARE (passthrough).
66
+ // A relation OBJECT TOKEN (`selfWorkspace()`/`selfObject(decl)`) is the OPTIONAL explicit sugar — it ALWAYS
67
+ // qualifies its type by the dispatching domain (the author's explicit intent), no classification needed.
68
+
69
+ interface DispatchContext {
70
+ /** The dispatching domain key — the qualifier (`module.domain === intent.domain`). */
71
+ readonly domain: string;
72
+ /** `<type>#<relation>` for every LOCAL non-global authored relation of this domain — the qualify set. */
73
+ readonly qualifiable: ReadonlySet<string>;
74
+ }
75
+
76
+ const EMPTY_QUALIFIABLE: ReadonlySet<string> = new Set<string>();
77
+ let _ctx: DispatchContext | undefined;
78
+
79
+ /** Engine-entry hook: publish the dispatching domain + its qualifiable `(type,relation)` classification. */
80
+ export function __setDispatchContext(ctx: DispatchContext | undefined): void {
81
+ _ctx = ctx;
82
+ }
83
+
84
+ /**
85
+ * Engine-entry / probe hook: set only the dispatching domain (no schema classification). Used where the
86
+ * classification is unavailable (the proof generator's static plan probe): a token still resolves, and a
87
+ * bare string stays bare (the probe reads the relation/subject off the ops, never the object string).
88
+ */
89
+ export function __setDispatchDomain(domain: string | undefined): void {
90
+ _ctx = domain === undefined ? undefined : { domain, qualifiable: EMPTY_QUALIFIABLE };
91
+ }
92
+
93
+ /** Resolve a `writeTuple`/`removeTuple` object for a given relation (token → qualify; string → classify). */
94
+ function resolveTupleObject(object: string | RelationObjectToken, relation: string): string {
95
+ // A TOKEN is explicit sugar — always qualify its type by the dispatching domain.
96
+ if (typeof object !== "string") {
97
+ if (!isRelationObjectToken(object)) {
98
+ throw new Error(`writeTuple: object must be a string or a relation object token; got ${JSON.stringify(object)}`);
99
+ }
100
+ if (_ctx === undefined || _ctx.domain === "") {
101
+ throw new Error(
102
+ "writeTuple: a relation object token was used outside a plan dispatch (no dispatching domain to " +
103
+ "qualify by). Tokens (selfWorkspace()/selfObject()) resolve only inside a directive plan.",
104
+ );
105
+ }
106
+ return `${qualifyRelationType(_ctx.domain, object.type)}:${object.id}`;
107
+ }
108
+ // A PLAIN STRING is classified by `(type, relation)` against the dispatching domain's schema.
109
+ const colon = object.indexOf(":");
110
+ if (colon < 0 || _ctx === undefined) return object; // malformed / no context ⇒ verbatim
111
+ const type = object.slice(0, colon);
112
+ if (_ctx.qualifiable.has(`${type}#${relation}`)) {
113
+ return `${qualifyRelationType(_ctx.domain, type)}${object.slice(colon)}`;
114
+ }
115
+ return object; // global / unknown / per-record ⇒ BARE
116
+ }
50
117
 
51
118
  /** Tuple subject lifecycle — the same vocabulary the kernel read drops a non-active leaf by. */
52
119
  export const RELATION_STATUSES = ["active", "revoked"] as const;
@@ -139,7 +206,13 @@ function compact(obj: Record<string, unknown>): RelationSubjectLeaf {
139
206
  }
140
207
 
141
208
  interface WriteTupleInput {
142
- object: string;
209
+ /**
210
+ * The tuple OBJECT `<type>:<id>`. A plain STRING passes through verbatim (the bare lane — a global
211
+ * `workspace:self`, a read-visibility grant, an already-qualified literal). A relation OBJECT TOKEN
212
+ * (`selfWorkspace()`/`selfObject(decl)`/`objectOf(decl,id)`) qualifies its type by the dispatching
213
+ * domain at plan runtime — so a seed for a NAMESPACED tenant type matches its qualified gate object.
214
+ */
215
+ object: string | RelationObjectToken;
143
216
  relation: string;
144
217
  /**
145
218
  * THE AUTHORITY SINK (the endorse model — security_model.md): who is granted the relation. A plain
@@ -166,7 +239,11 @@ function lowerTuple(
166
239
  revokedAt?: string | undefined;
167
240
  },
168
241
  ): PlannedOp[] {
169
- const rt = instance(RelationTuple, relationTupleInstanceId(p.object, p.relation));
242
+ // Resolve the object ONCE (token → qualified string, or a plain string verbatim) and use it for BOTH
243
+ // the deterministic aggregate id AND the stored `object` field, so the tuple's reltuple id matches the
244
+ // qualified gate object's (`identity/src/relations.rs::reltuple_id`).
245
+ const object = resolveTupleObject(p.object, p.relation);
246
+ const rt = instance(RelationTuple, relationTupleInstanceId(object, p.relation));
170
247
  // MATERIALISE PrincipalEvidence subjects/grantedBy → the raw principal (the endorse model). The
171
248
  // subject's endorsement is recorded on the setEntry op so the discovery harvest lifts it into
172
249
  // nomosFlowGate.endorse; grantedBy is free attribution (materialised, not gated).
@@ -192,7 +269,7 @@ function lowerTuple(
192
269
  return [
193
270
  // upsert the aggregate head (first relate creates it); without this the referential
194
271
  // gate would reject a setEntry on an absent id.
195
- withMarker(set(rt, "object", p.object), "ensures"),
272
+ withMarker(set(rt, "object", object), "ensures"),
196
273
  set(rt, "relation", p.relation),
197
274
  subjectE ? { ...subjectEntry, endorsement: { facet: subjectE.facet, reason: subjectE.reason } } : subjectEntry,
198
275
  ];
@@ -63,10 +63,16 @@ import {
63
63
  * (`allow: [key()]`, never a userset/wildcard: a `key:*` would let any key claim to author
64
64
  * as anyone). A workspace that admits signed authorship composes this into its law.
65
65
  */
66
- export const identityRelations = relationType("identity").relation("signer", {
67
- rewrite: self(),
68
- allow: [key()],
69
- });
66
+ export const identityRelations = relationType("identity")
67
+ // GLOBAL (un-namespaced) — relation-namespacing opt-out. The kernel hardcodes bare
68
+ // `identity:<principal>` (`executor::signer_object`) for captured-principal signer verification, so
69
+ // this type MUST stay bare (qualifying it would need a Rust change). `signerObject()` writes the bare
70
+ // string object; no token, no migration.
71
+ .asGlobal()
72
+ .relation("signer", {
73
+ rewrite: self(),
74
+ allow: [key()],
75
+ });
70
76
 
71
77
  /** The Zanzibar OBJECT for a principal's signer relation — `identity:<principal>` (the gate mirrors
72
78
  * this as `executor::signer_object`; the subject is `key:<sha256hex(pubkey)>`). */
@@ -156,6 +156,13 @@ export const workspaceSelfWriteOnce = workspaceInvariant("WorkspaceSelfWriteOnce
156
156
  // born platform with no name plumbing. (A pure tenant runs no governance ⇒ no schema ⇒ the gate is
157
157
  // inert there; an un-backfilled workspace with no admin tuple refuses fail-closed.)
158
158
  export const workspaceRelations = relationType("workspace")
159
+ // GLOBAL (un-namespaced) — relation-namespacing opt-out. The kernel/host address this governance type
160
+ // by its BARE name (`workspace:self#admin`, the operated-probe `expand(workspace:root, admin)`), so it
161
+ // MUST stay bare (qualifying it would need a Rust change). grantAdmin/grantCreation write bare
162
+ // `workspace:self` string tuples + gate on the bare object — all consistent, byte-identical, no
163
+ // migration. (A TENANT that wants a workspace-scoped role uses a namespaced `relationType("workspace")`
164
+ // + a `selfWorkspace()` seed; only THIS framework governance type is global.)
165
+ .asGlobal()
159
166
  .relation("admin", { rewrite: self(), allow: [user(), key()] })
160
167
  .relation("creator", { rewrite: self(), allow: [user(), key()] })
161
168
  .relation("platformCreator", { rewrite: self(), allow: [user(), key()] });
package/src/index.ts CHANGED
@@ -413,6 +413,16 @@ export {
413
413
  anyUser,
414
414
  group,
415
415
  DEFAULT_RELATION_CAPS,
416
+ // RELATION-TYPE NAMESPACING — the qualifier helpers + the seed-object tokens (selfWorkspace()/
417
+ // selfObject()/objectOf()) that make a namespaced tenant seed match its qualified gate object.
418
+ RELATION_TYPE_DELIMITER,
419
+ qualifyRelationType,
420
+ qualifiedTypeId,
421
+ isRelationObjectToken,
422
+ selfWorkspace,
423
+ selfObject,
424
+ objectOf,
425
+ type RelationObjectToken,
416
426
  type RelationTypeDecl,
417
427
  type RelationTypeBuilder,
418
428
  type RelationDefAst,
@@ -91,6 +91,19 @@ export interface RelationTypeDecl {
91
91
  readonly relations: ReadonlyMap<string, RelationDefAst>;
92
92
  /** Per-type cap override; absent ⇒ the schema/default caps apply. */
93
93
  readonly caps?: RelationCaps;
94
+ /**
95
+ * GLOBAL (un-namespaced) type — the RELATION-NAMESPACING opt-out. A tenant relation type is
96
+ * QUALIFIED by its owning domain key at compile (`estate` → `estate__estate`) so two domains that
97
+ * each declare the same bare type never collide (kernel last-wins schema merge). A `global` type is
98
+ * EXEMPT: its id, gate object, and tuple object all stay BARE. Reserved for the KERNEL-COUPLED
99
+ * framework types the kernel hardcodes by bare name and which we cannot qualify without a Rust
100
+ * change: the governance `workspace` (`workspace:self#admin`, the operated-probe expand), the signer
101
+ * `identity` (`executor::signer_object` = bare `identity:<principal>`), and the synthesized
102
+ * read-visibility `workspace` (the read gate hardcodes `ObjectRef::new("workspace","self")`). A
103
+ * global-only package emits byte-identically to the pre-namespacing compiler (no era key). Default
104
+ * (absent ⇒ namespaced) is the safe posture — a tenant NEVER wants a bare, collision-prone id.
105
+ */
106
+ readonly global?: boolean;
94
107
  /** Brand so a plain object is never mistaken for a declaration (mirrors workspaceInvariant). */
95
108
  readonly __isRelationType: true;
96
109
  }
@@ -99,6 +112,72 @@ export function isRelationTypeDecl(v: unknown): v is RelationTypeDecl {
99
112
  return typeof v === "object" && v !== null && (v as { __isRelationType?: unknown }).__isRelationType === true;
100
113
  }
101
114
 
115
+ // ─────────────────────────────── relation-type NAMESPACING ───────────────────────────────
116
+
117
+ /**
118
+ * The relation-type QUALIFIER delimiter. COLON-FREE by law: the kernel splits an object address on
119
+ * the FIRST colon (`identity/src/relations.rs::ObjectRef::parse` → `split_once(':')`), so a `:`-bearing
120
+ * qualifier (`estate::workspace`) would corrupt the (type,id) split. `__` is the ratified delimiter.
121
+ */
122
+ export const RELATION_TYPE_DELIMITER = "__";
123
+
124
+ /** Qualify a bare relation type by its owning domain key: `("estate","workspace") → "estate__workspace"`. */
125
+ export function qualifyRelationType(domainKey: string, type: string): string {
126
+ return `${domainKey}${RELATION_TYPE_DELIMITER}${type}`;
127
+ }
128
+
129
+ /**
130
+ * The qualified id a decl serializes under, given its owning domain key. A `global` decl (a kernel-
131
+ * coupled framework type — see {@link RelationTypeDecl.global}) is NEVER qualified; everything else is.
132
+ */
133
+ export function qualifiedTypeId(domainKey: string, decl: RelationTypeDecl): string {
134
+ return decl.global === true ? decl.type : qualifyRelationType(domainKey, decl.type);
135
+ }
136
+
137
+ // ─────────────────────────────── relation OBJECT tokens (the namespacing-aware seed lane) ───────────────────────────────
138
+
139
+ /**
140
+ * A relation OBJECT token — the namespacing-aware replacement for a hardcoded `"workspace:self"` STRING
141
+ * in a plan's `writeTuple({ object })`. A plain string passes through the tuple lowering VERBATIM (the
142
+ * BARE lane — governance's global `workspace:self`, a read-visibility grant, an already-qualified
143
+ * literal). A TOKEN carries only the (type, id); the framework qualifies its `type` by the DISPATCHING
144
+ * domain at plan runtime (`framework/relations.ts::writeTuple`), producing the SAME `<domain>__<type>`
145
+ * the compiler wrote onto the schema + gate object. Authors of a seed for a NAMESPACED tenant type swap
146
+ * `object: "workspace:self"` → `object: selfWorkspace()` — the qualifier flows through, they name no
147
+ * domain. (A namespaced type's tuple object MUST match its qualified gate object or the gate finds no
148
+ * tuple; the token is what guarantees it from one place.)
149
+ */
150
+ export interface RelationObjectToken {
151
+ readonly __isRelationObjectToken: true;
152
+ /** The BARE object type (`workspace`, or a per-record aggregate type) — qualified at plan runtime. */
153
+ readonly type: string;
154
+ /** The object id (`self` for a workspace-scope object, or a record id). */
155
+ readonly id: string;
156
+ }
157
+
158
+ export function isRelationObjectToken(v: unknown): v is RelationObjectToken {
159
+ return (
160
+ typeof v === "object" &&
161
+ v !== null &&
162
+ (v as { __isRelationObjectToken?: unknown }).__isRelationObjectToken === true
163
+ );
164
+ }
165
+
166
+ /** The workspace-scope object token `<domain>__workspace:self` — the default gate-object shape, qualified. */
167
+ export function selfWorkspace(): RelationObjectToken {
168
+ return { __isRelationObjectToken: true, type: "workspace", id: "self" };
169
+ }
170
+
171
+ /** The workspace-scope object token for a specific relation type: `selfObject(EstateRelations)`. */
172
+ export function selfObject(decl: RelationTypeDecl): RelationObjectToken {
173
+ return { __isRelationObjectToken: true, type: decl.type, id: "self" };
174
+ }
175
+
176
+ /** A PER-RECORD object token `<domain>__<type>:<id>` (the objectFrom gate's tuple side). */
177
+ export function objectOf(decl: RelationTypeDecl, id: string): RelationObjectToken {
178
+ return { __isRelationObjectToken: true, type: decl.type, id };
179
+ }
180
+
102
181
  // ─────────────────────────────── the rewrite combinators ───────────────────────────────
103
182
 
104
183
  /** `_this` — the directly-stored tuples (and their recursive userset subjects). */
@@ -174,6 +253,13 @@ export interface RelationTypeBuilder extends RelationTypeDecl {
174
253
  * tuples — legal but inert; a purely-`computed`/`from` relation needs no `allow`.
175
254
  */
176
255
  relation(name: string, opts?: { rewrite?: Rewrite; allow?: AllowedSubject[] }): RelationTypeBuilder;
256
+ /**
257
+ * Mark this type GLOBAL (un-namespaced) — the relation-namespacing opt-out (see
258
+ * {@link RelationTypeDecl.global}). RESERVED for kernel-coupled framework types the kernel hardcodes
259
+ * by bare name; a tenant type must NOT call this (it would re-introduce the cross-domain collision
260
+ * namespacing exists to dissolve).
261
+ */
262
+ asGlobal(): RelationTypeBuilder;
177
263
  }
178
264
 
179
265
  /**
@@ -206,6 +292,11 @@ export function relationType(type: string, caps?: Partial<RelationCaps>): Relati
206
292
  relations.set(name, { rewrite: opts?.rewrite ?? null, allowed });
207
293
  return builder;
208
294
  },
295
+ asGlobal(): RelationTypeBuilder {
296
+ // Mutate in place so a later `.relation(...)` chain still returns the SAME (now-global) builder.
297
+ (builder as { global?: boolean }).global = true;
298
+ return builder;
299
+ },
209
300
  };
210
301
  return builder;
211
302
  }
package/src/usd.ts CHANGED
@@ -42,7 +42,7 @@ import {
42
42
  import type { WireSchema, WireRefMode } from "./wire.js";
43
43
  import type { DomainModule } from "./codegen_dart.js";
44
44
  import { domainInterface } from "./shape_compat.js";
45
- import { relationType, user, key, anyUser } from "./relation_schema.js";
45
+ import { relationType, user, key, anyUser, qualifiedTypeId, qualifyRelationType } from "./relation_schema.js";
46
46
  import { harvestPrincipalFlows } from "./wire_encode.js";
47
47
 
48
48
  /** The referential marker of a directive prim (kernel `RefMode` wire string). */
@@ -260,6 +260,21 @@ export interface UsdLayer {
260
260
  readonly v: 1;
261
261
  readonly requires: Record<string, { readonly relation: string; readonly object: string }>;
262
262
  };
263
+ /**
264
+ * THE RELATION-TYPE NAMESPACE ERA KEY (relation-namespacing). Present IFF a layer qualified ≥1
265
+ * (non-global) `relationType` by its owning domain key (`estate` → `estate__workspace`), so two
266
+ * domains in one package that each declare the same bare type no longer collide at the kernel's
267
+ * last-wins schema merge. `types` is the SORTED set of qualified ids this layer produced; `delimiter`
268
+ * is the ratified colon-free `"__"` (the kernel splits object addresses on the first colon). OMITTED
269
+ * for a layer that declares no relation type OR only GLOBAL (kernel-coupled framework) types — those
270
+ * emit BYTE-IDENTICALLY to the pre-namespacing compiler (the era rule; the marker is a witness, the
271
+ * kernel needs no new arm because the qualification is already baked into the `nomosRelations` ids).
272
+ */
273
+ readonly nomosRelationNamespace?: {
274
+ readonly v: 1;
275
+ readonly delimiter: string;
276
+ readonly types: readonly string[];
277
+ };
263
278
  /**
264
279
  * THE CROSS-CUSTODY ATTESTATION GATE (home_holons §3/§5). Present IFF ≥1 directive declares
265
280
  * `.attestationAuthorized()`: the SORTED set of their ids. A directive named here is authorized by a
@@ -632,6 +647,7 @@ function readProjectionForModule(mod: DomainModule, manifest: CanonicalManifest)
632
647
  */
633
648
  function serializeRelationSchema(
634
649
  decls: readonly import("./relation_schema.js").RelationTypeDecl[],
650
+ domainKey: string,
635
651
  ): UsdLayer["nomosRelations"] {
636
652
  if (decls.length === 0) return undefined;
637
653
  type AllowedW = { kind: string; type?: string; ns?: string; relation?: string };
@@ -639,10 +655,15 @@ function serializeRelationSchema(
639
655
  a.kind === "direct" ? `direct:${a.type}` : a.kind === "wildcard" ? "wildcard" : `userset:${a.type}#${a.relation}`;
640
656
  const byType = new Map<string, { relations: Map<string, { rewrite: unknown; allowed: unknown[] }>; caps?: { maxDepth: number; maxFanout: number } }>();
641
657
  for (const d of decls) {
642
- let entry = byType.get(d.type);
658
+ // RELATION-TYPE NAMESPACING: a tenant type keys under `<domainKey>__<type>` so two domains that each
659
+ // declare the same bare type never collide at the kernel's last-wins schema merge; a `global` type
660
+ // (kernel-coupled framework type) keys BARE. `computed`/`ttu` targets inside a rewrite are
661
+ // type-RELATIVE relation names resolved on the (now-qualified) type — so no rewrite rewrite is needed.
662
+ const typeId = qualifiedTypeId(domainKey, d);
663
+ let entry = byType.get(typeId);
643
664
  if (entry === undefined) {
644
665
  entry = { relations: new Map(), ...(d.caps ? { caps: d.caps } : {}) };
645
- byType.set(d.type, entry);
666
+ byType.set(typeId, entry);
646
667
  } else if (d.caps !== undefined) {
647
668
  if (entry.caps !== undefined && (entry.caps.maxDepth !== d.caps.maxDepth || entry.caps.maxFanout !== d.caps.maxFanout)) {
648
669
  throw new Error(`relationType("${d.type}"): conflicting caps across declarations`);
@@ -682,7 +703,10 @@ function synthesizeReadRelationTypes(
682
703
  if (a.requiresRead !== undefined) caps.add(a.requiresRead);
683
704
  }
684
705
  if (caps.size === 0) return [];
685
- const builder = relationType("workspace");
706
+ // GLOBAL (un-namespaced): the read gate hardcodes `ObjectRef::new("workspace","self")` in Rust
707
+ // (`wasm_git_holon.rs`), so the synthesized read-visibility `workspace` type MUST stay bare — it is
708
+ // NOT namespaced by the owning domain (site-6 exemption; a Rust change would be needed to qualify it).
709
+ const builder = relationType("workspace").asGlobal();
686
710
  // Sorted for byte-determinism (the serializer also sorts, but keep the source stable). PURE
687
711
  // DIRECT (`rewrite` omitted ⇒ `null`, not `self()`): a flat membership relation — the read-cap is
688
712
  // exactly "who was granted it". (`self()`/`Rewrite::This` inside a rewrite expands to an EMPTY
@@ -724,29 +748,87 @@ export function emitUsd(
724
748
  // into. The `nomosReadVisibility` key (type → cap) is RETAINED as the type-to-relation map; the
725
749
  // synthesized schema is what makes that cap a checkable relation. SORTED + omit-when-empty.
726
750
  const readRelationTypes = synthesizeReadRelationTypes(manifest);
751
+ // THE OWNING DOMAIN KEY — the relation-namespacing qualifier (the SAME key the openapi routes
752
+ // dispatch by, `module.domain ?? module.name`, so `intent.domain` at plan runtime === the key the
753
+ // schema/gate qualified under; the tuple object a token produces then lines up by construction).
754
+ const domainKey = l.module.domain ?? l.module.name;
755
+ // The NON-GLOBAL (namespaced) relation type names this layer declares — the set whose gate objects
756
+ // must be qualified. A `global` type (governance `workspace`, signer `identity`, read-visibility
757
+ // `workspace`) stays bare on BOTH the schema and the gate side, so it is excluded here.
758
+ const namespacedTypes = new Set(
759
+ (l.module.relationTypes ?? []).filter((d) => d.global !== true).map((d) => d.type),
760
+ );
761
+ // THE AMBIGUITY GUARD (relation-namespacing): a bare `writeTuple({object:"workspace:self", relation:R})`
762
+ // is auto-qualified iff `(type,R)` is a LOCAL non-global authored relation, and left BARE iff it is a
763
+ // GLOBAL / read-visibility cap. If ONE `(type,R)` is BOTH — a non-global authored relation AND a
764
+ // synthesized read-visibility cap (always on type `workspace`) — the bare object is genuinely
765
+ // ambiguous. Refuse at compile with a clear message (the maintainer's rule: never silently pick).
766
+ {
767
+ const readCaps = new Set(readRelationTypes.flatMap((rt) => [...rt.relations.keys()]));
768
+ for (const d of l.module.relationTypes ?? []) {
769
+ if (d.global === true || d.type !== "workspace") continue;
770
+ for (const rel of d.relations.keys()) {
771
+ if (readCaps.has(rel)) {
772
+ throw new Error(
773
+ `relation-namespacing conflict in domain "${domainKey}": relation "workspace#${rel}" is ` +
774
+ `declared BOTH as a local authored relationType("workspace") relation AND as a ` +
775
+ `read-visibility cap (a requires("${rel}") aggregate). A bare writeTuple object for ` +
776
+ `"${rel}" would be ambiguous (qualify vs stay bare). Rename one of them.`,
777
+ );
778
+ }
779
+ }
780
+ }
781
+ }
782
+ // THE RELATION-NAMESPACE ERA KEY (`nomosRelationNamespace`): present IFF this layer qualified ≥1
783
+ // (non-global) relation type — the marker that this bundle's relation ids are domain-qualified.
784
+ // Absent when a layer declares no relation type OR only global ones ⇒ byte-identical to the
785
+ // pre-namespacing compiler (the `nomosRelations`/`nomosInvariantGate` era discipline).
786
+ const qualifiedTypeIds = [...namespacedTypes].map((t) => qualifyRelationType(domainKey, t)).sort();
787
+ const nomosRelationNamespace =
788
+ qualifiedTypeIds.length > 0 ? { v: 1 as const, delimiter: "__", types: qualifiedTypeIds } : undefined;
789
+ // Qualify the TYPE half of a gate object by the owning domain — but ONLY when this layer declares
790
+ // that type as a NON-GLOBAL relation type (so the gate object matches its qualified schema id). A
791
+ // global-type object (`workspace:self` for governance/read-visibility) or a type this layer doesn't
792
+ // declare passes through BARE — byte-identical to the pre-namespacing compiler.
793
+ const qualifyGateObject = (obj: string): string => {
794
+ const colon = obj.indexOf(":");
795
+ if (colon < 0) return obj;
796
+ const ty = obj.slice(0, colon);
797
+ return namespacedTypes.has(ty) ? `${qualifyRelationType(domainKey, ty)}${obj.slice(colon)}` : obj;
798
+ };
727
799
  // THE RELATION SCHEMA (P1): per-type rewrites lowered to the nomosRelations AST, UNIONED with the
728
- // synthesized read relations. SORTED keys (byte-determinism — the layered flatten reproduces these
729
- // bytes); omit-when-empty (era rule). A read-cap name colliding with an authored `workspace`
730
- // relation is an author error (caught in the merge).
731
- const nomosRelations = serializeRelationSchema([
732
- ...(l.module.relationTypes ?? []),
733
- ...readRelationTypes,
734
- ]);
800
+ // synthesized read relations. Keys are QUALIFIED by the owning domain (namespacing) except global
801
+ // types. SORTED keys (byte-determinism); omit-when-empty (era rule). A read-cap name colliding with
802
+ // an authored `workspace` relation is an author error (caught in the merge).
803
+ const nomosRelations = serializeRelationSchema(
804
+ [...(l.module.relationTypes ?? []), ...readRelationTypes],
805
+ domainKey,
806
+ );
735
807
  // THE RELATION GATE (P4): directives declaring `.requiresRelation` → directiveId → {relation,
736
- // object}. SORTED keys; omit-when-empty (era rule — a relation-gate-free layer is byte-identical).
808
+ // object}. The object's TYPE half is qualified to match the (now-namespaced) schema id. SORTED
809
+ // keys; omit-when-empty (era rule — a relation-gate-free layer is byte-identical).
737
810
  const relationGateReq: Record<
738
811
  string,
739
812
  { relation: string; object: string; objectFrom?: { recordType: string; idField: string } }
740
813
  > = {};
741
814
  for (const d of [...l.module.directives].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))) {
742
815
  if (d.requiresRelationGate !== undefined) {
816
+ const objectFrom = d.requiresRelationGate.objectFrom;
743
817
  relationGateReq[d.id] = {
744
818
  relation: d.requiresRelationGate.relation,
745
- object: d.requiresRelationGate.object,
819
+ object: qualifyGateObject(d.requiresRelationGate.object),
746
820
  // PER-RECORD object (home_holons §6): carried through to the wasm gate; omitted when absent
747
- // so a self-relative directive's USD is byte-identical (the era rule).
748
- ...(d.requiresRelationGate.objectFrom !== undefined
749
- ? { objectFrom: d.requiresRelationGate.objectFrom }
821
+ // so a self-relative directive's USD is byte-identical (the era rule). The recordType is the
822
+ // object type of the per-record tuple, so it namespaces exactly like the gate object type.
823
+ ...(objectFrom !== undefined
824
+ ? {
825
+ objectFrom: {
826
+ recordType: namespacedTypes.has(objectFrom.recordType)
827
+ ? qualifyRelationType(domainKey, objectFrom.recordType)
828
+ : objectFrom.recordType,
829
+ idField: objectFrom.idField,
830
+ },
831
+ }
750
832
  : {}),
751
833
  };
752
834
  }
@@ -951,6 +1033,7 @@ export function emitUsd(
951
1033
  }
952
1034
  : {}),
953
1035
  ...(nomosRelations !== undefined ? { nomosRelations } : {}),
1036
+ ...(nomosRelationNamespace !== undefined ? { nomosRelationNamespace } : {}),
954
1037
  ...(nomosRelationGate !== undefined ? { nomosRelationGate } : {}),
955
1038
  ...(nomosAttestationGate !== undefined ? { nomosAttestationGate } : {}),
956
1039
  ...(nomosBirthGate !== undefined ? { nomosBirthGate } : {}),
package/src/usd_layers.ts CHANGED
@@ -171,6 +171,9 @@ function domainLayerAttrLines(domain: string, layer: UsdLayer, indent: string):
171
171
  ...(layer.nomosRelations !== undefined
172
172
  ? [`${indent}custom string nomos:relationsHex = ${usdaString(hexUtf8(JSON.stringify(layer.nomosRelations)))}`]
173
173
  : []),
174
+ ...(layer.nomosRelationNamespace !== undefined
175
+ ? [`${indent}custom string nomos:relationNamespaceHex = ${usdaString(hexUtf8(JSON.stringify(layer.nomosRelationNamespace)))}`]
176
+ : []),
174
177
  ...(layer.nomosRelationGate !== undefined
175
178
  ? [`${indent}custom string nomos:relationGateHex = ${usdaString(hexUtf8(JSON.stringify(layer.nomosRelationGate)))}`]
176
179
  : []),
@@ -736,7 +739,23 @@ export function flattenLayeredUsd(layers: readonly UsdLayer[]): UsdDocument {
736
739
  const t = new Map<string, NonNullable<UsdLayer["nomosRelations"]>["types"][string]>();
737
740
  for (const l of stack) {
738
741
  if (l.nomosRelations === undefined) continue;
739
- for (const [type, spec] of Object.entries(l.nomosRelations.types)) t.set(type, spec);
742
+ for (const [type, spec] of Object.entries(l.nomosRelations.types)) {
743
+ // RELATION-NAMESPACING SAFETY: after qualification, two domains' same bare type key under
744
+ // DISTINCT ids (`estate__workspace` vs `home__workspace`) and never reach here as the same key.
745
+ // A collision on the SAME canonical id can now only be a genuinely SHARED id (two layers
746
+ // contributing the same global type, or a future cross-domain shared type). Silent last-wins
747
+ // would DROP one layer's relations — so a collision on a key whose spec DIFFERS is a HARD ERROR
748
+ // (loud, not last-wins). An identical re-contribution (framework dedup) is fine.
749
+ const prior = t.get(type);
750
+ if (prior !== undefined && JSON.stringify(prior) !== JSON.stringify(spec)) {
751
+ throw new Error(
752
+ `nomosRelations: relation type "${type}" is contributed with DIFFERING definitions by two ` +
753
+ `layers — the qualified ids collide. A shared relation type must be identical across ` +
754
+ `domains (or namespaced per-domain). Refusing to silently last-wins.`,
755
+ );
756
+ }
757
+ t.set(type, spec);
758
+ }
740
759
  }
741
760
  if (t.size > 0) {
742
761
  const typesRec: NonNullable<UsdLayer["nomosRelations"]>["types"] = {};
@@ -744,6 +763,18 @@ export function flattenLayeredUsd(layers: readonly UsdLayer[]): UsdDocument {
744
763
  relations = { v: 1, types: typesRec };
745
764
  }
746
765
  }
766
+ // THE RELATION-NAMESPACE ERA KEY (relation-namespacing): the SORTED union of every layer's qualified
767
+ // type ids. Present iff ANY layer namespaced ≥1 type; omit-when-empty (byte-identical for global-only
768
+ // / relation-free packages). `delimiter` is the ratified colon-free `"__"`.
769
+ let relationNamespace: UsdLayer["nomosRelationNamespace"];
770
+ {
771
+ const ns = new Set<string>();
772
+ for (const l of stack) {
773
+ if (l.nomosRelationNamespace === undefined) continue;
774
+ for (const id of l.nomosRelationNamespace.types) ns.add(id);
775
+ }
776
+ if (ns.size > 0) relationNamespace = { v: 1, delimiter: "__", types: [...ns].sort() };
777
+ }
747
778
  // THE RELATION GATE (P4): directiveId → {relation, object} merged later-wins, SORTED. Omit-when-empty.
748
779
  let relationGate: UsdLayer["nomosRelationGate"];
749
780
  {
@@ -825,6 +856,7 @@ export function flattenLayeredUsd(layers: readonly UsdLayer[]): UsdDocument {
825
856
  ...(readVisibility !== undefined ? { nomosReadVisibility: readVisibility } : {}),
826
857
  ...(encryption !== undefined ? { nomosEncryption: encryption } : {}),
827
858
  ...(relations !== undefined ? { nomosRelations: relations } : {}),
859
+ ...(relationNamespace !== undefined ? { nomosRelationNamespace: relationNamespace } : {}),
828
860
  ...(relationGate !== undefined ? { nomosRelationGate: relationGate } : {}),
829
861
  ...(attestationGate !== undefined ? { nomosAttestationGate: attestationGate } : {}),
830
862
  ...(birthGate !== undefined ? { nomosBirthGate: birthGate } : {}),
@@ -956,6 +988,7 @@ export function parseUsdaDocument(text: string): UsdDocument {
956
988
  const encryptionByDomain = new Map<string, UsdLayer["nomosEncryption"]>();
957
989
  const relationsByDomain = new Map<string, UsdLayer["nomosRelations"]>();
958
990
  const relationGateByDomain = new Map<string, UsdLayer["nomosRelationGate"]>();
991
+ const relationNamespaceByDomain = new Map<string, UsdLayer["nomosRelationNamespace"]>();
959
992
  const attestationGateByDomain = new Map<string, UsdLayer["nomosAttestationGate"]>();
960
993
  const birthGateByDomain = new Map<string, UsdLayer["nomosBirthGate"]>();
961
994
  const parentAttestedBirthByDomain = new Map<string, UsdLayer["nomosParentAttestedBirth"]>();
@@ -1006,6 +1039,11 @@ export function parseUsdaDocument(text: string): UsdDocument {
1006
1039
  if (typeof relGateHex === "string") {
1007
1040
  relationGateByDomain.set(domain, JSON.parse(unhexUtf8(relGateHex)) as UsdLayer["nomosRelationGate"]);
1008
1041
  }
1042
+ // THE RELATION-NAMESPACE ERA KEY (relation-namespacing): the hex-carried qualified-type witness.
1043
+ const relNsHex = p.attrs.get("nomos:relationNamespaceHex");
1044
+ if (typeof relNsHex === "string") {
1045
+ relationNamespaceByDomain.set(domain, JSON.parse(unhexUtf8(relNsHex)) as UsdLayer["nomosRelationNamespace"]);
1046
+ }
1009
1047
  // THE ATTESTATION GATE (home_holons §3/§5): the hex-carried attestation-authorized directive set.
1010
1048
  const attGateHex = p.attrs.get("nomos:attestationGateHex");
1011
1049
  if (typeof attGateHex === "string") {
@@ -1122,6 +1160,7 @@ export function parseUsdaDocument(text: string): UsdDocument {
1122
1160
  const enc = encryptionByDomain.get(domain);
1123
1161
  const rel = relationsByDomain.get(domain);
1124
1162
  const relGate = relationGateByDomain.get(domain);
1163
+ const relNs = relationNamespaceByDomain.get(domain);
1125
1164
  const attGate = attestationGateByDomain.get(domain);
1126
1165
  const birthGate = birthGateByDomain.get(domain);
1127
1166
  const parentAttestedBirth = parentAttestedBirthByDomain.get(domain);
@@ -1140,6 +1179,7 @@ export function parseUsdaDocument(text: string): UsdDocument {
1140
1179
  ...(rv !== undefined ? { nomosReadVisibility: rv } : {}),
1141
1180
  ...(enc !== undefined ? { nomosEncryption: enc } : {}),
1142
1181
  ...(rel !== undefined ? { nomosRelations: rel } : {}),
1182
+ ...(relNs !== undefined ? { nomosRelationNamespace: relNs } : {}),
1143
1183
  ...(relGate !== undefined ? { nomosRelationGate: relGate } : {}),
1144
1184
  ...(attGate !== undefined ? { nomosAttestationGate: attGate } : {}),
1145
1185
  ...(birthGate !== undefined ? { nomosBirthGate: birthGate } : {}),