Chapter 6 of 6 · 9 min read
Deletion has to find the copies nobody registered
On this page
The erasure request came through the enterprise customer's admin console on 3 February and named one user: a former employee of theirs who had used the assistant for eleven months. Support raised a ticket, an engineer ran the deletion procedure — a transactional delete across the users, conversations and messages tables, 4,912 rows in total — and support replied the same afternoon that the request had been completed. The one-month statutory clock — the period the GDPR gives a controller to answer an erasure request, running from the day it arrives — had been beaten by twenty-seven days and somebody said so in the channel.
On 20 April a regression test failed in CI, and the diff in the failure output contained the sentence "my supervisor keeps scheduling me over my dialysis appointments and HR told me to raise it with him". It was case 1,132 of the evaluation set, added in November from a production failure, with the subject's name in the expected-output field. The same content was in the vector index, which had been rebuilt from the document store every night since February. It was in the analytics warehouse in a fct_conversation_turns table. It had been in the error reporter until 12 March, when the 90-day policy expired it, which is the only reason it was not in the list.
The reply on 3 February was not a lie about a system; it was a claim about an architecture, made by someone who knew one part of it.
Seven systems, four mechanisms, and two with no expiry at all
The delete-and-let-the-rest-age-out reasoning is appealing because it is nearly true. Most derived stores do have a retention policy, most of those policies are 30 to 90 days, and if you say nothing and wait a quarter, most of the copies really do disappear. The reasoning fails on the stores where it happens not to hold, and it fails silently, because nothing in the system distinguishes "this will expire" from "this will never expire" without someone going to look.
| System | Deletion mechanism | Latency | The catch |
|---|---|---|---|
| Application database | hard delete + cascade | seconds | soft-delete flags, audit tables, point-in-time-recovery (PITR) backups, 35 days |
| Request traces, 30-day TTL | vendor delete-by-attribute API, or wait | minutes to 30 days | needs subject_ref on the span to be findable at all |
| Evaluation set | manual removal + regenerate fixtures | hours | no expiry by design; often has no provenance field |
| Vector index, nightly rebuild | delete from source, then reindex | up to 24 h | no expiry; deleting the index alone is undone by the rebuild |
| Analytics warehouse | delete from raw + rebuild derived models | hours to days | BI extracts, materialised views, CSVs in Slack |
| Error-reporting SaaS | vendor per-user deletion endpoint | hours | only works if events carry the user identifier |
| Model provider | wait out retention, or nothing to delete under ZDR | up to 30 days | Files and Batch artefacts must be deleted explicitly |
The evaluation set is the hardest of the seven and the one most likely to be missed entirely, because it is not infrastructure. It is a directory of YAML or JSONL in a Git repository, curated deliberately from the cases where the system behaved badly, which selects precisely for the conversations that were emotionally significant to the person who had them. It is permanent on purpose — an eval set that expired would be useless for measuring regressions over time — and it is versioned, which means the content survives in Git history after you delete the file. Removing it properly means rewriting history or, more practically, treating the eval repository as a store that must never contain raw customer content in the first place.
The analytics warehouse is the one that resists enumeration. Raw events land in a staging table, a dozen transformation models build derived tables from it, some of those are materialised and some are views, a BI tool caches query results for performance, dashboards have scheduled exports that email a spreadsheet, and at least one analyst has a CSV in a Slack thread from an investigation in October. Deleting from the raw table and rebuilding the models handles the first two layers; the caches and the extracts are outside the lineage graph entirely, since that graph records only which table is built from which and has no knowledge of what left the warehouse. The practical control is upstream — do not land raw conversation text in the warehouse at all, land the structural fields from the first chapter and a subject reference — because the alternative is trying to track derived copies through a system whose entire purpose is producing derived copies. Where text is already there, the honest manifest entry names the layers that were rebuilt and states that ad-hoc extracts are governed by policy rather than by a delete statement, which is a weaker claim and a true one.
The vector index is the instructive one, because it is where ordering matters. Issuing a delete against the index looks like it works, the vectors disappear, and the nightly job rebuilds them from the document store at 02:00. The rule is that a derived store cannot be deleted from until its source has been, and that a derived store must be verified after its next rebuild rather than after the delete call returns.
The loop from Verify back to Dispatch is the part that separates a workflow from a checklist. A deletion job that runs once and reports success is measuring whether the calls returned 200; a job that re-probes each store and re-dispatches on a hit is measuring whether the content is gone.
Discovery is a property of what you wrote down months earlier
None of the above is possible if the derived records cannot be attributed to a subject. A span holding a redacted prompt and no subject reference is unfindable — you cannot delete what you cannot select, and scanning 30 days of trace text for a person's name is both unreliable and a fresh privacy problem in itself. The subject_ref pseudonym from the first chapter is what makes deletion tractable, which is a slightly awkward fact: the field that keeps traces linkable to a person is also the field that lets you honour the request to unlink them.
That gives a rule for every store that receives derived content. Either it carries the subject reference, in which case it is deletable, or it carries no attributable content at all, in which case it is out of scope. A store that holds attributable content without the reference is the one state that has no good outcome, and it is where most teams find themselves on the first request.
class Store(Protocol):
name: str
def delete(self, subject_ref: str) -> DeleteResult: ...
def probe(self, subject_ref: str) -> int: # residual record count
...
def run_erasure(subject_ref: str, stores: list[Store]) -> Manifest:
manifest = Manifest(subject_ref=subject_ref, opened_at=now())
for store in ordered_by_dependency(stores): # sources before derived
result = store.delete(subject_ref)
manifest.record(store.name, result)
for store in stores:
residual = store.probe(subject_ref)
manifest.verify(store.name, residual)
if residual and not store.pending_rebuild:
manifest.reopen(store.name)
return manifestMaking probe a required method of the protocol is the design decision that carries the most weight. It forces every store owner to answer, at integration time rather than during a live request, the question of how one would ever check — and a store whose owner cannot write a probe is a store that should not be receiving customer content.
What completion evidence looks like
"Completed" is not evidence. What a customer's compliance team can act on is a manifest per data subject request — the umbrella term for an individual's demand to see, correct or erase what you hold, and the reason the record below is keyed dsr_ — naming each system, what was done to it, by what mechanism, how many records were affected, when the verification ran, and what the verification returned. Residual items belong in it too, with an expiry date and the reason.
{
"request_id": "dsr_2026_0203_41",
"subject_ref": "u_8f21c",
"received_at": "2026-02-03T09:12:00Z",
"certified_at": "2026-02-04T02:41:00Z",
"systems": [
{"name": "app_db", "action": "hard_delete", "records": 4912,
"verified_at": "2026-02-03T09:44:00Z", "residual": 0},
{"name": "traces", "action": "delete_by_attribute", "records": 1188,
"verified_at": "2026-02-03T09:51:00Z", "residual": 0},
{"name": "eval_set", "action": "case_removed_and_history_rewritten",
"records": 1, "case_ids": ["1132"],
"verified_at": "2026-02-03T14:20:00Z", "residual": 0},
{"name": "vector_index", "action": "source_deleted_then_reindexed",
"records": 402, "verified_at": "2026-02-04T02:41:00Z", "residual": 0},
{"name": "warehouse", "action": "raw_delete_and_models_rebuilt",
"records": 9330, "verified_at": "2026-02-03T23:10:00Z", "residual": 0},
{"name": "error_reporter", "action": "vendor_user_deletion_api",
"records": 7, "verified_at": "2026-02-03T10:02:00Z", "residual": 0}
],
"residual": [
{"name": "db_pitr_backups", "basis": "technical_impossibility",
"expires_at": "2026-03-10", "control": "restore_replays_erasure_log"},
{"name": "model_provider", "basis": "zero_retention_configured",
"note": "no stored payloads; usage metadata retained as business record"}
]
}Two things that look like deletion requests are not, and treating them identically produces the wrong outcome. A restriction request asks you to stop processing while a dispute is resolved, which means suspending the record rather than destroying it — deleting in response to one is unrecoverable and does not satisfy it. And a request that collides with a legal hold — a standing instruction to preserve records that may become evidence, which overrides ordinary deletion schedules — a live employment tribunal or a regulatory retention duty is a request you must partially refuse, in writing, with the basis stated. In a business-to-business product the request usually arrives through the customer rather than from the individual, because your customer is the controller and you are the processor — the controller decides why and how personal data is used, the processor only acts on the controller's documented instructions — which means the decision about whether to erase is theirs and the obligation to execute promptly and to evidence it is yours. Building the workflow so that it takes an instruction and returns a manifest, rather than deciding anything, keeps that boundary in the right place.
Backups are the residual entry that appears on every honest manifest. You cannot surgically remove one subject from a point-in-time backup without destroying the backup's integrity, and regulators have generally accepted that the data is placed beyond use and deleted on the normal backup rotation — provided you say so, give the date, and have a mechanism that re-applies the erasure if a restore ever happens. The restore_replays_erasure_log control is that mechanism: an append-only log of executed erasures that the restore procedure replays before the restored system takes traffic.
Making the second request boring
The February request took eleven working hours spread over two days, and the April discovery meant re-opening it and telling the customer that the original completion notice had been wrong. What changed afterwards was mostly upstream of deletion.
The eval set stopped containing raw customer text. Cases are now built from redacted structure plus a synthesised paraphrase written by an engineer, with a source_subject_ref field required by the schema so that any case can be traced back and pulled. That single change moved the hardest of the seven systems from "permanent and unfindable" to "permanent and findable", and reduced the number of stores holding raw content by one.
Every store that touches derived content registers itself, with an owner, a retention value, and an implementation of the two-method protocol. A test in CI enumerates the exporters and destinations from the egress allowlist and fails if any of them is missing from the registry, which is the same mechanism as the inventory check and reuses its list.
Erasure runs on a schedule against a synthetic subject. A probe account generates traffic through the full pipeline once a week, then an erasure is executed against it and the manifest is checked for zero residuals. When a new store appears and nobody registers it, the drill fails on a Tuesday morning in staging rather than during a live request with a statutory clock running.
The last change was the cheapest and had the largest effect on volume: shortening the raw window and pushing redaction earlier. A store that never held the content has nothing to delete, and the entries on the manifest above that read residual: 0 in seconds are the ones where the answer had already been arranged.
What remains is to run it. Pick a real subject reference from last month, execute the full workflow against a copy of production, and time each system — the number that matters is not whether the mechanism exists but how long it takes and where it stalls, and the only way to learn that is on a day when nobody is waiting for the answer.
Data retention and privacy in logs — read the current version at https://daviddominguez.dev/learn/en/data-retention-and-privacy-in-logs/deletion-has-to-find-the-copies-nobody-registered