# Data Architecture, Tenancy & Residency

V3 (the Lilith Metaverse) is an embodied, multi-user world that hosts three
public-facing tenants — Tara Studio, Saraswati Stage, and Lilith Commons — for
users who live in different legal jurisdictions and expect their data to stay
where the law says it should. The defining architectural choice is the same one
V2 makes about compliance: **V3 invents no datastore and no residency engine of
its own.** Every durable row, hot key, and binary asset lands in infrastructure
already operated for V1, and every cross-zone decision is made by the shared
`@oshun/data-residency` package composing the canonical rule tables in
`@oshun/contracts`. V3's contribution is the _shape_ of the data — the tenant
and realm axes on rooms, venues, sessions, consent, and avatar bindings — plus
the launch-router that decides which tenant is even available in which market
region. The legality of moving any of that across a border is decided upstream,
by code that V1, V2, and every other Oshun product share.

That split matters because it is what lets an erasure request, a cross-region
consent grant, or a special-category-data block resolve _identically_ whether
the subject is a Tara yoga student in Frankfurt or a Saraswati concert-goer in
São Paulo. This page is the data-and-platform companion to the world-server and
identity pages; it explains the data model, the multi-tenant isolation model,
and the per-region residency routing that the rest of V3 rides on. The section
hub is [../V3_ARCHITECTURE.md](../V3_ARCHITECTURE.md).

## What ships, honestly

The **residency machinery is real, and it is the shared platform's, not V3's.**
`@oshun/data-residency` (`libs/shared/data-residency/src/`) is a substantive
package: a `ResidencyEnforcementService` (`enforcer.ts:144`) that evaluates a
proposed transfer and emits a canonical audit event, an `AsyncLocalStorage`-
backed routing context (`traffic-shaping.ts:63`) that pins routine traffic to a
subject's home plane, a home-zone resolver (`home-zone.ts:22`), and a DSAR
router (`dsr-routing.ts:46`). It composes two exhaustive contract modules:
`data-residency-rules.ts` (2,047 lines — a per-artifact rule for _every_
`OshunArtifactType` plus a complete 49-entry cross-border transfer matrix) and
`data-residency-deployment-policy.ts` (the `evaluateDeployedTransfer` decision
function and three canonical postures). The BFF wires both in through
`registerResidencyRoutingContext` (`residency-routing.ts:32`) and
`createResidencyGuard` (`residency-guard.ts:94`), and the routing behaviour is
covered by real assertions in `residency-routing.test.ts`.

The **V3-specific data shapes are real Zod contracts.** `libs/contracts/src/v3/`
carries the tenant and realm primitives, the `Room`/`Venue`/`LilithSession`/
`Presence` schemas, `AvatarBinding`, and `EmbodiedConsent`, each stamped with a
tenant. The **launch-router is real** (`v3-lilith-launch.ts`) and the per-region
tenant-availability matrix is checked-in data
(`V3/regions/tenant-region-availability.json`).

Three honest qualifications. **First**, the monolith's Postgres detail — a `v3`
schema in a `lilith` database, a `v3.pxstream_sessions` table, `pgvector`
discovery embeddings, TimescaleDB eval hypertables — is **description, not
code**. The one durable table that actually exists is
`v3_session_boundary_writes`, written by the world server's Rust
`PostgresDurableSessionStore` (see
[World Server and Gateway](./world-server-and-gateway.md)); the named
schema/table/extension topology is the target design, not a migration you can
point at. **Second**, the enforcer is deliberately I/O-light: it decides
_whether_ a transfer is legal and which audit event to emit, but, in its own
words, "storage decisions (which DB shard / which processing pool) belong
upstream of the enforcer" (`index.ts:11-15`). The per-zone Postgres/S3
partitioning the monolith promises is infrastructure the decision layer assumes,
not infrastructure this package provisions. **Third**, the transfer _mechanisms_
(SCC, IDTA, Data Privacy Framework) are modelled as data; the executed legal
paperwork they stand for is an operator obligation the deployment policy merely
_declares_ it holds (`acknowledgedTransferMechanisms`). As with V2: the
enforcement seams are coded, the obligations are documented and audited.

## The data model

### The hot / durable boundary

V3 splits state along a **hot / durable** line. Live, mutable, reconstructable
state lives in Redis; the authoritative record lives in Postgres; large binaries
live in object storage behind the CDN. The world server proves this split with
real backends rather than mocks:
`apps/v3/lilith-world-server/src/durable_persistence.rs` defines a
`PostgresDurableSessionStore` (`:164`) that uses `sqlx` to persist a JSON
checkpoint envelope per `SessionBoundaryKind` (`Start`/`Checkpoint`/`End`) and a
`RedisHotStateStream` (`:281`) that `XADD`s each hot-state checkpoint under the
`v3:hot-state` prefix. The decisive property is that a checkpoint's participant
transforms are **prost-encoded with the canonical wire bytes** before being
wrapped in JSON, so the durable copy can never drift from the on-wire copy, and
`recover_room_from_redis_hot_state` (`:362`) replays the latest stream entry
into a fresh `RoomRegistry::restore_checkpoint`. A room's checkpoint is owned by
the _room_, not by any one attendee — so transient avatar motion is never the
record of truth, and "no hot state is authoritative" is enforced by the recovery
path actually rehydrating from Postgres + Redis. The lifecycle and
crash-recovery details live in
[World Server and Gateway](./world-server-and-gateway.md); platform-wide
persistence conventions are in
[Persistence & Data](../../platform/persistence-data.html).

The data classes map onto this boundary cleanly. Avatar transform / expression /
IK is hot-only and never persisted — the `Presence` schema
(`libs/contracts/src/v3/lilith.ts:233`) carries `position`, `rotation`,
`velocity`, an `expressionState` (mood, gaze, up-to-16 lip-sync visemes,
gesture) and an `activityState`, all of which are live snapshot data. What _is_
durable is the _binding_ history: `AvatarBinding` (`lilith.ts:18`) records which
avatar a user wore, from when (`activeFrom`), the swap cooldown, and — crucially
— the `provenanceBundleId` and `consentRecordId` that license the likeness.
"Avatar history" in a data-subject export therefore means binding/costume/swap
rows, not motion data.

### Tenant and realm: two axes, honestly distinguished

The monolith speaks loosely of a "tenant realm." In the contracts these are
**two independent dimensions**, and the page is more precise than the monolith:

- `V3TenantSchema` (`primitives.ts:9`) is an enum of **four** values —
  `lilith-platform`, `lilith-commons`, `tara-studio`, `saraswati-stage`. Three
  are the public product tenants; `lilith-platform` is the operator/platform
  tenant that owns cross-cutting infrastructure. (The launch-router only ever
  surfaces the three product tenants; `lilith-platform` is never a launch
  target.)
- `V3RealmSchema` (`primitives.ts:17`) is
  `production | preview | training | private` — an **environment/visibility**
  axis, _not_ a residency zone and not a tenant. A `Room` and a `Venue` are
  keyed by `(tenant, realm)`, so a Tara venue can exist in `production` and
  `preview` realms simultaneously without colliding.

Residency zone is a _third_, orthogonal axis carried by the platform residency
context (below), and the launch _region_ is a _fourth_ (market) axis carried by
the router. Keeping these four separate — tenant, realm, residency zone, market
region — is exactly what prevents the "who can see what, stored where, legal to
move where" question from collapsing into one over-loaded field.

## The multi-tenant isolation model

V3 does not invent a parallel tenancy model; it reuses the V1 tenancy graph,
which is itself a real contract: `libs/contracts/src/common/tenant.ts` defines
`TenantKindSchema`, `TenantHierarchyRelationshipSchema`,
`TenantHierarchyEdgeStatusSchema`, `TenantPolicyInheritanceSourceSchema`, and a
`TenantResidencyZoneSchema`. V3's tenants are nodes in that graph; the isolation
guarantees are layered:

### Row scoping and the tenant context

Every V3 entity that can belong to someone carries a tenant. `Venue`
(`lilith.ts:269`) and `AvatarBinding` (`:22`) carry a single `tenant`;
`EmbodiedConsent` (`consent.ts:22`) carries both `tenant` and `realm` and a
`feature`/`scope`/`scopeRef` triple so a consent grant is scoped to exactly one
feature in one place. A `LilithSession` (`lilith.ts:220`) carries a
`tenantScope: z.array(V3TenantSchema).min(1).max(4)` — a session can
legitimately span more than one tenant (a Commons user wandering into a
Saraswati venue) but the scope is explicit and bounded, never implicit. On the
BFF side, the residency guard threads
`built.auth.tenantId ?? request.tenantContext?.tenantId` into every audit event
it emits (`residency-guard.ts:144`), so a residency decision is always
attributable to a tenant.

### Room-boundary segregation

A room belongs to exactly one tenant. Shards may co-host rooms from different
tenants for packing efficiency, but interest management, presence, replication,
and chat are all scoped to a single room — the `Room` schema (`lilith.ts:253`)
binds a room to one `shardId`, one `sceneGraphRef`, and a bounded
`participantIds` set, and the world server replicates within a room, never
across one. This is **logical segregation by room scope**, not physical
per-tenant shard partitioning: two tenants can share a shard and still never
exchange an event, because no event crosses a room boundary in the first place.

### The same pattern, used elsewhere

The "fail closed at the tenant boundary" pattern is not unique to V3. Sophia's
ingestion pipeline has its own `TenantBoundary`/`ResidencyEnforcer` seam
(`libs/sophia/ingestion/src/tenant/isolation.ts`) that routes every chunk
through a residency decision "before chunks enter any index … no shared keys, no
shared context, no cross-tenant routing." V3 and Sophia compose the _same_
shared residency idea at different layers — which is the point of putting it in
`@oshun/data-residency`
([shared libraries](../../platform/shared-libraries.html)).

## Per-region residency via `@oshun/data-residency`

Residency in V3 is the composition of two independent mechanisms that meet at
the BFF: a **market-region launch gate** and a **regulatory-zone transfer
enforcer**.

### Two vocabularies

The launch-router (`v3-lilith-launch.ts`) speaks **market regions** — `us-ca`,
`de`, `gb`, `in`, `br` at Wave 1, then `kr`, `gcc`, `cn-mainland`, `ru` — and
decides _tenant availability and client tier_ per region. The residency enforcer
speaks **regulatory zones** — the seven canonical `OshunResidencyZone` values
`eu | uk | us | ca | latam | apac | global` (`data-residency-rules.ts:86`) — and
decides _whether an artifact may legally move_. They are deliberately different
granularities (Germany and the UK are distinct launch regions but `de`→`eu` and
`gb`→`uk` zones; `br`→`latam`; `in`/`kr`→`apac`). The router never imports the
zone enum and the enforcer never imports the region IDs; they compose only at
the BFF, each owning the decision it is competent to make.

### The rule table and the decision function

The heart of the enforcer is the pure function
`checkResidencyTransfer(artifactType, source, target)`
(`data-residency-rules.ts:1704`). It reads the per-artifact rule and the
source→target matrix cell and returns an `OshunResidencyDecision` whose `status`
is `allowed | blocked | requires_safeguards`. The logic is genuinely
domain-specific, not a generic allow-list:

- **Same zone** is always `allowed` with no transfer audit (`:1713`).
- **Operator-only** artifacts (`model_card`, `workflow_template`,
  `policy_bundle`) replicate freely, even through `global`, because they have no
  data-subject linkage (`:1732`).
- **Pinned** artifacts (`profile`, `session`, `conversation_history`,
  `memory_scope`, …) are blocked from the `global` pool outright (`:1788`).
- **Special-category** biometric artifacts — `voice_profile` and `avatar_pack`,
  both `sensitivityTier: 'special_category'` with
  `requiresLocalProcessing: true` (`:748`, `:771`) — are the hard case:
  cross-zone egress is `blocked` unless the artifact's mechanism set contains
  `explicit_consent`, in which case the decision is `requires_safeguards` with
  consent as the _only_ acceptable mechanism and supplementary measures forced
  on (`:1804`–`:1838`). This is the GDPR Art. 9 rule encoded as a branch, not a
  comment.

For V3 this is exactly the right shape: an avatar likeness pack or a cloned
voice is special-category data that is pinned and locally processed, and it can
only leave the user's zone with documented explicit consent — which is why
`AvatarBinding` carries a `consentRecordId` at all.

### The deployment policy and postures

`evaluateDeployedTransfer(policy, request, rule)`
(`data-residency-deployment-policy.ts:245`) wraps the rule decision in a
deployment posture. A deployment opts into `block`, `audit_only`, or `monitor`
(`:49`), with per-cross-border-rule overrides, a `supportedZones` gate that
refuses unknown targets early, a `defaultHomeZone` for pre-rollout tokens that
lack a `homeZone` claim, and the `acknowledgedTransferMechanisms` set it has
actually executed paperwork for. Three canonical policies ship:
`OSHUN_CANONICAL_BLOCKING_POLICY` (hard GA enforcement, `:456`),
`OSHUN_CANONICAL_AUDIT_ONLY_POLICY` (audit everything, block only pinned-region
egress, `:486`), and `OSHUN_CANONICAL_MONITOR_POLICY` (warning-only drift
detection, `:519`). The same artifact thus behaves differently across a rollout
without any rule change — the policy, not the rule, decides whether a
`requires_safeguards` outcome becomes a 403 or a warning audit line.

### The BFF seam

```mermaid
flowchart TB
  U[V3 user · JWT homeZone claim] --> RH["onRequest hook<br/>registerResidencyRoutingContext"]
  RH --> HZ["resolveHomeZoneFromClaim<br/>(claim ?? defaultHomeZone)"]
  HZ --> CTX["createResidencyRoutingContext<br/>AsyncLocalStorage route zone"]
  CTX -->|routine traffic| DS["domain-service-adapters<br/>baseUrl.zones[routeZone]"]
  DS -->|home-plane URL + headers| SVC["e.g. tara.eu.internal"]
  CTX -->|customer-data write/read| G["createResidencyGuard.enforce"]
  G --> ENF["ResidencyEnforcementService.evaluate"]
  ENF --> CHK["checkResidencyTransfer +<br/>evaluateDeployedTransfer"]
  CHK -->|block| R403[HTTP 403 · stable reason]
  CHK -->|emitAudit| AUD["@oshun/audit-platform publisher"]
```

Two seams do the work. The **routing** seam (`registerResidencyRoutingContext`,
`residency-routing.ts:32`) runs as a Fastify `onRequest` hook: it resolves the
subject's home zone from the JWT `hz` claim, checks for an explicit cross-region
consent record via the consent engine (`hasExplicitCrossRegionConsent`, `:125` —
reading `data_processing` consent with a `transfer` permission), builds a
routing context, sets the `X-Oshun-Residency-Route-Zone` response header, and
runs the rest of the request inside `runWithResidencyRoutingContext`.
Downstream, `domain-service-adapters.ts` resolves a _per-zone_ upstream URL
(`baseUrl.zones?.[context.routeZone]`, `:2603`) and **fails loud** if a
residency-routed request has no plane configured for that zone (`:2608`,
`:2628`) — it will not silently fall back to the default plane. The routing test
proves an `eu`-home user's readiness probes all leave for `*.eu.internal` hosts
(`tara.eu.internal`, `nisaba.eu.internal`, …) with the home zone stamped on
every header, and that a `us` target is honored _only after_ explicit
data-processing consent — without it, target-zone hints are ignored and the
request stays on the home plane (`residency-routing.test.ts`).

The **enforcement** seam (`createResidencyGuard`, `residency-guard.ts:94`) is
the per-route check a handler calls before reading or writing customer data. It
maps a `ResidencyEnforcementError` to a stable HTTP 403 carrying `reason`,
`acceptableMechanisms`, and `policyId` (`:156`), and on the non-throwing path
emits the canonical audit event through the deployment's publisher into
[`@oshun/audit-platform`](../../platform/shared-libraries.html). V3's
`/api/v3/lilith/*` routes ride this shared middleware rather than
re-implementing it; the platform decides legality, V3 declares the artifact
type.

### Storage and compute residency, and where they really live

_Storage residency_ — durable rows pinned to the subject's zone — is the
**outcome** the rule table demands and the **infrastructure** the enforcer
assumes; the per-zone Postgres/S3 partitioning is operated for V1 and is not
code in this package. _Compute residency_ is the launch-router plus the Pixel
Streaming matchmaker: `resolveV3LilithLaunchDecision`
(`v3-lilith-launch.ts:230`) gates tenant availability per market region from the
checked-in `V3_LILITH_TENANT_REGION_AVAILABILITY` matrix (`:92`) and selects the
client tier (`native` → `pixel-streaming` → `fallback-web`), so an EU user is
kept on an EU plane and a user in a Pixel-Streaming-restricted region is routed
to the locally rendered fallback. The tier mechanics are in
[Tier Routing and Pixel Streaming](./tier-routing-and-pixel-streaming.md); the
launch decision contract is `LilithLaunchDecision` (`lilith.ts:124`).

### DSAR routing

A data-subject request never spins up its own console.
`createDsrResidencyRoutingDecision` (`dsr-routing.ts:46`) accepts one of the
four canonical kinds (`access | erasure | portability | rectification`),
resolves the subject's home zone the same way the routing hook does, and
produces a zone-scoped queue name — `themis.privacy.dsr.<routeZone>` — plus the
residency headers, the cross-region-safeguard flag, and a hard
`standaloneDsrOpsConsoleAllowed: false`. That last field is asserted in the spec
(`dsr-routing.spec.ts:35`): DSAR for V3 users flows into V1's existing pipeline,
home-zone-routed, never a bespoke V3 deletion path. The audited residency
outcomes surface to operators through the V3 admin route
`GET /admin/privacy/residency-audit` (`admin-residency-audit.ts`).

## Edge cases and failure modes

- **No `homeZone` claim → deterministic default, not failure.**
  `resolveHomeZoneFromClaim` (`home-zone.ts:22`) falls back to the deployment's
  `defaultHomeZone` for pre-rollout tokens so existing sessions never see an
  "unknown zone" error — the contract can roll out before every token is
  re-issued.
- **Target-zone hint without consent is ignored, not honored.**
  `createResidencyRoutingContext` (`traffic-shaping.ts:96`) only routes to a
  requested target when `crossRegionConsentPresent` is true; otherwise it pins
  to the home zone and records the decision as `home_zone_default` /
  `legacy_default_home_zone`. A malformed target header is rejected with HTTP
  400 _before_ any downstream call (`residency-routing.ts:115`).
- **Missing zone plane fails loud.** The domain adapter throws rather than
  leaking a residency-routed request onto the wrong plane (`:2608`/`:2628`) —
  the same fail-closed posture Sophia's ingestion guard takes.
- **Special-category egress with no mechanism is a hard block.** `voice_profile`
  and `avatar_pack` return `special_category_blocked_egress` unless explicit
  consent is on file (`data-residency-rules.ts:1815`), and the deployment policy
  re-surfaces that as `blocked_special_category_egress` under the `block`
  posture (`data-residency-deployment-policy.ts:341`).
- **Coverage is compile-enforced.** The rule table is typed
  `Record<OshunArtifactType, …>`, so adding an artifact without a residency rule
  is a compile error, and `assertResidencyCoverageComplete` (`:2037`) is a
  runtime belt-and-braces check.

## Where this connects

- **Sideways:** [World Server and Gateway](./world-server-and-gateway.md) owns
  the real Postgres + Redis checkpoint persistence whose residency this page
  governs; [Authoring and Content Pipeline](./authoring-and-content-pipeline.md)
  produces the provenance bundles and asset manifests that `AvatarBinding` and
  recordings reference;
  [V1 Integration and Identity Bridge](./v1-integration-and-identity-bridge.md)
  supplies the JWT `homeZone` claim, the tenant context, and the consent engine
  the routing hook reads;
  [Tier Routing and Pixel Streaming](./tier-routing-and-pixel-streaming.md) is
  the compute-residency half of the story.
- **Platform foundations it composes:**
  [Persistence & Data](../../platform/persistence-data.html) and the
  [shared libraries](../../platform/shared-libraries.html)
  (`@oshun/data-residency`, `@oshun/contracts`, `@oshun/audit-platform`).
- The section hub: [../V3_ARCHITECTURE.md](../V3_ARCHITECTURE.md).
