# Multi-tenancy

> The tenant() schema mixin (auto-scoped reads, auto-filled inserts, tenant-resolved keyed writes), the assertOwnTenant write-guard, and the typed TenantMismatch error.



---

<!-- source: en/plugins/multitenancy.md -->
## Multi-tenancy

_The tenant() schema mixin (auto-scoped reads, auto-filled inserts, tenant-resolved keyed writes), the assertOwnTenant write-guard, and the typed TenantMismatch error._

`@voltro/plugin-multitenancy` is the first-party multi-tenancy primitive. It has two surfaces: a **schema mixin** (`tenant()`) and a **write-time guard** (`assertOwnTenant` + the typed `TenantMismatch` error).

**Status:** ✓ shipped.

## The `tenant()` mixin

Add it to any table whose rows belong to a tenant. It contributes a `tenantId` reference (to the app's `tenants` table) and tells the runtime to auto-scope reads + auto-fill writes:

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

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

`tenant()` transitively requires `audit()` — composing it pulls in the
`createdAt` / `updatedAt` / `createdBy` / `updatedBy` columns too. The mixin
is also available on the `./mixin` subpath (`@voltro/plugin-multitenancy/mixin`)
for projects that don't want the full barrel.

What the runtime does for a `tenant()`-marked table:

- **Reads are auto-scoped.** The runtime AND-merges `eq('tenantId', subject.tenantId)` into every subscription predicate against the table — tenant A never sees tenant B's rows, and a write in tenant A never wakes a subscription in tenant B.
- **Inserts are auto-filled.** On insert, `tenantId` is stamped from `ctx.request.subject.tenantId` when the caller didn't pass it explicitly. The wrapper never overrides a value the caller did pass, and it refuses the insert outright when the subject has no tenant.
- **Set-based writes are auto-scoped.** `updateMany` / `deleteMany` and the fluent `update(t).where(...)` / `delete(t).where(...)` builders get the same `eq('tenantId', …)` AND-merged onto their `WHERE`, so a tenant-blind predicate is confined rather than executed as written.
- **Keyed-by-id writes are resolved inside the tenant.** `update(t, id, patch)`, `delete(t, id)`, `hardDelete(t, id)` and `patchJson(t, id, …)` address a row by primary key, so the runtime resolves that key within `subject.tenantId` first and fails with `TenantRowNotFound` (`@voltro/runtime`) when there is no such row there. The error is raised identically whether the row is missing or belongs to another tenant — reporting the two differently would let a caller probe for row ids in other tenants.

## The write-guard — `assertOwnTenant`

Isolation itself is enforced by the runtime on every path above. What `assertOwnTenant` covers is the one question the framework deliberately does not answer for you: a mutation whose input carries an explicit `tenantId` it intends to USE. Without a check, a client authenticated as tenant A could submit `tenantId: 'B'` and your handler would happily read that claim. Guard such a mutation:

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

const execute = async (input: { tenantId: string }, ctx) => {
  assertOwnTenant(input.tenantId, ctx.request.subject)
  // safe to use input.tenantId for the write
}
```

`assertOwnTenant(inputTenantId, subject)` throws `TenantMismatch` when `inputTenantId` doesn't equal the subject's `tenantId`. Anonymous subjects have no tenant scope at all, so the guard always throws for them — anonymous + tenant-scoped writes need an `apiKey` / `serviceAccount` subject instead.

It checks a **claimed** `input.tenantId`, so a mutation whose input carries none never reaches it. That is why it is an early, typed convenience and not the boundary — the boundary is the store enforcement listed above.

## The typed error — `TenantMismatch`

`TenantMismatch` is a `Schema.TaggedError` carrying `inputTenantId` + `subjectTenantId` (empty string for anonymous subjects). Declare it on the mutation's `error:` schema so the rpc layer surfaces the rejection typed. **Import it from the browser-safe `@voltro/plugin-multitenancy/guard` subpath in the descriptor (`*.mutation.ts`)** — the package root also re-exports the schema mixin, which pulls `@voltro/database` into the client rpcGroup bundle (a browser-safety violation):

```ts
import { defineMutation } from '@voltro/protocol'
import { TenantMismatch } from '@voltro/plugin-multitenancy/guard'
import { Schema } from 'effect'

export const createProject = defineMutation({
  name:   'projects.create',
  guards: [{ scope: 'projects:write' }],   // WHO may call; TenantMismatch bounds WHICH tenant
  input:  Schema.Struct({ tenantId: Schema.String, name: Schema.String }),
  output: Schema.Struct({ id: Schema.String }),
  error:  TenantMismatch,
  target: { table: 'projects', op: 'insert' },
})
```

The client then pattern-matches on `error._tag === 'TenantMismatch'`.

## Why it's a separate package

Multi-tenancy is a product decision, not a transport-protocol primitive — keeping it out of `@voltro/protocol` lets apps that don't need tenancy skip the dependency entirely, while `@voltro/protocol` stays focused on the wire (`Subject`, `AuthMiddleware`).

## Data residency — pin a tenant to a region

For regulated / EU buyers ("EU data stays in the EU"), pin each tenant to a home **region** and route its request-scoped store there. This composes namespace isolation (a tenant's rows live in their own namespace) with region routing — declare the tenant→home mapping plus which regions *this* deployment serves:

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

setResidencyConfig({
  homes: [
    { tenantId: 'acme_eu',   region: 'eu-west-1' },
    { tenantId: 'globex_us', region: 'us-east-1', connectionKey: 'us_primary' },
  ],
  servableRegions: ['eu-west-1'],   // this deployment serves only EU
})
```

The serve pipeline binds the store per request via `bindResidentStore(subject, config, stores)`, where `stores` is the per-region `DataStore` handles this deployment holds. It returns `{ store, placement }` — `placement` being `{ region, namespace, connectionKey? }`, the tenant's rows in its home region.

It **fails closed** at every fork — no mapped home (or an anonymous caller) throws `TenantResidencyUnresolved`; a tenant homed in a region this deployment doesn't serve throws `TenantRegionUnavailable` (the gateway routes that request to the home region's deployment instead). There is no default store: a US deployment can never return a store for an EU-homed tenant. Per-region infra (the actual stores + connection strings via the secrets backend) is yours to provision; v1 routes the primary store — cross-region analytics across tenants is a separate concern.
