Architecture
Tenant isolation
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:
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#
Composition
structuralThe leg never writesFROMand never writes the tenant predicate. It cannot omit what it does not author.Fragment guards, at construction time
__post_init__A leg query is rejected outright if its fragment mentionstenant_id,is_current,$1, a semicolon, orFROM 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.Runtime tripwire
raisesEvery 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.Source lint
a testA test greps the retrieval package and asserts thatFROM chunksappears in exactly one file. This is the layer that survives a refactor by someone who never read this page.
What was considered instead#
| Option | Verdict |
|---|---|
| Convention plus code review | What 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 functions | Nothing stops a developer from not applying it |
| Postgres row-level security | Strictly better, and orthogonal rather than alternative — see below |
Isolation is not only a database concern#
| Surface | How it is scoped |
|---|---|
| Every corpus read | Through the scope, with the predicate welded on and every row re-checked |
| Answer cache | Every key is namespaced by tenant, on both the exact and the semantic path |
| Conversation history | Switching 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 dashboard | The 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#
| Threat | Status |
|---|---|
| A developer forgets the tenant filter | Defended, structurally — the query is unwritable |
| A refactor introduces a new read path | Defended by the source lint and the fragment guards |
| A bug returns a foreign row anyway | Detected at runtime, and it raises rather than filtering |
| Cross-tenant context via conversation history | Defended — switching clears the conversation, and the UI says so first |
| A client asserting a tenant it does not own | NOT 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 consumer | Not covered. This is what row-level security would add |
| The admin statistics endpoint | Reads across tenants by design. Protect it with a token, block it at the edge, or do not deploy it |