@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 +1 -1
- package/src/codegen_proof.ts +249 -9
- package/src/engine_entry.ts +34 -3
- package/src/framework/relations.ts +80 -3
- package/src/framework/signing_identity.ts +10 -4
- package/src/framework/workspaces.ts +7 -0
- package/src/index.ts +10 -0
- package/src/relation_schema.ts +91 -0
- package/src/usd.ts +99 -16
- package/src/usd_layers.ts +41 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@githolon/dsl",
|
|
3
|
-
"version": "0.
|
|
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",
|
package/src/codegen_proof.ts
CHANGED
|
@@ -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)
|
|
496
|
-
|
|
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
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
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(),
|
package/src/engine_entry.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
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")
|
|
67
|
-
|
|
68
|
-
|
|
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,
|
package/src/relation_schema.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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.
|
|
729
|
-
//
|
|
730
|
-
// relation is an author error (caught in the merge).
|
|
731
|
-
const nomosRelations = serializeRelationSchema(
|
|
732
|
-
...(l.module.relationTypes ?? []),
|
|
733
|
-
|
|
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}.
|
|
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
|
-
|
|
749
|
-
|
|
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))
|
|
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 } : {}),
|