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-platformis the operator/platform tenant that owns cross-cutting infrastructure. (The launch-router only ever surfaces the three product tenants;lilith-platformis never a launch target.)V3RealmSchema(primitives.ts:17) isproduction | preview | training | private— an environment/visibility axis, not a residency zone and not a tenant. ARoomand aVenueare keyed by(tenant, realm), so a Tara venue can exist inproductionandpreviewrealms 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 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
allowedwith no transfer audit (:1713). - Operator-only artifacts (
model_card,workflow_template,policy_bundle) replicate freely, even throughglobal, because they have no data-subject linkage (:1732). - Pinned artifacts (
profile,session,conversation_history,memory_scope, …) are blocked from theglobalpool outright (:1788). - Special-category biometric artifacts —
voice_profileandavatar_pack, bothsensitivityTier: 'special_category'withrequiresLocalProcessing: true(:748,:771) — are the hard case: cross-zone egress isblockedunless the artifact's mechanism set containsexplicit_consent, in which case the decision isrequires_safeguardswith 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#
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 (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; 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
homeZoneclaim → deterministic default, not failure.resolveHomeZoneFromClaim(home-zone.ts:22) falls back to the deployment'sdefaultHomeZonefor 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 whencrossRegionConsentPresentis true; otherwise it pins to the home zone and records the decision ashome_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_profileandavatar_packreturnspecial_category_blocked_egressunless explicit consent is on file (data-residency-rules.ts:1815), and the deployment policy re-surfaces that asblocked_special_category_egressunder theblockposture (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, andassertResidencyCoverageComplete(:2037) is a runtime belt-and-braces check.
Where this connects#
- Sideways: World Server and Gateway owns
the real Postgres + Redis checkpoint persistence whose residency this page
governs; Authoring and Content Pipeline
produces the provenance bundles and asset manifests that
AvatarBindingand recordings reference; V1 Integration and Identity Bridge supplies the JWThomeZoneclaim, the tenant context, and the consent engine the routing hook reads; Tier Routing and Pixel Streaming is the compute-residency half of the story. - Platform foundations it composes:
Persistence & Data and the
shared libraries
(
@oshun/data-residency,@oshun/contracts,@oshun/audit-platform). - The section hub: ../V3_ARCHITECTURE.md.