ADR-098: Domain and world as distinct entities with a one-to-one link
Context
Two rows model what a reader could mistake for one thing:
platform.domains(id, org_id, slug, name)— a tenant’s access/grouping primitive. Everything bound to it is access-shaped:domain_members,api_keys,invites,audit_log,llm_budget_state. It is the URL coordinate (/orgs/{org}/domains/{domain}), the API-key scope (ADR-084 D1), and the RBAC anchor (ADR-086). Lives inspectral.platform.worlds.world_models(id, org_id, domain_id, name, …)— an authored decision-logic artifact: the parent ofrules,world_model_versions,world_model_card,release_notes. Lives inspectral.worlds.
Two drifts surfaced while planning the tenancy-rename + RLS bundle (SPEC-489 + SPEC-524):
-
Ambiguous cardinality.
worlds.world_modelscarriesunique(domain_id, name)— which permits N world models per domain. But every downstream consumer keys off(org, domain)with no world selector: decision routing(org, domain, action)at the active version (ADR-076 D2), the active-version pointer(org, domain) → version(ADR-085 D2), action discoverability(org_id, domain_id)(ADR-089 D1), and the World Agent session boundary — “a single(org, domain)world instance” (ADR-081 D2). No consumer can route to, activate, or even name a specific world within a domain. Theunique(domain_id, name)is a fossil of the retiredworkspaceprimitive (a generic work-grouping that could hold many things); whenworkspacedied (ADR-086), its many-children semantics should have died with it. -
Inconsistent identifier. The foreign key to
worlds.world_models(id)is named two different ways across the worlds schema:world_idonworld_model_versions/world_model_card/release_notes/source_materials/distillation_runs, butworld_model_idonrules/rule_relationship/rule_source_citations/approval_decisions. One referent, two names — the surface symptom of the unsettled identity question.
The question under both: are domain and world the same entity? They are not. A domain is one tenant’s access boundary; a world’s value is meant to span many tenants in a market (the in-band decision-support direction per ADR-076 + the strategic shift). If the two were one entity — if a world’s identity were its tenant’s domain_id — a world could never be shared, which forecloses the trajectory. They are distinct entities whose link happens to be one-to-one today.
Decision
D1 — Domain and world are distinct entities; the world keeps its own identity; the link lives on the platform side
platform.domains and worlds.world_models are two distinct entities in two distinct contexts. worlds.world_models.id is the world’s own surrogate identity and is not collapsed into domain_id. The world stays independently addressable — the precondition for elevating a single-tenant-authored world to a market-centric shared artifact (D5).
The cross-context link is a single nullable column on the platform side: platform.domains.world_id uuid — a soft reference (no foreign key) to worlds.world_models(id), matching the established no-cross-context-FK convention already used for worlds.world_models.created_by (a uuid with no FK to core.users). The link is deliberately not placed on the worlds side: a worlds.world_models.domain_id column would (a) drag a platform tenancy identifier into a worlds-context table, and (b) bind the cardinality the wrong way — unique(domain_id) on the world relaxes toward one domain → many worlds, the opposite of the N:1 sharing D5 reserves. Placing the link on platform.domains means many domains can point at one world with no schema change. A soft uuid (not a hard FK) keeps the foundational platform context from declaring a schema-level dependency on the downstream worlds context; no SQL grants cross the boundary (ADR-063).
worlds.world_models therefore carries no platform tenancy columns — no org_id, no domain_id. The org is reachable when needed via platform.domains (domain → org_id); the worlds context does not need it as a column.
D2 — The domain→world link is one-to-one today
A domain is linked to exactly one world model, and a world model belongs to exactly one domain. Enforced at the schema on the platform side: platform.domains.world_id is a single column (so each domain has ≤1 world), and a partial unique(world_id) where world_id is not null ensures no two domains share a world yet. This pins the cardinality that ADR-086 D2 left neutral and that ADR-081 D2 / ADR-085 D2 / ADR-089 already assume by keying on (org, domain) alone.
The 1:1 is a constraint on the link, not an identity claim about the entities. Relaxing to N:1 sharing (D5) is exactly dropping the partial unique(world_id) — no column move, no identity surgery, because the world already keeps its own identity (D1) and the link already lives where many domains can reference one world.
D3 — The foreign key to a world is named world_id everywhere
The column (and every Python parameter, field, dict key, and SQL literal) referencing worlds.world_models(id) is named world_id. The inconsistent world_model_id naming on rules / rule_relationship / rule_source_citations / approval_decisions (and their composite constraints, indexes, and the world_model_health view) is retired in favor of the short form already used elsewhere. The compound candidate_world_model_id parameter on the distillation authoring path aligns to candidate_world_id, and the apps/operations operator-console DTO field aligns from world_model_id to world_id. The type/entity name WorldModel and module names (world_model_repository, world_model_card, world_model_versions, world_model_health) are unchanged — only the identifier of a world aligns to world_id.
D4 — Active-version ownership is the world’s; routing has a default and an off-path form
The active version of a world is owned by the world (in spectral.worlds), not by the (org, domain) tuple. Today, because the link is 1:1, “the active version for (org, domain)” and “the active version of the world linked to that domain” are the same row — which is why ADR-085 D2’s (org, domain) → active world_model_version framing is correct as a projection of the world-owned pointer. The active-version pointer table is later-stream work (it does not exist in the schema yet); when it lands it is keyed by the world.
Version resolution at the call surface has two forms:
- Default:
(org, domain)→ the linked world’s active version, resolved throughplatform.domains.world_id(D1). This is the binding path forPOST /api/decideand the action surfaces. - Off-path (routing hint):
(org, domain, version?)— an optional version selector that targets a specific (possibly non-active) version. This is required for operator testing of non-active world-model versions and aligns with the optionalworld_model_versionrequest field already in ADR-076 D2 + ADR-077 D2.
D5 — Future evolution: relaxation is reserved, not committed
The 1:1 link may evolve to deliver market-centric value. Two shapes are reserved; neither is committed now:
- N:1 sharing — many domains link to one shared market world. With the link on
platform.domains.world_id(D1), this is reachable by dropping the partialunique(world_id)(D2) and pointing multiple domains at the same world — no column move. - Derived/composed worlds — a broadly-applicable market-defined base world plus per-customer override rules, composed into a derived world that is linked 1:1 into the customer’s domain. This shape would realize shared-market value and per-customer differentiation together.
Which shape (if any) is right is a question for the future ADR that relaxes the constraint; neither is evaluated or chosen here. Because the world keeps its own identity (D1) and the link lives where many domains can already reference one world, either shape is reachable without re-keying the world or moving the link. The seam is built to permit relaxation; the constraint is 1:1 until a future ADR relaxes it. When relaxation lands, the active-version routing keys (D4) and the consumers that key on (org, domain) alone gain a world_id selector.
D6 — The worlds context is tenancy-scoped by its own world_id, not by platform’s domain_id
Because worlds.world_models carries no platform tenancy columns (D1), worlds-side row-level security is scoped by the world’s own identifier, bound as the session variable app.world_id (alongside app.org_id / app.domain_id / app.user_id). The RLS backstop on shared worlds artifacts is id = current_setting('app.world_id')::uuid (worlds.world_models) and world_id = current_setting('app.world_id')::uuid (worlds.rules and other world-child tables) — replacing the per-user created_by predicate that incorrectly conflated authorship with tenancy (SPEC-524; the enshrinement review gate is intrinsically author≠reviewer). This is consistent with ADR-033: the app layer is the primary tenancy boundary, RLS is the dumb backstop.
The (org, domain) → world_id resolution lives at the apps/api composition layer, which legitimately reads platform.domains (the only context owning that link). The operator request resolves the target domain’s world_id at entry and binds app.world_id in request_scope. Per-user created_by RLS is retained only on the platform personal-records tables where the actor genuinely is the owner (audit_log, domain_members, api_keys, agent-memory). Every worlds-context table — including the authoring audit and approval-decision records — is world-scoped: those are the world’s history, shared with any operator authorized for the world, with operator_id kept as provenance.
Under N:1 relaxation (D5), app.world_id scoping is already correct: many domains resolving to the same world see the same world’s artifacts — which is the shared-market behavior.
The world-scoped worlds.* tables carry FORCE ROW LEVEL SECURITY with platform_role-applicable policies keyed on app.world_id, so the app.world_id backstop confines the owning platform_role too — not only app_role. This matters because the authoring orchestrator, enshrinement, publication, and distillation flows run under SET LOCAL ROLE platform_role (the worlds schema owner), which would otherwise bypass RLS; with FORCE, a platform_role transaction bound to one world cannot reach another’s rows, so the application-layer world-match guards (e.g. the distillation world-scope assertion, the enshrinement prior.world_id == world_id check) are defense-in-depth rather than the sole control. World creation is the one exception — the new world’s id does not exist until its INSERT returns, so world_models admits the platform_role INSERT unconditionally (WITH CHECK (true)) while its SELECT/UPDATE/DELETE stay app.world_id-confined; the world_models SELECT additionally admits the unbound case so the operator “list all worlds” read spans worlds. The three non-world-scoped worlds tables (world_model_versions, world_model_card — global published-authority records; world_agent_conversations — operator-owned) are not FORCEd.
Alternatives considered
Shared identity — make worlds.world_models.domain_id the primary key (drop the surrogate id). Rejected. It is the truest expression of “one entity” and dissolves the naming question entirely — but it permanently binds a world’s identity to one tenant’s domain, foreclosing the market-centric trajectory (D5). The whole point of keeping the world addressable is to let it outlive a single domain’s ownership.
Keep unique(domain_id, name) (preserve the unconstrained 1:N). Rejected. No consumer can route to, activate, or name a world within a domain, so the permitted second world is unreachable phantom capacity. It also misrepresents the model to every reader and is the root of the world_model_id / world_id identifier confusion. If multi-world-per-domain becomes a real need it arrives via D5 with the consumer-side keys updated in lockstep — not as silent latent cardinality.
Treat domain and world as one concept in two facets. Rejected. Tempting because the link is 1:1 and the corpus already speaks of “the (org, domain) world instance” — but the market-centric direction proves they are distinct: a shareable artifact and a single tenant’s access boundary are different things. “Conceptually one” would smuggle back the identity fusion D1 rejects.
Place the link on the worlds side (worlds.world_models.domain_id, unique(domain_id)). Rejected because it relaxes the wrong way — unique(domain_id) on the world permits one domain → many worlds, the opposite of the N:1 many domains → one world sharing D5 reserves. To later share a world across domains you would have to move the link anyway. It also imports platform tenancy identifiers (org_id/domain_id) into a worlds-context table and forces worlds RLS to either reach cross-schema into platform.domains or carry a denormalized tenancy column. The platform-side soft world_id (D1) avoids all three.
Consequences
- Schema. The link is
platform.domains.world_id uuid(soft, no FK) with a partialunique(world_id) where world_id is not null(1:1 today; drop to relax to N:1).worlds.world_modelsdropsorg_id,domain_id,unique(domain_id), the(org_id, domain_id)tenancy + published indexes, and the two platform FKs — it carries no platform tenancy columns; worlds-side RLS scopes byapp.world_id(D6). The FK columnworld_model_id→world_idonworlds.rules,rule_relationship,rule_source_citations,approval_decisions, with their composite unique/FK constraints, indexes, and theworld_model_healthview, are renamed in lockstep. Pre-deployment, all of this lands as in-place edits of the existing platform + worlds migrations (no append-only ALTER; local DB reset replays clean) so the schema lineage carries no residualdomain_idon worlds orworld_model_idanywhere. - Code + tests.
worlds.world_modelslosesorg_id/domain_idacross theWorldModeltype, the repository (_SELECT_COLS,list_world_models,create_world_model), and theCreateWorldModel/ListWorldModelsuse cases; the(org, domain) → world_idresolution moves to the apps/api composition layer and a platform-side setter writesplatform.domains.world_id. Theworld_ididentifier replacesworld_model_idacrossspectral.worlds.*,apps/apioperator surfaces, theapps/operationsoperator console, and the worlds test suite, in lockstep with the SQL string literals. - Session vars. A new
app.world_id(SESSION_VAR_WORLD_ID) joinsapp.org_id/app.domain_id/app.user_id, bound byrequest_scopefor worlds-side RLS (D6). - ADR-086 D2 is amended: the domain→world link is 1:1 (this ADR pins what D2 left neutral). ADR-085 D2/D4 active-version framing is amended: ownership is the world’s, surfaced through the 1:1 domain link; the
(org, domain)-keyed projection is correct under 1:1 and gains aworld_idkey if D5 relaxation lands. ADR-081 D2 and ADR-089 are consistent as-is (their(org, domain)framing is the 1:1 projection) and gain a reciprocal cross-reference note. world_model_idin ADR prose (ADR-030, ADR-042 schema examples) aligns toworld_id.- Codex access-control + world-model-system pages state the 1:1 domain↔world link explicitly where they were previously silent.
- Tenancy bundle. This ADR lands before SPEC-489 + SPEC-524; SPEC-524’s worlds-RLS realignment narrows, because the world’s tenancy key and the domain link are settled here.
- The domain/world entity model is settled by this ADR; relaxation (D5) requires a future ADR.
Future concerns
- Cross-customer feedback aggregation presupposes the D5 relaxation. Aggregating customer-operator feedback across deployments — correlating the flagged-decision signals of many customers who share one market world into a single override-pattern cluster — is reachable only once the domain↔world link is N:1 (or composed/derived worlds, D5). Under the 1:1 link (D2), each domain resolves to exactly one world via
platform.domains.world_id, so a world’s accumulated feedback is, by construction, that one deployment’s own signals. The platform feedback loop is therefore single-deployment at alpha: one domain → one world → that deployment’s recurring patterns. The override-pattern aggregation substrate keys by(world_id, pattern), so it is forward-compatible the day D5 lands — when multiple domains point at one world, the same aggregation correlates them into one cluster with no schema or handler change; only this ADR’sunique(world_id)(D2) stands between the alpha loop and cross-customer correlation. The future ADR that relaxes the cardinality is also where cross-customer aggregation is evaluated.