Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Data handling: PII, retention, and deletion

A user asks you to delete their data.

You delete their sessions. Their preferences are still in long-term memory, their phone number is still in a trace from March, and a checkpoint from a long-running job still holds a frozen copy of both.

That is the whole chapter in one paragraph. An agent does not store user data in one place. It stores it in four places with four different lifetimes, and each needs its own retention story and its own deletion story. Most teams build the first one and discover the other three during a compliance review, or during an incident, which is a worse time to discover them.

This is also one of the questions you will be asked in a system design interview. “How do you handle privacy and data retention” is a standard follow-up to any conversation-memory design, and the answer that lands is not “we encrypt at rest” — it is being able to name the four stores and say what deletion means in each.

The sibling chapter on security treats the model as an untrusted caller and puts policy in front of every tool. This chapter is the other half: what happens to the data after a legitimate call succeeds.


The four stores

Start by drawing the map, because you cannot write a retention policy for a store you have not noticed.

Saying it out loud. The answer that lands when someone asks how you handle privacy isn’t “we encrypt at rest” — it’s being able to name the four stores and say what deletion means in each. Sessions hold the verbatim transcript and are easy: delete by owner. Long-term memory holds derived facts, lives indefinitely by design, and is hard because a memory can be blended from several people’s sources. Traces hold everything including tool payloads, live for weeks to months, and need a user-to-trace index you built before the request arrived. Checkpoints are sealed snapshots of all of the above and are very hard, often only solvable by destroying the whole thing or shredding a key. Four stores, four retention policies, four deletion paths — and a privacy design that names only one of them isn’t a design.

Sessions

What lands here: the verbatim transcript. Every user message, every agent reply, every tool call and tool result, exactly as they happened. This is the richest store you have and the one people think of first.

How long it naturally lives: short, if you configured it. The sessions chapter in Part 3 sets a TTL and deletes inactive sessions automatically, which is both a cost control and a privacy control. Left unconfigured it lives forever, because nothing in a database deletes itself.

What deletion requires: a DELETE by session ID, or by user ID with an index on the owner column. This is the easy one, and it should not fool you into thinking the rest is easy.

Saying it out loud. Sessions hold the verbatim transcript — every user message, every reply, every tool call and result exactly as they happened. It’s the richest store you have and the one everybody thinks of first. Left alone it lives forever, because nothing in a database deletes itself, so you set a TTL and let inactive sessions age out, which is a cost control and a privacy control at the same time. Deletion here is genuinely easy: delete by session ID or by owner, with an index on the owner column. The trap is letting that ease convince you the other three stores are easy too.

Long-term memory

What lands here: extracted facts about a person. “Prefers window seats.” “Allergic to shellfish.” “Has complained twice about delivery times.” Not the transcript — what someone concluded from the transcript.

How long it naturally lives: forever, and that is not an oversight. Indefinite persistence is the entire point of a memory system; an agent that forgets you between sessions is the product you were trying to escape. The design goal and the privacy problem are the same property, which is why this store is the interesting one.

What deletion requires: more than deleting source turns. A memory is derived, so the source session going away does not take the memory with it. Worse, a memory can be derived from several sources, and some may belong to other people — the memory chapters in Part 3 build exactly this, a sources list on every record, specifically so that erasure is implementable later. Deleting a subject from memory means walking derived records, not just owned ones, and we come back to it below because that is where the real work is.

Saying it out loud. Long-term memory holds extracted facts about a person — prefers window seats, allergic to shellfish — not the transcript but what someone concluded from it. It lives forever, and that isn’t an oversight: indefinite persistence is the entire point, because an agent that forgets you between sessions is the product you were trying to escape. So the design goal and the privacy problem are literally the same property, which is what makes this store the interesting one. And deletion is harder than it looks, because a memory is derived — deleting the source session doesn’t take the memory with it, and a single memory may have several sources, some belonging to other people.

Traces and logs

What lands here: everything. Prompts, completions, tool arguments, tool results, retrieved documents, latencies, errors. Your observability layer was designed to capture enough to debug a failure at three in the morning, and “enough to debug” and “the user’s full conversation plus their database rows” turn out to be the same thing.

How long it naturally lives: months. Default retention on hosted observability platforms is typically thirty to ninety days, log aggregators often much longer, and log archives in object storage frequently forever because nobody set a lifecycle rule.

This is the store people forget contains user data. It is not in the product surface, it was built by the platform team, and it is usually the one with the broadest internal read access — every engineer on call can query it. If you take one action from this chapter, go and look at what your trace payloads contain and how long they are kept.

What deletion requires: either a retention sweep you can wait for, or a targeted purge by subject. Purging traces is harder than purging a database because traces are often append-only, sharded by time, and held by a vendor whose delete API is per-trace rather than per-user — so you need an index from user to trace IDs, built before the request arrives.

Saying it out loud. Traces contain everything: prompts, completions, tool arguments, tool results, retrieved documents. Your observability layer was designed to capture enough to debug a failure at 3 a.m., and it turns out “enough to debug” and “the user’s whole conversation plus their database rows” are the same thing. This is the store people forget holds user data — it isn’t in the product surface, the platform team built it, and it usually has the broadest internal read access of anything you own, because every on-call engineer can query it. Default retention is typically 30 to 90 days on hosted platforms and often forever in object storage because nobody set a lifecycle rule. If you take one action from this chapter, go look at what your trace payloads contain and how long they’re kept.

Checkpoints

What lands here: a frozen copy of everything else. A checkpoint is a serialized snapshot of the agent’s state at a point in a long run — the message history, the working state, the intermediate results. The long-horizon-operations checkpoint chapter in the agentic-ai-evaluation-guide covers why you want them: a multi-hour run that dies at minute 200 should resume, not restart.

How long it naturally lives: as long as the job might need to resume, plus however long nobody cleaned up. In practice checkpoints outlive their jobs, because deleting them feels risky and keeping them is free.

What deletion requires: possibly the destruction of the checkpoint. A checkpoint is a sealed blob; you cannot surgically remove one user from it and still have a valid resume point, because the state was consistent and now it is not. The honest options are to delete the whole checkpoint and accept that the run cannot resume, or to make it undecryptable for that user — the crypto-shredding idea below.

StoreLifetimeContainsDeletion difficulty
SessionsHours to weeks (TTL)Verbatim transcriptEasy — delete by owner
Long-term memoryIndefinite by designDerived facts about a personHard — derived and blended
Traces and logsWeeks to months, often longerEverything, plus tool payloadsMedium — needs a subject index
CheckpointsUntil the job is done, then forgottenFrozen copy of all the aboveVery hard — sealed and consistent

Four stores, four policies, four deletion paths. A privacy design that names only one of them is not a design.

Saying it out loud. A checkpoint is a frozen copy of everything else — the message history, the working state, the intermediate results, serialized at a point in a long run so a job that dies at minute 200 resumes instead of restarting. In practice they outlive their jobs, because deleting them feels risky and keeping them is free. And they’re the hardest deletion problem you have, because a checkpoint is a sealed consistent snapshot: you can’t surgically remove one user and still have a valid resume point. So the honest options are destroying the whole checkpoint and accepting the run can’t resume, or making it undecryptable for that user via crypto-shredding.


What counts as sensitive

Personal data under GDPR is any information relating to an identified or identifiable natural person. That is a deliberately wide definition and it includes things engineers do not think of as personal: IP addresses, device identifiers, cookie IDs, and a user ID that you can join back to a person. The US framing is narrower in wording and similar in practice — CCPA as amended by CPRA covers information that identifies, relates to, or could reasonably be linked with a consumer or household.

Some categories carry extra weight, and getting one of these wrong is a materially bigger problem than getting a name wrong.

Special category data under GDPR Article 9 — health, racial or ethnic origin, political opinions, religious beliefs, trade union membership, genetic data, biometric data used for identification, sex life and sexual orientation — is prohibited from processing by default, with a short list of exceptions such as explicit consent. The default is no, and you work back from there. Biometrics deserve a specific mention because they arrive by accident: voice prints and face embeddings are Article 9 territory in the EU and attract aggressive state statutes in the US, Illinois BIPA being the one with the litigation history, so if you built voice input you may be storing biometrics without having called them that.

Financial data is not an Article 9 category but attracts its own regimes: PCI DSS if you touch card numbers, and sectoral rules if you touch account data. The practical rule for an agent is that a full card number should never reach your logs, and the standard says so.

Children’s data has its own bar. COPPA in the US applies below 13, GDPR sets a digital-consent age between 13 and 16 depending on the member state, and California’s amended CCPA now treats personal information about consumers under 16 as sensitive personal information — a change effective 1 January 2026, which also added neural data to the sensitive category. If your agent might be used by minors, that is a design constraint, not a terms-of-service line.

Now the point that is specific to agents, and that generic privacy guidance will not tell you.

An agent’s tool results are usually more sensitive than its conversation.

The conversation is what the user chose to type. The tool result is what your lookup_customer function returned: the whole row, including the fields the user never mentioned and the agent never needed. A retrieval tool returns document chunks that may belong to other people entirely, and a run_query tool returns whatever the query matched.

So the data flowing into your context — and therefore into your session store, your traces, and your model provider — is frequently broader than anything the user disclosed. The security chapter’s excessive-functionality point has a privacy twin: a tool that returns twelve fields when the agent needs two is a privacy defect before it is a design one. Project your tool results. SELECT the columns you need.

Saying it out loud. Personal data under GDPR is anything relating to an identifiable person, which is deliberately wide — IP addresses, device IDs, a user ID you can join back to a person. Then some categories carry extra weight: Article 9 special category data like health, biometrics, and religious belief is prohibited by default, so you work back from no. Biometrics arrive by accident, which is the part to flag — if you built voice input, voice prints are Article 9 in the EU and attract aggressive state statutes in the US. But the agent-specific point that generic privacy guidance won’t tell you is this: an agent’s tool results are usually more sensitive than its conversation. The conversation is what the user chose to type; the tool result is the whole row your lookup returned, including the fields nobody mentioned and the agent never needed. So excessive functionality has a privacy twin — a tool that returns twelve columns when the agent needs two is a privacy defect before it’s a design one.


Minimisation first

The cheapest privacy control is not collecting the data.

This is not a slogan, it is GDPR Article 5. Data minimisation requires that personal data be adequate, relevant, and limited to what is necessary for the purpose; storage limitation requires that it be kept in identifiable form no longer than necessary; purpose limitation requires that you collect it for a specified purpose and not repurpose it incompatibly. Those sit alongside lawfulness, accuracy, integrity and confidentiality, and the accountability duty — and minimisation is the one an agent architecture most often violates by accident.

Three practical moves, in increasing order of how much they cost you.

Redact at the boundary. Scrub sensitive values on the way in, before anything is persisted. The sessions chapter puts redaction on the write path for exactly this reason: if the value never lands in the store, a breach of the store does not expose it, and a deletion request does not have to reach it. Redaction at read time protects nothing, because the data is already sitting in your database.

Pseudonymise rather than delete. Replace the value with a stable token derived from it — a keyed hash, not a plain one, so the mapping cannot be brute-forced from a small domain like phone numbers. The same email always produces the same token, so you can still count distinct users, join a trace to a session, and see that the same person appears in three tickets. Note what pseudonymisation is not: under GDPR, pseudonymised data is still personal data, because you or someone else can reverse it. It reduces risk, it does not remove you from scope.

Work with the placeholder. This is the move people skip, and it is often available. Your agent almost never needs the actual email address; it needs a stable handle it can pass to a tool that resolves it on the far side of the boundary. Store [EMAIL:02b2927e86] in the context, keep the mapping in a small vault with its own access control and its own deletion path, and have the send_email tool resolve the token at call time. Now the model provider never sees the address, the trace never contains it, the checkpoint never freezes it, and the deletion story for that field collapses to “delete one row in the vault.” That pattern — tokenise at the edge, resolve inside the tool — is the highest-leverage privacy design in this chapter, and it costs you a lookup.

Saying it out loud. The cheapest privacy control is not collecting the data, and that’s not a slogan, it’s GDPR Article 5 — minimisation, storage limitation, purpose limitation. Three moves in increasing order of cost. Redact at the boundary, on the write path, because if the value never lands in the store then a breach of the store doesn’t expose it and a deletion request never has to reach it; redaction at read time protects nothing. Pseudonymise with a keyed hash rather than a plain one, so a small domain like phone numbers can’t be brute-forced — and be clear that pseudonymised data is still personal data under GDPR, so it reduces risk without removing you from scope. And the highest-leverage one: work with the placeholder. Your agent almost never needs the real email, it needs a stable handle that the send_email tool resolves on the far side. Then the model provider never sees it, the trace never has it, the checkpoint never freezes it, and erasure for that field collapses to deleting one row in a vault. That costs you a lookup.


Redaction done properly

Here is the bug this section exists for, and it is a real one from earlier in this book.

While building the memory system in Part 3, the session store got a redactor. It scrubbed email addresses out of message text and the demo output looked clean. It also silently passed through a tool_call event whose args dict contained the same email address, because the redactor only handled strings and that content was a dict.

That is the entire class of failure. Redaction that covers the obvious surface and misses the structured one is worse than no redaction, because it produces a demo where everything looks redacted.

The failure modes, enumerated, because each one has bitten someone:

Nested structures. Tool arguments and results are JSON. A regex over a string does not see inside a dict, and a redactor that walks one level does not see inside a list of dicts inside a dict.

Free text inside structured fields. A note or description or query field is a string sitting inside a payload. Whatever the user typed goes in there, and any pattern can appear.

Non-string leaves. A card number stored as an integer, a date of birth as an epoch, a coordinate pair as floats. Regex sees none of them, which is why key-name rules matter as well as value patterns.

Model echo. The user types their account number, the model repeats it back in its reply, and now it is in the assistant turn too. Redaction on the inbound path only is half a control.

Retrieved content. A document chunk from a knowledge base can contain someone else’s PII that your user never had. This is where redaction most often is not applied at all, because “it is our own corpus” feels safe.

Detection approaches, with honest error rates.

Regex is exact for structured identifiers with checksums or rigid formats — card numbers, national insurance numbers, IBANs — and genuinely good there. It is poor for names, addresses, and anything context-dependent, where it either misses constantly or matches everything.

Named-entity recognition models catch names, organisations, and locations that regex cannot. They are probabilistic, they were trained on a distribution that is not yours, and their recall on unusual names, non-Latin scripts, and transliterations is meaningfully worse than the headline number. Treat published F1 scores as an upper bound on a benchmark, not a prediction about your traffic.

LLM-based detection is the most flexible and the most expensive, it adds latency to the write path, and it is itself subject to prompt injection because you are asking a model to classify attacker-controlled text.

Managed services — Google Cloud DLP and Model Armor, AWS Comprehend PII, Azure AI Language — combine the above and ship maintained pattern libraries, which is real value. They also mean the data crosses another boundary to be inspected, so read the section on third parties.

Nothing in that list is exact. Plan for a false-negative rate that is not zero, which means redaction is a risk-reduction control and not a boundary — the same distinction the security chapter draws about input filtering.

Which leaves the one design principle worth taking away:

Redaction runs on the serialized record, not on the field you remembered to check.

Serialize the whole event — message, tool call, tool result, state delta, metadata — walk every node, and scrub every leaf. If a new field appears next quarter because someone added a tool, it is covered by construction rather than by whoever reviewed the pull request. Then test the redactor against your real event shapes, not against a string.

Saying it out loud. Here’s the failure class in one sentence: redaction that covers the obvious surface and misses the structured one is worse than no redaction, because it produces a demo where everything looks clean. The real bug was a redactor that scrubbed emails out of message text and passed the same email straight through inside a tool call’s nested args dict, because it only handled strings. The failure modes to name are nested structures, free text inside structured fields, non-string leaves like a card number stored as an integer, model echo where the assistant repeats the account number back, and retrieved content, which is where redaction is most often not applied at all because “it’s our own corpus” feels safe. No detector is exact — regex is good for checksummed identifiers and poor for names, NER is probabilistic and its recall on unusual names and non-Latin scripts is meaningfully worse than the headline F1, and LLM detection is expensive and itself injectable. So redaction is risk reduction, not a boundary. The design principle is that it runs on the serialized record, walking every node and scrubbing every leaf, so a field somebody adds next quarter is covered by construction rather than by whoever reviewed the pull request.


Retention

A retention policy is a table with one row per store, and it is a document you can hand to an auditor.

Per store, decide four things: how long you keep it, what starts the clock, what happens at the end (delete, or anonymise), and who approved it. The last matters more than it looks, because a retention period nobody signed off is one an engineer will quietly extend during an incident and never revert.

A defensible schedule for a typical agent looks roughly like this.

StoreRetentionEnd actionRationale
Sessions, active30 days from last activityDeleteProduct needs recent context; nothing needs a year-old transcript
Sessions, archived transcripts12 monthsDeleteSupport disputes and quality review
Long-term memoryLife of account, plus 30 daysDelete on account closureIndefinite is the product; account closure is the clock
Traces, full payload14 daysDeleteLong enough to debug an incident found within a fortnight
Traces, metadata only13 monthsDeleteLatency and cost trends need seasonality, and metadata is not the payload
Checkpoints7 days past job completionDeleteResume is a short-horizon need
Audit log of tool calls12 to 24 monthsDeleteSecurity investigations, and it is small

Two of those rows carry the real idea.

Tiering. The traces split into two rows because “keep traces long enough to debug an incident” and “keep nothing you do not need” are only in tension if traces are one thing. Full payloads — prompts, completions, tool arguments — are what you need to debug, and you need them for days. Metadata — timestamps, durations, token counts, tool names, status codes, trace IDs — is what you need for trend analysis, and it is barely personal data once the payload is gone. Keep the payloads briefly and the metadata for a year, and the tension mostly dissolves.

The audit log is deliberately long and deliberately small. The tool-call audit record from the security chapter — who, what tool, what decision, why — is the artifact you hand to whoever investigates, and a two-week retention on it makes a security investigation impossible. It is affordable at that retention precisely because it holds decisions, not payloads.

Two more implementation notes. Enforce retention in the storage layer, not in a cron job somebody has to maintain: object lifecycle rules, database TTL columns with a partition-drop, index lifecycle management. A retention policy that depends on a script running is a retention policy that has silently not run since the last migration.

And write down the legal hold exception before you need it. When litigation or a regulatory investigation is reasonably anticipated, you must suspend deletion for the relevant records, and that has to be a mechanism you can turn on, not a thing you discover your automation has already defeated.

Saying it out loud. A retention policy is a table with one row per store, and per store you decide four things: how long, what starts the clock, what happens at the end, and who approved it. That last one matters more than it looks, because a period nobody signed off is one an engineer quietly extends during an incident and never reverts. Two rows carry the real idea. Tiering: split traces into full payloads kept for about two weeks, which is what you need to debug, and metadata — timestamps, durations, tokens, tool names — kept for thirteen months, which is what you need for trends and is barely personal data once the payload is gone. That mostly dissolves the tension between debuggability and minimisation. And the audit log is deliberately long and deliberately small: two weeks of retention on it makes a security investigation impossible, and you can afford a year or two precisely because it holds decisions rather than payloads. Enforce all of it in the storage layer — lifecycle rules, TTL columns, partition drops — because a retention policy that depends on a cron script is a policy that has silently not run since the last migration. And write down the legal hold exception before you need it.


Deletion and the right to erasure

GDPR Article 17 gives a data subject the right to have their personal data erased without undue delay in defined circumstances — the data is no longer necessary for its purpose, consent is withdrawn and there is no other lawful basis, the subject objects and no overriding legitimate ground exists, or the processing was unlawful. It is not absolute; freedom of expression, legal obligations, and the establishment or defence of legal claims are among the exemptions. CCPA as amended gives a comparable right to delete, and importantly requires the business to direct its service providers and contractors to delete too — the obligation flows down your vendor chain, it does not stop at your database.

The law is the easy half. Here is what erasure actually costs in each of the four stores.

Saying it out loud. GDPR Article 17 gives a right to erasure without undue delay in defined circumstances, and it isn’t absolute — legal obligations and the defence of legal claims are among the exemptions. CCPA gives a comparable right, and the part engineers miss is that it requires you to direct your service providers to delete too, so the obligation flows down the vendor chain rather than stopping at your database. The law is the easy half. The engineering half is that erasure costs almost nothing in sessions, a lot in memory because records are derived and blended, a medium amount in traces if you built the user-to-trace index at write time, and is often impossible cleanly in checkpoints. So the design you want is one function — erase_subject — that fans out across all four in a defined order, is idempotent and resumable because it spans systems that will individually fail, and returns a signed receipt at the end.

Sessions: easy

Delete by owner — you have the index, because the sessions chapter enforces owner isolation on every read anyway. Confirm the delete propagated to any read replica and any cache, and you are done.

Long-term memory: find the derived records, not just the source turns

The naive implementation deletes memories whose subject is the erased user, and it is wrong in two directions.

It misses derived memories about other people that were extracted from this user’s conversations. A memory attached to user B — “travels with the account holder” — may have been distilled partly from user A’s session, and erasing A’s sessions without touching that record leaves A’s data in your system under B’s name.

And it over-deletes blended memories, the ones with several sources where only some are being erased. Dropping those throws away information the user never asked you to forget and had no right to ask you to forget on someone else’s behalf.

The correct handling is three-way, and the memory chapter’s sources list is what makes it possible:

  • Sole source erased → delete the memory.
  • Some sources erased, some survive → strip the erased sources and flag for regeneration, then re-run extraction over the survivors.
  • No erased sources → leave it alone.

Regeneration is the expensive, correct completion of this, and it has a prerequisite: you have to still hold the surviving source material. Which is a real tension with the retention policy above, and a reason to be explicit that regeneration is best-effort past the source retention window.

Saying it out loud. The naive implementation deletes memories whose subject is the erased user, and it’s wrong in both directions at once. It misses derived memories about other people that were extracted from this user’s conversations — a note on user B saying “travels with the account holder” may have come partly from A’s session, so erasing A leaves A’s data in your system under B’s name. And it over-deletes blended memories, throwing away information the user never asked you to forget and had no standing to ask you to forget on someone else’s behalf. The correct handling is three-way, and it only works if every memory record carries a sources list: sole source erased means delete, some sources erased means strip them and flag for regeneration from the survivors, and no erased sources means leave it alone. Regeneration is the expensive correct ending, and it has a prerequisite — you must still hold the surviving source material, which is a genuine tension with your own retention policy.

Vector stores: the embedding and the index

Deleting the row is not the whole job.

An embedding is derived from text and it is not anonymous — embedding inversion attacks can reconstruct a meaningful approximation of the source text from the vector alone, so treat the vector as personal data in its own right. So you delete the vector, and its metadata payload, which is where the identifiers usually live.

Then the index. Approximate-nearest-neighbour indexes such as HNSW and IVF are graph or cluster structures that are built, not maintained; most implementations mark a deleted vector as a tombstone and skip it at query time while the data stays in the index segment until a compaction rebuilds it. That is fine for correctness of results and it is not fine for a deletion claim. So: issue the delete, force the compaction or rebuild, and then check the backups, which have their own retention and are the reason crypto-shredding exists.

A related limit, stated plainly rather than hand-waved: if personal data ever went into fine-tuning, deleting the training row does not remove it from the model weights. Machine unlearning is an active research area and not a compliance answer today; the practical remedies are retraining without the data, or not fine-tuning on personal data in the first place, which is the reason most teams should not. The EDPB’s Opinion 28/2024 addresses when an AI model can be considered anonymous, and it does not hand you an easy answer.

Saying it out loud. Deleting the row isn’t the whole job in a vector store. An embedding is derived from text and it isn’t anonymous — embedding inversion attacks can reconstruct a meaningful approximation of the source from the vector alone, so treat the vector as personal data in its own right. Then there’s the index: approximate-nearest-neighbour structures like HNSW and IVF are built rather than maintained, so most implementations tombstone a deleted vector and skip it at query time while the bytes sit in the segment until a compaction rebuilds it. That’s fine for result correctness and not fine for a deletion claim. So issue the delete, force the compaction, then check the backups. And the hard limit worth stating plainly: if personal data ever went into fine-tuning, deleting the training row doesn’t remove it from the weights — machine unlearning is a research area, not a compliance answer, so the remedies are retraining without it or not fine-tuning on personal data in the first place.

Traces: a sweep or a purge

If the retention window is short, erasure is “it will be gone in fourteen days,” and whether that satisfies “without undue delay” is a judgement call your legal team makes, not you. If it is long, you need a targeted purge, which needs an index from user ID to trace IDs that you built at write time. Check what your observability vendor actually supports — some offer a delete-by-attribute API, some only per-trace deletion, some nothing but retention configuration — and find out before you promise a customer a purge.

Checkpoints: often impossible cleanly

A checkpoint is a consistent frozen snapshot, so “delete the user from the checkpoint” and “keep the checkpoint” are usually mutually exclusive.

The three pragmatic answers, and you will use all of them.

Tombstoning. Mark the record deleted and stop serving it, rather than physically removing it. This is what you do when the storage layer cannot support a surgical delete, or when removing the row breaks referential integrity. It is a partial answer and you should be honest with yourself that a tombstoned record is still a record; it only counts as erasure if the underlying bytes become inaccessible and are scheduled to go.

Cascade deletion by subject. One entry point — erase_subject(user_id) — that fans out to every store, in a defined order, and returns a receipt. The alternative is a runbook with seven manual steps, which is a thing that gets six of them right at three in the morning.

Crypto-shredding. Encrypt each subject’s data with a per-subject data encryption key, and erase by destroying the key. Everything encrypted under it — including copies in backups, in sealed checkpoints, and in append-only stores you cannot rewrite — becomes ciphertext with no path to plaintext. This is the pragmatic answer wherever true deletion is infeasible, and it is the only workable one for immutable and archival storage. Two caveats worth stating: it requires that you designed for it up front, because you cannot retrofit per-subject keys onto data already encrypted under one global key; and whether destroying the key legally constitutes erasure is a position regulators have generally accepted but not universally codified, so take advice rather than my word.

Whatever you do, emit a receipt: a record of what was deleted, from which stores, when, and by which request, with a digest so it cannot be quietly edited later. That artifact turns “we deleted your data” from an assertion into evidence, and GDPR’s accountability principle is specifically about being able to demonstrate compliance rather than merely achieve it.

Saying it out loud. With checkpoints, “delete the user from it” and “keep it usable” are usually mutually exclusive, so you end up using three pragmatic answers together. Tombstoning marks the record deleted and stops serving it, which is a partial answer — be honest that a tombstoned record is still a record unless the bytes become inaccessible. Cascade deletion gives you one entry point that fans out to every store in a defined order and returns a receipt, versus a seven-step runbook that gets six of them right at 3 a.m. And crypto-shredding encrypts each subject’s data under a per-subject key and erases by destroying that key, which is the only workable answer for backups, sealed checkpoints, and append-only stores. Two caveats: you cannot retrofit per-subject keys onto data already encrypted under one global key, so it has to be designed in; and whether key destruction legally counts as erasure is a position regulators have broadly accepted rather than universally codified. Whatever you do, emit a receipt with a digest, because accountability is about demonstrating compliance, not just achieving it.


Third parties

Draw your trust boundary, then list everything that crosses it — for a typical agent, four categories.

Model providers. Every prompt goes to them, which means every session, every tool result, and every retrieved document. Retention and training posture as of this writing, and these are exactly the things that change, so verify against the current documentation before you repeat them to a customer:

  • Anthropic does not train on API inputs and outputs by default, and states that retained data is never used for training without express permission. Conversation content is not retained by default on the API, with exceptions for specific features and models that require a 30-day window. Zero data retention is available per organisation on request and covers the Messages and token-counting APIs, but explicitly does not cover a list of features including the Files API, batch processing, and code execution. Content flagged by automated trust-and-safety systems may be retained for up to two years even under ZDR — which is the sentence most people miss.
  • OpenAI has not trained on API data by default since March 2023. Default abuse-monitoring retention is up to 30 days, with ZDR available to approved customers, and some stateful endpoints store application state until you delete it, which makes them ineligible for ZDR. Note also that litigation has previously forced retention beyond the stated default, which is a useful reminder that a vendor’s retention policy is a promise about their intentions, not a guarantee about their legal obligations.
  • Google does not use prompts or responses from paid Gemini and Vertex AI services to improve its models. Zero data retention exists but is feature-dependent: several capabilities, including Search and Maps grounding, retain data for 30 days with no opt-out, and stateful APIs store conversation state unless you explicitly disable it. Free-tier Gemini Developer API usage is treated differently from paid usage, which is a trap in exactly the place you would expect — the prototype.

The shape is consistent across all three: no training on API data by default, a short default retention for abuse monitoring, ZDR available on request, and ZDR that covers the core inference endpoint but not the convenient stateful features you were about to adopt. Read the eligibility table, not the headline.

Vector stores. A hosted vector database holds embeddings of your documents and your users’ data. Ask where it runs, what its deletion semantics are, and whether deletes reach backups.

Observability vendors. This is where the full trace payload lives, and it is the third party most likely to have been adopted without a review, because it was a free tier during the prototype.

Third-party MCP servers. The security chapter covers the supply-chain risk of running someone else’s code inside your trust boundary; the privacy version is that every argument you send an MCP server is a disclosure to whoever operates it, and you frequently have no data processing agreement, no retention statement, and no deletion path at all. For a tool that receives personal data, that is not a supply-chain concern, it is an unlawful transfer.

Three controls, none optional.

Data processing agreements. Under GDPR you are the controller and each of these is a processor; Article 28 requires a contract with defined terms, including that the processor deletes or returns the data at the end of the service. CCPA has the analogous service-provider contract requirement, and it is the mechanism by which a deletion request reaches your vendors.

Regional routing and data residency. Know which region each vendor processes in, and whether your users’ data can leave it. All three major providers offer regional endpoints. Transfers out of the EEA need a lawful mechanism — an adequacy decision, standard contractual clauses, or the EU-US Data Privacy Framework, which has been challenged and whose durability is not something to assume.

A register. One page listing every third party, what data it receives, where it processes, its retention, and its deletion path. Boring, an hour of work, and the artifact that makes the other two enforceable.

Saying it out loud. Draw your trust boundary and list what crosses it, and for a typical agent that’s four categories: model providers, who see every prompt and therefore every session, tool result, and retrieved document; hosted vector stores; observability vendors, which is where the full trace payload lives and is the third party most likely to have been adopted without review because it was a free tier during the prototype; and third-party MCP servers, where every argument you send is a disclosure to whoever runs it, usually with no DPA and no deletion path — for a tool receiving personal data that isn’t a supply-chain concern, it’s an unlawful transfer. The pattern across the big model providers is consistent: no training on API data by default, a short abuse-monitoring retention, zero data retention available on request, and ZDR that covers the core inference endpoint but not the convenient stateful features you were about to adopt. Read the eligibility table, not the headline. And the three controls are data processing agreements, regional routing, and a one-page register of every vendor with what it receives, where it processes, its retention, and its deletion path — an hour of work, and it’s the artifact that makes the other two enforceable.


Build it: redaction on the record, deletion across the stores

Two pieces, both dependency-free.

The first is a redactor that operates on the serialized record and therefore catches the nested-payload bug described above. Note scrub: it recurses through dicts, lists, and tuples, applies value patterns to every string leaf, applies key-name rules regardless of value shape, and falls back to serializing anything it does not recognise rather than passing it through untouched — that last branch is what makes it safe against a field type nobody anticipated.

"""Redaction on the serialized record, plus cascade deletion by subject."""
from __future__ import annotations

import hashlib, json, re, time
from dataclasses import dataclass, field
from typing import Any

DETECTORS: list[tuple[str, re.Pattern]] = [
    ("EMAIL", re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.-]{2,}\b")),
    ("CARD",  re.compile(r"\b(?:\d[ -]?){13,19}\b")),
    ("SSN",   re.compile(r"\b\d{3}-\d{2}-\d{4}\b")),
    ("PHONE", re.compile(r"\b(?:\+\d{1,2}[ -]?)?\(?\d{3}\)?[ -]\d{3}[ -]\d{4}\b")),
    ("IBAN",  re.compile(r"\b[A-Z]{2}\d{2}[A-Z0-9]{10,30}\b")),
]

# Field names that are sensitive whatever their value looks like.
SENSITIVE_KEYS = {"password", "api_key", "token", "secret", "dob", "date_of_birth",
                  "diagnosis", "ssn", "national_id", "mrn"}


def pseudonym(label: str, value: str, salt: bytes) -> str:
    """A stable placeholder. Same input -> same token, so joins still work."""
    digest = hashlib.blake2s(salt + value.encode(), digest_size=5).hexdigest()
    return f"[{label}:{digest}]"


@dataclass
class Redactor:
    """Runs on the *serialized record*, not on the field you remembered to check."""
    salt: bytes = b"rotate-me"
    hits: list[str] = field(default_factory=list)

    def _string(self, s: str) -> str:
        for label, pattern in DETECTORS:
            def sub(m: re.Match) -> str:
                self.hits.append(label)
                return pseudonym(label, m.group(0), self.salt)
            s = pattern.sub(sub, s)
        return s

    def scrub(self, obj: Any) -> Any:
        """Walk every node. Dicts, lists, tuples, strings, and sensitive keys."""
        if isinstance(obj, str):
            return self._string(obj)
        if isinstance(obj, dict):
            out = {}
            for k, v in obj.items():
                if str(k).lower() in SENSITIVE_KEYS:
                    self.hits.append(f"KEY:{k}")
                    out[k] = "[REDACTED]"
                else:
                    out[k] = self.scrub(v)
            return out
        if isinstance(obj, (list, tuple)):
            return type(obj)(self.scrub(v) for v in obj)
        if isinstance(obj, (int, float, bool)) or obj is None:
            return obj
        # Anything else -- a dataclass, a model object -- gets serialized and
        # scrubbed as text rather than silently passed through untouched.
        return self._string(json.dumps(obj, default=str))


# The bug from Part 3, preserved so you can watch it fail.
def naive_redact(obj: Any) -> Any:
    """Scrubs strings. Silently returns structured payloads unchanged."""
    if isinstance(obj, str):
        for _, pattern in DETECTORS:
            obj = pattern.sub("[REDACTED]", obj)
    return obj

The second piece is erase_subject: one entry point that fans out across a mock session store, a memory store with derived memories, a trace log, and a sealed checkpoint, and returns a signed receipt. The memory handling is the three-way split from above.

def erase_subject(st: Stores, subject: str) -> dict:
    receipt: dict[str, Any] = {"subject": subject, "actions": []}

    def note(store, action, ids, detail=""):
        if ids:
            receipt["actions"].append({"store": store, "action": action,
                                       "ids": sorted(ids), "detail": detail})

    # -- sessions: hard delete, and remember which ones they were.
    owned = [sid for sid, s in st.sessions.items() if s["user_id"] == subject]
    for sid in owned:
        del st.sessions[sid]
    note("sessions", "hard_delete", owned)

    # -- memories about the subject: hard delete.
    direct = [mid for mid, m in st.memories.items() if m["subject"] == subject]
    for mid in direct:
        del st.memories[mid]
    note("memory", "hard_delete", direct)

    # -- derived memories: about someone else, but sourced from this subject.
    derived, orphaned = [], []
    for mid, m in list(st.memories.items()):
        if not any(s in owned for s in m["sources"]):
            continue
        remaining = [s for s in m["sources"] if s not in owned]
        if remaining:
            m["sources"] = remaining
            m["needs_regeneration"] = True
            derived.append(mid)
        else:
            del st.memories[mid]
            orphaned.append(mid)
    note("memory", "hard_delete", orphaned, "sole source was an erased session")
    note("memory", "flag_for_regeneration", derived,
         "blended sources; regenerate from survivors rather than keep or drop")

    # -- traces: targeted purge by subject and by the sessions we just deleted.
    purged = [t["trace_id"] for t in st.traces
              if t["user_id"] == subject or t["session_id"] in owned]
    st.traces[:] = [t for t in st.traces if t["trace_id"] not in purged]
    note("traces", "purge", purged)

    # -- checkpoints: sealed blobs. Tombstone, then crypto-shred.
    touched = [cid for cid, c in st.checkpoints.items()
               if any(s in owned for s in c["sessions"])]
    for cid in touched:
        st.checkpoints[cid].setdefault("tombstoned_subjects", []).append(subject)
        st.checkpoints[cid]["resumable"] = False
    note("checkpoints", "tombstone", touched,
         "sealed archive; marked unresumable, contents unreadable after key destroy")

    # -- the key. Once this is gone, anything we could not reach is ciphertext.
    if st.keys.pop(subject, None):
        note("kms", "destroy_key", [f"dek:{subject}"], "crypto-shred: residual "
             "copies in backups and sealed checkpoints become undecryptable")

    receipt["digest"] = hashlib.sha256(
        json.dumps(receipt["actions"], sort_keys=True).encode()).hexdigest()[:16]
    return receipt

Running it against a record that contains an email in the message text and the same email buried in a tool call’s nested filter list:

$ python privacy.py
1 naive redactor (strings only) -- the Part 3 bug:
    user_text : my email is [REDACTED], call me on [REDACTED]
    nested arg: ada@example.com   <-- MISSED

2 serialized-record redactor:
    user_text : my email is [EMAIL:02b2927e86], call me on [PHONE:9e5eb5e341]
    nested arg: [EMAIL:02b2927e86]   <-- CAUGHT
    nested note: caller also gave card [CARD:91fee1ed4b]
    password  : [REDACTED]
    detections: CARD, EMAIL, KEY:password, PHONE

3 pseudonyms are stable, so joins survive redaction:
    [EMAIL:02b2927e86] appears again in: ticket from [EMAIL:02b2927e86]

4 cascade deletion for u_88:
    sessions     hard_delete            s1,s2
    memory       hard_delete            m1,m2
    memory       flag_for_regeneration  m4  (blended sources; regenerate from survivors rather than keep or drop)
    traces       purge                  t1,t2
    checkpoints  tombstone              ck_7  (sealed archive; marked unresumable, contents unreadable after key destroy)
    kms          destroy_key            dek:u_88  (crypto-shred: residual copies in backups and sealed checkpoints become undecryptable)
    receipt digest: 3a7a85440de4b226

5 what survived:
    sessions   : ['s3']
    memories   : {'m3': False, 'm4': True}
    traces     : ['t3']
    checkpoints: {'ck_7': {'resumable': False, 'tombstoned': ['u_88']}}
    keys held  : ['u_91']

Read blocks 1 and 2 together, because that pair is the point of the chapter. The naive redactor produces output that looks clean — the visible message is scrubbed — while the email address the agent will actually query with sits untouched two levels down in the tool arguments. The same string, in the same record, on the same write.

Then read block 4 against block 5. m4 belongs to u_91 and was never about u_88, but it was derived partly from u_88’s session, so it is flagged rather than deleted or ignored — the middle case a naive implementation gets wrong in one direction or the other. Checkpoint ck_7 cannot be surgically edited, so it is tombstoned and marked unresumable, and the key destruction on the last line is what actually makes its contents unrecoverable. u_91’s key is still held, which is the property that makes per-subject keys worth their operational cost.

What to add for real use: hold the salt in a KMS and rotate it, since a low-entropy value like a phone number is brute-forceable against an unsalted hash; run the erasure as an idempotent, resumable job with a dead-letter queue, because it spans systems that will individually fail; add the vector-store leg with an explicit index compaction after the delete; write the receipt to append-only storage and sign it properly rather than digesting it; and add a verification pass that re-queries every store for the subject and asserts nothing comes back, because a deletion routine with no test is a deletion routine that stops working the day someone adds a store.

Saying it out loud. The demo makes two points worth repeating. First, the naive redactor produces output that looks clean — the visible message is scrubbed — while the exact same email address sits untouched two levels down inside the tool arguments, in the same record, on the same write. That’s the whole argument for redacting the serialized record rather than the fields you remembered. Second, watch the middle case in the cascade delete: a memory belonging to another user, derived partly from the erased user’s session, gets flagged for regeneration rather than deleted or ignored, because a naive implementation gets that wrong in one direction or the other. And the last line — destroying the per-subject key — is what actually makes the sealed checkpoint’s contents unrecoverable, while the other user’s key stays held. That’s the property that makes per-subject keys worth their operational cost.


What you should be able to do now

  • Name the four stores an agent accumulates user data in — sessions, long-term memory, traces and logs, checkpoints — and state for each what it contains, how long it naturally lives, and what deletion actually requires.
  • Classify data by sensitivity, including the categories that carry extra weight, and explain why an agent’s tool results are typically more sensitive than its conversation.
  • Apply minimisation before controls: redact on the write path, pseudonymise with a keyed stable token, and tokenise at the edge so the model provider and your traces never see the raw value.
  • Build a redactor that runs on the serialized record rather than on individual fields, and explain why regex-on-strings misses nested tool-call payloads, non-string leaves, and model echo.
  • Write a defensible retention schedule with a row per store, tiering full trace payloads separately from metadata, enforced in the storage layer and with a legal-hold exception.
  • Implement cascade deletion by subject that handles derived and blended memories correctly, tombstones what cannot be surgically edited, crypto-shreds what cannot be reached, and emits an auditable receipt.
  • Answer “does my data train their model?” for the major providers, and say what zero data retention does and does not cover.

Further reading