Context
Every tenant-owned table carries the forced equality policy of 0239 ontenant_id = current_setting('app.tenant_id').
Until now only TenantIsolationService.withTenant set that setting, and only a few drivers called it. The action
pipeline’s unit of work, its queries, the queue worker, the outbox relay and inbox, the workflow journal and the
scheduler set at most the app.scope_* settings of Database.withScope. So a tenant-scoped query outside those
drivers was refused (isolation failed closed, nothing leaked), and services worked around it by writing set_config
themselves, where a session-level setting would survive into another tenant’s transaction on a pooled connection.
Decision
-
One isolation context per unit of work.
@stackra/actionsdeclares aCallIsolationport:run(actor, scope, work, { readOnly }). The pipeline runs every call inside it, actions in their unit of work and queries in a read-only transaction, after the tenancy check and before permission.@stackra/tenancybinds the driver, which composesDatabase.withScopeandTenantIsolationService.withTenantin one transaction. With no driver bound, a call to a model with a tenant policy fails closed, as before. -
Background units use the stored actor. The queue worker, outbox relay and inbox, workflow runner and
scheduler run each job, delivery, step or run through the same port, with the actor and scope stored when the
work was enqueued. A unit stored without an actor runs as a platform call (
app.tenant_id=''), which matches no tenant row. The event bus is trusted for actor identity (ADR 0142), but not blindly: where@stackra/tenancybindsScopeOwnership, the inbox downgrades an envelope whose actor’s tenant does not own the envelope’s scope to the platform actor and logs it by event id and type only, and an automation refuses a principal from another scope. The relay and the inbox give each unit a transaction of its own, and the isolation driver refuses (tenant_switch_refused) to replace the tenant of a transaction that already runs for another, so a transaction never carries one actor’s tenant into another’s unit. The scheduler only dispatches onto the queue. A job it pushes in a scope carries the scheduler’s system actor acting in that scope as itsorigin, so the queue worker runs it through the port bound to that scope; a schedule is created in the scope its work belongs to (a tenant’s own scope), and a scope that is not a tenant’s matches no tenant row. A platform run carries no actor scope. A platform schedule that works across tenants lists them (CronScopes), so each tenant’s run is its own job in its own isolation context, never one cross-tenant transaction. -
Transaction-local only. Every
app.*setting is written withselect set_config(name, value, true)inside the unit’s transaction, and nowhere else.pnpm validate(tools/scripts/session-settings.mjs) enforces two rules over.ts,.tsx,.js,.cjs,.mjs,.mts,.ctsand.sqlfiles underpackages/,services/,apps/andworkers/. (a) The session form is refused everywhere, with no exemption of any kind:set_config(with a third argument other than the literaltrue,SET app.,SET LOCAL app.andSET SESSION app.(also with"app"quoted), and a startup option-c app.. (b) Anyset_config(of anapp.*name, or of a name that is not a literal, is refused whatever its third argument, outside an explicit allow list: the isolation driver,TenantIsolationService, the database drivers’withScopeandonReplica(and the memory twin), the iam policy snapshot store (it sets its own tenant in its own transaction),src/testing/helpers and test files. -
RDS Proxy (0473). AWS documents that for PostgreSQL a
SETstatement,SET LOCALincluded, pins the client connection for the rest of the session, as does usingset_configto assign default values. It also documents that function calls are not inspected for session state. Soselect set_config(name, value, true)is the form to use. Its state ends with the transaction, so correctness never depends on pinning. A session-levelset_config(..., false)would not be detected and would leak to the next client of the backend, which is why decision 3 forbids it. The pinning behaviour is confirmed on the first real proxy by the CloudWatch metricDatabaseConnectionsCurrentlySessionPinnedunder load (planned check, below). Until then this paragraph is from the AWS documentation (Avoiding pinning an RDS Proxy, 2026-10-31), not a measurement. -
Testing.
@stackra/tenancy/testingshipsasTenant(tenantId, work), which runsworkthrough the realCallIsolationdriver on the current database.runAsTenantstays the database-free helper for fillingBelongsToTenant, and its docblock points toasTenantfor isolation. Isolation is proven on real Postgres (usePostgres) under a role withoutBYPASSRLS. -
A
requiresNewpath keeps its tenant. ArequiresNewtransaction runs on another connection and inherits noapp.*setting, so one that touches a tenant-policy table must re-apply the tenant transaction-locally in its own transaction. The paths that exist today, classified:A newrequiresNewpath states its row in this table in the change that adds it.
Consequences
- Services stop writing
set_config; a hand-rolled one failspnpm validate. - Every query now runs in a short read-only transaction, which costs one round trip per call.
- Every call and unit holds a connection for the length of its transaction, so pools are sized for isolation: STA-1716.
- A queued unit must carry its actor; producers that enqueue without one get platform isolation, so their tenant rows are invisible to the job until they pass the actor.
Enforcement
tools/scripts/session-settings.mjsrepo check (STA-1699): the two rules of decision 3.@stackra/tenancyreal-Postgres isolation tests (STA-1699): tenant A sees only its rows through the pipeline, a platform call sees none, settings do not survive the transaction.- planned — RDS Proxy pinning under load measured by
DatabaseConnectionsCurrentlySessionPinnedon the first deployed proxy (infra follow-up of STA-1699).
Note 2026-11-01: idempotency keys join the call’s transaction (decision 6)
The Postgres idempotency store no longer usesrequiresNew, so it leaves the table of decision 6. When a call joined
an outer transaction (a listener in the inbox’s unit, a workflow step), its complete committed on its own before
the outer transaction did; if that commit then failed, the retry replayed a success that never committed. Every claim,
completion and release now joins the running transaction (a savepoint of it), or opens its own when none runs: a key
completed inside a failed outer commit is gone and the retry runs again. A concurrent claim of the same key waits for
the transaction that inserted it. The Valkey and memory stores cannot join a transaction, so the pipeline refuses a keyed call that started inside a
running transaction with such a store bound (idempotency_store_not_transactional), before anything is claimed or
run; a deployment whose calls join outer transactions binds the Postgres store. Test: postgres-idempotency-store.driver.int.test.ts forces the outer commit
to fail with a deferred constraint and claims the key again.
Note 2026-11-01: a job’s tenant comes from its platform-written scope (decision 2)
Owner approval, 2026-11-01 (STA-1823). Decision 2 said a scope that is not a tenant’s matches no tenant row, so it fails closed. That rule is reversed for platform-written scopes. Failing closed there was wrong data, not safety: a job pushed with{ scope } and no origin (segments, webhook delivery, search maintenance, a dead-letter hook) ran with
app.tenant_id = '' and saw zero rows, and a schedule stored in a branch scope got the branch id as its tenant, which
is no tenant either. Neither leaked anything and both returned silently empty results.
The rule now, in CallIsolationDriver (@stackra/tenancy), for a single scope:
- A
joborsystemactor takes the tenant its scope belongs to, read from theScopeTreewithtenantOf; a branch resolves to its parent tenant, whateveractor.scopeIdsays. This is safe because the scope of a job or a schedule is written by the platform and never comes from client input (ADR 0098, ADR 0327 decision 7);QueueServicerefuses a pushed scope that is not shaped like aScopeRef(scope_not_platform_written), and the driver refuses a scope the tree does not show or shows at another path (tenant_scope_unresolved), so a job never runs with an empty tenant because of a missing node. A scope above every tenant (the platform) keeps the actor’s ownscopeId. - Every other actor kind, a user above all, stays bound to its own tenant. When that tenant and the scope’s tenant are
both known and differ the call is refused (
tenant_scope_mismatch); neither is picked. - The queue worker with no
CallIsolationbound refuses a scoped job (job_isolation_missing, dead-lettered at once) instead of running it with a scope session alone, which set no tenant. The tenant rule is not repeated there.
scope_nodes only and the call’s own scope session follows it. The decision text above is unchanged. Tests:
call-isolation.driver.test.ts (the rules and each refusal) and queue-tenant-from-scope.int.test.ts (real Postgres,
a real worker and a tree table under the scope package’s row policy).
Supersedes and sources
Supersedes: none. Amends 0239 and 0138. Sources: the core trace of 2026-10-31 (STA-1699); AWS, “Avoiding pinning
an RDS Proxy” (docs.aws.amazon.com/AmazonRDS/latest/UserGuide/rds-proxy-pinning.html).