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:
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.
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:
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:
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:
[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-tenantIf 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:
// 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):
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:
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.
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
const notes = table('notes', {
id: id(),
title: text(),
}).with(softDelete(), tenant())A scoped read filters both predicates:
WHERE tenantId = subject.tenantId
AND deletedAt IS NULLA 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():
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:
// 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:
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-aFor cross-tenant isolation tests:
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 rowThis is the kind of test you write ONCE per table with the tenant()
mixin.
What can still go wrong
- A raw
@effect/sqlquery 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. ArequireScope/hasScopeguard 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 usectx.storeonly. If they reach for@effect/sqldirectly, 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.