Skip to content

Architecture

Tenant isolation

The requirement is not 'remember to filter by tenant'. It is that a developer cannot write the unsafe query — the failure mode being defended against is silent, and a leak that nobody notices is the only kind that matters.

One object may read the corpus#

TenantScope is the only thing in the codebase permitted to read the chunks table. A retrieval leg supplies a fragment — a projection, a predicate and an ordering — and the scope composes the real SQL:

composed by app/retrieval/tenant_scope.py
SELECT <leg projection>
FROM chunks c
WHERE c.tenant_id = $1          -- welded on, unconditionally, by the scope
  AND c.is_current              -- archived versions are never retrievable
  AND (<leg predicate>)         -- leg parameters start at $2
ORDER BY <leg ordering>
LIMIT $n

$1 belongs to the scope and is bound by it. Functions in the retrieval layer take a TenantScope, never a tenant_id: str — so there is no signature that would even accept an unscoped read, and the unsafe query is unwritable rather than discouraged.

Four layers, weakest to strongest#

  1. Composition

    structural
    The leg never writes FROM and never writes the tenant predicate. It cannot omit what it does not author.
  2. Fragment guards, at construction time

    __post_init__
    A leg query is rejected outright if its fragment mentions tenant_id, is_current, $1, a semicolon, or FROM chunks. This fires when the object is built, so an unsafe leg blows up in the first test that constructs it rather than in production.
  3. Runtime tripwire

    raises
    Every returned row's tenant is re-checked, and a mismatch raises. It raises rather than filtering — dropping the offending rows and continuing would serve a slightly-wrong result set that nobody notices. A leak Python quietly corrects is a leak nobody investigates. Crashing produces an incident, which produces a fix.
  4. Source lint

    a test
    A test greps the retrieval package and asserts that FROM chunks appears in exactly one file. This is the layer that survives a refactor by someone who never read this page.

What was considered instead#

OptionVerdict
Convention plus code reviewWhat most codebases do, and it works until the one pull request that adds a quick debug query. The whole point is that the failure is silent
A decorator on leg functionsNothing stops a developer from not applying it
Postgres row-level securityStrictly better, and orthogonal rather than alternative — see below

Isolation is not only a database concern#

SurfaceHow it is scoped
Every corpus readThrough the scope, with the predicate welded on and every row re-checked
Answer cacheEvery key is namespaced by tenant, on both the exact and the semantic path
Conversation historySwitching tenant clears the conversation. Carrying it across would feed one tenant's answers into another's prompt as context — not a chunk leak, but a leak
The operations dashboardThe one endpoint that deliberately reads ACROSS tenants, and therefore the one that needs its own authentication before anything is public

Testing a negative#

The leakage test seeds both tenants with near-identical content, so the tenant predicate is the only thing separating them, plants a secret in tenant B, and asserts tenant A never sees it.

Every assertion is paired with a control proving that the thing whose absence is being asserted was actually present and findable. A leakage test that passes because the index was empty is worse than no test at all, because it retires the question.

What this does and does not defend against#

ThreatStatus
A developer forgets the tenant filterDefended, structurally — the query is unwritable
A refactor introduces a new read pathDefended by the source lint and the fragment guards
A bug returns a foreign row anywayDetected at runtime, and it raises rather than filtering
Cross-tenant context via conversation historyDefended — switching clears the conversation, and the UI says so first
A client asserting a tenant it does not ownNOT defended. The tenant arrives in the request body, which is correct for a demonstration with no login and a trivial cross-tenant read in production
Raw SQL sessions, or a future non-Python consumerNot covered. This is what row-level security would add
The admin statistics endpointReads across tenants by design. Protect it with a token, block it at the edge, or do not deploy it