Engineering note

Tenant isolation in agent memory, by construction

July 14, 20266 min readagent memory · multi-tenancy · isolation · PII · inspeximus
The takeaway

Most agent-memory libraries scope by a user_id you pass on each call. inspeximus 1.6.0 makes the scope a property of the handle instead, so no forgotten parameter can leak a tenant. Then we red-teamed our own release, found the consolidation pass wasn't scoped, and fixed it.

This is a small, honest note about a default-posture change, and about catching our own mistake while shipping it. No breakthrough claim — just an engineering choice we can defend and a number we measured.

The usual way to keep one user's agent memories out of another's recall is a filter argument: you pass user_id (or agent_id, run_id, group_id) on every read and the store returns matching rows. mem0, Zep and Letta all work this way and it is reasonable. Its one weakness is that the scoping lives in the caller: forget the argument on one code path and that path sees everyone. This is not a competitor bug — it is a property of putting the check at the call site.

In inspeximus 1.6.0 the scope is a property of the handle instead. You bind once — store.for_tenant(id) returns an isolated view over one shared store, or Inspeximus(tenant=id) binds a whole store — and after that there is no per-call argument to forget. This is not a new idea; it is complete mediation and fail-safe defaults, named by Saltzer and Schroeder in 1975. It is a default-posture choice, not an invention.

Be clear about what this is not. It is not a security boundary. This is logical multi-tenancy in one process: for_tenant() returns a view that shares the same underlying list of records, filtered by a tag. Any code holding the unbound store, or a bug in the library, sees everything. Mutually hostile tenants want separate stores, separate keys, or database-enforced row-level security — not a shared-process library. What this buys you is that the ordinary code paths cannot leak a tenant by omission.

Where isolation actually leaks: the write path

Cross-tenant bleed rarely comes from the obvious recall call. It comes from the write path — entity merge, deduplication, consolidation, supersession, erasure — where a "which tenant?" check is easy to drop because the code is about reshaping records, not answering a query. The database world keeps re-learning this: CVE-2024-10976 was PostgreSQL applying the wrong row-level-security policy when a query was reused across user-id changes. The filter was fine; one path was unfiltered.

We are not above this. When we red-teamed our own 1.6.0 before writing this, the audit found that inspeximus's consolidation pass — the periodic step that links near-duplicates and supersedes stale records — scanned the whole store with no tenant scope. So one tenant's consolidation could have linked its record to another tenant's, or superseded it. Our probe hadn't tested that path. That is the exact class of bug the store-side approach is supposed to prevent, sitting in our own release.

We fixed it: consolidation, clustering, contradiction scan and conflict check now scope to the acting tenant. Then we extended the probe to measure the leak through a consolidation pass.

What we measured

Five tenants share one physical store. Each writes a private secret plus an adversarial set built to bleed across the boundary — the same supersession key with a different value, an echo of a neighbor's value, a shared subject id, PII rows, and a near-duplicate line that an unscoped dedup would link across tenants. Then every tenant runs every cross-tenant probe.

These are deterministic probes, not a sampled probability: they show the enforced paths have no defect on this adversarial set, not that leakage is provably zero. The contrast that motivates the feature — the same corpus in one store recalled without the soft scope argument leaks all five secrets — is a configuration contrast within inspeximus, not a benchmark against another product. The probe and the tests are in the repo; run them and contradict us.

Prior art, honestly

We are not first, and one competitor is close. Zep's Graphiti enforces its group_id namespace at the storage layer across reads and writes — the same place inspeximus puts the check. Two differences remain: Graphiti's group_id is optional and defaults to a shared namespace when omitted (fail-open by default), and it needs a graph database (Neo4j/FalkorDB). inspeximus's narrow delta is fail-closed by default, in a single zero-dependency file, with the consolidation path scoped too. A recent preprint, MemTrust, surveys the broader gap and characterizes several memory systems as relying on "basic namespace separation."

The PII layer is a floor, not a DLP

1.6.0 also adds a zero-dependency PII layer: a regex detector tags records, recall(redact_pii=True) masks PII in the returned text only, and forget_pii() sweeps and tombstones PII rows. Regex catches structured formats — email, SSN, card — and detects human names essentially not at all. On a hard 48-type benchmark (PIIBench) even full Microsoft Presidio, regex plus a trained model, scores about F1 0.14. So this is a sane default and a data-minimization convenience, not protection and not compliance; use a real DLP for detection.

The honest boundary

The guarantee ends at recall()'s return value. The moment a tenant's memory is recalled into an LLM's shared context window, or into a cross-agent shared summary, the store's scoping no longer applies — nothing the store controls does. Read isolation also says nothing about a tenant writing to bias another's recall beyond the key-level poisoning we tested. Those seams — cross-context and cross-agent leakage — are the interesting unsolved part, and they are where a store-level guarantee stops. We claim auditable, in-library isolation of the stored records and their write paths, not a security boundary and not provable forgetting.

inspeximus is MIT-licensed and zero-dependency: github.com/DanceNitra/inspeximus. Every number here regenerates from the probe and tests in the repo.

FAQ

Does mem0 or Zep already do multi-tenant isolation? Yes. They scope memory by user_id / agent_id / run_id / group_id. The difference is where the check lives — a property of the handle (inspeximus) versus an argument you pass per call — and that inspeximus scopes the consolidation and erasure paths, not only recall.

Is this a security feature? No. It is logical, in-process isolation that prevents leakage by omission on ordinary code paths. For a real trust boundary between hostile tenants, use separate stores or database-enforced isolation.

Can I still run one store for everyone? Yes. An unbound store is the admin view and sees everything; store.for_tenant(id) hands out isolated views that share the one physical store with no duplication.

Is the PII detector enough for GDPR? No. It is a regex floor for data minimization. Use a real DLP for detection; use forget_pii() for an auditable erasure of the stored record, understanding it does not reach an external index, cache, or the model's context.

← More writing from Agora