<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Beñat Espiña</title>
    <description>The latest articles on DEV Community by Beñat Espiña (@benatespina).</description>
    <link>https://dev.to/benatespina</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4231951%2Fc9c888ec-e3a4-42a0-8b23-d6e4baa17b7c.jpg</url>
      <title>DEV Community: Beñat Espiña</title>
      <link>https://dev.to/benatespina</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/benatespina"/>
    <language>en</language>
    <item>
      <title>The right to be forgotten, in a log that never deletes</title>
      <dc:creator>Beñat Espiña</dc:creator>
      <pubDate>Fri, 06 Nov 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/benatespina/the-right-to-be-forgotten-in-a-log-that-never-deletes-58ko</link>
      <guid>https://dev.to/benatespina/the-right-to-be-forgotten-in-a-log-that-never-deletes-58ko</guid>
      <description>&lt;p&gt;Sooner or later an email arrives: &lt;em&gt;please delete all the data you hold about me&lt;/em&gt;. If your system stores state in tables, it is an afternoon of &lt;code&gt;DELETE&lt;/code&gt; statements and a check of the backups. If it is event sourced, it is a crisis of identity. The whole point of the event store is that nothing in it is ever changed or removed. That is what makes a rebuild honest, an audit trail complete and a bug reproducible. And now the law, Article 17 of the GDPR, says one person’s part of it has to go.&lt;/p&gt;

&lt;p&gt;Teams usually reach for one of two answers, and both are wrong. The first is to rewrite history: find the person’s events and edit them in place. It breaks every promise the log made, it misses every backup and every copy a consumer already took, and it turns an append-only store into one that sometimes isn’t. The second is to decide the log is special and the law does not apply to it. It does.&lt;/p&gt;

&lt;p&gt;There is a third answer, and it starts with a question most teams skip.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does that field belong in the event at all?
&lt;/h2&gt;

&lt;p&gt;An event records a decision. &lt;code&gt;OrderPlaced&lt;/code&gt; is the fact that customer &lt;code&gt;c-42&lt;/code&gt; ordered two items. Their name, their address and their phone number are not part of that fact: they are details the courier needs and the invoice prints. A command handler that copies them into the event because “they were in the command” has made the event the owner of data another part of the system owns.&lt;/p&gt;

&lt;p&gt;So the first tool is not cryptography, it is a boundary. The event carries the id, the details live behind a port whose implementation can delete them, and erasing the person is a &lt;code&gt;DELETE&lt;/code&gt; on that store. Mathias Verraes calls it &lt;a href="https://verraes.net/2019/05/eventsourcing-patterns-forgettable-payloads" rel="noopener noreferrer"&gt;forgettable payloads&lt;/a&gt;. It is enough whenever the data is not needed to explain the past, and it keeps the data out of every copy of the log.&lt;/p&gt;

&lt;p&gt;But sometimes the data &lt;em&gt;is&lt;/em&gt; the decision. &lt;code&gt;AddressChanged&lt;/code&gt; records the address someone moved to.&lt;code&gt;EmailVerified&lt;/code&gt; records which email was verified. A &lt;code&gt;ComplaintFiled&lt;/code&gt; records what the customer wrote. Take the data out and the event means nothing. Those fields stay in the log, and forgetting them has to work on the log itself, in every backup, without rewriting a byte.&lt;/p&gt;

&lt;h2&gt;
  
  
  Throw away the key
&lt;/h2&gt;

&lt;p&gt;That is crypto-shredding. Every person gets a key. Their fields are stored encrypted with it. Forgetting the person is deleting the key: the events stay exactly where they were, and what they held is noise from then on, in the live database and in every copy taken before.&lt;/p&gt;

&lt;p&gt;Here is a customer’s stream before and after:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Position&lt;/th&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;th&gt;Email before&lt;/th&gt;
&lt;th&gt;Email after forget&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;000 118&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CustomerRegistered&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ana@example.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;null&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;000 342&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EmailChanged&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ana@work.example&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;null&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;000 977&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OrderPlaced&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;(no personal field)&lt;/td&gt;
&lt;td&gt;(no personal field)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;001 204&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PersonalDataForgotten&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Nothing above the last row was edited. The positions are the same, the event types are the same, the order still folds and the read models still rebuild. Only the key is gone.&lt;/p&gt;

&lt;p&gt;In &lt;a href="https://docs.bounda.dev/getting-started/" rel="noopener noreferrer"&gt;Bounda&lt;/a&gt;, marking a field is one word in the event’s schema:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/domain/customer/customer-registered.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;personal&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PayloadArgs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kt"&gt;object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;personal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"free"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"pro"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And forgetting someone is one call with their id, here customer &lt;code&gt;c-42&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"c-42"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The &lt;code&gt;null&lt;/code&gt; is the point
&lt;/h2&gt;

&lt;p&gt;The detail I care most about is the type. Whoever &lt;em&gt;writes&lt;/em&gt; the event passes a &lt;code&gt;string&lt;/code&gt;. Whoever_reads_ it, a fold, a projection, a policy, sees &lt;code&gt;string | null&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That &lt;code&gt;null&lt;/code&gt; is not a style choice. It is what makes the compiler walk you to every place that assumed the email would always be there: the projection that copied it into a &lt;code&gt;NOT NULL&lt;/code&gt; column, the fold that lower-cased it, the policy that sends a welcome email. A forgotten person’s events still fold, still project and still replay. They carry a hole where the data was, and your code is forced to have decided what a hole means before the first erasure request arrives, not after.&lt;/p&gt;

&lt;p&gt;Another reasonable choice is to hand back an empty value, so existing code keeps running unchanged. Bounda makes the opposite trade: every place that reads a personal field has to say what a missing one means.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data you copied is the hard part
&lt;/h2&gt;

&lt;p&gt;Deleting the key makes the events unreadable. It does nothing to everything that was built from them, and in an event-sourced system a lot is built from them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Read models.&lt;/strong&gt; The customer directory projected the email into a row.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Snapshots.&lt;/strong&gt; The aggregate’s folded state, cached in the clear so a long stream loads fast.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remembered answers.&lt;/strong&gt; A command sent with an idempotency key stores what it answered, and a rejection’s message may quote the state it was decided on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scheduled work.&lt;/strong&gt; A reminder queued for next week, with the email in its payload.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dead letters.&lt;/strong&gt; A command that gave up, with its payload, kept for an operator.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The outside world.&lt;/strong&gt; The mailing provider got the contact when the welcome email went out.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In Bounda, &lt;code&gt;forget&lt;/code&gt; handles the runtime’s own copies in the same transaction that deletes the key: it removes the snapshots and remembered answers that held the person’s fields, cancels the scheduled commands that carry them and removes their dead letters. Then it appends one more event,&lt;code&gt;PersonalDataForgotten&lt;/code&gt;, to the person’s own stream.&lt;/p&gt;

&lt;p&gt;That event is what the rest of your app needs. A read model that projects a personal field must also project the forgetting, and until it does, the types Bounda generates fail to compile, naming the read model and the file to add:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/read/customer-directory/projections/bounda/personal-data-forgotten.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;project&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Projection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;personId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a policy on the same event is where the outside world gets told: erase the contact at the mailing provider, drop the profile from the search index.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ways to forget
&lt;/h2&gt;

&lt;p&gt;Event stores have settled on three answers, and each fits a different system. As of November 2026:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rewrite the events.&lt;/strong&gt; &lt;a href="https://martendb.io/events/protection" rel="noopener noreferrer"&gt;Marten&lt;/a&gt; masks them: a rule per event type says how to change its members, and running it updates the stored rows. It needs no keys and nothing new in the read path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delete the stream.&lt;/strong&gt; &lt;a href="https://docs.kurrent.io/server/v25.0/features/streams" rel="noopener noreferrer"&gt;KurrentDB&lt;/a&gt; deletes or expires whole streams, and a scavenge later removes them from disk. It fits a design with one stream per person, where everything about them lives in one place.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Throw away the key.&lt;/strong&gt; &lt;a href="https://docs.axoniq.io/axon-framework-reference/5.1/data-protection/" rel="noopener noreferrer"&gt;Axon&lt;/a&gt; encrypts marked fields in its Data Protection module and forgets by deleting the key; a forgotten &lt;code&gt;String&lt;/code&gt; reads as &lt;code&gt;""&lt;/code&gt; by default, so existing code keeps running unchanged. Bounda does the same, built in.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Rewrite&lt;/th&gt;
&lt;th&gt;Delete the stream&lt;/th&gt;
&lt;th&gt;Throw away the key&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The history&lt;/td&gt;
&lt;td&gt;Changes in place&lt;/td&gt;
&lt;td&gt;Loses the stream&lt;/td&gt;
&lt;td&gt;Stays as it was&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A copy taken before&lt;/td&gt;
&lt;td&gt;Keeps the data&lt;/td&gt;
&lt;td&gt;Keeps the data&lt;/td&gt;
&lt;td&gt;Keeps ciphertext, readable only with the key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What it asks of you&lt;/td&gt;
&lt;td&gt;A masking rule per event type&lt;/td&gt;
&lt;td&gt;One stream per person&lt;/td&gt;
&lt;td&gt;A key per person, kept somewhere&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Bounda’s version of the third makes three choices, each with its cost. The keys live in the same database as the events, which keeps a backup and its restore consistent and a test simple, but puts the keys in every backup too. A forgotten value is &lt;code&gt;null&lt;/code&gt; in the data and in the type, which makes the compiler find every reader, and makes every reader handle it. And forgetting is an event, so read models and policies can react to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does not solve
&lt;/h2&gt;

&lt;p&gt;A framework can carry the mechanism. Complying with the law is still the job of whoever processes the data.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Backups keep the keys.&lt;/strong&gt; The keys sit next to the events, so a backup of the database is a copy of the data for as long as the backup is kept.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logs keep what you printed.&lt;/strong&gt; If a handler logged a payload, the log has it in the clear.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What left the system stays out there,&lt;/strong&gt; unless a policy goes and erases it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unmarked fields are plain text.&lt;/strong&gt; The mark is per field per event: forget to mark one and it is readable forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Whether a ciphertext without its key is still personal data&lt;/strong&gt; is a legal question.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  See it happen
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://demo.bounda.dev" rel="noopener noreferrer"&gt;live demo&lt;/a&gt; is a theatre box office running on Bounda, one Durable Object per visitor. Buy a seat, then open the scene called &lt;strong&gt;Forget me&lt;/strong&gt;. Your email disappears from every event on the tape, however old, and from the sales book; your bookings leave the read models; the reminder scheduled for you is cancelled. The stream keeps every position.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://docs.bounda.dev/guides/personal-data/" rel="noopener noreferrer"&gt;personal data guide&lt;/a&gt; has the rest, and &lt;a href="https://docs.bounda.dev/concepts/forgetting-in-an-immutable-log/" rel="noopener noreferrer"&gt;Forgetting in an immutable log&lt;/a&gt; goes deeper into when a port is enough and when it is not.&lt;/p&gt;

</description>
      <category>eventsourcing</category>
      <category>typescript</category>
      <category>gdpr</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
