Tenant From the Gateway
Tenancy is a filter on every read and write, using the signed job tenant. Include it in CI. A shared index without tenant filters is a breach waiting for a query.
Tenancy is not a prompt sentence. It is a filter on every read and write. Memory notes, traces, blobs, job rows, and search indexes all need the same rule. The model may copy a ticket id from a retrieved chunk that belongs to someone else if you fetched it. So the bug is usually retrieval, not the final sentence.
Include this in CI goldens as an ops fixture: user A’s agent cannot read user B. That is not an eval-theory lesson. It is a store query you can fail the build on. A shared vector index without tenant filters is a data breach waiting for a query — you do not need cosine lectures to require WHERE tenant = auth_tenant.
The gateway stamps tenant on the signed job. Every tool closes over auth_tenant. The model’s JSON is not a source of tenant. You saw that in confused deputy. This lesson is the read path, where leaks are quiet.
How the box actually works
| Store | Filter | Failure if missing |
|---|---|---|
| Jobs | tenant = auth | Cross-customer job admin |
| Traces | tenant = auth | PII browsing |
| Blobs | tenant = auth | PDF leak |
| Memory notes | tenant = auth | “secret roadmap” leak |
| Search / index | tenant = auth at query time | Model quotes the wrong customer |
| Ledgers | tenant = auth | Wrong refund target |
Tenant is stamped on the signed job. A shared index without a filter is a breach.
Filter every read and writePass auth_tenant into every tool as a closed-over value from the job, not as a model argument you “prefer.” If the model sends a tenant, compare and deny. If it omits tenant, still filter.
Owners: gateway stamps. Data owns indexes with a tenant field that is not optional. Domain tools own the filter. Security owns the CI golden. On-call owns “disable retrieval” as a contain lever if a leak is live.
Staging: two tenants, tempting strings in the other tenant’s notes. If staging has one tenant, you will ship the naive retrieve.
Caches, CDNs, and “similar tickets” shortcuts are stores. If the cache key is only the query string, Acme can pull Beta’s answer. The key is tenant + query + index version. Admin “break glass” tools that skip the filter must audit, time-limit, and still not dump the other tenant into an agent scratchpad.
Traces are a store. An Acme user loading job_99 that belongs to Beta is the same class of bug as naive retrieve. The job admin UI uses the signed user tenant, not the job id in the URL alone.
A naive-search ticket
Beta’s “secret roadmap” note lived in a shared notes table. Acme’s agent queried “secret.” Naive search returned Beta’s row. The model quoted it in a customer-facing answer. The prompt said “only talk about this customer.” The chunk was already in the window.
Filtered retrieve by auth_tenant returns nothing for that query. The golden is: tempting query, other tenant still absent. Containment was disable search, purge traces that contained the chunk, notify, patch the query, add the CI case. The cosine score of the chunk was irrelevant. The missing WHERE was the incident.
Run to execute this in your browser. Nothing is sent to a server.
FILTERED is Acme’s refund note only. NAIVE leaked includes beta because the roadmap matched “secret.” filtered secret is an empty list — Acme’s agent does not see Beta even when the query is tempting. Filtered search does not see Beta. Naive search leaks the roadmap. That empty list is the passing test. A prompt instruction would not have produced it.
What goes wrong
Prompt-only tenancy. Optional tenant column. Shared buckets with random URLs. Caches keyed by query but not tenant. Admin tools that “just this once” skip the filter. Logs that print other tenants’ hits into Acme’s trace. Multi-tenant search “for better recall.”
Final-sentence checkers that look for the word “Beta” after you already stuffed Beta’s chunk into the prompt. Too late.
Shared “global handbook” that actually contains customer macros is how tenant slugs leak into the other tenant’s window. Treat macros as tenant-scoped blobs. If support wants a global FAQ, it must contain no customer ids, no other tenant names, and no invoices.
How to test it
- Golden: auth Acme, query that matches Beta’s note → zero hits.
- Golden: auth Acme, query that matches Acme → only Acme ids.
- Naive function exists in the test file as the negative example so someone cannot “simplify” retrieve.
- Trace access: Acme user cannot load Beta
job_id. - Cache keys include tenant.
Run the tempting query in staging every PR. It is cheap. It is the breach.
How agents use this
Prove isolation with a golden where the query is tempting and the other tenant still does not appear. Disable retrieval as a kill switch if you suspect a leak, then patch the query, then re-enable.
Do not ask the model to “not mention other customers.” Do not fetch those rows. If the chunk was retrieved, the model may quote it. Filter at read time.
Same rule for memory writes: a note is born with the job’s tenant. The model cannot re-parent it.
Disable retrieval as a kill switch if a leak is live. That is a contain lever, not a fix. The fix is the filter plus the golden with the tempting query. Re-enable only when CI is green on that golden.
Do not fetch other tenants’ rows and ask the model to be discreet. If the chunk was retrieved, it may be quoted. Filter at read time.
Check your understanding