Skip to main content
Proposed (STA-1699). Amends 0239 (who sets app.tenant_id) and 0138 (the tenancy step). Builds on 0473.

Context

Every tenant-owned table carries the forced equality policy of 0239 on tenant_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

  1. One isolation context per unit of work. @stackra/actions declares a CallIsolation port: 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/tenancy binds the driver, which composes Database.withScope and TenantIsolationService.withTenant in one transaction. With no driver bound, a call to a model with a tenant policy fails closed, as before.
  2. 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/tenancy binds ScopeOwnership, 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 its origin, 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.
  3. Transaction-local only. Every app.* setting is written with select 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, .cts and .sql files under packages/, services/, apps/ and workers/. (a) The session form is refused everywhere, with no exemption of any kind: set_config( with a third argument other than the literal true, SET app., SET LOCAL app. and SET SESSION app. (also with "app" quoted), and a startup option -c app.. (b) Any set_config( of an app.* 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’ withScope and onReplica (and the memory twin), the iam policy snapshot store (it sets its own tenant in its own transaction), src/testing/ helpers and test files.
  4. RDS Proxy (0473). AWS documents that for PostgreSQL a SET statement, SET LOCAL included, pins the client connection for the rest of the session, as does using set_config to assign default values. It also documents that function calls are not inspected for session state. So select set_config(name, value, true) is the form to use. Its state ends with the transaction, so correctness never depends on pinning. A session-level set_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 metric DatabaseConnectionsCurrentlySessionPinned under load (planned check, below). Until then this paragraph is from the AWS documentation (Avoiding pinning an RDS Proxy, 2026-10-31), not a measurement.
  5. Testing. @stackra/tenancy/testing ships asTenant(tenantId, work), which runs work through the real CallIsolation driver on the current database. runAsTenant stays the database-free helper for filling BelongsToTenant, and its docblock points to asTenant for isolation. Isolation is proven on real Postgres (usePostgres) under a role without BYPASSRLS.
  6. A requiresNew path keeps its tenant. A requiresNew transaction runs on another connection and inherits no app.* 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 new requiresNew path states its row in this table in the change that adds it.

Consequences

  • Services stop writing set_config; a hand-rolled one fails pnpm 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.mjs repo check (STA-1699): the two rules of decision 3.
  • @stackra/tenancy real-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 DatabaseConnectionsCurrentlySessionPinned on 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 uses requiresNew, 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:
  1. A job or system actor takes the tenant its scope belongs to, read from the ScopeTree with tenantOf; a branch resolves to its parent tenant, whatever actor.scopeId says. 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); QueueService refuses a pushed scope that is not shaped like a ScopeRef (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 own scopeId.
  2. 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.
  3. The queue worker with no CallIsolation bound 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.
The tree is read in a scope session over the root label of the scope’s path, because the node table’s row security shows a session only its own subtree and hides a branch’s tenant ancestor from the branch itself; that read touches 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).