# Multi-tenancy

> Multi-tenancy as a runtime primitive — the tenant() mixin, ctx.subject.tenantId, automatic read scoping, explicit write gates.



---

<!-- source: en/multi-tenancy/overview.md -->
## Overview

_Multi-tenancy as a runtime primitive — the tenant() mixin, ctx.subject.tenantId, automatic read scoping, explicit write gates._

Multi-tenancy is one of those features every B2B SaaS re-derives badly. Voltro treats it as a runtime primitive: drop the `tenant()` mixin on a table, and the framework makes cross-tenant access structurally impossible for **reads and writes alike** — including a write keyed by a row id that came straight from request input.

## The model

```text
       Subject (tenantId: 'acme')
              │
              ▼
   ┌──────────────────────────────┐
   │  Reads                        │  ← AND-merged
   │   select / query / subscribe  │     WHERE tenantId = subject.tenantId
   └──────────────────────────────┘
   ┌──────────────────────────────┐
   │  Inserts                      │  ← auto-stamped from the subject,
   │   store.insert(...)           │     refused when there is no tenant
   └──────────────────────────────┘
   ┌──────────────────────────────┐
   │  Set-based writes             │  ← AND-merged onto your WHERE
   │   updateMany / deleteMany     │     (same predicate as reads)
   │   update(t).where(...)        │
   └──────────────────────────────┘
   ┌──────────────────────────────┐
   │  Keyed-by-id writes           │  ← the row is RESOLVED inside your
   │   store.update(t, id, patch)  │     tenant first; TenantRowNotFound
   │   store.delete(t, id)         │     when it isn't there
   └──────────────────────────────┘
```

Every path is enforced by the framework, not by remembering a helper. The keyed row was the last one that wasn't: `update(table, id, patch)` addressed the row by primary key alone, so a mutation that took an id from request input could write into another tenant with no error and nothing in the code to review.

## What's in this section

- [The tenant() mixin](/docs/multi-tenancy/mixin) — how it works under the hood, when scoping kicks in, when it doesn't
- [Edge cases](/docs/multi-tenancy/edge-cases) — public queries, cross-tenant admins, anonymous subjects, x-tenant header resolution, vector + storage isolation

## The shortest possible end-to-end

Schema:

```ts
import { table, id, text } from '@voltro/database'
import { tenant } from '@voltro/plugin-multitenancy'

export const notes = table('notes', {
  id:    id(),
  title: text(),
}).with(tenant())
```

Query (auto-scoped):

```tsx
export const listNotes = defineQuery({
  name: 'notes.list',
  guards: [{ scope: 'notes:read' }],   // WHO may open it; tenant() decides WHICH rows
  input: Schema.Struct({}),
})
export default async (_input, ctx) => ctx.store.select('notes').all()
// SQL: SELECT * FROM notes WHERE tenantId = $1   (with subject.tenantId)
```

Mutation (explicit gate):

```tsx
import { assertOwnTenant, TenantMismatch } from '@voltro/plugin-multitenancy'

export const createNote = defineMutation({
  name:  'notes.create',
  guards: [{ scope: 'notes:write' }],
  input: Schema.Struct({ tenantId: Schema.String, title: Schema.String }),
  error: TenantMismatch,
})
export default async (input, ctx) => {
  assertOwnTenant(input.tenantId, ctx.subject)
  return ctx.store.insert('notes', input)
}
```

If a client posts `{ tenantId: 'their-tenant', title: 'hack' }` while their cookie's subject says `tenantId: 'acme'`, the mutation throws `TenantMismatch`. The audit log records it; the client sees a typed error variant.

**`guards:` and `tenant()` answer different questions, and both descriptors above need the first one.** `tenant()` decides **which rows** a call may touch; `guards:` decides **who may make the call at all** — and a wire-exposed procedure that declares neither `guards:`, `openAccess: '<reason>'` nor `internal: true` is refused at boot. Tenant scoping is not a substitute: it confines an *anonymous* caller to whatever tenant the request resolved to, which shapes the result rather than authorizing anybody. Get both, and a revoked membership also stops an open subscription mid-session, because guards are re-checked on every delivery. Full rules: [Authorization](/docs/authentication/authorization#every-procedure-decides-guards-or-openaccess).

## What a keyed write does now

A keyed write resolves its target row inside `subject.tenantId` before it writes. When there is no such row, it fails with `TenantRowNotFound` from `@voltro/runtime` — it does **not** return `null` / `false`:

```ts
// notes.rename.mutation.server.ts — `input.id` comes from the client
export default async (input, ctx) => {
  // Another tenant's note id → TenantRowNotFound. Nothing to remember.
  return ctx.store.update('notes', input.id, { title: input.title })
}
```

```ts
import { TenantRowNotFound } from '@voltro/runtime'
// declare it to surface the refusal typed at the client
export default defineMutation({
  name: 'notes.rename',
  guards: [{ scope: 'notes:write' }],
  input: RenameInput,
  error: TenantRowNotFound,
})
```

**The error is deliberately ambiguous, and that is the design.** It is raised identically whether the row does not exist at all or belongs to another tenant, and it carries nothing that separates the two. Reporting "forbidden" for a foreign row and "not found" for a missing one would turn every keyed write into a cross-tenant *existence oracle*: a caller walks ids and learns which are real in someone else's tenant. Failing loudly and identically gives your handler a signal to act on and gives an attacker one bit they already had — the id they themselves sent is not theirs.

The alternative — silently affecting zero rows — is worse than either. It reads to the handler as "the row is gone" rather than "you may not touch it", so a genuine isolation breach shows up as a confusing empty branch and never as a security signal.

## What is NOT auto-decided: which tenant an insert claims

The open question is only ever on the way IN. An insert that omits `tenantId` is stamped from the subject; an insert that *sets* one is not silently substituted, because a legitimate cross-tenant write exists (admin tooling, impersonation). That is where `assertOwnTenant` earns its place — it rejects a *claimed* `input.tenantId` that isn't the subject's, at the top of the executor and with a typed `TenantMismatch`. It is an ergonomic early check, no longer the thing standing between you and a cross-tenant write.

A genuine cross-tenant write runs as the system subject via `runAsSystem` (see [Edge cases](/docs/multi-tenancy/edge-cases)) — a subject with `tenantId: null`, for which every merge above is skipped by construction.

## Tenant scoping covers more than just the database

| Surface | Scoped by |
|---|---|
| `ctx.store` selects | `tenant()` mixin's subscription filter |
| `*.query.ts` subscriptions | Same — mixin applies inside the query's read tracker |
| Vector search (pgvector) | Same |
| `@voltro/plugin-storage` keys | Prefix convention: `<tenantId>/<key>` |
| `@voltro/plugin-search` indexes | Per-tenant index (or filter, depending on backend) |
| Workflows + agents | Inherit calling subject |
| AI audit log | `ctx.subject.tenantId` recorded on every call |

The mixin is the lever — every adjacent plugin reads from the same subject + the same column.

## Isolation model

The framework ships **two** isolation topologies. The default is **shared-schema**: one DB, one schema, a `tenantId` column kept apart by the `tenant()` mixin's WHERE filter. Opt into **namespace** isolation for *physical* separation — a per-request namespace, resolved from the request's tenant, into which every table reference is qualified. The runtime API is **identical** across both: the `tenant()` mixin, handler code, and `ctx.store` calls don't change. Only store resolution differs.

### Opting in

```ts
// app.config.ts
export default {
  type: 'api' as const,
  name: 'myApi',
  store: 'postgres' as const,
  tenancy: { isolation: 'namespace' },   // default: 'shared-schema'
}
```

Or via env — `VOLTRO_TENANT_ISOLATION=namespace` — which overrides the config field. The same flag is read by `voltro dev` and `voltro serve`, so the topology can't drift between dev and prod.

### Provisioning — a new tenant's first request creates its namespace

The namespace is provisioned **lazily, on first use**: a tenant nobody has seen
before gets its schema and tables created — and its
[`lifecycle: 'onTenantCreate'` seeds](/docs/data/queries) fired — the first time
a request touches its store, memoised per process afterwards. There is nothing
to pre-register, and a failed provision is retried on the next request rather
than cached.

Eager provisioning is **your** move, because only your app knows its tenants:
call `provisionTenantNamespace(tables, namespace, sqlLayer, dialect)` from a
seed or startup file over your own tenant table when you want the DDL paid at
deploy time instead of on a tenant's first request.

### One mechanism, per-dialect mapping

Namespace isolation is **one** mechanism — a per-request namespace `tenant_<sanitised-id>`, derived from `subject.tenantId` — mapped to each dialect's native physical container:

| Dialect | Namespace is a… | Table reference |
|---|---|---|
| postgres / mssql | **schema** | `tenant_<id>.todos` |
| mysql / mariadb | **database** (SCHEMA ≡ DATABASE — this *is* database-per-tenant) | `tenant_<id>.todos` |
| sqlite | **attached database** (`ATTACH DATABASE '<id>.db' AS tenant_<id>`) | `tenant_<id>.todos` |

Database-per-tenant falls out of the same seam for free — only the namespace id differs; the mapping to a physical container is a per-dialect detail. Isolation is **physical**: it no longer depends on a predicate being present, so a query that forgets the tenant filter — or a table that never carried the `tenant()` mixin at all — still cannot read another tenant's rows.

### Postgres — reads are one statement, writes take a `SET LOCAL search_path` transaction

On postgres a namespaced **read** compiles the namespace straight into the identifier — `"tenant_<id>"."todos"` — and runs as a single statement outside any transaction. That is the same mechanism the other dialects have always used, and it is one round trip.

A namespaced **write** (and `raw()`) still runs inside a transaction whose first statement is `SET LOCAL search_path TO "tenant_<id>"`. Because it's `SET LOCAL` (transaction-scoped), the setting **resets at commit** — mandatory on a pooled connection, where a bare `SET search_path` would persist and leak into the next request that checks out the same connection. A write wants its transaction anyway; `raw()` executes SQL text you wrote, which the framework cannot qualify on your behalf.

Reads used to take the transaction too, which made every tenant read `BEGIN` + `SET LOCAL` + `SELECT` + `COMMIT` — four round trips holding one pooled connection for all four. Measured against a local postgres, that cost **2.2×** a shared-schema read, and the same factor applied to how long the connection was held, so effective pool capacity under tenant isolation was materially lower than the pool size suggested. Qualifying the identifier also removes the leak surface rather than managing it: nothing is set on the connection, so there is nothing to reset.

One consequence worth knowing: an **eager** (`with:`) read under namespace isolation uses the portable multi-query walker rather than the single-roundtrip JSON aggregate, because the JSON-aggregate compiler does not qualify relation tables. That has always been true on mysql / mssql / sqlite; postgres now matches them. It shows up as `voltro_db_eager_fallback_total{reason="not-compilable"}` — see [Database metrics](/docs/observability/overview).

### Same transaction guarantees as the shared schema

Writes and explicit `transactional()` blocks run inside a transaction, and it is worth stating explicitly what that transaction gives you — it is **exactly** what a shared-schema transaction gives you, with no exceptions:

- a typed error thrown inside it arrives typed (`_tag`, payload, prototype intact), so a mutation's declared `error:` union matches;
- a transient conflict (serialization failure / deadlock, including one raised at COMMIT) is retried with backoff;
- the caller's write attribution (`traceId` / `subjectId` / `procedure`) is carried onto every ChangeEvent the transaction produces.

There is one transaction bracket behind both topologies, so there is no "namespace mode is a bit different" caveat to remember. See [Transactions](/docs/database/transactions).

### Fail closed on a missing tenant

A request with **no resolvable tenant** does NOT fall back to a shared or default namespace (which could read another tenant's data) — it **fails closed**: the store refuses the operation and throws `TenantNamespaceUnresolved`. The tenant id is sanitised into a safe identifier (`tenant_<id>`, `[a-z0-9_]` only); anything that could break out of an identifier position is rejected or escaped before it reaches SQL.

### Provisioning a tenant's namespace

When namespace isolation is on, the auto-migrate DDL fans out per tenant: it creates the container (`CREATE SCHEMA` / `CREATE DATABASE` / `ATTACH DATABASE`) and runs the table DDL inside it. Provision a new tenant's namespace eagerly at migrate time or lazily on first use via `provisionTenantNamespace(tables, namespace, sqlLayer, dialect)` from `@voltro/database/sql`.

### CDC namespace tagging

The postgres `LISTEN/NOTIFY` payload carries the writing schema (`TG_TABLE_SCHEMA`) so a write in tenant A's namespace doesn't spuriously wake tenant B's subscriptions on the same-named table. mariadb's binlog already carries the database name; mysql / mssql / sqlite emit through the framework's own path, which already knows the namespace. A spurious wake is **not** a leak — the re-query runs against the woken subscription's OWN namespace — so suppressing cross-namespace wakes is purely a wasted-work optimization.



---

<!-- source: en/multi-tenancy/mixin.md -->
## The tenant() mixin

_What tenant() adds — the tenantId reference, the auto-index, the read scoping — and how it composes with other mixins._

The `tenant()` mixin is the lever that turns a normal table into a tenant-scoped one. This page covers exactly what it does.

## What it adds

```ts
import { table, id, text } from '@voltro/database'
import { tenant } from '@voltro/plugin-multitenancy'

export const notes = table('notes', {
  id:    id(),
  title: text(),
}).with(tenant())
```

`tenant()` takes **no arguments**. It returns a `MixinDefinition` you apply with `.with(...)` — never spread it into the field object. The mixin contributes:

1. **A `tenantId` column** — `reference(requireTenants())`, an FK into your app's `tenants` table (not a bare text column).
2. **An auto-index** on `tenantId` (`indexes: [{ fields: ['tenantId'] }]`). The name is auto-generated as `<tableName>_tenantId_idx`.
3. **Read scoping** — the runtime AND-merges `WHERE tenantId = ctx.subject.tenantId` into every subscription against this table.
4. **Insert auto-fill** — when an insert's row payload omits `tenantId`, the runtime stamps it from the request subject.
5. **Write scoping** — `updateMany` / `deleteMany` and the fluent `update(t).where(...)` / `delete(t).where(...)` builders get the same predicate AND-merged onto their `WHERE`, and a **keyed-by-id** write (`update(t, id, patch)`, `delete(t, id)`, `hardDelete`, `patchJson`) resolves its target row inside the caller's tenant before writing — see [What it does NOT do](#what-it-does-not-do).

The mixin's stable id is `voltro/tenant`. The execution lives in the runtime's `wrapStoreWithMixinBehaviour` (write side) and the CLI's `applyTenantScope` (read side) — both key off that id. The mixin source is `voltro/packages/plugin-multitenancy/src/mixin.ts`.

## tenant() requires audit()

`tenant()` transitively requires `audit()` — every tenant-scoped row is also a who/when-stamped artefact in the audit trail. The dependency resolver dedupes if you apply both explicitly, so `.with(tenant())` alone is enough.

## Composing with other mixins

```ts
import { table, id, text } from '@voltro/database'
import { tenant }     from '@voltro/plugin-multitenancy'
import { audit }      from '@voltro/plugin-audit'
import { softDelete } from '@voltro/plugin-soft-delete'

export const notes = table('notes', {
  id:    id(),
  title: text(),
}).with(softDelete(), tenant(), audit())   // tenant() pulls in audit() anyway
```

The behaviors compose. A read against `notes`:

- Filters by `tenantId` (from `tenant()`)
- ALSO filters out `deletedAt IS NOT NULL` (from `softDelete()`)
- Returns the audit columns alongside

Order in the `.with(...)` chain doesn't matter for these — mixin read predicates are AND-merged.

## The auto-fill behaviour

```ts
// Mutation:
ctx.store.insert('notes', { title: 'Hello' })
```

If you DON'T pass `tenantId`, the runtime auto-fills it from `ctx.subject.tenantId`. The row is created in the caller's tenant. This is the safe default — it's hard to accidentally create a cross-tenant row.

When you DO pass an explicit `tenantId` (an admin writing into another tenant), the framework does NOT silently substitute the subject's value — silent substitution is a footgun. Guard the write with `assertOwnTenant` (see below); a genuine cross-tenant write runs as the `system` subject via `runAsSystem` (see [Edge cases](/docs/multi-tenancy/edge-cases)).

## Keyed writes resolve inside your tenant

`ctx.store.update(table, id, patch)`, `delete(table, id)`, `hardDelete(table, id)` and `patchJson(table, id, ...)` address a row by primary key. On a `tenant()` table the runtime resolves that key **inside `subject.tenantId`** before writing, so an id that came straight from request input cannot reach another tenant's row:

```ts
export default async (input, ctx) => {
  // input.id is client-supplied. Another tenant's id → TenantRowNotFound.
  return ctx.store.update('notes', input.id, { title: input.title })
}
```

When the row is not in your tenant the call **fails** with `TenantRowNotFound` (`@voltro/runtime`) rather than returning `null` / `false`. Declare it in the descriptor's `error:` to surface it typed at the client.

The error is raised **identically** whether the row is missing or belongs to another tenant, and carries nothing that separates them — reporting the two differently would let a caller probe for row ids in other tenants. Do not try to recover the distinction; there is nothing on the wire to recover it from, on purpose.

Not affected: subjects with no tenant at all — a schedule firing, a resumed workflow, a `*.subscribe.ts` handler — still span tenants by design, and so does a write through the raw store.

## What it does NOT do

- **Decide which tenant an insert claims.** An insert that omits `tenantId` is stamped from the subject, but one that *sets* it is not silently substituted — a legitimate cross-tenant write exists. `assertOwnTenant` is the early, typed check for a handler that means to USE a claimed `input.tenantId`:

  ```ts
  import { assertOwnTenant, TenantMismatch } from '@voltro/plugin-multitenancy'

  export default async (input, ctx) => {
    assertOwnTenant(input.tenantId, ctx.request.subject)   // throws TenantMismatch on spoof
    return ctx.store.insert('notes', input)
  }
  ```

  Declare `error: TenantMismatch` on the mutation descriptor so the rpc layer surfaces the rejection typed. It checks a *claimed* `tenantId` — a mutation whose input carries none never reaches it, which is why it is no longer what stands between you and a cross-tenant write.

- **Apply to raw SQL.** A hand-written `@effect/sql` query bypasses the mixin. Write the filter yourself.

## Performance considerations

The auto-injected `tenantId = $1` filter is fast — the mixin's single-column index covers it. For high-cardinality tables (events, logs, audits), add a composite index with `tenantId` as the leftmost column on the actual hot query:

```ts
table('messages', {
  id:        id(),
  channelId: text(),
  body:      text(),
  createdAt: timestamp().default('now'),
})
  .with(tenant())
  .index('messages_tenant_channel_created',
    ['tenantId', 'channelId', { col: 'createdAt', order: 'desc' }])
```

Now `WHERE tenantId = $1 AND channelId = $2 ORDER BY createdAt DESC LIMIT 50` is served from the index. Indexes are declared at the table level — there is no column-level `.index()` modifier.

## When NOT to use the mixin

- **Truly global tables** — feature flags, system config, audit retention policies. These don't belong to any single tenant. Leave them un-mixin'd.
- **Cross-tenant aggregates** — usage reports, cross-tenant leaderboards, admin dashboards. Run the read as the `system` subject via `runAsSystem` (see [Edge cases](/docs/multi-tenancy/edge-cases)); a system subject has `tenantId: null` by construction and the AND-merge is skipped.

The mixin is opt-in per table. You declare it for the tables that should be scoped + leave the rest free.

## See also

- [Overview](/docs/multi-tenancy/overview) — the read/write asymmetry model
- [Edge cases](/docs/multi-tenancy/edge-cases) — cross-tenant reads, anonymous subjects, storage isolation



---

<!-- source: en/multi-tenancy/edge-cases.md -->
## Edge cases

_Cross-tenant admins, anonymous subjects, public queries, x-tenant header resolution, vector + storage isolation._

The `tenant()` mixin handles the 95% case. The remaining 5% is here.

## Cross-tenant reads — `.unscoped()`

For staff that need to read across tenants (support, billing ops) the
fluent `ctx.store.select(...)` builder exposes `.unscoped()`, which drops
the automatic `tenantId` filter:

```ts
const all = await ctx.store.select('notes').unscoped().all()
```

`.unscoped()` is a raw capability — it is NOT role-gated by the
framework. Whether the caller is *allowed* to read cross-tenant is YOUR
decision: gate on a scope before you call it.

```ts
import { hasScope } from '@voltro/protocol'

if (!hasScope(ctx.request.subject, 'admin:full')) {
  throw new Error('cross-tenant read requires admin scope')
}
const all = await ctx.store.select('notes').unscoped().all()
```

The subject carries `scopes` (resolved by the auth strategy), never a
`roles` field. Use `hasScope` / `requireScope` from `@voltro/protocol`;
`admin:full` is the blanket bypass.

The same `.unscoped()` modifier exists on the `update(...)` and
`delete(...)` builders for cross-tenant writes — both equally ungated, so
guard them the same way.

## Anonymous subjects

Anonymous requests have a `null` `id` and may carry a `tenantId` or
`null`. The fluent `select` only AND-merges the tenant filter when the
subject's `tenantId` is non-null, so an anonymous subject WITHOUT a
tenant reads with no tenant filter — be deliberate about which tables
you expose to it.

For **truly public** tables that don't need scoping, drop the mixin:

```ts
const publicArticles = table('public_articles', {
  id:    id(),
  body:  text(),
  // NO .with(tenant()) — anyone can read
})
```

For tables that ARE tenant-scoped but should serve **anonymous** users
with the tenant inferred from a request header, the anonymous fallback
in `composeAuthStrategies` produces an `anonymousSubject(tenantId)` from
the `x-tenant` header when no strategy matches:

```ts
import { anonymousSubject } from '@voltro/protocol'
// the composer's default fallback reads `x-tenant` and yields
//   { type: 'anonymous', id: null, tenantId: 'acme' }
```

A request with `x-tenant: acme` reads tenant-scoped tables under that
tenant. Use this for per-tenant marketing pages, public listings filtered
by tenant slug, status pages. Never expose any table that should require
auth this way — the header is client-controlled and unauthenticated.

## The empty-tenant sentinel — why a scoped read silently returns empty

A subject that authenticated but has **no resolved org** carries the
empty-string tenant `''`, not `null`. This is deliberate: `null` means a
`system` subject and reads *unscoped*, so an org-less user must NOT be
treated as one — that would stream every tenant's rows. Instead the
auto-merged filter becomes `eq('tenantId', '')`, which matches no real
row, so the read **fails closed**: it returns empty rather than leaking.

The cost is a debugging trap — an empty result with no error looks
identical to "no such row". So `voltro dev` warns once per tenant-scoped
table when it happens:

```txt
[warn] tenant-scoped read with an EMPTY tenant — every row is filtered out
  table: weeklyUpdates
  cause: the subject authenticated but has no resolved org (tenantId=''),
         so the auto-merged eq('tenantId', '') matches no real row —
         this is NOT 'no such row'
  fix:   resolve the subject to an active org before the read, or mark the
         query .unscoped() if it is deliberately cross-tenant
```

If you see empty reads in development, this line tells you whether the
cause is the data or an unresolved org. `voltro serve` does not emit it —
it is a development diagnostic, not a production log.

`voltro dev` also warns ONCE, earlier, the first time an authenticated
subject resolves with no active org at all — "authenticated, but no active
org → all tenant-scoped reads will be empty" — catching the whole class at
the door before any specific read. Resolve the user to an active org during
auth, or route org-less users to an onboarding flow.

## Subdomain-based tenant resolution

For `tenant1.your-product.com` / `tenant2.your-product.com`, write a
custom strategy and add it to the chain in `app.config.ts`:

```ts
// app.config.ts
import { composeAuthStrategies } from '@voltro/protocol'
import { anonymousSubject } from '@voltro/protocol'

export default {
  type: 'api' as const,
  name: 'myApi',
  auth: {
    strategies: [
      {
        id: 'subdomain',
        resolve: async (input) => {
          const host = input.headers.host ?? ''
          const sub = host.split('.')[0]
          const tenant = await lookupTenantBySubdomain(sub)
          return tenant
            ? { kind: 'matched', subject: anonymousSubject(tenant.id) }
            : { kind: 'skip' }
        },
      },
    ],
  },
}
```

The built-in signed-cookie password strategy always runs FIRST, so when
the user signs in, the cookie subject (with their own `tenantId`)
overrides the anonymous subdomain one.

## Public queries that bypass the mixin

For a `*.query.ts` that serves data from a tenant-scoped table to
anonymous users (read public articles for tenant X):

```tsx
export default async (input, ctx) => {
  // input.tenantId comes from the URL slug
  return ctx.store.select('articles')
    .unscoped()                                      // drop the auto-filter
    .where('tenantId', input.tenantId)               // … and add ours explicitly
    .where('published', true)
    .all()
}
```

The `unscoped()` + explicit `where('tenantId', …)` pattern makes intent
obvious — the next reader sees exactly which tenant the query serves.

## Anonymous vector search

Same pattern for pgvector:

```ts
ctx.store.select('public_docs')
  .unscoped()
  .where('tenantId', publicTenantId)
  .nearestNeighbours('embedding', query)
  .limit(5)
  .all()
```

For multi-tenant SaaS where each tenant has its own knowledge base plus
an anonymous "marketing" tenant ID for public demos.

## Object storage (R2 / S3) keys

`@voltro/plugin-storage` keys are tenant-prefixed. The stored object key
is composed as `<tenantPrefix>/<tenantId | 'global'>/<key>` — the
`tenantId` comes from the request subject, and `tenantPrefix` is an
optional static app/env namespace set in `storagePlugin({ tenantPrefix })`.
Two tenants uploading the same `key` land at distinct paths, so one
tenant can't read another's object through the service.

## Backup + restore considerations

Shared-schema multi-tenancy means **one backup covers all tenants**:

- Restoring a snapshot brings every tenant back to that point —
  including tenants that weren't asking for the restore.
- Per-tenant point-in-time recovery is not possible with shared schema.
  It IS possible with the namespace isolation topology (schema- /
  database-per-tenant), see [Overview](/docs/multi-tenancy/overview).

If a single tenant wants to "roll back" their data, you need a
`tenant_snapshots` table you maintain explicitly, or a schema-per-tenant
deployment topology.

## Soft-delete + multi-tenancy

```ts
const notes = table('notes', {
  id:    id(),
  title: text(),
}).with(softDelete(), tenant())
```

A scoped read filters both predicates:

```sql
WHERE tenantId = subject.tenantId
  AND deletedAt IS NULL
```

A row soft-deleted in tenant A is gone for subjects in A and invisible to
any other tenant (they couldn't see A's rows anyway). To bring a soft-
deleted row back, use the `update` builder's `.restore()` (clears
`deletedAt`); read soft-deleted rows with `.withDeleted()`:

```ts
await ctx.store.update('notes').where('id', noteId).restore()
const trash = await ctx.store.select('notes').withDeleted().all()
```

## Workflows that need to cross tenants

Some workflows legitimately cross — billing aggregation, cross-tenant
analytics jobs. Run them as the `system` subject:

```tsx
// scheduled job
import { runAsSystem } from '@voltro/runtime'

await runAsSystem(async (ctx) => {
  const usage = await ctx.store.select('events')
    .unscoped()
    .where('createdAt', '>', cutoff)
    .all()
  // aggregate, write, etc.
}, { id: 'job:billing-aggregator' })
```

`runAsSystem` binds `ctx.subject` to a `system` subject
(`{ type: 'system', id: 'job:billing-aggregator', tenantId: null }`). A
system subject has `tenantId: null` by construction, so the tenant
AND-merge is skipped; the `.unscoped()` above additionally drops the
soft-delete filter when the table carries `softDelete()`. The system
subject defaults to the `admin:full` scope; pass `{ scopes }` to narrow,
and `{ id }` for a named job (default `'system'`).

## Tests + multi-tenancy

Test contexts can fake any subject:

```ts
import { makeTestContext } from '@voltro/testing'

const ctx = makeTestContext({
  subject: { type: 'user', id: 'usr_test', tenantId: 'tenant-a' },
})

// All ctx.store calls now scoped to tenant-a
```

For cross-tenant isolation tests:

```ts
const ctxA = makeTestContext({ subject: { type: 'user', id: 'a', tenantId: 'A' } })
const ctxB = makeTestContext({ subject: { type: 'user', id: 'b', tenantId: 'B' } })

await ctxA.store.insert('notes', { title: 'A note' })
const fromB = await ctxB.store.select('notes').all()
expect(fromB).toEqual([])   // B cannot see A's row
```

This is the kind of test you write ONCE per table with the `tenant()`
mixin.

## What can still go wrong

- **A raw `@effect/sql` query that forgets the tenant filter** — the
  mixin doesn't intercept hand-written SQL. Audit raw queries carefully.
- **An `.unscoped()` call without a scope check in front of it** — the
  modifier is ungated by design. A `requireScope` / `hasScope` guard
  belongs immediately before every cross-tenant read or write.
- **A subject leaked across requests** — the framework's request-scoped
  subject resolution makes this hard, but a custom strategy can do it.
  If you write one, return a fresh subject each call.
- **Plugin code that bypasses `ctx.store`** — third-party plugins should
  use `ctx.store` only. If they reach for `@effect/sql` directly, they
  skip the mixin. Audit third-party plugins.

For high-stakes deploys (compliance, healthcare), add a CI check that
scans for `.unscoped()` calls and reviews each one. The framework can't
catch the cases where you legitimately opt out — that's a code-review
responsibility.



---

<!-- source: en/multi-tenancy/residency.md -->
## Data residency

_Pin each tenant's data to a HOME region and fail closed everywhere else — a deployment never serves or provisions a tenant homed in a region it doesn't hold, so a US deployment can never touch an EU-homed tenant's rows._

Data residency pins each tenant's data to a **home region** and makes every other
deployment **fail closed**. A deployment declares which regions it can serve (it
holds their stores); a request for a tenant homed elsewhere is refused, not
served from a fallback — the gateway is expected to route it to the home region's
deployment. So a US deployment can **never** read, bind, or provision an EU-homed
tenant's data.

> Residency **never falls back to a default store.** No home mapped → typed
> `TenantResidencyUnresolved`. Home region not served here → typed
> `TenantRegionUnavailable`. Both fail closed; that's the whole point.

## Declare it

Residency is declared in `app.config.ts` and wired by both boot paths — `voltro
dev` and `voltro serve` open one store per servable region and route every
request through it. There is nothing to call from your own code.

```ts
export default defineApiApp({
  tenancy: {
    isolation: 'namespace',
    residency: {
      // The regions THIS deployment holds stores for.
      servableRegions: ['eu-west'],
      // region → the NAME of the env var holding that region's database URL.
      regionUrlEnv: { 'eu-west': 'DB_URL_EU', 'us-east': 'DB_URL_US' },
      // tenant → home region. An array, or a function resolving one at boot.
      homes: [
        { tenantId: 'acme', region: 'eu-west' },
        { tenantId: 'globex', region: 'us-east' },
      ],
    },
  },
})
```

`regionUrlEnv` takes a variable NAME, not a URL: a connection string is a
secret and `app.config.ts` is committed. Everything else about a region's store
— pool bounds, TLS, `search_path`, statement timeouts — is inherited from the
primary connection, so a region cannot silently run with different limits than
the deployment it belongs to.

`homes` may be a function (`() => Promise<TenantHome[]>`) if the mapping lives
in your own table or control plane. It is resolved **once, at boot**: adding a
tenant home needs a restart. That is deliberate — the alternative is a cache
with a staleness window on a decision whose entire value is that it is never
wrong.

### It requires namespace isolation, and refuses to boot without it

`residency` without `isolation: 'namespace'` is a **boot refusal**, not a
warning. The region keeps regions apart; the namespace keeps tenants apart
inside a region. With only the first, every tenant in a region would share one
set of tables — the region boundary held and the tenant boundary dropped, which
looks like residency and is not.

The other boot refusals, all for the same reason (a deployment that looks like
it enforces residency and does not is worse than one that will not start):

- a servable region with no `regionUrlEnv` entry;
- a servable region whose env var is unset;
- an empty `servableRegions`;
- a tenant mapped to two different regions.

## What a request gets

Every request resolves its subject's tenant → home region → **that region's
store**, and only then binds the tenant's namespace inside it. Residency picks
which database; the namespace picks which tenant's tables in it.

Three refusals, none of which falls back:

| Situation | Result |
|---|---|
| Subject has no resolvable tenant | `TenantResidencyUnresolved` |
| Tenant has no home mapped | `TenantResidencyUnresolved` |
| Tenant homed in a region this deployment does not serve | `TenantRegionUnavailable`, naming the region so a gateway can route it |

`ctx.storeForTenant(id)` resolves residency for **that** tenant, not the
caller's — so a handler acting on another tenant either reaches that tenant's
region or is refused. It is the seam background work must use: a schedule or a
workflow runs with no request and, under the system subject, no tenant, so
`ctx.store` on those paths is the primary store. A job that touches one tenant's
rows has to say which tenant, and `storeForTenant` is how it says so.

A transaction is never re-routed. When a mutation hands its transaction-scoped
store to a nested call, that store is used as given — it already went through
residency to exist, and moving writes off the connection holding the lock would
be a worse failure than the one residency prevents.

## Driving it yourself

The resolvers are exported for a control plane that owns the mapping itself —
`setResidencyConfig`, `residentPlacement`, `bindResidentStore`. The declaration
above calls the first two for you; reach for them directly only if you are
building the region topology outside the framework.

```ts
import { bindResidentStore } from '@voltro/database'

// stores: ReadonlyMap<region, StoreHandle>
const { store, placement } = bindResidentStore(subject, config, stores)
// placement = { region, namespace, connectionKey? }
```

## Provision a new resident tenant

`provisionResidentTenant` runs an injected provisioner against the tenant's HOME
store + namespace, behind the SAME fail-closed guards as binding:

```ts
import { provisionResidentTenant } from '@voltro/database'

await provisionResidentTenant(subject, config, stores, async (store, placement) => {
  // e.g. provisionTenantNamespace(tables, placement.namespace, sqlLayer, dialect)
})
// A US deployment provisioning an EU-homed tenant → TenantRegionUnavailable.
```

## How it composes with the `tenant()` mixin

Residency is the **physical** placement (which region's store); the
[`tenant()` mixin](/docs/multi-tenancy/mixin) is the **logical** scope (the
`WHERE tenantId = …` filter within a store). They stack: residency routes the
request to the right region's store, then the mixin scopes the rows inside it.
Namespace isolation within a region uses the same `resolveTenantNamespace` the
mixin's physical-isolation mode uses.
