Reaktor Multitenancy — one ambient scope, enforced everywhere
Most apps reaktor will ever host are multitenant: one running system serves many isolated customers — organizations, workspaces, families, events — and the platform's first promise is that tenant A can never see, mutate, or starve tenant B. The second promise is quieter but just as important: you should get that guarantee by construction, not by remembering to type WHERE tenant_id = ? on every query for the rest of the product's life.
Source reconciliation, 5 October 2026. Reaktor now has tenant-bearing login, tokens, sessions and target authority resolution, plus scoped graph query policies. The remaining design is to carry verified scope into every data binding and durable execution path. This page distinguishes those current pieces from the proposed uniform enforcement; it does not certify every product store as isolated.
- One entry point the tenant is selected at authentication, stamped into the token as
tid, and lands inAuthContext.tenantId— the single ambient carrier - The caller never supplies it handlers read the ambient tenant; they never accept a
tenantIdargument (forgettable, forgeable). The adapter binds it - Enforced below the app generalize
MandatoryTenantParameterizationto every store: Postgres Row-Level Security, tenant-prefixed object keys, tenant-qualified Durable Object and actor names - Isolation is a declared property pool (shared, row-isolated), bridge (schema/DB per tenant), or silo (dedicated stack) — chosen per tenant, materialized by the runtime, changeable without app code
- Silo has two commercial forms Reaktor-managed GCP cells where the customer chooses location/capacity and pays through Reaktor, or customer-owned GCP cells attached to Reaktor Cloud and paid directly to GCP
- Tenancy has four faces isolation (can't see), fairness (can't starve — quotas, cells, shuffle-sharding), residency (must stay in-region — GDPR/DPDP), and lifecycle (onboard, configure, export, erase)
- The 80/20 reaktor encodes the tenant model in the graph and the adapters enforce it; physical silos, cross-region consensus, and the RLS engine itself are delegated to Postgres / k3s / Cloudflare as executors of a reaktor-declared intent (§12)
1. What multitenancy actually demands
Start with a concrete failure, because every multitenant disaster is the same bug. A developer writes a perfectly correct feature — "list the current user's invoices" — as SELECT * FROM invoice WHERE user_id = ?. Six months later someone adds a "team invoices" view and writes SELECT * FROM invoice WHERE team_id = ?, forgetting that team_id is not unique across tenants in the shared table. Now tenant A's finance lead can page through tenant B's invoices. No exploit, no malice — a missing predicate. This is the confused-deputy of SaaS, and it has produced real, public breaches at companies far more disciplined than a weekend project.
The lesson is the spine of this whole document: "remembering to filter" is not a security model. A multitenant platform earns trust only when isolation is enforced somewhere the application code cannot bypass. Reaktor has scoped enforcement building blocks; each admitted binding must prove its isolation boundary.
Tenancy is not one requirement but four distinct faces, and conflating them is how designs go wrong:
| Face | The promise | The failure it prevents | Primary reaktor surface |
|---|---|---|---|
| Isolation | A tenant sees and mutates only its own data | Cross-tenant data leakage | Per-store enforcement (§5) |
| Fairness | No tenant can exhaust shared capacity | The noisy neighbor | Quotas, cells, shuffle-sharding (§7) |
| Residency | A tenant's data stays where law requires | Compliance violation (GDPR, DPDP) | DataDescriptor.residency + placement (§8) |
| Lifecycle | Onboard, configure, export, and erase a tenant cleanly | Orphaned data, impossible GDPR erasure | Tenant control plane (§9) |
Isolation is about visibility; fairness is about capacity; residency is about geography; lifecycle is about time. A system can ace one and fail another — strong row-level isolation with no rate limiting still dies to one runaway tenant. The model below carries a single tenant identity through all four.
The four faces do not enforce in the same place, and that asymmetry is the main structural fact of this design. Isolation, residency and lifecycle attach to a binding: they are enforced below the application, in the store adapter, the key derivation, the placement policy (§3.3, §5). Fairness attaches to a call: it is enforced in front of the application, in the interceptor chain — and it is the one face whose machinery is not tenancy-specific at all. A per-tenant quota is the server doc's resilience stack with authContext.tenantId as its key; a per-tenant bulkhead is a pool partition. Nothing about a token bucket knows what a tenant is.
That line matters because it decides what this document is allowed to build. Three faces are the data layer's job and belong in adapters, where a forgotten predicate is impossible. One face is the call chain's job and must be borrowed from the resilience middleware, not reimplemented here — a tenancy design that grows its own limiter, its own breaker and its own shed logic ends up maintaining a second, weaker copy of reaktor-server §5. Tenancy supplies the key and the policy. The mechanism is someone else's.
2. Current source and enforcement gaps
| Surface | Present source | Remaining boundary |
|---|---|---|
| Auth/session | LoginInteractorNode resolves membership and passes tenant/context to session and token creation; JwtMinter.mintAccessToken stamps tid when supplied; claims parsing preserves it | A nullable tenant can be legitimate for an unscoped operation; tenant-required operations must reject missing scope |
| Target authority | Authenticated grant/resolve handlers and RemoteAuthorityClient preserve principal, tenant, application/audience, context and target | Desktop journey and executor admission still need qualification; a signed tenant label is not permission to every resource |
| Memgraph product queries | GraphDb.tenant() and MandatoryTenantParameterization bind tenant parameters; read/write shape policies provide guardrails | These validate a supported query vocabulary, not arbitrary Cypher/procedures or direct-driver access |
| Local object storage | PK (store_name, key), per-store cache/state and local gates | Account/tenant namespace and reset must be integrated by the product; no automatic framework-wide isolation |
| Postgres and edge stores | Heimdall repositories have scoped membership/role/session records; product schemas and Worker bindings have their own policies | Generic database/storage clients do not inherit RLS, key prefixes, namespace partitioning or tenant permission automatically |
| Actors and DOs | Product-specific ownership/naming and provider storage | A name is not an authorization proof; validate scope at admission and execution |
| Bus/Work | Envelope/definition scope contracts are proposed; product-specific durable work exists | Shared scope propagation, revalidation, effect ledger and provider conformance remain to be built |
| Data descriptors | Proposed semantic contracts | No complete automatic planner, placement or lifecycle executor |
The previous assertion that JwtMinter never emits tid and all production contexts are null is obsolete. Do not use that old inventory as a current security verdict. The relevant source is reaktor-auth/.../jwt/Authentication.kt, AuthClaims.kt, runtime/nodes/LoginInteractorNode.kt and RemoteAuthorityClient.kt; login integration tests also assert the tenant claim. Source inspection is not a fresh production security audit.
The MLS module's conversation scope and product server authorization still have to be joined through explicit contracts. A separate tenant-bearing type does not automatically establish request-chain or dataset isolation. Current storage and Data inspection authority document the data/inspector boundary.
3. The model — tenant as an ambient, enforced scope
The design rests on three rules. Each is a generalization of something reaktor already does in one place.
3.1 The tenant enters once, at the auth boundary
A request's tenant is decided at authentication and nowhere else. The flow:
- A principal authenticates. They may belong to many tenants (a human is in two workspaces; a consultant serves five clients) — so the human↔tenant relation is many-to-many, modeled by
Membership(principalId, appId, tenantId?, contextId?)from the auth redesign. Identity is global; membership is the attachment to a specific tenant. - The active tenant for this session is selected — by explicit choice (a workspace switcher), by subdomain, or by the sole membership — and resolved through the authenticated membership-selection path; extension points must not bypass that membership check.
JwtMinterstampstidinto a tenant-scoped access token, alongside the existingsub/appId/sid.- On every request,
ReaktorSecurityWebFilterverifies the token and landstidinAuthContext.tenantId— already coded.
The tenant is now part of a verified, signed context. A client cannot assert a tenant by passing a parameter; it can only present a token a trusted minter signed. This is the difference between naming authority and holding it.
3.2 The tenant is ambient, never an argument
No handler signature ever contains tenantId: String. The tenant lives in AuthContext, which is propagated as an ambient value — a coroutine context element on the JVM, the request scope at the edge — exactly as reaktor already propagates the rest of the auth context. Handlers and interactors read authContext.tenantId; they never receive it as data they could get wrong or an attacker could substitute.
This is deliberately close to the object-capability discipline (hold the scope, don't name it), though AuthContext is ambient authority, not true ocap — the core doc already flags that "capability" is overloaded in reaktor and that none of it is ocap. The practical payoff is the same: removing tenantId from the call surface removes the entire class of "passed the wrong tenant" bugs, because there is no place to pass it.
3.3 The boundary binds the tenant — the Mandatory Parameterization Law
This is the load-bearing rule, lifted directly from Memgraph:
Every store adapter and every transport edge is responsible for applying the ambient tenant. A path that cannot prove it scoped the tenant must fail closed, not read globally.
MandatoryTenantParameterization is the reference implementation: it inspects the query and refuses to run one that didn't name the tenant. Generalized, the law says the enforcement point lives in the adapter, below the application — in the database engine (RLS), in the key derivation (object storage), in the address derivation (DOs, actors) — so that even a buggy or malicious call site cannot escape it. The next two sections are this law applied store by store and edge by edge.
4. Isolation models — pool, bridge, silo
The canonical multitenancy question (AWS's SaaS Tenant Isolation Strategies names it exactly) is how physically separate each tenant's data and compute are. There are three points on a spectrum, and a mature platform supports all three — often mixing them, pooling the free tier while siloing a regulated enterprise customer:
| Model | What's shared | Isolation mechanism | Cost / density | When |
|---|---|---|---|---|
| Pool | Everything (one schema, one cluster) | Row discriminator + enforced predicate (RLS) | Cheapest, highest density | Default; free and standard tiers |
| Bridge | Compute, not storage | Schema-per-tenant (Postgres) or DB-per-tenant (D1) | Medium | Mid-tier; per-tenant backup/restore |
| Silo | Nothing | Dedicated stack (own DO / Deployment / DB / region) | Most expensive | Premium, regulated, or noisy tenants |
The reaktor move is the same one that runs through the whole framework: the isolation model is a declared property, not a fork in the code. A tenant carries a TenantPolicy { isolation = Pool | Bridge | Silo; residency = …; quota = … }, and the runtime materializes it — a pooled tenant gets RLS in the shared store; a siloed tenant gets a dedicated Durable Object namespace or a dedicated DataDescriptor store ref. Crucially, a tenant can be promoted pool → silo without touching application code, because handlers only ever read authContext.tenantId; where that tenant's data physically lives is the adapter's concern. This is the server doc's "cell" idea stated as a per-tenant declaration. Pool by default is right for density; the declaration is what lets the one tenant that needs a silo get one without a rewrite.
4.1 Physical Cells And Billing Ownership
For cloud tenancy, a cell is the physical materialization of bridge/silo policy: a regional GCP+k3s execution boundary known to the Cloud Control Plane. A tenant can land on four progressively stronger shapes:
| Shape | Who shares infra | Who pays GCP | When |
|---|---|---|---|
| Pooled Reaktor cell | Many tenants | Reaktor, recovered through product pricing | Default density tier |
| Bridge namespace/schema | Shared cluster, tenant-specific namespace/schema/database | Reaktor, recovered through product pricing | Mid-tier isolation and backup/restore |
| Dedicated Reaktor-managed cell | One tenant or tenant group | Customer pays Reaktor; Reaktor pays GCP | Enterprise convenience: choose region/capacity, no cloud account ceremony |
| Customer-owned GCP cell | One customer account/project | Customer pays GCP directly; Reaktor bills management/control plane | Enterprise control: customer owns IAM root, billing, revocation, jurisdiction |
These are not different application architectures. They are different TenantPolicy and CloudCell materializations:
@Serializable
data class TenantPolicy(
val tenantId: String,
val isolation: IsolationTier,
val residency: Set<String>,
val placement: TenantPlacement,
val quota: TenantQuota,
)
@Serializable
sealed interface TenantPlacement {
data class SharedPool(val allowedRegions: Set<String>) : TenantPlacement
data class DedicatedReaktorCell(val region: String, val capacityClass: String) : TenantPlacement
data class CustomerOwnedGcpCell(val connectionId: String, val allowedModes: Set<String>) : TenantPlacement
}
The user-facing choice is therefore simple: "pick a location and capacity and let Reaktor run it for you" or "bring your own GCP account and let Reaktor manage it." The runtime still sees one ambient tenant scope and one placement policy.
5. Enforcement per store — generalizing MandatoryTenantParameterization
The law from §3.3, applied. Each row is the same shape: the adapter binds the ambient tenant; the call site cannot opt out. Existing surfaces are marked →; proposed ones +.
→ Memgraph (done, the template). GraphDb.tenant(authContext.tenantId) returns a TenantGraphDb whose policy rejects any unparameterized Cypher. Nothing to add except sourcing the tenant from AuthContext instead of a literal.
+ Postgres — Row-Level Security (the most important addition). The industry-standard pooled-isolation mechanism, and the one that makes the database itself the enforcement boundary. Two layers:
- Every tenant-scoped table gains a
tenant_idcolumn (an Exposed column mixin so it's declared once). - A
TenantScopedDatabasewrapper — mirroringTenantGraphDb— opens every transaction by setting a session variable and relying on a PostgresCREATE POLICYthat filters on it:
ALTER TABLE invoice ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoice
USING (tenant_id = current_setting('app.tenant_id')::uuid);
// per-transaction, set from the ambient AuthContext — never from a parameter
transaction { exec("SET app.tenant_id = '${authContext.requireTenant()}'"); block() }
Now a query that forgets WHERE tenant_id = ? still returns only the current tenant's rows — the engine enforces it. This is defense in depth: the Exposed DSL should auto-add the predicate (fast path), and RLS guarantees correctness if the DSL is ever bypassed. Supabase, Citus, and Crunchy all build pooled SaaS on exactly this; it is the proven answer to the "forgotten WHERE."
+ On-device ObjectStore (SQLite). On a single-user phone tenancy is usually moot — but a device can hold multiple workspaces (the Slack account-switcher pattern) and B2B field apps go offline with several clients' data. Extend the primary key to (tenant_id, store_name, key) and have the store read the active tenant from the ambient context. The proposed sync engine projects server data into AuthContext-scoped buckets; a bucket is then naturally (tenant, shape), and switching the active tenant swaps the visible bucket without touching rows.
+ R2 / object storage. Tenant-prefix every key — {tenantId}/media/... — so isolation is a path-prefix ACL and per-tenant lifecycle/erase is a prefix sweep. The same key derivation the notifications design already uses.
+ ClickHouse (analytical tier). Make tenant_id the leading column of the sort key (so per-tenant scans are contiguous), apply a ROW POLICY for read isolation, and use ClickHouse's native per-user QUOTA/SETTINGS PROFILE for analytical fairness. The data layer treats ClickHouse as the aligned analytical binding; other qualified engines must enforce the same tenant scope through their own facilities.
+ Durable Objects (silo by construction). A DO addressed idFromName("{tenantId}:{grain}") is a per-tenant single-writer silo — strong isolation almost for free at the edge. The notifications design already keys DOs {tenantId}:{appId}:…; make tenant-qualified naming the default DO addressing convention, not a per-feature choice.
| Store | Today | The binding mechanism |
|---|---|---|
| Memgraph | enforced | GraphDb.tenant() + mandatory parameterization |
| Postgres (JVM + edge) | none | tenant_id column + RLS via session GUC; DSL auto-predicate |
| On-device SQLite | none | PK (tenant_id, store_name, key); sync buckets |
| R2 | none | {tenantId}/… key prefix |
| ClickHouse | none | leading sort key + ROW POLICY + native QUOTA |
| Durable Objects | none | idFromName("{tenantId}:{grain}") |
The pattern repeats on purpose. One mental model — the adapter binds the tenant, the call site cannot escape it — covers every store, and each is tested once at the adapter (like MandatoryTenantParameterizationTest) rather than at every call.
6. The edge carries the tenant
Isolation at rest is necessary but not sufficient — the tenant has to survive every hop, or an async job picks up where a synchronous request left off and loses its scope.
The bus Envelope carries tenantId. The bus contract should make tenant scope a first-class field; it is simply not in the shipping envelope yet. Making Envelope.tenantId real means a message published under tenant A is consumed under tenant A — the transactional-outbox row, the Pub/Sub message, the Queue job all carry the scope, and the consumer re-establishes the same AuthContext.tenantId before doing work. For ordered streams, prefix the Pub/Sub orderingKey with the tenant so per-tenant ordering is preserved and one tenant's backlog can't reorder another's.
A per-tenant actor is a bulkhead. Today ActorKey(name, messageType) is global. Qualify it — ActorKey(tenantId, name, messageType) — and a grain becomes per-tenant by construction: tenant A's session actor and tenant B's are different instances with separate mailboxes and state. That is bulkhead isolation for free — a stuck or flooded tenant actor cannot block another's. The actor model already gives each actor a serialized mailbox; tenant-qualifying the key extends that isolation across tenants.
ClusterActorRef placement reads the tenant. When the distributed-actor cluster becomes real, the placement key is (tenant, grain): it decides which cell and which region a tenant's grains live in — the hook for both shuffle-sharding (§7) and residency (§8). The tenant is the natural sharding dimension because it is exactly the boundary across which you want failures and load not to cross.
7. Fairness — quotas, noisy neighbors, cells
Isolation stops tenant A seeing tenant B. Fairness stops tenant A starving tenant B — the noisy neighbor, where one tenant's traffic spike degrades everyone on the shared pool. This is the face that is not enforced in a store adapter (§1), and every primitive below is an existing resilience interceptor keyed by authContext.tenantId rather than a new tenancy mechanism. Four layered defenses:
Quotas — a per-tenant RateLimitInterceptor. The server doc's rate limiter, keyed by authContext.tenantId: each tenant gets a token bucket, enforced at the edge in a per-tenant Durable Object (cheap, strongly consistent) and on the JVM via the interceptor chain. A tenant that exhausts its bucket is shed first, protecting the pool. Two decisions have to be made up front rather than discovered in an incident. Fail-open or fail-closed when the limiter store is unreachable: fail-open for a fairness quota (a limiter outage should not become an availability outage), fail-closed for a paid-tier entitlement (where unmetered use is the loss). And shard the limiter — a single global counter Durable Object saturates around ~1k req/s and becomes exactly the bottleneck it was added to prevent. Keying it per tenant already shards it, which is the cheap answer; a genuinely global limit needs N counter DOs summed, not one.
Bulkheads — bound the constrained resource, not the request count. A quota counts requests; a bulkhead bounds the thing that actually runs out. At tenant granularity that resource is almost always connections, not CPU or QPS (server §9.6), so the useful per-tenant bound is a partition of the pool, a bounded actor mailbox, or a Durable Object. This is why §6's tenant-qualified ActorKey is a fairness primitive and not only a correctness one: a per-tenant grain with its own mailbox is a bulkhead by construction, so a flooded tenant backs up its own queue rather than everyone's. The two defenses are not redundant — a tenant comfortably inside its request quota can still drain a shared pool with slow queries, and only the bulkhead stops that.
Cells — bounded blast radius. A cell is a partition of the unified system graph placed on a regional GCP/k3s node-set; tenants are assigned to cells, and a cell's failure or overload touches only its tenants. The cell might be shared, dedicated and Reaktor-managed, or customer-owned and attached through reaktor-agent. This is the standard cell-based architecture expressed in reaktor as a graph partition + a ClusterActorRef assignment policy rather than hand-wired infrastructure.
Shuffle-sharding — isolation without dedicated capacity. Instead of assigning each tenant to one cell, assign each to a random small subset of virtual shards (the server doc's 256-shard ring). Two tenants rarely share their full shard set, so one tenant's abuse degrades only the few neighbors overlapping its shards — a fraction of the fleet — without giving anyone dedicated hardware. AWS Route 53 uses this to contain abusive clients; it is the highest-leverage fairness trick because it buys near-silo blast-radius at pool cost.
Metering must stay bounded. Per-tenant cost and usage attribution is essential, but raw tenant IDs in metric labels are a cardinality bomb. Follow performance & observability §18.2: meter with a bounded tenant.hash, never raw tenant names, and keep high-cardinality per-tenant detail in ClickHouse (where tenant is the sort key) rather than in the metrics system.
8. Residency & compliance
Some tenants are legally required to keep personal data in a region — EU customers under GDPR, Indian customers under the DPDP Act (2023). Residency is almost always a property of the tenant, not the individual datum: "this customer is an EU customer, so everything they own lives in the EU region."
That maps onto the data layer contracts: tenant/security scope, consistency domain, sync view and placement/shard are related but distinct. PartitionClass and placement policies in the data graph declare routing/residency requirements; qualified runtimes enforce them. A Durable Object jurisdiction is one provider mechanism, not a universal residency implementation. Multi-region writes select transactional/replicated authority or a qualified merge profile according to business invariants. Backups, logs, replicas, telemetry and cross-region delivery must satisfy the placement policy too. These are proposed contracts, not implemented DataDescriptor/SyncCommit guarantees.
9. The tenant lifecycle & control plane
A tenant is born, configured, and (sometimes) erased. Each stage is dramatically simpler because the tenant is the ambient scope everywhere.
Onboard. Provision a tenant = create the Membership rows and, if its TenantPolicy says silo, materialize the dedicated DO namespace / schema / deployment. Owned by the graph-backed auth runtime surface (apps, tenants, memberships, grants) from the auth redesign.
Configure. Per-tenant feature flags, branding, and policy are just tenant-scoped data — a capability + node-activation facet read against authContext.tenantId, the same flag machinery the server doc describes, with the tenant as the activation dimension.
Export & erase — the payoff of doing it right. GDPR's right-to-erasure is a nightmare in a system where tenant data is smeared across tables with inconsistent keys. In reaktor it is one predicate per store: because every store is tenant-scoped by the same rule, "export tenant X" and "erase tenant X" are a single tenant_id filter (Postgres), a key-prefix sweep (R2, {tenantId}/), a MATCH (n {tenant_id: $tenant_id}) DETACH DELETE n (Memgraph), and a partition drop (ClickHouse). The uniformity that makes isolation enforceable is the same uniformity that makes erasure tractable. Erase is a gated GraphOperation with blast-radius shown and an audit trail — exactly the devops gated-operation model.
The tenant is a slice of the unified graph. This is where multitenancy "integrates deep." In the unified system graph, a tenant is not a row in one table — it is a coloring of the whole product graph: the data it owns, the actors serving it, the cell and region it lives in, its quota consumption, its residency. reaktorDesktop gains a Tenants pane that renders that slice: list tenants, open one to see its graph subgraph and live per-tenant metrics, and run gated per-tenant operations (suspend, set quota, change isolation tier, export, erase) with MCP/agent access governed by the same SafetyClass gates as every other operation. Tenancy stops being an invisible WHERE clause and becomes a first-class, inspectable, operable dimension of the system.
10. Proving isolation — testing
Isolation you cannot test is isolation you do not have. The architecture makes testing tractable in a way per-query filtering never could:
- Per-adapter isolation tests. Because each store enforces tenancy in one adapter, you test it once —
MandatoryTenantParameterizationTestis the template; add the Postgres-RLS, ObjectStore, and DO equivalents. A passing adapter test certifies every call through it. - A cross-tenant leakage harness. A property test that seeds two tenants and asserts an invariant directly: any read executed under tenant A's
AuthContextreturns zero of tenant B's rows, across every store. Run it in CI as a hard gate; it is the single most valuable multitenant test a platform can own. - Tenant fuzz in the shadow environment. The shadow dev environment replays production traffic; add a mode that rewrites the tenant on replayed requests and asserts no response ever crosses tenants — catching leaks that only appear under real query shapes.
The throughline: enforcement at the boundary turns "test every query" (impossible) into "test every adapter" (a handful of tests) plus "one global leakage invariant."
11. What the industry has learned — and what we copy
Multitenancy is a solved problem in the sense that the field has converged on a small set of patterns; the value is in knowing which to adopt wholesale and which to adapt to the graph.
Salesforce — the original pooled multitenant. One physical schema, an OrgID discriminator on every row, a metadata-driven runtime, serving millions of tenants. Take: pooled-with-a-discriminator scales almost arbitrarily if the discriminator is mandatory and enforced by the platform rather than the application — which is precisely what MandatoryTenantParameterization does and what Postgres RLS does for the relational tier.
AWS SaaS Tenant Isolation Strategies + shuffle-sharding. The canonical pool/bridge/silo taxonomy and the cell/shuffle-shard blast-radius tools. Take: the isolation model is a per-tenant decision, and platforms should let you mix tiers — so it must be a declared property (§4), which is reaktor's entire bias toward declared-in-the-graph behavior.
Postgres RLS in Supabase / Citus / Crunchy. Production SaaS built on the database filtering rows itself, so an application bug cannot leak across tenants. Take: the enforcement boundary belongs below the app — generalize Memgraph's policy into RLS for Postgres (§5). This is the single most important thing reaktor is missing.
Slack / Stripe — one identity, many memberships, fast switch. A human belongs to many workspaces/organizations and switches instantly; the active tenant is per-session. Take: model human↔tenant as many-to-many Membership, select the active tenant at login, stamp it as tid (§3.1).
Google Zanzibar / ReBAC. Authorization as graph reachability: "cross-tenant access" becomes "no tuple connects them." Take: because reaktor is a graph, tenant isolation and authorization unify — a tenant is a graph object, and a leak is a path that shouldn't exist (the auth redesign direction). Tenancy and authz are the same query.
Cloudflare Durable Objects as silos. A DO addressed by tenant name is a cheap, strongly-isolated, single-writer per-tenant silo. Take: the edge hands you silo isolation almost for free via idFromName(tenantId) (§5) — make it the default addressing convention.
The cautionary tale — every breach is a missing predicate. Public multitenant data exposures are overwhelmingly a forgotten WHERE, an IDOR on a shared table, a cache keyed without the tenant. Take: "remembering" is not a model; only enforced-by-construction (mandatory parameterization + RLS + tenant-qualified keys) survives contact with a growing codebase. This is the doc's whole reason for existing.
Each tool owns one slice — Salesforce the discriminator, AWS the isolation taxonomy, Postgres the enforcement engine, Zanzibar the authorization model, Cloudflare the cheap silo. reaktor's bet is to unify them under one ambient tenant scope declared in the graph and enforced at every boundary — which no single tool above does end-to-end, because none of them models the whole app as a graph.
12. The 80/20 — what reaktor encodes vs delegates
Following the framework's standard split: reaktor encodes the ~80% of multitenancy that carries semantic meaning in its own model, and delegates the hyperscale-infra ~20% to executors of a reaktor-declared intent.
reaktor owns (declared in the graph, runtime reads it):
- the tenant as an ambient, signed scope (
tid→AuthContext.tenantId) - the many-to-many
Membershipmodel andTenantResolverselection - per-store enforcement adapters (mandatory parameterization, RLS wrapper, tenant-keyed stores/DOs/actors)
- the isolation model as a declared
TenantPolicy(pool/bridge/silo) - per-tenant quota policy, cell and shuffle-shard assignment, and residency — all as graph-declared properties
- tenant cell placement and billing mode: pooled, bridge, dedicated Reaktor-managed GCP, or customer-owned GCP
- tenant lifecycle (onboard/export/erase) as gated graph operations, and the Tenants pane
reaktor delegates (infra as executor):
- the RLS engine itself — Postgres does the row filtering; reaktor declares the policy
- physical silo provisioning — a dedicated k3s namespace/DB or DO namespace; reaktor declares "this tenant is silo," the devops plane materializes it
- GCP infrastructure ownership — Reaktor-managed cells are paid through Reaktor; customer-owned cells are paid directly to GCP and attached through the Cloud Control Plane
- cross-region consensus for multi-region-active tenants — the cluster/CRDT layer executes; reaktor declares residency + merge policy
- edge DDoS/abuse scrubbing — Cloudflare absorbs it; reaktor declares the quota
- the limiter, breaker and bulkhead mechanisms themselves — the resilience interceptor chain owns them (Bucket4j/resilience4j on the JVM, a counter DO at the edge); tenancy supplies the key they are keyed by and the policy they enforce, and adds no limiter of its own (§7)
The line is the usual one: reaktor declares what isolation, fairness, residency, and lifecycle each tenant gets, with semantic meaning in the graph; the backend is a mechanical executor of that declaration. The ~20% that is genuinely someone else's job (a consensus protocol, a DDoS scrubber) is delegated, not reinvented.
13. Build plan & status
Sequenced so the cheapest, highest-leverage fixes land first. Nothing here is built beyond the Memgraph island and the inert AuthContext.tenantId carrier.
| Phase | Work | Unblocks |
|---|---|---|
| T0 — make the chain live | Implement TenantResolver; have JwtMinter stamp tid; map the active Membership at login. (Smallest change, largest effect — turns the dead carrier on.) | A real, non-null AuthContext.tenantId everywhere |
| T1 — Postgres RLS | tenant_id column mixin (Exposed); TenantScopedDatabase wrapper setting the session GUC; CREATE POLICY per tenant table; DSL auto-predicate | Pooled relational isolation, enforced by the engine |
| T2 — the other stores | Tenant-key the ObjectStore PK + sync buckets; R2 key prefixing; ClickHouse sort key + row policy + quota; default tenant-qualified DO naming | Isolation across every store, not just two |
| T3 — the edges | Real Envelope.tenantId; tenant-prefixed orderingKey; tenant-qualified ActorKey (bulkheads); ClusterActorRef placement reads tenant | Tenant survives every async hop; per-tenant bulkheads |
| T4 — fairness, cells & residency | Per-tenant RateLimitInterceptor and pool/mailbox bulkheads; cell/shuffle-shard assignment; DataDescriptor.residency placement; bounded tenant.hash metering. Depends on the server §5 stack landing first — deadlines, idempotency, retry budgets (§7) | Noisy-neighbor defense + compliance |
| T5 — tenant cells & billing modes | TenantPlacement for shared pools, bridge namespaces, dedicated Reaktor-managed GCP cells, and customer-owned GCP cells; placement policy wired to the Cloud Control Plane | Customers can choose Reaktor-billed capacity or BYO GCP without app rewrites |
| T6 — control plane | Graph-backed auth runtime tenant CRUD; TenantPolicy (pool/bridge/silo); gated export/erase ops; reaktorDesktop Tenants pane + cross-tenant leakage CI harness | Onboard/operate/erase; provable isolation |
T0 alone is worth shipping immediately: it is roughly one line in the minter plus a resolver implementation, and it converts AuthContext.tenantId from decorative to load-bearing — at which point the Memgraph island can source its tenant from the real context instead of a literal.
14. Open decisions
- Tenant vs context. The live schema has
context(an in-app scope) and the redesign addstenant. Are they distinct levels (tenant ⊃ context, e.g. org ⊃ project) or doescontextcollapse intotenant? TheAuthContextalready carries bothtenantIdandcontextId; decide whether enforcement keys on one or both. - Default isolation tier. Pool-by-default is assumed. Confirm, and define the promotion path's data migration (pool → silo without downtime).
- Where the active tenant is selected. Subdomain, explicit switcher, or token-audience? Affects
TenantResolverand whether one token can act for multiple tenants (it should not — onetidper token). - RLS performance. RLS + a session GUC per transaction interacts with connection pooling (PgBouncer/Hyperdrive in transaction mode reuse connections); validate the GUC is set per transaction, not per connection, and benchmark against the DSL-only fast path.
- What a
TenantQuotaactually bounds. §4.1'sTenantPolicycarries aquotafield whose shape is undefined. Decide whether it bounds requests (a rate limiter), concurrency (a bulkhead), stored bytes and rows (a capacity quota), or cost — they need different enforcement points and different failure behavior, and only the first is aRateLimitInterceptor. A single scalar will not carry all four. - Cells before they're needed. Shuffle-sharding adds real complexity; defer until tenant count and noisy-neighbor incidents justify it — but keep the
ClusterActorRefplacement key tenant-shaped from the start so the door stays open.
15. References
Isolation models & SaaS architecture
- AWS — SaaS Tenant Isolation Strategies (pool / bridge / silo) and the SaaS Lens of the Well-Architected Framework
- AWS Builders' Library — Workload isolation using shuffle-sharding; Amazon Route 53 shuffle-sharding
- Cell-based architecture (AWS reference architecture) — blast-radius containment
- Salesforce — The Force.com Multitenant Architecture (metadata-driven,
OrgIDdiscriminator)
Enforcement engines
- PostgreSQL — Row Security Policies (
CREATE POLICY,current_setting) - Citus / Crunchy Data / Supabase — multi-tenant SaaS on Postgres RLS
- Google — Zanzibar: Google's Consistent, Global Authorization System (ReBAC; tenant-as-graph-object)
- Cloudflare — Durable Objects (per-name single-writer silos), Workers Analytics Engine (bounded metering)
Compliance
- EU GDPR — data residency & right-to-erasure (Art. 17)
- India DPDP Act, 2023 — data residency obligations
reaktor
- reaktor-db —
TenantGraphDb,DataDescriptor.residency, sync buckets, ClickHouse tier - reaktor-server — cells, shuffle-sharding, quotas, multi-region
- auth-analysis — identity/principal/
Membership,AuthContext, ReBAC - reaktor-bus —
Envelope, durable async delivery, tenant-scoped routing - reaktor-mesh — live peer/session transport and future CRDT sync
- reaktor-distributed-actors —
ClusterActorRef, sharding - unified system graph — the tenant as a graph slice
See also
- Auth Architecture and Redesign — the identity/principal/membership model and where
tidis minted - Reaktor Server — the high-scale tenancy primitives this doc unifies
- Reaktor Server §5 — Resilience middleware — the interceptor chain that fairness (§7) is keyed into rather than duplicating
- Reaktor Data Layer — per-store residency,
TenantGraphDb, sync buckets - Security Implementation Plan — the threat model isolation defends against
Related docs
- Auth Architecture - Use before changing reaktor-auth or request authorization boundaries.
- Data Layer - Use before choosing a store, designing offline-first sync, or deciding authority vs cache vs projection for a datum; the centerpiece is the tier 1↔2/3 server-authoritative sync engine.
- Server - Use before adding any production backend primitive (resilience, messaging, data patterns) or designing the Spring/Workers server runtime.
- Service Layer - Use before adding service APIs, splitting reaktor-service out of reaktor-graph, wiring handlers to Spring/Workers, or making modules graph-projectable without hard graph dependencies.
- Security Plan - Use before enabling deploy actions, remote agents, or privileged commands.
- Reaktor Bus - Use before designing durable cross-process delivery: async events, jobs, fan-out, transactional outbox, DLQ, replay, and service-to-service handoff.