An event store keeps every event, forever. That is what makes a rebuild honest, an audit trail complete and a bug reproducible. It is also what a privacy law such as the GDPR runs into head on: a person may ask that their data be erased, and a log whose point is to never erase anything has to answer. The answer is not to delete events. Decide, for each field, whether it belongs in the event at all, and when it does, make it unreadable instead of absent.
Most personal data does not belong in an event
Section titled “Most personal data does not belong in an event”An event records a decision of the domain. OrderPlaced is the fact that customer c-42 placed
an order for two items; the customer’s name, their address and their phone number are not part of
that fact. They are details the shipping provider needs, the invoice shows, the welcome email is
sent to. A command handler that puts them in the event because “they were in the command” has
made the event the owner of data that another part of the system owns.
The first tool is a port. The event carries the id and the data lives behind a port whose implementation can delete:
export interface ContactDetails { save(customerId: string, details: { email: string; phone: string }): Promise<void>; find(customerId: string): Promise<{ email: string; phone: string } | null>; erase(customerId: string): Promise<void>;}A command saves the details through the port and returns CustomerRegistered { customerId }; a
policy that sends the welcome email reads them through the port when it runs. Erasing the person
is erase(customerId) on the store the implementation writes to: a table, a KV namespace, a
profile service. On Cloudflare that store is another binding, a KV or D1 next to the Durable
Object, so the object’s SQLite holds nothing to forget. Mathias Verraes calls this
forgettable payloads:
the event keeps a reference, the referenced data has a lifetime of its own.
This is enough when the data is not needed to replay the past: a rebuilt read model lists the
orders of c-42 by id, and a query joins the current details through the port when it shows
them. It also keeps the data out of every copy of the stream: backups, the global stream an
operator reads, the events a policy of another aggregate receives.
When the event has to carry it
Section titled “When the event has to carry it”Sometimes the data is the decision. AddressChanged records the address the customer moved to;
EmailVerified records which email was verified; a ComplaintFiled records what the customer
wrote. Leaving it out makes the event meaningless, and a projection that shows it would have to
call a port, which
a projection never does. Then the field stays in the
event, and forgetting it has to work on the event itself, in the log, in every backup, without
rewriting anything.
That is crypto-shredding, Verraes’s throw away the key: every person has a key, their fields are stored encrypted with it, and forgetting the person is deleting the key. The events stay where they were, byte for byte; what they held is noise from then on, in the live database and in every copy taken before. Bounda does this for you:
import type { Event } from "./+types/address-changed";
export const payload = ({ z, personal }: Event.PayloadArgs) => z.object({ address: personal(z.object({ street: z.string(), city: z.string() })) });personal marks a field; the aggregate’s id names the person, or a field marked with person
does when the event belongs to someone else’s aggregate. A process’s state marks its fields the
same way, and its instance carries on once they read as null. app.forget("c-42") deletes the key
and appends PersonalDataForgotten to the person’s own stream, and
app.exportPersonalData("c-42") hands the person every marked field and every sealed error the
store holds of them, and the rows of the read models that name them, for the right of access and
the right to portability. app.restrict("c-42")
keeps the data and refuses every command that touches the person until unrestrict, the GDPR’s
restriction of processing, and the reactions it refused run once it is lifted. With
runtime.personalData.retention the runtime forgets whoever no event has named for that long, the
GDPR’s storage limitation, and a process that calls forget at its timeout forgets someone once a
purpose of the domain ends. The guide has the rest: the types a
forgotten field takes, what read models have to do, what an export holds, who opened what, what
a restriction refuses, how retention counts and where the keys live.
What forgetting changes for the rest of the app
Section titled “What forgetting changes for the rest of the app”Forgetting is an event of its own because what the log held has leaked, legitimately, into everything that was built from it. A read model that projected the address has the address in a row; a policy that sent it to the shipping provider gave it to a system of its own. Deleting the key makes the event unreadable and does nothing to those.
So every read model that projects a personal field must also project PersonalDataForgotten,
and the generator fails the build of one that does not. A read model says which of its columns
are copies, with .personal(), and whose, with .person(): the person’s export then carries
those rows too, since the right of access covers everything kept about them and not only the
log. The runtime’s own copies are its job:
snapshots, which hold an aggregate’s state in the clear,
go with the key, in the same transaction. A policy on the same event is where the
outside world gets told: erase the contact at the mailing provider, delete the profile the search
index holds, as keeping an external index
does on the way in.
And every handler that reads a personal field reads it as T | null. The null is not a style
choice: it is what makes the compiler find every place that assumed the data would always be
there, the projection that copied it into a NOT NULL column, the evolve that lower-cased it.
A forgotten person’s events still fold, still project, still replay; they just carry a hole
where the data was.
How others do it
Section titled “How others do it”- Marten rewrites. A masking rule per event type says how to change its members, and
ApplyEventDataMaskingruns it over the streams or tenant you choose, updating the stored rows in place; projections only show the masked values after a rebuild. There is no key: the erasure is destructive, which Marten’s own documentation lists as the reason it is still considering crypto-shredding for a later version. - Axon shreds. Its Data Protection module, a commercial add-on, marks fields with
@PersonalDataand the subject with@DataSubjectId, encrypts them on the way to the store, anddeleteKey(subjectId)forgets. After that aStringreads as a replacement value,""by default, other references asnull, primitives as their default. Keys live in a crypto engine of its own: a JPA or JDBC table, Vault, a Java key store. Nothing tells the rest of the app that a key is gone. - KurrentDB, EventStoreDB’s successor, deletes streams. A soft delete hides a stream and
lets it be reopened; a hard delete leaves a tombstone and the name can never be used again;
$maxAgeand$maxCounthide events by age or count. All of them hide at read time: nothing leaves the disk until a scavenge rewrites the chunks, started by an operator on every node, and the store keeps one event per stream to say it existed. Crypto-shredding is left to the application; Kurrent’s own guidance on the GDPR is a blog post and a webinar.
Bounda’s choice sits with Axon’s, with three differences. The key is per person, in the same
database as the events, so that forgetting is one transaction, an adapter’s backup and restore
keep the two together and a test needs no second store; what makes that safe is envelope
encryption, the practice of AWS and Google Cloud KMS, Rails and Laravel: each key is stored
wrapped under a master key the database never holds, read from the host’s environment or kept in
a KMS, so a copy of the database opens nothing without it. Keeping the keys in another store
would split forgetting across two systems; deriving them from the master key would leave nothing
to destroy. The forgotten value is null, in the type as in the data, rather than a
replacement the type hides. And forgetting is an event, because the read models and the policies
have work to do when it happens.
What stays outside
Section titled “What stays outside”- Data in the clear elsewhere. A field marked
personalin one event and left plain in another is readable through the second one. The mark is per field per event, so mark every event that carries the data, or carry it in one. A field marked late is not left out: the runtime seals in place what was written before. - What left the store. Logs that printed a payload, the external systems a policy wrote to, and backups. A backup of the whole database restores the keys too, wrapped: with the master key it is a copy of the data of everyone it holds, including a person forgotten after it was taken. With a forget ledger, a record of every forgetting kept outside the database, the app forgets those persons again when it starts on the restored copy, so a restore is no longer outside; a backup that sits unrestored still is. Rotating the master key and destroying the old one once those backups expire makes them unreadable as a whole. Without a master key, a backup opens on its own.
- Errors. A dead letter keeps the error a handler threw, its message and stack encrypted with
the key of the person the work names, so one that quotes a decrypted field is unreadable once
they are forgotten; what the stream and the stores keep of a failure is the error’s class and
code. A command’s payload marked
personalis encrypted where it is stored, scheduled or dead-lettered, andforgetcancels it; a payload left unmarked is not. - Who read what, outside the client. Every export, dead letter opened and query of a read
model with personal fields goes to an access log,
never sampled, and a read whose record cannot be kept fails. Code that reads a read model’s
tables directly or through
client.rawgoes around it. - Encrypted data under the GDPR. Whether a ciphertext without a key still counts as personal data is a legal question Verraes raises and this page does not answer.
Where to read more
Section titled “Where to read more”- Personal data: the guide, with
personal,person,forgetand the read model’s part. - Your projection should not call anyone, for why the data a projection shows has to be in the event.
- Mathias Verraes, Forgettable payloads and Crypto-shredding.
- Marten’s protected information, Axon’s data protection and KurrentDB’s streams and scavenging.