Lilith Metaverse · Architecture

Data Architecture, Tenancy & Residency

A focused page within the Lilith Metaverse Architecture documentation. The full map and every sibling page live in the Architecture hub.

6sections13 minread1diagram

On this page

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.

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); 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 XADDs 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; platform-wide persistence conventions are in Persistence & Data.

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).

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 regionsus-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 deeu and gbuk zones; brlatam; in/krapac). 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#

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. 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 (nativepixel-streamingfallback-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; 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#