How do you delete a user from an append-only event store?
MMO Reconnect is event-sourced. Every meaningful thing that happens — you claim a past character, you make a claim public, you remove one — is an immutable event appended to a log. We don’t rewrite the past in place; we append the next fact and let read models follow. That is the whole appeal: the log is the truth, and it never lies about what happened.
Then a user clicks Delete my account, and the whole premise seems to fall apart. “Append-only” and “erase everything about me” are, on their face, opposites. So how do you honor deletion in a system whose defining property is that it never forgets?
The short answer: you decide where the erasable data lives long before anyone asks to be forgotten. By the time a delete request arrives, it should be a small, boring transaction — not surgery on the event log.
Where the personal data lives
The trick is that our event streams carry almost nothing personal to begin with.
A character name like Faelor on Bristlebane, in Fire and Fury is not personal data — it is historical record about a game world, self-asserted, and never secret to begin with — the name was visible in that world to everyone who played there. What is personal — your handle, your email, your Discord identity, your avatar — never enters an event. It lives in exactly one place: a single identity document, a mutable record keyed to an opaque player id.
This is a recognized event-sourcing pattern, sometimes called forgettable payloads: keep the data you might one day have to erase out of the immutable log and in a store you can delete freely. The events reference an opaque PlayerId; the document is what turns that id back into a person. Delete the document and the id points at no one.
We considered the popular alternative — crypto-shredding, where you encrypt personal fields inside events and “delete” by throwing away the key — and rejected it. If your personal data is document-only, deletion is just a document delete. You don’t need key management, you don’t need to reason about which fields were encrypted with which key three schema versions ago. The simpler design falls out of the earlier decision to keep the streams clean.
Why “just delete the streams” is the wrong instinct
The reflex, when someone asks to be forgotten from an event store, is to reach for the event streams and destroy them. Don’t.
Our streams are PII-free by construction, so deleting them buys zero privacy. What it costs is real: you’d need stream-surgery machinery, and you’d punch holes in a log whose whole value is that it’s complete — all to erase data that already identifies no one. And unlinkability, the thing you might delete the streams to protect, you already have for free: the identity document is gone, so a returning user mints a brand-new id with nothing tying it to the old events — whether those events still exist or not.
So the streams stay. Erasure happens everywhere else.
Erasure can’t be eventual
Our first sketch of the delete cascade was the intuitive one: for each of the user’s claims, publish a “remove this claim” message; let each be handled in its own transaction. In a message-driven system that feels natural.
It’s also wrong for this operation. A per-message cascade is eventual: there’s a window where the account is gone but a claim row is still live, and a partial failure can leave an orphaned, still-visible claim after the account has been erased. For most features, “converges in a few seconds” is fine. For erasure it is not — a claim that outlives the account isn’t a lag, it’s an incident.
Account deletion deserves the strongest consistency guarantee the stack offers. So it takes it.
One transaction
Deletion is a single Marten transaction, issued by the owner over DELETE /api/me. In one SaveChangesAsync() we:
- resolve the player’s live claims from their owner-scoped index,
- append a
ClaimRemoved(…, AccountDeleted)event to each claim stream, - append
AccountDeletedto the player stream, and - hard-delete the identity document — the only home of the handle, email, Discord id, and avatar.
// DELETE /api/me — the strongest guarantee the stack offers.
public static async Task<IResult> DeleteAccount(
IDocumentSession session, PlayerId playerId, HttpContext http)
{
// Fold the player stream and guard on its terminating fact: a second
// delete writes nothing, because AccountDeleted is never appended twice.
var stream = await session.Events.FetchForWriting<Player>(playerId);
if (stream.Aggregate is { Deleted: false })
{
// Live claims come from the owner's inline index; soft-deleted rows drop out.
var claims = await session.Query<OwnerClaim>()
.Where(c => c.OwnerPlayerId == playerId)
.ToListAsync();
foreach (var claim in claims)
session.Events.Append(claim.ClaimId, new ClaimRemoved(claim.ClaimId, AccountDeleted, now));
stream.AppendOne(new AccountDeleted(playerId, now));
// The single home of the handle, email, Discord id, avatar.
session.Delete<IdentityRecord>(playerId);
await session.SaveChangesAsync(); // it all commits together, or none of it does
}
await http.SignOutAsync(); // the cookie can't be reused
return Results.NoContent();
}
Marten lets a document session append events and delete documents in the same unit of work. The inline read models — the owner’s claim index, the per-claim detail view — scrub synchronously inside that transaction, so the moment the commit lands they’re already consistent. The asynchronous read models — the public search index chief among them — catch up a beat later on the projection daemon, riding the very same ClaimRemoved and AccountDeleted events that everything else consumes. That’s the quiet payoff of terminating events: the delete handler never reaches into a read model to scrub it — it appends the terminating facts and each projection cleans up after itself. Surfaces keyed to a claim — the search and guild-membership rows — inherit erasure for free, riding the same ClaimRemoved cascade that an ordinary claim removal already drives. Surfaces built on the player stream don’t come free: a play-intent row on the Legends page never sees a ClaimRemoved — an intent isn’t a claim — so the search projection carries one small AccountDeleted handler to scrub those directly. Still a terminating event doing the work, still no cleanup logic in the delete handler itself — just an honest note that a player-stream fact needs a player-stream event to erase it.
A couple of nice properties fall out for free. Sourcing the claim ids from the live index means the query returns exactly the not-yet-removed claims, which gives us per-claim idempotency and quietly sweeps up operator-hidden claims too (hiding is a flag, not a delete). And a second delete writes nothing — the player stream already carries AccountDeleted, so the operation is idempotent at the top level as well.
The streams stay — anonymous by construction
After the commit, the player and claim streams still exist. They reference a PlayerId that no document resolves anymore. The events are still an honest record that something happened, but there’s no longer anything, anywhere, that turns that id back into a person. The handle didn’t need scrubbing from a dozen tables because it was never denormalized into them — it lived in the one document we deleted.
A stale session cookie held open in another tab reads as signed out: GET /api/me returns 401 the instant the player’s stream carries AccountDeleted, so there’s no post-erasure shell to render. And because the hard-delete frees the Discord id, the same Discord account signing in tomorrow gets a fresh, unlinkable player id — a new person as far as the system is concerned.
Being honest about “zero rows”
Our launch acceptance test for this was blunt: after a delete, sweep every read model keyed to that player id and assert zero rows. Worth being precise about what “zero” means, because it isn’t uniform.
Hard-deleted things — the identity document, the claim-detail view, the flat search and guild-membership tables — leave zero physical rows. One read model — the owner’s own claim index — instead keeps its PII-free rows as soft deletes (anonymous by construction, exactly like the streams); those are zero via the default query that hides soft-deleted rows. Both satisfy “nothing user-visible survives,” but one is gone from disk and the other is tombstoned. Naming that distinction out loud is how you keep an erasure guarantee from quietly rotting into a half-truth.
When we’ll have to revisit this
The one-transaction cascade is right because of our scale: a person claims a handful of characters, so we’re appending a handful of events in one transaction. That assumption is load-bearing, and we’ve written down what would break it:
- Unbounded claims per player. Append thousands of events in a single transaction and the latency and locking stop being free; the cascade would move to batched or background removal behind a documented erasure SLA.
- Export, cool-off, or undo. Any of those turns immediate, unconditional erasure into something stageable and reversible — a different design.
- PII ever landing in an event. This is the big one. The whole approach rests on personal data being document-only. The day something personal gets appended to a stream, deletion can no longer be a document delete, and crypto-shredding or stream rewriting comes back onto the table.
The takeaway
“How do you delete from an append-only store?” is the wrong question, or at least the wrong altitude. The real question is where you let personal data accumulate in the first place. Get that right — keep it out of the immutable log, in one deletable place — and the right-to-be-forgotten stops being a contradiction and becomes a small transaction: append a few terminating events, delete one document, sign out. The log stays honest. The person is gone.
Append-only doesn’t mean unforgetting. It means you have to decide, up front, what you were never going to remember.