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
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 — 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',
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):
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.
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:
// 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 })
}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) — 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
// 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 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.
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 declarederror: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.
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.