jatin.blog ~ $
$ cat ai-engineering/memory-privacy-multi-tenancy.md

Memory Privacy, Isolation, and Multi-Tenancy

How to isolate tenant memory, resist stored prompt injection, and verify deletion.

Jatin Bansal@blog:~/ai-engineering$ open memory-privacy-multi-tenancy

The on-call page fires at 02:14. A customer-support agent at a B2B SaaS company has told a user the previous question on the account was about a different company’s product. The first reaction is “model hallucination”; the second is “let’s check the trace.” The trace is worse than the hallucination would have been. The retrieval call against the memory store returned three episodes: two from the correct account, one from a different tenant entirely. The namespace argument on the retrieval call was missing. The code path was a debug endpoint that had been quietly used in production for six weeks. The cross-tenant leak had been live the whole time; this is the first conversation where a user noticed. The bug is one missing function argument. The blast radius is every customer the agent has ever served.

Threat model

Privacy-and-isolation work is engineering against a specific threat model. Five attack classes show up in production traces, in roughly descending order of frequency.

1. The unscoped-query bug

The opening anecdote. A code path issues a retrieval against the memory store without a tenant filter; the store returns top-K by similarity across all tenants; the agent surfaces the foreign content as its own memory. The damage scales with how interesting the cross-tenant overlap is: a customer-support agent in a niche industry can return a direct competitor’s data because the embeddings cluster.

The mitigation is structural: the API surface refuses to run an unscoped query, the underlying client is never called directly, and every retrieval emits a span with the tenant ID attached so an observability sweep can detect “any retrieval without a tenant tag” as a hard alert.

2. The namespace-confusion attack

A more subtle version. The system is correctly scoped but the scope value comes from user-controllable input. A user under tenant A constructs a request that smuggles tenant B’s identifier into the scope: either by directly setting it (the API trusts a client-side header) or by indirection (the agent’s tool call accepts a “switch context” command that the user can trigger). The retrieval is technically correctly scoped; it’s scoped to the wrong tenant.

The mitigation is to derive the tenant scope server-side from an authenticated session, never trust a client-supplied value, and treat any cross-tenant operation as a privileged-API call with separate audit. The pattern is the same as a web app trusting X-User-Id headers: a known anti-pattern with a thirty-year track record of postmortems.

3. Memory injection (MINJA/MEXTRA)

The 2025 MINJA paper demonstrated that an attacker who can only interact with an agent via queries and observations can inject malicious records into the agent’s memory bank. These records are designed to elicit a sequence of malicious reasoning steps when the agent later processes a different target query: the attack persists across sessions, hidden in the memory. The companion MEXTRA paper showed that adversarial probing of the memory module can leak private user-agent interaction data. The reported injection success rate exceeds 98%; the end-to-end attack success rate exceeds 70% across GPT-4, GPT-4o, GPT-4o-mini, Gemini-2.0-Flash, and Llama-3.1-8B. Evaluated defenses: LlamaGuard, embedding-level sanitization, prompt-based detection: were ineffective.

The mitigations are layered. First, write-side authentication: every memory write is attributed to an actor (user-content vs. agent-summary vs. system-fact), and the retrieval prompt distinguishes these (the provenance layer from the temporal-reasoning article is the structural prerequisite). Second, content-class gating: user-supplied content is never directly written to a long-lived store without a distillation pass; a write-policy gate decides what becomes durable, and adversarial inputs that are clearly trying to inject memory are caught by a small classifier. Third, retrieval-side classification: at read time, a sanity check on the retrieved memories flags content that looks like instruction injection rather than fact recall, and routes it for review rather than direct use. None of these is a complete defense; together they raise the bar significantly. The MINJA result is the empirical ceiling on what’s possible: assume any production system has some attack surface here, instrument accordingly.

4. Embedding-similarity leakage

Embeddings of one tenant’s content can be similar to embeddings of another tenant’s content. If the same vector index serves both tenants and the scope filter is post-retrieval (the index returns top-K globally and the filter is applied after), an attacker who controls one tenant’s content can craft inputs whose embeddings sit close to a target tenant’s sensitive embedding, then use the retrieval call’s latency or metadata leak to confirm a hit. This is the LLM-memory analogue of cache-timing attacks on cryptographic operations: the side-channel is real even when the direct read fails.

The mitigation is to push the scope filter into the index, not after it: Pinecone’s per-tenant namespaces are physically separate; pgvector’s per-tenant tables or row-level security give the same property; a single shared index with a metadata filter applied after top-K is the vulnerable shape. The 2026 Burn-After-Use paper on enterprise LLM multi-tenant architectures formalizes this: “No conversational histories or vector embeddings are shared across tenants, effectively preventing cross-department inference.”

5. Derived-artifact contamination

The hardest class. A tenant’s data was written, retrieved, summarized by a reflection pass, the summary was added to a semantic store, that summary was retrieved into a system-wide “common knowledge” prompt-cache prefix, and now every tenant’s prompts ship the contaminated cache. The original tenant data is correctly isolated; the derivative is not. The same shape applies to fine-tuned model variants trained on user data: the model itself is a derived artifact, and deletion of the source data doesn’t unlearn the weights.

The mitigation is the derived-artifact registry: every artifact whose content depends on tenant data is tracked with its sources, and any source-tenant deletion triggers cascading invalidation. The 2025 unlearning-at-scale literature describes the model-weights side; the application-layer side is a more tractable engineering problem of cache and summary invalidation. The honest position, per the cross-session identity article’s deletion discussion, is that some derived artifacts (deployed fine-tunes) cannot be fully scrubbed, and the product surface should be transparent about it.

Isolation invariants

A production multi-tenant memory layer maintains four invariants. Each invariant has a specific implementation pattern; together they’re the contract.

Invariant 1: Every operation carries a tenant scope

The scope is a typed value: a TenantScope class with required fields, never a bare string concatenation. The API surface for the memory layer takes TenantScope as a required argument; the underlying database driver is never exposed directly. A code review of the harness should be able to grep for db.query( or client.query( and find zero occurrences outside the scope-checking layer.

The reader-from-a-different-namespace bug from the shared-memory article is the common failure mode here: a templated namespace tuple, a missing component, a substring match on a partial namespace. The mitigation is the typed value plus an exact-match contract on the database layer; partial-prefix matches are explicitly disabled.

Invariant 2: Default deny on missing scope

If a query arrives without a scope, the system errors rather than returning anything. This is a posture choice: the safe default is “no results unless explicitly scoped”: and it’s the inverse of the “match everything” default that ships in most prototype code paths. The implementation is one line at the top of every memory function: if scope is None: raise ScopeRequiredError(...). Skipping it costs you the audit report.

Invariant 3: Every write is attributed and timestamped

Every memory write records who wrote it (user content vs. agent summary vs. system fact vs. external tool result), when, and which scope. The attribution is what lets the retrieval prompt distinguish “user said X” from “the agent inferred X” from “the system asserted X”: without it, MINJA-class attacks succeed because the model has no way to know whether a retrieved memory is trustworthy. The provenance layer from temporal reasoning is the substrate; this invariant is what makes the substrate mandatory rather than optional.

Invariant 4: Deletion is verifiable

When a tenant is deleted, the system can demonstrate the deletion. The demonstration has three parts: an audit log entry attesting to the operation, a verification query that confirms zero rows remain in any tenant-keyed table or namespace, and a derived-artifact invalidation log listing every cache, summary, or reflection that was rebuilt because it depended on the deleted tenant’s data. Production systems that ship the deletion API without the verification query cannot answer the GDPR demand “prove this user is gone”; the verification is the engineering deliverable, not the deletion itself.

The deletion pipeline

The GDPR right-to-erasure machinery is the most-asked-about part of this layer. The naive implementation is one DELETE statement; the production implementation is a pipeline with seven steps, in order:

  1. Authentication and authorization. The deletion request is verified: the requester is the tenant or an authorized agent acting on their behalf. The request itself goes to an audit log before execution.
  2. Scope enumeration. The system lists every store, namespace, and derived artifact that references the tenant. The list is the contract for the deletion; an incomplete list is the source of every deletion-coverage bug.
  3. Cache invalidation. Every prompt-cache prefix, embedding-cache entry, and intermediate-result cache that references the tenant is dropped. The prompt-caching article’s cache-key discipline is the prerequisite: cache keys that include the tenant ID can be invalidated by key prefix; cache keys that don’t have to be wholesale-purged.
  4. Hard deletion on primary stores. Every primary store row, vector, and graph node in the tenant’s namespace is deleted. This is the database operation everyone thinks of; it’s step 4 of 7.
  5. Derived-artifact rebuild. Summaries, reflections, semantic facts, and procedural skills that were derived from the tenant’s data are dropped. Some of these are tenant-private (already covered by step 4); others are shared (a cross-tenant semantic-fact store, a global procedural-skill cache). The shared derivatives need a rebuild pass that excludes the deleted tenant’s contributions.
  6. Verification query. A read against every namespace listed in step 2 confirms zero remaining rows. The verification is logged with the deletion audit entry.
  7. Compliance attestation. A signed record of the deletion is emitted: what was deleted, when, by whom, with the verification result. This is the artifact a GDPR auditor asks for.

Step 5 is the one most implementations skip. The memory-reflection article noted that reflections create derivative claims that survive their sources; the privacy-layer corollary is that those derivatives have to be tracked so they can be invalidated. The mature pattern is a derived-artifact registry: every reflection, every summary, every semantic-fact entry records a derived_from: [episode_ids] field, and the deletion pipeline traverses the graph forward from the deleted tenant’s episodes to find and rebuild every dependent.

The 2026 EDPB enforcement audit flagged anonymization-as-deletion as the most common cheat: pseudonymizing the user’s identifier and claiming the data is gone, when the embeddings are still reidentifiable. The regulatory direction is clear: pseudonymization is not erasure for AI-backed systems. The hard-deletion pipeline is the engineering answer.

Tenant-scoped memory in Python

A minimal but production-shaped implementation. A TenantScope typed value, a scoped memory API where every operation requires the scope, an audit log on every read/write/delete, and a deletion pipeline with a verification step. Uses the Anthropic SDK for the model, psycopg for Postgres, and pgvector for the embedding column. Install: pip install anthropic psycopg[binary] pgvector.

python
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
"""
memory_isolation.py

A tenant-scoped memory layer with:
  - TenantScope typed value (required, structural)
  - Default-deny on missing scope
  - Attributed/timestamped writes
  - Verifiable hard-deletion pipeline with derived-artifact tracking
"""

from __future__ import annotations
import json
import time
import uuid
from dataclasses import dataclass
from typing import Iterable
import anthropic
import psycopg
from pgvector.psycopg import register_vector

SCHEMA = """
CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE IF NOT EXISTS memory (
    id          UUID PRIMARY KEY,
    tenant_id   TEXT NOT NULL,
    content     TEXT NOT NULL,
    embedding   vector(1024),
    actor       TEXT NOT NULL,         -- 'user' | 'agent' | 'system' | 'tool'
    written_at  DOUBLE PRECISION NOT NULL,
    derived_from JSONB                 -- list of episode IDs this row is derived from
);
CREATE INDEX IF NOT EXISTS memory_tenant_idx ON memory(tenant_id);
CREATE INDEX IF NOT EXISTS memory_embedding_idx ON memory
    USING hnsw (embedding vector_cosine_ops);

CREATE TABLE IF NOT EXISTS audit_log (
    id        UUID PRIMARY KEY,
    tenant_id TEXT NOT NULL,
    op        TEXT NOT NULL,           -- 'read' | 'write' | 'delete' | 'verify'
    detail    JSONB NOT NULL,
    ts        DOUBLE PRECISION NOT NULL
);

CREATE TABLE IF NOT EXISTS deletion_attestation (
    id             UUID PRIMARY KEY,
    tenant_id      TEXT NOT NULL,
    requested_at   DOUBLE PRECISION NOT NULL,
    completed_at   DOUBLE PRECISION,
    rows_deleted   INTEGER,
    caches_purged  INTEGER,
    derived_rebuilt INTEGER,
    verified       BOOLEAN NOT NULL DEFAULT FALSE
);
"""


# -----------------------------------------------------------------------------
# Scope: typed, required, structural
# -----------------------------------------------------------------------------


@dataclass(frozen=True)
class TenantScope:
    """A typed tenant scope. Never construct from untrusted client input directly.

    The scope is normally derived from an authenticated server-side session; do
    not accept client-supplied tenant_id headers without an authorization check.
    """
    tenant_id: str

    def __post_init__(self):
        if not self.tenant_id or "/" in self.tenant_id or len(self.tenant_id) > 128:
            raise ValueError(f"invalid tenant_id: {self.tenant_id!r}")


class ScopeRequiredError(Exception):
    """Raised when a memory op is called without a tenant scope."""


# -----------------------------------------------------------------------------
# Memory layer
# -----------------------------------------------------------------------------


class TenantScopedMemory:
    """Production-shaped scoped memory.

    Every public method takes a TenantScope as a required argument.
    The underlying psycopg connection is private; no caller goes around the scope.
    """

    def __init__(self, dsn: str):
        self._dsn = dsn
        with psycopg.connect(dsn, autocommit=True) as conn:
            conn.execute(SCHEMA)
            register_vector(conn)

    # -- internal: every query goes through this method, which enforces the scope -
    def _conn(self) -> psycopg.Connection:
        conn = psycopg.connect(self._dsn, autocommit=True)
        register_vector(conn)
        return conn

    def _audit(self, conn, scope: TenantScope, op: str, detail: dict):
        conn.execute(
            "INSERT INTO audit_log (id, tenant_id, op, detail, ts) VALUES (%s,%s,%s,%s,%s)",
            (uuid.uuid4(), scope.tenant_id, op, json.dumps(detail), time.time()),
        )

    # -- write -----------------------------------------------------------------
    def write(self, scope: TenantScope, content: str, embedding: list[float],
              actor: str, derived_from: list[str] | None = None) -> str:
        if scope is None:
            raise ScopeRequiredError("write without tenant scope")
        if actor not in {"user", "agent", "system", "tool"}:
            raise ValueError(f"unknown actor class: {actor}")

        memory_id = str(uuid.uuid4())
        with self._conn() as conn:
            conn.execute(
                "INSERT INTO memory (id, tenant_id, content, embedding, actor, written_at, derived_from) "
                "VALUES (%s, %s, %s, %s, %s, %s, %s)",
                (memory_id, scope.tenant_id, content, embedding, actor, time.time(),
                 json.dumps(derived_from or [])),
            )
            self._audit(conn, scope, "write",
                        {"id": memory_id, "actor": actor, "len": len(content)})
        return memory_id

    # -- read (similarity search within scope) ---------------------------------
    def search(self, scope: TenantScope, query_embedding: list[float], k: int = 5
               ) -> list[dict]:
        if scope is None:
            raise ScopeRequiredError("search without tenant scope")

        with self._conn() as conn:
            # Scope filter is in the query, BEFORE the ANN op: pgvector with
            # HNSW supports filtered ANN with this shape.
            rows = conn.execute(
                "SELECT id, content, actor, written_at "
                "FROM memory WHERE tenant_id = %s "
                "ORDER BY embedding <=> %s LIMIT %s",
                (scope.tenant_id, query_embedding, k),
            ).fetchall()
            self._audit(conn, scope, "read",
                        {"k": k, "returned": len(rows)})
        return [
            {"id": r[0], "content": r[1], "actor": r[2], "written_at": r[3]}
            for r in rows
        ]

    # -- deletion pipeline -----------------------------------------------------
    def delete_tenant(self, scope: TenantScope, cache_purger=None) -> dict:
        """The full GDPR-shaped deletion pipeline. Returns the attestation record."""
        if scope is None:
            raise ScopeRequiredError("delete without tenant scope")

        attestation_id = str(uuid.uuid4())
        requested_at = time.time()

        with self._conn() as conn:
            # Step 1: open the attestation row.
            conn.execute(
                "INSERT INTO deletion_attestation "
                "(id, tenant_id, requested_at) VALUES (%s, %s, %s)",
                (attestation_id, scope.tenant_id, requested_at),
            )

            # Step 2: scope enumeration: list every row that will be deleted.
            row_count = conn.execute(
                "SELECT COUNT(*) FROM memory WHERE tenant_id = %s",
                (scope.tenant_id,),
            ).fetchone()[0]

            # Step 3: derived-artifact enumeration.
            # In this minimal impl, the only derived artifacts are memory rows
            # whose `derived_from` references one of the deleted episode IDs.
            # In production, a registry table tracks reflections, summaries, etc.
            episode_ids = [r[0] for r in conn.execute(
                "SELECT id FROM memory WHERE tenant_id = %s",
                (scope.tenant_id,),
            ).fetchall()]
            derived_count = 0
            if episode_ids:
                # Find rows in other tenants that derived from this tenant's rows.
                # These need to be rebuilt without the deleted contribution.
                derived_rows = conn.execute(
                    "SELECT id, tenant_id, derived_from FROM memory "
                    "WHERE tenant_id != %s "
                    "AND derived_from ?| %s",
                    (scope.tenant_id, [str(eid) for eid in episode_ids]),
                ).fetchall()
                derived_count = len(derived_rows)
                # In a real impl, each of these triggers a rebuild job.
                # Here we mark them for rebuild via a separate column or queue.

            # Step 4: cache invalidation.
            caches_purged = cache_purger(scope) if cache_purger else 0

            # Step 5: hard deletion on primary store.
            conn.execute("DELETE FROM memory WHERE tenant_id = %s", (scope.tenant_id,))

            # Step 6: verification query. Defensive: re-read and confirm zero.
            remaining = conn.execute(
                "SELECT COUNT(*) FROM memory WHERE tenant_id = %s",
                (scope.tenant_id,),
            ).fetchone()[0]
            verified = remaining == 0

            # Step 7: close the attestation.
            completed_at = time.time()
            conn.execute(
                "UPDATE deletion_attestation SET completed_at=%s, rows_deleted=%s, "
                "caches_purged=%s, derived_rebuilt=%s, verified=%s WHERE id=%s",
                (completed_at, row_count, caches_purged, derived_count,
                 verified, attestation_id),
            )
            self._audit(conn, scope, "delete",
                        {"attestation_id": attestation_id, "rows": row_count,
                         "verified": verified})

        return {
            "attestation_id": attestation_id,
            "tenant_id": scope.tenant_id,
            "rows_deleted": row_count,
            "caches_purged": caches_purged,
            "derived_rebuilt": derived_count,
            "verified": verified,
            "requested_at": requested_at,
            "completed_at": completed_at,
        }


# -----------------------------------------------------------------------------

Four properties the implementation enforces: every public method takes a TenantScope (no default value, no Optional: the type system rejects unscoped calls); the database connection is encapsulated and never exposed (the only way to bypass scope enforcement is to subclass and call the private connection, which is visible in code review); every operation writes an audit-log row in the same transaction as the operation itself (you cannot have a memory mutation that’s invisible to compliance); the deletion pipeline has all seven steps including the verification query that confirms zero rows remain.

The deliberate omission is the cross-tenant derived-artifact enumeration: the code finds derived rows but doesn’t trigger their rebuild. A production implementation pairs this with a job queue (the sleep-time compute substrate from the consolidation article is the natural fit) that rebuilds shared semantic facts and reflections excluding the deleted tenant’s contributions.