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 guarantees cross-tenant data leakage is structurally impossible for reads. Writes get a typed TenantMismatch error if you forget the explicit check.

The model

       Subject (tenantId: 'acme')


   ┌─────────────────────────┐
   │  Queries (read)          │  ← runtime AND-merges
   │   ctx.store.select(...) │     WHERE tenantId = subject.tenantId
   └─────────────────────────┘
   ┌─────────────────────────┐
   │  Mutations (write)      │  ← you call
   │   ctx.store.insert(...) │     assertOwnTenant(input.tenantId, subject)
   └─────────────────────────┘

Reads are auto-scoped because the subject + table mixin contain enough information. Writes are NOT auto-scoped because the input gets to propose a tenant — your code decides whether to honour it (typical: never) or assert against the subject (typical: always).

What's in this section

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

The shortest possible end-to-end

Schema:

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

export const listNotes = defineQuery({ name: 'notes.list', 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):

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

export const createNote = defineMutation({
  name:  'notes.create',
  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.

Why the asymmetry

Reads always have a single answer: "what does this subject see?" → AND the tenant. Auto-scopable.

Writes have an open question: "which tenant should this go in?" The input gets to claim — sometimes legitimately (impersonation by an admin, switching active tenant). Your code decides whether to honour the claim. The framework can't auto-enforce because legitimate exceptions exist.

Defaulting to "auto-reject mismatching writes" would still work for 99% of mutations + would make the 1% impossible. We picked "assert explicitly" instead — slightly more code, full flexibility.

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

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

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 fast-path — SET LOCAL search_path

On postgres each request 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. The other dialects qualify identifiers directly (tenant_<id>.todos).

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.