@githolon/dsl 0.29.1 → 0.30.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.29.1",
3
+ "version": "0.30.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",
@@ -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";
@@ -382,6 +383,27 @@ function relationSeedLegLines(
382
383
  ? ` — a COMPUTED gate: granting "${leg.grantedRelation}" makes the actor satisfy "${leg.relation}" via the rewrite`
383
384
  : ``;
384
385
 
386
+ if (leg.bornContext !== undefined) {
387
+ // LAYER 4 — THE BORN-DOMAIN GENESIS-AUTHORITY PATH (co2 home). R is established ONLY by the birth genesis
388
+ // (e.g. birthHome → seedHomeCreator seats creator), and the seed carries an owner-binding PREREQUISITE a
389
+ // standalone re-seed can't satisfy. This leg is proven by `githolon proof` (offline, on the local wasm):
390
+ // the runner BIRTHS the workspace as the proof actor (= owner), then dispatches the gated directive INSIDE
391
+ // the born workspace as that owner, asserting ADMITTED at the real kernel gate (a refusal degrades to a
392
+ // diagnostic — never a false green). The LIVE `--live` lane proves the underlying birth via the child-birth
393
+ // leg; the gated-directive-in-born-context proof is the offline runner's (birthing a throwaway home per
394
+ // gated directive live would leave orphan workspaces).
395
+ const bc = leg.bornContext;
396
+ return [
397
+ `// ${n()}. RELATION BOOTSTRAP — LAYER 4 (born-domain genesis authority): \`${leg.domain}/${leg.gatedDirectiveId}\``,
398
+ `// is GATED (\`.requires("${leg.relation}")\`), established ONLY by the birth genesis (\`${bc.birthDirectiveId}\` →`,
399
+ `// \`${bc.seatsViaDirectiveId}\` seats "${leg.relation}") — NOT a standalone-replayable seed (its owner-binding`,
400
+ `// prerequisite would false-fail). Proven by \`githolon proof\` (offline): birth the workspace as the owner,`,
401
+ `// then dispatch \`${leg.gatedDirectiveId}\` INSIDE the born workspace as that owner (real-gate admitted).`,
402
+ `console.log("– relation-bootstrap leg (born-context) — \`${leg.domain}/${leg.gatedDirectiveId}\` .requires(\\"${leg.relation}\\") is proven in the BORN workspace by \`githolon proof\` (offline); the birth is proven live by the child-birth leg");`,
403
+ ``,
404
+ ];
405
+ }
406
+
385
407
  if (speculative) {
386
408
  // LAYER 3 (seed-path replay). The gate relation is reachable only through ttu/intersection/exclusion — no
387
409
  // directly-grantable base. We REPLAY the domain's declared seed and let the REAL GATE arbitrate: if the
@@ -492,8 +514,10 @@ export interface RelationBootstrapLeg {
492
514
  * `writeTuple(R)`), `"computed-base"` (R is computed; the grant seats a rewrite BASE relation the granter
493
515
  * was found for), or `"seed-path"` (LAYER 3: R is reachable only through ttu/intersection/exclusion — no
494
516
  * directly-grantable base — so a DECLARED SEED directive that establishes root authority is replayed and
495
- * the REAL KERNEL GATE is the arbiter of whether it authorizes the write). Audit + runner semantics. */
496
- readonly via: "marker" | "inferred" | "computed-base" | "seed-path";
517
+ * the REAL KERNEL GATE is the arbiter of whether it authorizes the write), or `"born-context"` (LAYER 4:
518
+ * R is established by the domain's BIRTH GENESIS — the gated directive is proven INSIDE the born workspace
519
+ * as the owner; see {@link BornContextAuthority}). Audit + runner semantics. */
520
+ readonly via: "marker" | "inferred" | "computed-base" | "seed-path" | "born-context";
497
521
  /** LAYER-3 ANTI-FALSE-GREEN FLAG. `true` ⇒ this is a SPECULATIVE seed-path leg: codegen could NOT prove
498
522
  * (via rewrite resolution) that replaying `grantDirectiveId` authorizes the gated write — it discovered a
499
523
  * seed that writes relation tuples and is DEFERRING to the real kernel gate. The runner replays the seed,
@@ -503,6 +527,37 @@ export interface RelationBootstrapLeg {
503
527
  readonly seedSpeculative?: boolean;
504
528
  /** The relations the discovered seed directive writes (layer-3 audit/diagnostic — what authority it seats). */
505
529
  readonly seedWritesRelations?: readonly string[];
530
+ /** LAYER 4 — THE BORN-DOMAIN GENESIS-AUTHORITY PATH. Present iff the gate relation is established by the
531
+ * domain's BIRTH GENESIS (not a standalone-replayable seed): a `.births()` directive whose genesis chain
532
+ * seats R (e.g. co2's `birthHome` → `seedHomeCreator` seats `creator`), and whose born workspace runs the
533
+ * SAME law — so the gated directive can be proven INSIDE the born context, dispatched as the genesis-
534
+ * established OWNER, where the seed's own prerequisite invariants (HomeCreatorSeedBindsOwner) hold. This
535
+ * TAKES PRECEDENCE over a standalone seed (`grantDirectiveId`) for a born domain: a standalone re-seed
536
+ * would trip the seed's owner-binding prerequisite (HomeOwner unset) and FALSE-fail. When present, the
537
+ * runner IGNORES the standalone grant fields and proves via {@link BornContextAuthority}. */
538
+ readonly bornContext?: BornContextAuthority;
539
+ }
540
+
541
+ /** THE BORN-DOMAIN GENESIS AUTHORITY (layer 4): the self-birth directive whose genesis seats the gate relation
542
+ * for the OWNER, plus what the runner needs to birth it (as the proof actor = the home owner) and then prove
543
+ * the gated directive INSIDE the born workspace. The real kernel gate is the arbiter — a refusal degrades to
544
+ * a diagnostic (never a false green). */
545
+ export interface BornContextAuthority {
546
+ /** The self-birth directive (`birthHome`) whose genesis chain seats the gate relation. */
547
+ readonly birthDirectiveId: string;
548
+ /** The synthesized birth payload (auto-stamped `…At` omitted; framework/law-hash placeholders removed — the
549
+ * runtime injects the REAL hashes + the proof actor as owner). */
550
+ readonly birthPayload: Record<string, unknown>;
551
+ readonly birthAutoStamped: readonly string[];
552
+ /** The birth payload field carrying the home OWNER — the runtime sets it to the proof actor, so the born
553
+ * home is owned by (and seats the relation for) the actor the gated dispatch then runs as. */
554
+ readonly ownerField?: string;
555
+ /** The birth payload field carrying the framework-controller hash (runtime injects the real one). */
556
+ readonly frameworkHashField?: string;
557
+ /** The birth payload field carrying the child law hash (runtime injects the deployed package hash). */
558
+ readonly lawHashField?: string;
559
+ /** The genesis step directive that actually seats the relation (audit — e.g. `seedHomeCreator`). */
560
+ readonly seatsViaDirectiveId: string;
506
561
  }
507
562
 
508
563
  /** Run a directive's plan with a synthesized+probe payload under birth-probe globals and return the drained
@@ -686,6 +741,127 @@ export function resolveGrantableBaseRelations(
686
741
  return out;
687
742
  }
688
743
 
744
+ // ── LAYER 4: born-domain genesis-authority discovery (the co2 home case) ─────────────────────────────────
745
+ //
746
+ // A gate relation R may be established ONLY by the domain's BIRTH GENESIS — a `.births()` directive whose
747
+ // genesis chain seats R for the owner (co2's `birthHome` genesis runs `seedHomeCreator`, seating `creator`).
748
+ // The seed itself carries a PREREQUISITE (an owner-binding workspaceInvariant reading state set by an EARLIER
749
+ // genesis step), so a standalone re-seed FALSE-fails (the prior state is absent). The law-true authority path
750
+ // is to BIRTH the workspace (replaying the whole genesis, seating R for the owner) and prove the gated
751
+ // directive INSIDE the born workspace, dispatched as that owner. Discovery probes each `.births()` directive's
752
+ // genesis chain for a step that writeTuples R; if found, the born workspace (running the SAME law) is where
753
+ // the gated directive proves.
754
+
755
+ /** Probe a `.births()` directive's genesis chain (birth-probe globals) → the recorded steps + the synthesized
756
+ * parent payload. `undefined` ⇒ not probeable / no recipe. Mirrors {@link discoverChildBirthLeg}'s probe. */
757
+ function probeBirthGenesis(
758
+ birthDir: Directive<unknown>,
759
+ where: string,
760
+ ): { chain: Array<{ domain?: string; directiveId?: string; actor?: unknown }>; synth: SynthesizedPayload } | undefined {
761
+ let synth: SynthesizedPayload;
762
+ try {
763
+ synth = synthesizePayload(birthDir.payloadSchema as z.ZodTypeAny, where);
764
+ } catch {
765
+ return undefined;
766
+ }
767
+ const probePayload: Record<string, unknown> = { ...synth.value };
768
+ for (const f of synth.autoStamped) probePayload[f] = "2026-01-01T00:00:00Z";
769
+ let recipe: { genesisChain?: unknown } | undefined;
770
+ try {
771
+ withBirthProbeGlobals(() => {
772
+ __resetAuthoring();
773
+ __resetReads();
774
+ __resetBirths();
775
+ try {
776
+ birthDir.plan(probePayload as never, deterministicPorts({ physical: 1, replica: 1, principal: "principal:probe" }));
777
+ } finally {
778
+ try { __drainAuthoring(); __drainReads(); } catch { /* drained */ }
779
+ }
780
+ recipe = __drainBirths()[0] as typeof recipe;
781
+ });
782
+ } catch {
783
+ return undefined;
784
+ }
785
+ if (recipe === undefined || !Array.isArray(recipe.genesisChain)) return undefined;
786
+ return { chain: recipe.genesisChain as Array<{ domain?: string; directiveId?: string; actor?: unknown }>, synth };
787
+ }
788
+
789
+ /**
790
+ * Discover the BORN-DOMAIN genesis authority for a gate `relation`: a `.births()` directive in `mod` whose
791
+ * genesis chain seats `relation` (a genesis step whose directive `writeTuple`s R) AND whose born workspace
792
+ * runs the SAME law (so the gated directive is addressable there). Returns what the runner needs to birth the
793
+ * workspace as the proof actor (= the owner) + the owner/hash payload fields. `undefined` ⇒ no birth seats R.
794
+ */
795
+ export function discoverBornContextAuthority(
796
+ mod: DomainModule,
797
+ relation: string,
798
+ ): BornContextAuthority | undefined {
799
+ const domain = mod.domain ?? mod.name;
800
+ const directives = mod.directives as Directive<unknown>[];
801
+ const byId = new Map(directives.map((d) => [d.id, d]));
802
+ for (const bd of directives) {
803
+ if (bd?.birthsChild !== true || typeof bd.payloadSchema !== "object") continue;
804
+ const probed = probeBirthGenesis(bd, `${domain}/${bd.id}`);
805
+ if (probed === undefined) continue;
806
+ const { chain, synth } = probed;
807
+ // Find the genesis step (in THIS domain) whose directive writeTuples `relation` — that step seats R.
808
+ let seatsVia: string | undefined;
809
+ for (const step of chain) {
810
+ if (step === null || typeof step !== "object" || step.domain !== domain || typeof step.directiveId !== "string") continue;
811
+ const stepDir = byId.get(step.directiveId);
812
+ if (stepDir === undefined) continue;
813
+ if (probeRelationsWritten(stepDir, `${domain}/${stepDir.id}`).includes(relation)) {
814
+ seatsVia = step.directiveId;
815
+ break;
816
+ }
817
+ }
818
+ if (seatsVia === undefined) continue;
819
+
820
+ // Resolve the owner field (the payload field whose value the genesis routes as the seed's subject/actor)
821
+ // + the framework/law-hash fields (matched by value against the genesis install steps), mirroring
822
+ // discoverChildBirthLeg. The owner field is the payload field carried as a genesis step actor.
823
+ const fieldForValue = (v: unknown): string | undefined =>
824
+ Object.entries(synth.value).find(([, sv]) => sv === v && typeof v === "string" && v.length > 0)?.[0];
825
+ const ownerField = (() => {
826
+ for (const step of chain as Array<{ actor?: unknown }>) {
827
+ const exact = fieldForValue(step?.actor);
828
+ if (exact !== undefined) return exact;
829
+ if (typeof step?.actor === "string") {
830
+ for (const [field, value] of Object.entries(synth.value)) {
831
+ if (typeof value === "string" && value.length > 0 && (step.actor === `user:${value}` || step.actor === `key:${value}`)) return field;
832
+ }
833
+ }
834
+ }
835
+ return undefined;
836
+ })();
837
+ const installSteps = chain.filter((s) => s !== null && typeof s === "object" && s.directiveId === "installDomain") as Array<{ domain?: string; payload?: { domainHash?: unknown } }>;
838
+ const frameworkHashField = fieldForValue(installSteps.find((s) => s.domain === "bootstrap")?.payload?.domainHash);
839
+ const lawHashField = fieldForValue(installSteps.find((s) => s.domain === "nomos")?.payload?.domainHash);
840
+
841
+ const birthPayload: Record<string, unknown> = { ...synth.value };
842
+ if (frameworkHashField !== undefined) delete birthPayload[frameworkHashField];
843
+ if (lawHashField !== undefined) delete birthPayload[lawHashField];
844
+
845
+ return {
846
+ birthDirectiveId: bd.id,
847
+ birthPayload,
848
+ birthAutoStamped: synth.autoStamped,
849
+ ...(ownerField !== undefined ? { ownerField } : {}),
850
+ ...(frameworkHashField !== undefined ? { frameworkHashField } : {}),
851
+ ...(lawHashField !== undefined ? { lawHashField } : {}),
852
+ seatsViaDirectiveId: seatsVia,
853
+ };
854
+ }
855
+ return undefined;
856
+ }
857
+
858
+ /** Does any workspaceInvariant fire ON `directiveId` — i.e. does that directive carry a PREREQUISITE (a
859
+ * declared invariant reading state another step must establish first)? A standalone replay of such a
860
+ * directive can FALSE-fail (the prior state is absent), so a born domain must prove via the genesis path. */
861
+ function directiveHasPrerequisiteInvariant(mod: DomainModule, directiveId: string): boolean {
862
+ return (mod.workspaceInvariants ?? []).some((inv) => inv.on === directiveId);
863
+ }
864
+
689
865
  /** Discover the RELATION-BOOTSTRAP leg for a module: the first gated (`.requires(R)`) directive plus the
690
866
  * sibling that ESTABLISHES the actor's authority for R. In priority order: (1) a DIRECT granter of R (a
691
867
  * `.grants(R)` marker or a plan-probed `writeTuple(R)`) — the original lane; (2) REWRITE-AWARE — when R is
@@ -764,14 +940,54 @@ export function discoverRelationBootstrapLeg(mod: DomainModule): RelationBootstr
764
940
  // guard: `seedSpeculative`). Prefer a seed that seats the MOST relations (a genesis/owner seed like
765
941
  // estate's, which lands admin/creator/estateOwner together — the broadest root authority).
766
942
  const seed = discoverSeedDirective(candidates, domain);
767
- if (seed === undefined) return undefined; // no seed path → the caller emits the diagnostic
768
- found = { granter: seed.dir, via: "inferred" };
769
- grantedRelation = seed.relationsWritten[0] ?? relation; // cosmetic — the seed's primary relation
770
- via = "seed-path";
771
- seedSpeculative = true;
772
- seedWritesRelations = seed.relationsWritten;
943
+ if (seed !== undefined) {
944
+ found = { granter: seed.dir, via: "inferred" };
945
+ grantedRelation = seed.relationsWritten[0] ?? relation; // cosmetic — the seed's primary relation
946
+ via = "seed-path";
947
+ seedSpeculative = true;
948
+ seedWritesRelations = seed.relationsWritten;
949
+ }
773
950
  }
774
951
  }
952
+
953
+ // (4) BORN-DOMAIN GENESIS AUTHORITY (the co2 home case) — precedence over a standalone seed. The standalone
954
+ // path is "clean" ONLY when a granter was found, it isn't the speculative seed-path lane, AND that granter
955
+ // carries NO prerequisite invariant (a standalone re-seed of a prerequisite-gated granter — e.g.
956
+ // seedHomeCreator, gated by HomeCreatorSeedBindsOwner — would FALSE-fail because the prior genesis state is
957
+ // absent). When the standalone path is NOT clean, prefer proving the gated directive INSIDE the born
958
+ // workspace, whose genesis seats R for the owner.
959
+ const standaloneClean =
960
+ found !== undefined && !seedSpeculative && !directiveHasPrerequisiteInvariant(mod, found.granter.id);
961
+ if (!standaloneClean) {
962
+ const bornContext = discoverBornContextAuthority(mod, relation);
963
+ if (bornContext !== undefined) {
964
+ return {
965
+ domain,
966
+ gatedDirectiveId: gated.id,
967
+ relation,
968
+ grantedRelation: relation,
969
+ gatedPayload,
970
+ gatedAutoStamped: gatedSynth.autoStamped,
971
+ ...(gatedMintField !== undefined ? { gatedMintField, gatedAggregateId: gated.aggregateId } : {}),
972
+ // grant fields are UNUSED for a born-context leg (the runner births + dispatches inside); populate them
973
+ // with the genesis seed's id / empty payload so the shape stays uniform (never dispatched standalone).
974
+ grantDirectiveId: bornContext.seatsViaDirectiveId,
975
+ grantPayload: {},
976
+ grantAutoStamped: [],
977
+ grantSubjectFields: [],
978
+ grantGrantedByFields: [],
979
+ via: "born-context",
980
+ bornContext,
981
+ };
982
+ }
983
+ // No born-domain path. If a standalone granter exists but carries a prerequisite it can't satisfy alone
984
+ // (and isn't the speculative lane), do NOT emit a doomed passing leg — return undefined so the caller's
985
+ // loud diagnostic names the unmet prerequisite. A speculative seed-path stays (it degrades safely).
986
+ if (found !== undefined && !seedSpeculative && directiveHasPrerequisiteInvariant(mod, found.granter.id)) {
987
+ return undefined;
988
+ }
989
+ }
990
+ if (found === undefined) return undefined; // no granter at all → the caller emits the diagnostic
775
991
  const granter = found.granter;
776
992
 
777
993
  // Synthesize the grant payload + resolve which fields route into subject / grantedBy (so runtime seats the
@@ -844,6 +1060,19 @@ export function diagnoseRelationBootstrapGap(mod: DomainModule): string | undefi
844
1060
  if (discoverRelationBootstrapLeg(mod) !== undefined) return undefined; // resolved — no gap
845
1061
  const relation = gated.requiresRelationGate!.relation;
846
1062
  const object = gated.requiresRelationGate!.object;
1063
+ // NAME THE UNMET PREREQUISITE (co2 home diagnostic): when a granter of R exists but carries a prerequisite
1064
+ // invariant a standalone re-seed can't satisfy AND no birth genesis seats R, say so precisely.
1065
+ const directivesAll = mod.directives as Directive<unknown>[];
1066
+ const prereqGranter = directivesAll.find(
1067
+ (d) => d !== gated && typeof d?.payloadSchema === "object" && d.birthsChild !== true
1068
+ && probeGrantsRelation(d, relation, `${domain}/${d.id}`) && directiveHasPrerequisiteInvariant(mod, d.id),
1069
+ );
1070
+ if (prereqGranter !== undefined) {
1071
+ const inv = (mod.workspaceInvariants ?? []).find((i) => i.on === prereqGranter.id);
1072
+ return `${domain}/${gated.id} is gated by .requires("${relation}") but the proof cannot seed authority: the only granter of "${relation}" is `
1073
+ + `${domain}/${prereqGranter.id}, which carries the prerequisite invariant '${inv?.id ?? "?"}' (it reads state an EARLIER step must establish), so a standalone re-seed FALSE-fails — and no \`.births()\` genesis seats "${relation}" to prove it in a born workspace. `
1074
+ + `Establish "${relation}" via a birth genesis (a \`.births()\` whose chain runs ${prereqGranter.id} after seeding its prerequisite), or prove ${gated.id} by hand. (The negative conformance leg still proves the gate refuses an actorless offer.)`;
1075
+ }
847
1076
  const bases = resolveGrantableBaseRelations(mod.relationTypes, object, relation).filter((b) => b !== relation);
848
1077
  const tried = bases.length > 0
849
1078
  ? `no directive grants "${relation}" directly, nor any of its directly-grantable rewrite bases [${bases.join(", ")}]`
@@ -939,9 +1168,15 @@ function withBirthProbeGlobals<T>(run: () => T): T {
939
1168
  return h.toString(16).padStart(8, "0").repeat(8);
940
1169
  },
941
1170
  };
1171
+ // RELATION-NAMESPACING: a probed seed plan may build a relation OBJECT TOKEN (`selfWorkspace()`),
1172
+ // which qualifies its type by the dispatching domain. The proof generator's STATIC probe reads the
1173
+ // relation name + subject entry key off the drained ops (never the object string), so a sentinel
1174
+ // dispatch domain is enough to let the token resolve without throwing — no real domain is needed.
1175
+ __setDispatchDomain("proof");
942
1176
  try {
943
1177
  return run();
944
1178
  } finally {
1179
+ __setDispatchDomain(undefined);
945
1180
  if (had && prior !== undefined) g.nomos = prior; else delete g.nomos;
946
1181
  }
947
1182
  }
@@ -1537,7 +1772,7 @@ function generateBirthOnlyProof(
1537
1772
  `import { readFileSync } from "node:fs";`,
1538
1773
  `import { connect, encodeIntentOfferBytes } from "@githolon/client";`,
1539
1774
  `import { ${factory}, ${hashConst} } from "./${opts.packageName}.client.ts";`,
1540
- `void connect; void ${factory};`,
1775
+ `void connect; void encodeIntentOfferBytes; void ${factory};`,
1541
1776
  ``,
1542
1777
  `const CLOUD = (process.env.NOMOS_CLOUD || "https://nomos.captainapp.co.uk").replace(/\\/+$/, "");`,
1543
1778
  `const WS = process.env.NOMOS_WS || ${JSON.stringify(slug + "-proof-")} + Math.random().toString(36).slice(2, 8);`,
@@ -1545,6 +1780,11 @@ function generateBirthOnlyProof(
1545
1780
  ...v2IdentityAndCleanupPreamble(opts, hashConst, n),
1546
1781
  ``,
1547
1782
  ...liveChildBirthLegLines(cb, opts, n),
1783
+ // THE RELATION-BOOTSTRAP LEG (incl. LAYER 4 born-context): a birth-only domain may ALSO carry a gated
1784
+ // non-birth directive whose authority is established by the birth genesis (co2 home's updateHomeProfile
1785
+ // via birthHome→seedHomeCreator). Emit it so the birth-only proof names it (the born-context proof itself
1786
+ // runs in the offline `githolon proof` lane). No-op when the domain has no gated directive.
1787
+ ...relationSeedLegLines(primaryLegs.relationBootstrap, factory, n, primaryLegs.relationBootstrap === undefined ? diagnoseRelationBootstrapGap(mod) : undefined),
1548
1788
  `console.log(\`\\nALL GREEN — generated proof for "${opts.packageName}": deploy → child birth (\${WS} → the born child) → verified\`);`,
1549
1789
  ``,
1550
1790
  ...v2CatchFinally(),
@@ -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 } : {}),