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 in AuthContext.tenantId — the single ambient carrier
  • The caller never supplies it handlers read the ambient tenant; they never accept a tenantId argument (forgettable, forgeable). The adapter binds it
  • Enforced below the app generalize MandatoryTenantParameterization to 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:

FaceThe promiseThe failure it preventsPrimary reaktor surface
IsolationA tenant sees and mutates only its own dataCross-tenant data leakagePer-store enforcement (§5)
FairnessNo tenant can exhaust shared capacityThe noisy neighborQuotas, cells, shuffle-sharding (§7)
ResidencyA tenant's data stays where law requiresCompliance violation (GDPR, DPDP)DataDescriptor.residency + placement (§8)
LifecycleOnboard, configure, export, and erase a tenant cleanlyOrphaned data, impossible GDPR erasureTenant 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

SurfacePresent sourceRemaining boundary
Auth/sessionLoginInteractorNode resolves membership and passes tenant/context to session and token creation; JwtMinter.mintAccessToken stamps tid when supplied; claims parsing preserves itA nullable tenant can be legitimate for an unscoped operation; tenant-required operations must reject missing scope
Target authorityAuthenticated grant/resolve handlers and RemoteAuthorityClient preserve principal, tenant, application/audience, context and targetDesktop journey and executor admission still need qualification; a signed tenant label is not permission to every resource
Memgraph product queriesGraphDb.tenant() and MandatoryTenantParameterization bind tenant parameters; read/write shape policies provide guardrailsThese validate a supported query vocabulary, not arbitrary Cypher/procedures or direct-driver access
Local object storagePK (store_name, key), per-store cache/state and local gatesAccount/tenant namespace and reset must be integrated by the product; no automatic framework-wide isolation
Postgres and edge storesHeimdall repositories have scoped membership/role/session records; product schemas and Worker bindings have their own policiesGeneric database/storage clients do not inherit RLS, key prefixes, namespace partitioning or tenant permission automatically
Actors and DOsProduct-specific ownership/naming and provider storageA name is not an authorization proof; validate scope at admission and execution
Bus/WorkEnvelope/definition scope contracts are proposed; product-specific durable work existsShared scope propagation, revalidation, effect ledger and provider conformance remain to be built
Data descriptorsProposed semantic contractsNo 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:

  1. 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.
  2. 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.
  3. JwtMinter stamps tid into a tenant-scoped access token, alongside the existing sub/appId/sid.
  4. On every request, ReaktorSecurityWebFilter verifies the token and lands tid in AuthContext.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:

ModelWhat's sharedIsolation mechanismCost / densityWhen
PoolEverything (one schema, one cluster)Row discriminator + enforced predicate (RLS)Cheapest, highest densityDefault; free and standard tiers
BridgeCompute, not storageSchema-per-tenant (Postgres) or DB-per-tenant (D1)MediumMid-tier; per-tenant backup/restore
SiloNothingDedicated stack (own DO / Deployment / DB / region)Most expensivePremium, 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:

ShapeWho shares infraWho pays GCPWhen
Pooled Reaktor cellMany tenantsReaktor, recovered through product pricingDefault density tier
Bridge namespace/schemaShared cluster, tenant-specific namespace/schema/databaseReaktor, recovered through product pricingMid-tier isolation and backup/restore
Dedicated Reaktor-managed cellOne tenant or tenant groupCustomer pays Reaktor; Reaktor pays GCPEnterprise convenience: choose region/capacity, no cloud account ceremony
Customer-owned GCP cellOne customer account/projectCustomer pays GCP directly; Reaktor bills management/control planeEnterprise 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:

  1. Every tenant-scoped table gains a tenant_id column (an Exposed column mixin so it's declared once).
  2. A TenantScopedDatabase wrapper — mirroring TenantGraphDb — opens every transaction by setting a session variable and relying on a Postgres CREATE POLICY that 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.

StoreTodayThe binding mechanism
MemgraphenforcedGraphDb.tenant() + mandatory parameterization
Postgres (JVM + edge)nonetenant_id column + RLS via session GUC; DSL auto-predicate
On-device SQLitenonePK (tenant_id, store_name, key); sync buckets
R2none{tenantId}/… key prefix
ClickHousenoneleading sort key + ROW POLICY + native QUOTA
Durable ObjectsnoneidFromName("{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 — MandatoryTenantParameterizationTest is 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 AuthContext returns 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 Membership model and TenantResolver selection
  • 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.

PhaseWorkUnblocks
T0 — make the chain liveImplement 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 RLStenant_id column mixin (Exposed); TenantScopedDatabase wrapper setting the session GUC; CREATE POLICY per tenant table; DSL auto-predicatePooled relational isolation, enforced by the engine
T2 — the other storesTenant-key the ObjectStore PK + sync buckets; R2 key prefixing; ClickHouse sort key + row policy + quota; default tenant-qualified DO namingIsolation across every store, not just two
T3 — the edgesReal Envelope.tenantId; tenant-prefixed orderingKey; tenant-qualified ActorKey (bulkheads); ClusterActorRef placement reads tenantTenant survives every async hop; per-tenant bulkheads
T4 — fairness, cells & residencyPer-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 modesTenantPlacement for shared pools, bridge namespaces, dedicated Reaktor-managed GCP cells, and customer-owned GCP cells; placement policy wired to the Cloud Control PlaneCustomers can choose Reaktor-billed capacity or BYO GCP without app rewrites
T6 — control planeGraph-backed auth runtime tenant CRUD; TenantPolicy (pool/bridge/silo); gated export/erase ops; reaktorDesktop Tenants pane + cross-tenant leakage CI harnessOnboard/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 adds tenant. Are they distinct levels (tenant ⊃ context, e.g. org ⊃ project) or does context collapse into tenant? The AuthContext already carries both tenantId and contextId; 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 TenantResolver and whether one token can act for multiple tenants (it should not — one tid per 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 TenantQuota actually bounds. §4.1's TenantPolicy carries a quota field 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 a RateLimitInterceptor. 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 ClusterActorRef placement 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, OrgID discriminator)

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

See also

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