Skip to content

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 key / path / schema / collection / dataset / index per tenant; a name boundary a query can't cross.
  • dedicated — a separate instance/connection per tenant (a routed client).

tenant_aware=True reaches tagged on a column store and namespace on a key/path store (the prefix is a separate key). A per-tenant resolver reaches namespace; a routed client reaches dedicated.

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 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 namespace (key prefix) per-tenant namespace RoutedRedisClient dedicated
S3 / GCS object storage namespace (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.

† 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) namespace via tenant_aware key prefix A sharded gatewayTenantShardedSignalSource + a RealtimeShard per instance binds each assigned tenant and reads its key (tenant-aware Socket.IO gateway).
Queue (RabbitMQ, SQS) namespace via name prefix 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) 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 a consumer per namespaced topic.
Outbox tenant-global by default; as a Postgres/Mongo table it can reach taggeddedicated (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 fails closed (outbox_relay_tenant_unbound).
Inbox tenant-global by default; can be tenant_aware Dedup is keyed by the globally-unique event id, so per-tenant isolation is optional (ids can't collide across tenants). Runs under whatever tenant the gateway binds — the shard tenant in namespace mode — so a tenant-aware inbox works there too.
Realtime end-to-end outbox tenant-global → stream namespace 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 namespace.
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.

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, resolver → namespace, tenant_awaretagged/namespace by backend). 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.