---
title: 'ADR 0011: Tenant isolation'
description: A repository layer that refuses an unscoped query, plus row-level security underneath it — either of which would be sufficient.
label: '0011 Tenant isolation'
---

## Context

Veefore is multi-tenant, and a cross-tenant read is the worst possible bug in a
security product. One organization seeing another's agents, policies,
credentials metadata or audit trail is not a degradation; it is the end of the
product's credibility.

The standard approach is to scope every query by organization id and review
carefully. The standard failure is one query that forgets.

## Decision

**Two independent layers, each sufficient on its own.**

### Layer 1 — the repository scope guard

Every method in the data layer **requires** a tenant scope as its first
argument. Not "should have" — a query without one is refused before it reaches
the database.

```ts
await ctx.repos.agents.findById({ organizationId: actor.organizationId }, agentId);
```

Passing an id from another organization returns nothing, and a request that
attempts it is refused with `TENANT_MISMATCH`.

**`TENANT_MISMATCH`, not `404`.** A cross-tenant attempt is distinguishable in
the audit trail from a typo. A `404` looks like a mistyped id; a stream of
`TENANT_MISMATCH` looks like enumeration.

Crossing the boundary requires an **explicit, named justification** in code.
Background jobs use it; request handlers do not.

### Layer 2 — PostgreSQL row-level security

`DATABASE_ENABLE_RLS=true`, applied by migration `0009` to every tenant-scoped
table. The database itself refuses to return rows outside the current tenant
context, regardless of what the query asked for.

### Structural reinforcement

**An agent belongs to exactly one environment.** A staging identity cannot act
in production — not because a rule forbids it, but because the agent is not
there.

**The tenant never comes from a header.** It comes from the authenticated
session. Scope headers select a scope *within* your organization.

## Alternatives

**Application-level scoping alone, with careful review.** What most products do.
It works right up until one query forgets, and the consequence of that one query
is the worst bug in the product.

The specific weakness is additive: a new repository method, a raw query added
under time pressure, a join written by someone who did not know the convention.
Every one of those is a new chance, and review catches most of them.

**RLS alone.** Elegant: the database is the single enforcement point. It also
means the application has no way to catch the mistake *before* the query runs,
and so no useful error — a scoping bug presents as "no rows", which looks like
missing data rather than a bug. And it couples correctness to the RLS policies
being applied, which is a migration that can be skipped.

**A database per tenant.** The strongest isolation available. Also an
operational model that does not scale to many tenants: migrations across
thousands of databases, connection pooling per tenant, cross-tenant reporting
that requires fan-out.

**A schema per tenant.** Lighter than a database each, with most of the same
operational problems and weaker isolation.

**Trust the tenant from a header or a path parameter.** Convenient for a
service-to-service caller. Means a tenant id is an input rather than a derived
fact, and inputs can be supplied.

## Consequences

**Both layers must be maintained, and RLS must actually be enabled.**
`DATABASE_ENABLE_RLS` defaults to off for local convenience, which means a
production deployment can run with one layer and look fine. Called out in
[Configuration](/operate/configuration),
[Database](/operate/database) and the
[isolation checklist](/secure/tenant-isolation).

**The scope guard catches legitimate exceptions too.** A job queue is inherently
cross-tenant: claiming a job means reading across tenants.

:::note This caused a real outage
Every background job claim hit the scope guard. The error was swallowed by the
poll loop, and 67 jobs wedged every queue permanently with no alert — webhooks
stopped delivering and usage stopped rolling up, and nothing visibly broke.

The fix was to make the cross-tenant intent **explicit at the call site**, not
to weaken the guard. Claiming now carries a justification, abandoned jobs are
reclaimed and dead-lettered, and overlapping poll ticks are prevented.

The trade is real: a guard strict enough to catch mistakes will also catch
legitimate exceptions, and the right response is to name the exception.
:::

**A new repository method inherits the guard by signature.** The scope is the
first parameter, so omitting it is a type error rather than a runtime
possibility. That is the property that makes layer 1 hold as the codebase grows.

**`TENANT_MISMATCH` has to be reviewed, not just emitted.** Distinguishing it
from `404` only helps if someone looks. It is in the isolation checklist for
that reason.

**Workspaces and environments are not isolation boundaries.** They divide
resources *within* a tenant and are useful for scoped role grants. Using
organizations to separate your own teams fragments members, roles, SSO and
audit across boundaries designed to be impermeable — so the guidance is one
organization per tenant, and it has to be repeated.

**Cross-tenant A2A is the one deliberate interaction**, and it is built so the
interaction does not weaken isolation: an explicitly registered connection, a
verified passport, your capability list, your policy, and no visibility into
anything else. [ADR 0010](/architecture/adr/0010-a2a-architecture)

## Related

- [Tenant isolation](/secure/tenant-isolation)
- [Tenancy](/concepts/tenancy)
- [Database](/operate/database)
- [Data model](/architecture/data-model)
