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

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

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

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-a

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