---
id: 57
title: "Deletion is a feature: design it on day one"
kind: note
status: current
date: 2026-08-12
authors:
  - "Theo Zourzouvillys"
tags: [data, security, privacy, architecture, multi-tenancy]
references:
  - id: gdpr17
    title: "GDPR Article 17 — Right to erasure ('right to be forgotten')"
    url: https://gdpr-info.eu/art-17-gdpr/
    abstract: "The legal floor under this note in much of the world: data subjects can require erasure of their personal data 'without undue delay', including data that has propagated to processors. A deletion capability is not optional product surface; in many markets it's a compliance requirement with deadlines."
  - id: nist88
    title: "NIST SP 800-88 Rev. 1 — Guidelines for Media Sanitization"
    url: https://csrc.nist.gov/pubs/sp/800/88/r1/final
    abstract: "The reference for what 'actually gone' means at the storage layer, including cryptographic erasure: destroying the key that encrypts the data as a sanitisation technique — the mechanism that makes deletion tractable in backups and archives you can't rewrite."
summary: "A deleted_at column is not deletion. Real deletion is a workflow with an SLA: it must reach every replica, projection, index, cache, log, and backup — and you must prove it ran. Partition by owner, propagate tombstones on the event rails, crypto-shred what you can't rewrite."
supersedes: null
superseded_by: null
aliases: []
crossrefs:
  ZFN-15: "Partitioning by tenant is what makes tenant deletion an operation instead of a query festival — data organised by owner can be dropped by owner."
  ZFN-24: "Deletion rides the same rails as every other write: committed once in the owning store, propagated asynchronously and reliably to every derived copy."
  ZFN-36: "The same discipline pointed at the opposite guarantee: a deletion you haven't tested by verifying absence is a hope, exactly like an untested backup."
---

## TL;DR

**Deletion is not `UPDATE … SET deleted_at = now()`. It's a distributed workflow with a
deadline, and if you didn't design it, you can't do it.** Every copy your architecture makes —
replicas, projections, search indexes, caches, analytics tables, logs, traces, backups, the
vendor you sync to — is a place deletion must reach. And "we deleted it" is a claim someone will
eventually ask you to prove: a regulator ([GDPR Article 17](ref:gdpr17)), an enterprise customer
offboarding, your own incident response after a breach.

Design it on day one, because every piece is architectural:

- **Partition data by its owner** ([ZFN-15](/zfn/15-partition-customer-data-by-tenant/)) so
  "delete this tenant" is a drop, not a treasure hunt.
- **Propagate deletion as an event** on the same reliable rails as every other change
  ([ZFN-24](/zfn/24-one-transactional-store-per-write/),
  [ZFN-48](/zfn/48-emit-async-work-into-the-wal/)) — a tombstone every derived store must
  honour.
- **Crypto-shred what you can't rewrite**: encrypt per owner, and deletion of the key sanitises
  every backup and archive at once ([NIST SP 800-88](ref:nist88)).
- **Keep personal data out of logs** so the immutable stores never contain what deletion must
  reach.
- **Separate soft-delete (a product undo feature) from erasure (a compliance workflow)** — one
  word for both is how the second quietly never gets built.
- **Test it by verifying absence**, the way you test backups by restoring
  ([ZFN-36](/zfn/36-test-backups-by-restoring/)).

## Context

Systems are built to remember. Every pattern in the modern data stack — replication, projections,
event journals, caches, immutable logs, append-only analytics — is a machine for making copies,
and each copy is made by a different subsystem with a different owner. Deletion has to swim
against all of it, and it's the only common data operation that regularly *isn't designed at
all*. It gets a column.

The `deleted_at` column is fine for what it actually is: an undo feature. Users delete things by
accident; a grace window is good product. The trouble starts when the same mechanism is allowed
to stand in for erasure:

- The row is still there, so every query must remember to filter it — and the one that forgets
  resurrects data its owner was told is gone.
- Nothing happened downstream: the search index still matches, the cache still serves
  ([ZFN-21](/zfn/21-caches-sparingly-immutable-only/)), the analytics table still joins, the
  read replica of a projection nobody owns still answers.
- The backups hold every copy ever made, indefinitely, and nobody can say which ones.

Then the forcing event arrives — an Article 17 request with a statutory clock on it, an
enterprise contract with "certified deletion within 30 days" in the DPA, a breach where the
question is "why did we still *have* that?" — and the team discovers that deletion is a
cross-team, cross-store project being invented under a deadline. The cost asymmetry is the same
one as [tenancy](/zfn/15-partition-customer-data-by-tenant/): a day-one design decision, or a
brutal retrofit.

There's a deeper point under the compliance one: **data you were supposed to delete and didn't is
pure liability**. It serves no product purpose, the customer believes it's gone, and it's still
in scope for your next breach. Minimising what you retain is the same security posture as
minimising what you expose.

## Recommendation

**Treat deletion as a first-class workflow with an owner, a deadline, and a proof.**

- **Two named operations, never conflated.** *Soft-delete*: user-visible, reversible, a grace
  window — product owns it. *Erasure*: irreversible, propagating, deadline-bound — the platform
  owns it, and soft-deleted data flows into it when the window lapses.

- **Organise data so deletion is an operation, not a search.** Owner-partitioned stores
  ([ZFN-15](/zfn/15-partition-customer-data-by-tenant/)) turn "erase tenant" into dropping a
  partition, a keyspace, a prefix. The interesting question for any new store in a design review:
  *"how does data leave this?"* If the answer is a full scan with a WHERE clause someone must
  write later, the design isn't done.

- **Propagate on the rails you already trust.** Erasure is a write like any other: committed in
  the owning store, emitted as a tombstone event in the same transaction
  ([ZFN-48](/zfn/48-emit-async-work-into-the-wal/)), consumed by every projection, index, and
  cache — which each must treat "delete" as a mandatory part of their contract, not an optional
  message type. A derived store that can't process a tombstone is a derived store that can't
  legally exist.

- **Crypto-shred the immutable tail.** Backups, WAL archives, cold object storage — you can't
  rewrite them, and restoring-scrubbing-rearchiving every backup is fantasy. Encrypt per tenant
  (or per user, where individual erasure matters) with keys held in one place; erasure of the key
  is erasure of every copy at once ([NIST SP 800-88](ref:nist88) calls this cryptographic
  erasure). This only works if it's designed in — key-per-owner from the start, and a key store
  whose *own* deletion is real. Where old data predates the scheme, document the honest
  fallback: backup expiry windows, stated in the retention policy the customer sees.

- **Starve the stores you can't clean.** Logs, traces, and metrics are effectively immutable and
  widely replicated — so personal data must not enter them. IDs, not emails
  ([ZFN-56](/zfn/56-typed-prefixed-ids/) helps: an opaque ID in a log is a pointer to data you
  *can* erase, not a copy of it). Enforce at the emission layer; retrofit-scrubbing a log archive
  is the same fantasy as rewriting backups.

- **Track third parties as deletion targets.** Every vendor you sync data into — support desk,
  analytics, email — is part of your erasure surface. The inventory of "where does customer data
  go?" is the deletion runbook's first page.

- **Record the tombstone, not the corpse.** Proof of deletion is a durable record that erasure of
  X ran to completion on date D across systems S — retained *after* the data is gone
  ([ZFN-49](/zfn/49-verify-by-computation-not-lookup/): store the small set of revocations, not
  the world they revoke). The tombstone also prevents resurrection when a nine-day-old backup is
  restored: replaying tombstones after restore is part of the
  [restore rehearsal](/zfn/36-test-backups-by-restoring/).

- **Rehearse it.** Pick a real (test) tenant, run erasure, then go *look for the data* —
  primaries, projections, indexes, caches, a restored backup. An erasure workflow you've never
  verified by absence is exactly as trustworthy as a backup you've never restored.

## Consequences

**Easier:**

- **The compliance clock stops being frightening.** An Article 17 request or an offboarding DPA
  clause is a runbook execution, not a project.
- **Breach blast radius shrinks** to data you actually meant to have.
- **Restores stop resurrecting the dead** — the tombstone log replays over whatever comes back.
- **"Where does customer data live?" has an answer**, which pays off in every audit, incident,
  and architecture review, not just deletion.

**Harder:**

- **Every derived store now carries a contract obligation** — consume tombstones, converge to
  absence — and that's a real tax on the "just add a projection" reflex.
- **Key-per-owner encryption is operational machinery**: a key store with real availability
  needs, key lifecycle, and the sharp edge that losing keys *is* deletion, of the involuntary
  kind.
- **Analytics and ML pipelines feel it most** — aggregates are fine, but row-level copies of
  personal data in a warehouse are deletion targets, and honouring that shapes what you're
  allowed to materialise.
- **It's genuinely at odds with "keep everything forever" instincts** — event journals with
  personal data in the payload now need payload erasure or key-shredding designs of their own
  ([ZFN-12](/zfn/12-queues-topics-journals/)); "we might need it someday" stops being a free
  argument.

## References

- [ZFN-15](/zfn/15-partition-customer-data-by-tenant/) — owner-partitioned data is what makes
  erasure droppable rather than searchable.
- [ZFN-24](/zfn/24-one-transactional-store-per-write/) and
  [ZFN-48](/zfn/48-emit-async-work-into-the-wal/) — the reliable propagation rails a tombstone
  rides.
- [ZFN-36](/zfn/36-test-backups-by-restoring/) — the sibling discipline: prove the guarantee by
  exercising it; here, prove absence.
- [ZFN-21](/zfn/21-caches-sparingly-immutable-only/) — every cache you didn't add is a place
  deletion doesn't have to reach.
- [ZFN-49](/zfn/49-verify-by-computation-not-lookup/) — store the tombstones, not the corpses.
- [GDPR Article 17](ref:gdpr17) and [NIST SP 800-88](ref:nist88) — the legal floor and the
  storage-layer definition of gone.

## Changelog

- **2026-08-12**: First published as a Field Note.
