> ## Documentation Index
> Fetch the complete documentation index at: https://docs.stackra.figentra.com/llms.txt
> Use this file to discover all available pages before exploring further.

# 0495 — Every call and background unit runs in a transaction-local isolation context; session-level settings are forbidden

> Proposed · 2026-10-31

> [!NOTE]
>
> Proposed (STA-1699). Amends
> [0239](https://docs.stackra.figentra.com/adr/0239-rls-shape-equality-on-tenant-id-with-read-models-for-org-views.md) (who sets
> `app.tenant_id`) and [0138](https://docs.stackra.figentra.com/adr/0138-pipeline-order-is-fixed.md) (the tenancy step). Builds on
> [0473](https://docs.stackra.figentra.com/adr/0473-connection-pooling-a-managed-proxy-per-cloud-a-pool-budget-per-service-and-a-direct-url-for-session-tools.md).

## 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:

   | Path | Touches a tenant-policy table? | Classification |
   | - | - | - |
   | rate-limit buckets | no (no RLS) | needs nothing |
   | workflow journal (`workflow.*`) | no (no RLS) | needs nothing; steps re-enter the isolation |
   | `webhook_deliveries` | no (no RLS) | needs nothing |
   | access-log sink | yes | sets its tenant transaction-locally, itself |
   | iam policy snapshot store | yes | sets its tenant transaction-locally, itself |

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


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.