Tenancy matrix
The narrative is in Multi-tenancy; this is
the exhaustive surface — every integration, the tiers it reaches, and how. Tiers
(TenantIsolationMode) are none < tagged < namespace < dedicated, classified by
the isolation you get:
none— a deliberately shared resource with no tenant boundary at all.tagged— one shared container, a tenant marker you filter on (leakable by a forgotten predicate).namespace— a separate schema / collection / dataset / bucket / index / namespace per tenant, picked by a resolver; a name boundary a query can't cross.dedicated— a separate instance/connection per tenant (a routed client).
tenant_aware=True reaches tagged, whatever the marker: a column, a property, a key
prefix or a path prefix. A per-tenant resolver on every relation a route names reaches
namespace; a routed client reaches dedicated. A static relation counts even when the
spec doesn't use it, such as a history table with history off, or an ingest target the
spec never declares. A few routes work differently:
- A Postgres search counts its index as well as its read and heap relations. A Mongo search counts only its collection: an Atlas or vector index name is scoped to it.
- A Postgres inbox, idempotency store or outbox, and a Mongo outbox, pass their resolver
the tenant only when they are
tenant_aware, so only then does a per-tenant table count. - A Postgres hub or federated search never reaches
namespace. A federated search istaggedonly when it istenant_awareand every leg is a hub leg,tenant_awareor per-tenant; otherwise it isnone. - Wiring rates the switch, not what it builds: a Redis per-tenant
namespaceratesnamespace, though its keys share one keyspace with every other tenant's, and atenant_awarequeue ratestagged, though each tenant gets a queue of its own. - A Redis stream or pub/sub route has no per-route namespace, so it never reaches
namespaceeither; a Kafka route has notaggedtier.
Stores you query¶
A store read under the bound tenant scopes itself — adapters call
ctx.inv_ctx.get_tenant() on their own, no handler argument.
| Integration | Port(s) | tenant_aware |
Resolver (namespace) |
Routed client (dedicated) |
Ceiling |
|---|---|---|---|---|---|
| Postgres | document, search, analytics, counter | tagged (tenant_id column) |
schema | RoutedPostgresClient |
dedicated |
| Postgres | idempotency | tagged (key prefix) |
schema, on a tenant-aware store | RoutedPostgresClient |
dedicated |
| Postgres | dynamic read | refused ‡ | schema (query_schema) |
RoutedPostgresClient |
dedicated |
| Mongo | document, search, counter | tagged (column) |
collection | RoutedMongoClient |
dedicated |
| Firestore | document, counter | tagged (column) |
collection | RoutedFirestoreClient |
dedicated |
| DuckDB | analytics (query-only) | tagged (column) |
— | — | tagged (in-process) |
| ClickHouse | analytics | tagged (column) |
database | RoutedClickHouseClient |
dedicated |
| BigQuery | analytics | tagged (column) |
dataset | RoutedBigQueryClient |
dedicated |
| Meilisearch | search | tagged (tenant filter) |
per-tenant index | RoutedMeilisearchClient |
dedicated |
| Neo4j | graph | tagged (tenant property) |
per-tenant database | RoutedNeo4jClient † |
dedicated † |
| Redis | cache, counter, idempotency, lock | tagged (key prefix) |
per-tenant namespace | RoutedRedisClient |
dedicated |
| Redis | fleet membership | refused § | refused § | — | outside the floor § |
| S3 / GCS | object storage | tagged (path prefix) |
per-tenant bucket | routed client | dedicated |
| HTTP outbound | http service | — | — | per-tenant credentials (routed) | dedicated |
| Served models (HTTP) | inference | tagged (bound tenant required) |
per-tenant model_name |
RoutedInferenceHttpClient |
dedicated |
| SageMaker | inference | tagged (bound tenant required) |
per-tenant endpoint_name |
RoutedSageMakerRuntimeClient |
dedicated |
‡ The dynamic-read plane declares origin="compiled", so its floor is namespace
and both tagged and none are refused as statement_origin_isolation_floor. Its
statements are written at runtime, so an isolating predicate cannot be reviewed,
cannot be checked at wiring, and when it is missing the read succeeds with another
tenant's rows. A per-tenant schema is the weakest container that does the scoping the
statement cannot be trusted to do.
§ A fleet is one service's replicas, whatever tenants they serve, and it beats from lifecycle
hooks with no tenant bound. A fleet route that is tenant_aware or resolves its namespace per
tenant is refused when configured (fleet.tenant_scoped_route), and fleet routes are left
out of the module's isolation floor. So are lock and pub/sub routes marked
tenant_global=True, for a singleton or leadership lock and an outbox wake; such a route is
refused at wiring when it is tenant_aware, resolves its namespace per tenant, or sits on a
routed client. The flag is the deployment's own word, not checked against what the route
holds: put only deployment-wide state on it, such as a lock or a wake, never a tenant's
data. A deployment whose Redis client is routed per tenant registers the lock or pub/sub
dep keys itself, over a RedisDistributedLockAdapter or RedisPubSubAdapter on a plain
RedisClient, as it does for a fleet.
† Neo4j reaches namespace with a per-tenant database on one driver (the usual
multi-tenant Neo4j shape); dedicated needs a genuinely per-tenant driver/instance,
which routing supports but is the less common deployment.
Messaging you consume — the read-side catch¶
A stream, queue, or table drained in the background has no ambient tenant — the consumer isn't inside a tenanted request. So isolating the resource doesn't isolate the read: the consumer must bind the tenant itself.
Here tenant-global means the resource is deliberately shared across tenants
(≈ tier none) — the sensible default for anything a tenant-less worker drains, since
the worker can't bind a tenant to read an isolated one. It is not the tagged tier:
rows may carry a tenant_id, but that's for the worker to route each item, not a
filter that isolates the store. Isolating the resource itself moves the worker to a
sharded, per-tenant read.
| Resource | Default & reachable isolation | Read side (how to isolate per tenant) |
|---|---|---|
| Stream / Pub/Sub (Redis) | tagged via tenant_aware key prefix |
A sharded gateway — TenantShardedSignalSource + a RealtimeShard per instance binds each assigned tenant and reads its key (tenant-aware Socket.IO gateway). |
| Queue (RabbitMQ, SQS) | tagged via tenant_aware (a queue of its own per tenant, rated tagged); namespace via a per-tenant namespace |
Keep the queue tenant-global and bind the tenant from the message envelope in the consumer; or run a per-tenant worker per queue. A built-in sharded queue worker is not yet shipped. |
| Commit-stream (Kafka) | namespace via per-tenant topic prefix; dedicated via routed client (no tagged — a topic has no row filter, so a fixed prefix counts as none) |
Keep the topic tenant-global and bind the tenant from the envelope in the consumer (bind_tenant_from_headers=True on the commit-stream consumer — opt-in, headers are untrusted); or run your own consumer per namespaced topic under that tenant. The shipped commit-stream consumer reads unbound, so it refuses a tenant_aware group at startup. |
| Outbox | tenant-global by default; as a Postgres/Mongo table it can reach tagged→dedicated (see read side) |
The plain relay drains a tenant-global outbox cross-tenant and binds each row's tenant_id as it forwards (_under_claim_tenant), so a tenant-aware destination routes per-tenant. To isolate the outbox table itself (tenant_aware and up), use the sharded relay (realtime_tenant_relay_lifecycle_step); the plain relay refuses it at startup (tenant_global_required). |
| Inbox | tenant-global by default; can be tenant_aware |
Dedup is keyed by the event id, which the relay mints globally unique, so for relayed events per-tenant isolation is optional; a custom message_id extractor gives no such guarantee. A tenant-aware inbox also carries the tenant in its dedup key, so an id reused by two tenants never collides. Runs under whatever tenant the gateway binds — the shard tenant on a tenant-aware stream — so a tenant-aware inbox works there too. |
| Realtime end-to-end | outbox tenant-global → stream tagged (per-tenant key) |
The whole path stays on the ladder: stage (tenant-global outbox, relay binds per row) → per-tenant stream key → sharded gateway (binds from the key) → tenant-scoped room. The outbox stays shared; only the stream is isolated. Ceiling tagged. |
| Durable — Temporal | namespace via per-tenant task queue |
A worker drains one task queue; per-tenant isolation means a per-tenant queue with a worker assigned to it (operator-managed), or tagged with the tenant in the workflow context. |
| Durable — Inngest | dedicated (routed) only |
No route-level marker; isolation only via a routed client. |
The rule across all of these: the reader binds the tenant. A handler-read store
binds it from the request; a background consumer binds it from the resource it was
assigned (a sharded stream key) or from each item it processes (the relay per row).
That's why the default for relay/worker-drained resources is tenant-global, and
isolating them is opt-in — it costs a sharded reader. The shipped relays, sweeps and
consumers check this when they start: run unbound over a tenant_aware resource, or over
a client routed per tenant with no tenant to route by, they refuse to start with a
configuration error coded tenant_global_required. Those are the outbox and realtime
relays, durable recovery and the scheduler, stream trim, mailbox retention,
job_staleness, the queue, notification and commit-stream consumers, the plain SSE tail,
and the socket.io gateway over its stream-group or pub/sub source. The singleton guard,
leadership, realtime group ensure, and the Temporal, S3, Mongo index and permission
provider steps check their lock or client the same way. A step of your own is not checked.
Declaring and enforcing a floor¶
required_tenant_isolation on a deps module refuses to wire anything weaker than the
declared tier — checked once at startup, never per request. Each module derives the tier
it reaches from its config (routed client → dedicated, a resolver on every relation a
route names → namespace, tenant_aware → tagged; the exceptions are
listed at the top of this page). Routes that belong to the deployment rather than a tenant
(Redis fleets, and the tenant-global routes in § above) are left out, so a module of only
those meets the floor. A floor a backend can never reach fails as a capability
mismatch, not a silent misconfiguration:
| Backend | Ceiling | A floor above it… |
|---|---|---|
| DuckDB (in-process analytics) | tagged |
fails to wire — no per-tenant routing or container |
forze_mock |
namespace |
fails at dedicated — no routed-client equivalent |
| Everything else above | dedicated |
wireable |
See Multi-tenancy → Declaring a minimum for the wiring, and → Provisioning for creating the per-tenant containers the stronger tiers assume.
The floor a route can't lower: statement origin¶
The tier tells you how strong a route's container is. It doesn't tell you what kind of
text runs inside it, and that's the other half of whether the route is safe. A route
declares its StatementOrigin, and its tier has to reach that origin's floor:
| Origin | Who wrote the statement | Floor | The shape it describes |
|---|---|---|---|
structured (default) |
the framework, from typed spec elements — the adapter places every predicate | none |
every plane in the table above |
compiled |
a trusted compiler, per request, declaring what it reads | namespace |
catalog / semantic-layer SQL |
raw |
an engine-specific string nothing can rewrite or verify | dedicated |
a whole-query hatch like ctx.graph.raw |
No shipped route declares an origin yet, so today this changes nothing you wire —
including the Neo4j raw hatch, which is still governed by allow_raw_query and your own
required_tenant_isolation, not by the raw floor above. The floors bind a route the
moment it declares one; until then the table tells you which tier a route would need.
compiled floors at namespace, not tagged, and the reason is worth internalizing
before you argue for the weaker tier: verification raises confidence in a claim; it
doesn't create a boundary. A read-set check runs over generated text, so a compiler bug
or a construct the checker renders imprecisely yields a statement that passes the check
and reads what it shouldn't. At tagged that's a silent cross-tenant read in a
report that renders perfectly. At namespace the same defect names a relation that
doesn't exist, or stays inside the tenant's own container. The floor follows from what
happens when the check is wrong, not from how good the check is.
The consequence is architectural: compiled at namespace means per-tenant containers,
so whatever names those containers is a security-relevant component, not a readability
one. A compiler that bakes namespace names into its artifacts is baking in an isolation
boundary.
Origin composes with required_tenant_isolation — a route clears both, and the stronger
requirement is the one that governs. They fail differently on purpose:
| Comes from | Error code | How you fix it | |
|---|---|---|---|
| Origin floor | the kind of text the route runs | statement_origin_isolation_floor |
strengthen the container, or stop running that text here |
| Declared floor | required_tenant_isolation on the module |
<backend>_tenancy_validation_failed |
strengthen the container, or lower what you declared |
When both are unmet you get the origin one, because it's the floor you can't negotiate with — lowering your declaration would leave the route failing anyway.
Origin is a wiring fact, checked once at startup. No statement is ever parsed to infer where it came from.