<?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: Norbert Rosenwinkel</title>
    <description>The latest articles on DEV Community by Norbert Rosenwinkel (@norbertrosenwinkel).</description>
    <link>https://dev.to/norbertrosenwinkel</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%2F1566982%2Fee62eea2-9f08-4bcf-817f-39b55e810bbd.jpeg</url>
      <title>DEV Community: Norbert Rosenwinkel</title>
      <link>https://dev.to/norbertrosenwinkel</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/norbertrosenwinkel"/>
    <language>en</language>
    <item>
      <title>You can't delete an event. GDPR says you must. Crypto-shredding is the truce.</title>
      <dc:creator>Norbert Rosenwinkel</dc:creator>
      <pubDate>Wed, 03 Jun 2026 15:24:11 +0000</pubDate>
      <link>https://dev.to/norbertrosenwinkel/you-cant-delete-an-event-gdpr-says-you-must-crypto-shredding-is-the-truce-26ak</link>
      <guid>https://dev.to/norbertrosenwinkel/you-cant-delete-an-event-gdpr-says-you-must-crypto-shredding-is-the-truce-26ak</guid>
      <description>&lt;h2&gt;
  
  
  Two rules that can't both be true
&lt;/h2&gt;

&lt;p&gt;Event sourcing has one rule: &lt;strong&gt;you never delete.&lt;/strong&gt; You append. The log is the source of truth, and rewriting history is the cardinal sin.&lt;/p&gt;

&lt;p&gt;GDPR Article 17 has one rule too: &lt;strong&gt;when a user asks, you erase their personal data.&lt;/strong&gt; Not "hide it," not "flag it deleted" — erase it, everywhere, including backups.&lt;/p&gt;

&lt;p&gt;Put an event-sourced system in front of a privacy regulator and those two rules collide head-on. The user's name, email, and address are baked into &lt;code&gt;CustomerRegistered&lt;/code&gt;, &lt;code&gt;AddressChanged&lt;/code&gt;, &lt;code&gt;OrderPlaced&lt;/code&gt; — dozens of immutable events, replicated to read models, snapshotted, and sitting in every nightly backup you've ever taken.&lt;/p&gt;

&lt;p&gt;"Just delete the events" breaks event sourcing. "Never delete" breaks the law. Most teams discover this tension &lt;em&gt;after&lt;/em&gt; they've committed to append-only.&lt;/p&gt;

&lt;p&gt;A word on why this isn't academic for me. I build from Germany. Article 17 is EU law — the GDPR, or DSGVO as we call it here — not a German invention, but Germany enforces it about as hard as anywhere in Europe: regional data-protection authorities that issue real fines, and "we were careful" has never been a defense that held up. That pressure is exactly why I wanted erasure to fall out of the architecture instead of being a promise I make to an auditor and then pray I can keep.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "delete the row" doesn't actually erase anything
&lt;/h2&gt;

&lt;p&gt;Say you give in and hard-delete the events for one user. You've still got their data in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;every &lt;strong&gt;read-model projection&lt;/strong&gt; rebuilt from those events,&lt;/li&gt;
&lt;li&gt;every &lt;strong&gt;snapshot&lt;/strong&gt; that rolled them up,&lt;/li&gt;
&lt;li&gt;every &lt;strong&gt;backup&lt;/strong&gt; taken before the deletion,&lt;/li&gt;
&lt;li&gt;every &lt;strong&gt;replica&lt;/strong&gt; and every &lt;strong&gt;export&lt;/strong&gt; that already left the building.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Chasing personal data across all of those, provably, on a 30-day regulatory clock, is a nightmare — and a single missed backup tape means you didn't comply. Physical deletion doesn't scale to a system designed to keep everything forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Crypto-shredding: delete the key, not the data
&lt;/h2&gt;

&lt;p&gt;The trick is to stop trying to delete the data and instead delete the &lt;em&gt;ability to read it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Encrypt each subject's personal data under a key that belongs &lt;strong&gt;only to that subject&lt;/strong&gt;. Keep the ciphertext wherever it lands — events, snapshots, backups, replicas. When the erasure request comes in, you don't hunt down the data. You &lt;strong&gt;destroy the one key.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The instant that key is gone, every copy of that ciphertext — including the ones on backup tapes you can't even reach — turns into undecryptable noise. The bytes still exist; they're just permanently meaningless. That's &lt;strong&gt;crypto-shredding&lt;/strong&gt;, and it's what makes Article 17 erasure architecturally sound instead of a manual scavenger hunt.&lt;/p&gt;

&lt;p&gt;The data model becomes: &lt;em&gt;plaintext is derived, ciphertext is permanent, and recoverability is a property of a key you control.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5xydnk286nfp7s7j0rk0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5xydnk286nfp7s7j0rk0.png" alt=" " width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The tenant-aware twist (so a leak doesn't cascade)
&lt;/h2&gt;

&lt;p&gt;There's a second property worth getting for free here. If you bind the ciphertext to &lt;em&gt;whose&lt;/em&gt; data it is, a leaked key from one tenant can't unlock another's.&lt;/p&gt;

&lt;p&gt;AES-GCM takes &lt;strong&gt;associated data (AAD)&lt;/strong&gt; — bytes that aren't encrypted but &lt;em&gt;must match&lt;/em&gt; at decryption time or the authentication tag fails. Fold the tenant id into the AAD, and a ciphertext encrypted for tenant A simply won't decrypt under tenant B's context — &lt;em&gt;even if the attacker has the right key bytes.&lt;/em&gt; The tenant binding is mathematical, not a &lt;code&gt;WHERE tenant_id = ...&lt;/code&gt; you hope nobody forgets.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqkk2lbxhxvwrhet8zsex.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqkk2lbxhxvwrhet8zsex.png" alt=" " width="800" height="206"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like in code
&lt;/h2&gt;

&lt;p&gt;This is the model &lt;a href="https://github.com/yesbert/Stratara" rel="noopener noreferrer"&gt;Stratara&lt;/a&gt; builds in (a .NET event-sourcing stack), but the technique is framework-agnostic — the pieces are a key store, an AES-GCM encryptor, and a key you can destroy.&lt;/p&gt;

&lt;p&gt;Mark the sensitive fields:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;CustomerRegistered&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;Guid&lt;/span&gt; &lt;span class="n"&gt;CustomerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;property&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;EncryptData&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;FullName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;property&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;EncryptData&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Email&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Encryption happens at the &lt;strong&gt;serialization boundary&lt;/strong&gt; — when the event is written to the store or the bus, not in memory. Your handler reads &lt;code&gt;customer.Email&lt;/code&gt; and gets plaintext; the bytes at rest are sealed.&lt;/p&gt;

&lt;p&gt;A key is addressed by a &lt;strong&gt;scope&lt;/strong&gt; — a sensitivity level optionally narrowed to a tenant and/or user:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;scope&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;KeyScope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DataSensitivityLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TenantScoped&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToString&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The store hands out a versioned, KEK-wrapped data-encryption key per scope (never plaintext at rest). Rotation adds a version and keeps old ciphertext readable. And the erasure request — the whole point — is one call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;keyStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;EraseScopeAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// every ciphertext under this scope is now permanently undecryptable — backups included&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqla8lb9bwe0tgisonmmw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqla8lb9bwe0tgisonmmw.png" alt=" " width="800" height="237"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For larger payloads (attachments, exports) the same scope + a &lt;code&gt;purpose&lt;/code&gt; label bind a whole stream:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;sealedStream&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;blobEncryptor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;EncryptAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;plainStream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;purpose&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"attachment"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What it costs at runtime
&lt;/h2&gt;

&lt;p&gt;The question that always follows "encrypt every sensitive field" is "what does that do to throughput?" Measured: sealing a field with AES-GCM runs around &lt;strong&gt;4 µs per object&lt;/strong&gt; — a few hundred thousand encrypt operations a second on a single core of a &lt;em&gt;fanless&lt;/em&gt; MacBook Air M4, and it parallelizes across them. The number worth knowing is what an object with &lt;em&gt;no&lt;/em&gt; &lt;code&gt;[EncryptData]&lt;/code&gt; fields costs: about &lt;strong&gt;45 nanoseconds&lt;/strong&gt; over plain &lt;code&gt;System.Text.Json&lt;/code&gt;. Routing everything through the secure serializer is essentially free; you only pay for the fields you actually mark. Encryption stays a per-field decision, not a per-system tax.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest limits (because there always are some)
&lt;/h2&gt;

&lt;p&gt;Crypto-shredding is genuinely strong, but it is not magic, and anyone selling it as magic is wrong:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It only shreds what stayed encrypted.&lt;/strong&gt; Personal data that escaped the encrypted boundary — written to a log line, an analytics event, a search index, a CSV someone emailed — is &lt;em&gt;not&lt;/em&gt; under the key and is &lt;em&gt;not&lt;/em&gt; shredded. The boundary is only as good as your discipline about what crosses it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It shreds recoverability, not the bytes.&lt;/strong&gt; If your threat model requires provable physical destruction, crypto-shredding doesn't give you that — it gives you computational irreversibility, which is what regulators actually accept, but know the difference.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A key extracted before the shred is a key forever.&lt;/strong&gt; If an attacker copied the DEK while it was live, destroying it later doesn't help for the data they already grabbed. Crypto-shredding protects against &lt;em&gt;future&lt;/em&gt; reads of &lt;em&gt;retained&lt;/em&gt; ciphertext, not past compromise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Key custody is now the whole ballgame.&lt;/strong&gt; You've moved the problem from "delete data everywhere" to "manage and destroy keys reliably." That's a better problem — it's small and centralized — but it's a real one. The data-encryption keys should themselves be wrapped by a master key (KEK) you keep in an HSM / KMS / vault, and your keystore backups need their own erasure story.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Name those, and crypto-shredding goes from a compliance checkbox to an actual control.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this earns its keep
&lt;/h2&gt;

&lt;p&gt;Beyond GDPR Article 17, the same per-subject-key model underwrites a lot of the compliance surface teams dread:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SOC 2 / ISO 27001&lt;/strong&gt; — demonstrable data-isolation and key-lifecycle controls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HIPAA&lt;/strong&gt; — per-patient cryptographic separation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-tenant SaaS&lt;/strong&gt; — a leaked row from one tenant is useless against another, by construction.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It turns "we'll be careful" into "the storage layer is careful by default."&lt;/p&gt;




&lt;p&gt;The hard part of event sourcing was never the append. It's the two things append-only quietly makes harder — &lt;em&gt;proving nobody rewrote history&lt;/em&gt;, and &lt;em&gt;erasing someone who has the right to be forgotten.&lt;/em&gt; The first is hash-chaining (I wrote that one up separately). The second is crypto-shredding, above.&lt;/p&gt;

&lt;p&gt;The technique drops into any append-only store. If you want it wired — &lt;code&gt;[EncryptData]&lt;/code&gt;, scoped keys, one-call erasure — it's in &lt;a href="https://github.com/yesbert/Stratara" rel="noopener noreferrer"&gt;Stratara&lt;/a&gt;, the .NET stack I maintain; zero-dependency samples in the repo, docs at &lt;a href="https://docs.stratara.tech" rel="noopener noreferrer"&gt;https://docs.stratara.tech&lt;/a&gt;. Source-available under FSL-1.1-MIT (not OSI-approved OSS; flips to plain MIT two years after each release).&lt;/p&gt;

&lt;p&gt;Happy to get torn apart in the comments — especially on the limits. And if the approach earns a place in your toolbox, a star on the repo helps the next person fighting the append-only-vs-Article-17 problem find it.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>security</category>
      <category>eventsourcing</category>
      <category>gdpr</category>
    </item>
    <item>
      <title>AI doesn't fail because the model is bad. It fails because there's nothing underneath it</title>
      <dc:creator>Norbert Rosenwinkel</dc:creator>
      <pubDate>Sun, 31 May 2026 13:17:48 +0000</pubDate>
      <link>https://dev.to/norbertrosenwinkel/ai-doesnt-fail-because-the-model-is-bad-it-fails-because-theres-nothing-underneath-it-1p1g</link>
      <guid>https://dev.to/norbertrosenwinkel/ai-doesnt-fail-because-the-model-is-bad-it-fails-because-theres-nothing-underneath-it-1p1g</guid>
      <description>&lt;p&gt;There's a question every system runs into the moment it goes to production and starts doing real things: &lt;strong&gt;what exactly happened, in what order, against what data — and can you prove it?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;AI is just making that question very loud right now. Picture the case that gets more likely with every tool-using agent: a support agent — not a human, an LLM with tool access — cancels a subscription, issues a refund, fires off three follow-up emails. The next day the customer says: I never cancelled. Now answer the question above.&lt;/p&gt;

&lt;p&gt;In most codebases the honest answer is: you can see the &lt;em&gt;current&lt;/em&gt; state of the database (subscription cancelled), but not the path that got it there. A few log lines the next refactor will overwrite. No reliable record of &lt;em&gt;which&lt;/em&gt; actor acted on behalf of &lt;em&gt;which&lt;/em&gt; customer. And undoing it means hand-writing a correction and hoping you catch every side effect.&lt;/p&gt;

&lt;p&gt;That's not a model problem. GPT wasn't "wrong." The problem sits one layer down.&lt;/p&gt;

&lt;h2&gt;
  
  
  The AI part is now the easy part
&lt;/h2&gt;

&lt;p&gt;Two years ago the model was the hard part. Today you wire up an agent that calls tools, makes plans, and takes actions in ten minutes. The demo looks fantastic — and that's exactly the trap. A demo doesn't move anything real. The moment something &lt;strong&gt;touches real state in production&lt;/strong&gt;, the problems no better prompt will solve show up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;State&lt;/strong&gt;: what was the situation &lt;em&gt;when&lt;/em&gt; the decision was made? A CRUD table only knows &lt;em&gt;now&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;History&lt;/strong&gt;: which steps led to the outcome? Without a record — gone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attribution&lt;/strong&gt;: who or what acted, and authorized by what?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reversibility&lt;/strong&gt;: a wrong action — how do you take it back &lt;em&gt;cleanly&lt;/em&gt;?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trust&lt;/strong&gt;: can someone quietly rewrite the record afterwards?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  This isn't actually an AI problem
&lt;/h2&gt;

&lt;p&gt;And here's the part that matters to me more than all the agent hype: &lt;strong&gt;none of this is new, and none of it is AI-specific.&lt;/strong&gt; A webhook that changes a record at night; a batch job; an admin clicking the wrong button under pressure; a second service writing in over the message queue — they all raise the exact same five questions. State, history, attribution, reversibility, trust are the properties of &lt;em&gt;good software&lt;/em&gt;, full stop.&lt;/p&gt;

&lt;p&gt;AI did just one thing: it took away your excuse. As long as the only actor was a human managing one click a minute, you could muddle through — grep the logs, guess when in doubt. An autonomous agent firing a hundred actions a second doesn't allow muddling through. It just makes the gap that was always there impossible to miss.&lt;/p&gt;

&lt;p&gt;That was the idea behind what I build from the start: &lt;strong&gt;not an AI framework, but a foundation for good software.&lt;/strong&gt; That an agent can run on top is an option — a very current one — but not the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a foundation like that stands on
&lt;/h2&gt;

&lt;p&gt;Nobody &lt;em&gt;needs&lt;/em&gt; a particular pattern to ship a feature — I'd never claim that. But the moment a system seriously manages state, no matter who touches it, these three decisions stop being academic:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. A system of record instead of a snapshot — CQRS + Event Sourcing.&lt;/strong&gt;&lt;br&gt;
Overwriting fields means throwing the past away. Make each change an immutable event instead, and history is &lt;em&gt;built in&lt;/em&gt;, not bolted on. You replay the stream and reconstruct exactly what happened — whether an agent, a job, or a human triggered it. "Undo" becomes a domain-level compensation event instead of a panicked &lt;code&gt;UPDATE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Structure that keeps the volatile at the edge — Clean Architecture + Vertical Slices.&lt;/strong&gt;&lt;br&gt;
Every integration point is restless — a third-party API, a payment provider, and yes, AI code with its weekly-changing prompts and models. Let that seep into the domain core and it rots it. A clean core in the middle, the volatile as an outer layer — and each capability as a vertical slice (command → handler → events → projection). New things get added without tearing open five layers, and without the new toppling the existing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Trust as a property, not a hope — audit, tamper-evidence, encryption, validation, authorization.&lt;/strong&gt;&lt;br&gt;
Any actor allowed to change state needs guardrails &lt;em&gt;the system&lt;/em&gt; enforces. Every action runs as a command that's validated, authorized, and audited — and the audit records &lt;em&gt;who triggered it&lt;/em&gt; separately from &lt;em&gt;whose data it touched&lt;/em&gt;, because with an agent acting on a customer's behalf those are two different identities. The record itself is tamper-evident (hash-chained — rewrite a row after the fact and it shows). And personal data stays encrypted per subject and erasable — because neither "the AI did it" nor "that's just the nightly job" is a free pass against GDPR.&lt;/p&gt;

&lt;p&gt;Only together do the three give an answer to "what happened — and can you prove it?" that an auditor will believe. That holds for your agent. It holds just as much for everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I have to be honest (the limits)
&lt;/h2&gt;

&lt;p&gt;So this doesn't turn into a list of miracle cures: a foundation doesn't make your software &lt;em&gt;correct&lt;/em&gt;. It makes what it does &lt;strong&gt;provable, replayable, and containable&lt;/strong&gt; — which is a different thing.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It records &lt;strong&gt;what&lt;/strong&gt; happened, not &lt;strong&gt;why&lt;/strong&gt; a model decided it. An AI black box stays a black box; you log inputs, actions, results — not the causality inside.&lt;/li&gt;
&lt;li&gt;It doesn't &lt;strong&gt;prevent&lt;/strong&gt; a dumb action. It makes it visible and handleable via compensation — but the email that already went out, no replay brings back.&lt;/li&gt;
&lt;li&gt;It trades "delete data everywhere" for "manage keys and permissions cleanly." Honestly: a better problem — but still a problem.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Build the architecture, not the plumbing
&lt;/h2&gt;

&lt;p&gt;And here's the catch: building this foundation &lt;em&gt;yourself&lt;/em&gt; — event store, command pipeline, outbox, projections, audit, encryption, and all the wiring in between — eats months before you've shipped a single feature. So most teams skip it, ship on CRUD, and hit the can-you-prove-it question later at full force — no AI required.&lt;/p&gt;

&lt;p&gt;I didn't want to pay those months again for every project. So I built the foundation once, cleanly, and lifted it out of our own products: &lt;a href="https://github.com/yesbert/Stratara" rel="noopener noreferrer"&gt;Stratara&lt;/a&gt;, a .NET 10 stack that brings exactly this — CQRS, event sourcing, mediator, outbox, sagas, projections, identity, plus tamper-evident streams and tenant-bound encryption, lockstep-versioned across 22 NuGet packages, à la carte. The idea behind it isn't "AI platform" — it's simply that you build the &lt;em&gt;architecture&lt;/em&gt; of your application, not the plumbing under it. That an agent fits safely on top is a nice side effect of the foundation being right.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fast enough that you actually leave it on
&lt;/h2&gt;

&lt;p&gt;The whole premise was an actor firing a hundred actions a second — so the foundation has to keep up, not buckle under its own audit guarantees. And here's the failure mode nobody admits to: the guarantees are real &lt;em&gt;and&lt;/em&gt; slow, so the first time a load test goes red, someone quietly switches them off. Audit sampling drops to one-in-ten. The projection rebuild moves to a nightly cron instead of running live. The thing that was supposed to make the system provable becomes the thing you disable to hit your p99. That's not a foundation — that's a feature flag waiting to be turned off.&lt;/p&gt;

&lt;p&gt;So the hot paths don't use reflection. Replaying a stream means calling an &lt;code&gt;Apply&lt;/code&gt; method for every event, and the naive way — &lt;code&gt;MethodInfo.Invoke&lt;/code&gt; per event — is exactly the cost that pushes people to cut corners. Instead, each apply-method, projection handler, and constructor is compiled &lt;strong&gt;once&lt;/strong&gt; into a strongly-typed delegate (&lt;code&gt;Expression.Lambda(...).Compile()&lt;/code&gt;) and cached. After that first compile it's a direct call, not a lookup. A compiled property write clocks about &lt;strong&gt;13× faster&lt;/strong&gt; than the reflection equivalent on this machine — and because it runs per event, that gap compounds linearly with stream length.&lt;/p&gt;

&lt;p&gt;The payoff shows up where it matters, in a full replay:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Events replayed&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Allocated&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;td&gt;0.11 ms&lt;/td&gt;
&lt;td&gt;64 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100,000&lt;/td&gt;
&lt;td&gt;1.13 ms&lt;/td&gt;
&lt;td&gt;64 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,000,000&lt;/td&gt;
&lt;td&gt;11.6 ms&lt;/td&gt;
&lt;td&gt;64 B&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A &lt;strong&gt;million events in ~12 ms&lt;/strong&gt; — and, the part I like more, at a &lt;strong&gt;constant 64 bytes&lt;/strong&gt; no matter how long the stream. Replaying a whole history hands the garbage collector essentially nothing to chase. The audit trail you keep for the auditor is the same data structure you replay in single-digit milliseconds for the app. You don't get to pick between provable and fast; you get both or you get neither, and here it's both.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Measured with BenchmarkDotNet on a fanless MacBook Air M4. Read the numbers as ratios, not server absolutes — a cooled box with real airflow moves the absolutes, not the shape. The benchmark project ships in the repo; &lt;code&gt;dotnet run -c Release&lt;/code&gt; reproduces it.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Scale by adding boxes, not by rewriting code
&lt;/h2&gt;

&lt;p&gt;Speed on one core is table stakes. The harder promise — and the one event sourcing usually breaks — is what happens when the hundred-actions-a-second actually arrive, from many actors, all at once.&lt;/p&gt;

&lt;p&gt;The textbook trap is the global lock. To keep one aggregate's events in order, the easy implementation serialises &lt;em&gt;every&lt;/em&gt; write, and now your throughput ceiling is one core no matter how many you bought. Stratara takes a different route, and it's worth walking through, because it &lt;em&gt;is&lt;/em&gt; the scaling story:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Commands don't block the caller.&lt;/strong&gt; The default write path is fire-and-forget: a command goes onto a message bus and returns &lt;code&gt;202 Accepted&lt;/code&gt; immediately, while a worker handles it out-of-process. The request thread never waits on business logic, and a traffic spike buffers in the bus instead of pinning your web tier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Workers compete for the work.&lt;/strong&gt; The bus — RabbitMQ or Azure Service Bus — hands each message to whichever worker is free. Add replicas (more pods, more nodes) and they share the load automatically. No leader to elect, no partitions to reassign by hand. Scaling out is a number in a deployment manifest.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fg48mkinyj620kbzhon96.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fg48mkinyj620kbzhon96.png" alt=" " width="800" height="133"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ordering survives parallelism — through buckets, not locks.&lt;/strong&gt; This is the clever part. Every aggregate id is hashed onto one of &lt;strong&gt;4096 buckets&lt;/strong&gt;, deterministically: the same id always lands in the same bucket. Writes within a bucket serialise through a single-writer lock, so one aggregate's events stay strictly ordered — but &lt;em&gt;different&lt;/em&gt; buckets run fully in parallel. Per-aggregate consistency and cross-aggregate concurrency at the same time, with zero global coordination.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fku5ix2f25azijaoakf39.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fku5ix2f25azijaoakf39.png" alt=" " width="800" height="244"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;4096 is a power of two on purpose (cheap bit-masking instead of a modulo), and every persisted row — events, snapshots, command log, outbox — carries its bucket id and is indexed on it. So the bucket axis isn't only a lock; it's a partition key you can shard the database along too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read models keep up by subscribing, not polling.&lt;/strong&gt; Projections never ask a table "anything new?" on a timer. They subscribe to the bus, and the write path publishes the event bundle the instant it commits. A read model trails its write by a beat, not by a poll interval — and you're not burning idle "is there work yet?" queries when traffic is quiet.&lt;/p&gt;

&lt;p&gt;Put together, scaling stops being an architecture project and becomes an operations one. Command, projection, and saga workers all scale as competing consumers; the one deliberate exception is the tamper-evidence hash worker, which stays single-instance by design — it's appending to a single chain, and you don't want two writers fighting over its head.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the bus drops, nothing waits in line
&lt;/h2&gt;

&lt;p&gt;One more thing the hundred-a-second premise demands: the fast path can't be the &lt;em&gt;only&lt;/em&gt; path, or a broker hiccup loses commands.&lt;/p&gt;

&lt;p&gt;So the dispatcher tries the bus first — the direct, fast publish. &lt;strong&gt;Only&lt;/strong&gt; if the bus is unreachable does the command land in a durable outbox table, where an &lt;code&gt;OutboxWorker&lt;/code&gt; re-publishes it once the bus is back. In the normal case there's no outbox round-trip on the hot path at all; the durable net only engages on failure.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmt83b2ibh6ar7yospel3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmt83b2ibh6ar7yospel3.png" alt=" " width="800" height="135"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Let me be precise, because this is where marketing usually overclaims: the guarantee is &lt;strong&gt;at-least-once&lt;/strong&gt;, not exactly-once. A command can arrive twice — a retry after a crash mid-publish — so handlers are written to be idempotent, and correlation ids make duplicates detectable. "No message silently lost," yes. "Each message exactly once, by magic," no — and anyone selling you the latter without idempotent handlers is selling you something.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is the floor
&lt;/h2&gt;

&lt;p&gt;None of these techniques is mine to claim — reflection-free dispatch, bucketed single-writer locks, push projections, an outbox fallback all predate me, and you could build any of them into your own append-only store. What eats the months is wiring &lt;em&gt;all&lt;/em&gt; of them together cleanly, lockstep-versioned, and keeping them honest under load. That's the part I didn't want to pay for twice. If you're in .NET and you want your next system — with or without an agent on top — standing on something production-grade from day one, this is my floor.&lt;/p&gt;

&lt;p&gt;Runnable, dependency-free samples are in the repo, docs at docs.stratara.tech. Source-available under FSL-1.1-MIT (not OSI-approved OSS; flips to plain MIT two years after each release).&lt;/p&gt;

&lt;p&gt;Reading is one thing, building another. Grab the repo, run a sample, put your own first action on top of it — and if it earns a place in your toolbox, a star helps the next person find it. Bring your ideas for where the foundation could get better, here in the comments or as an issue on GitHub. A foundation doesn't get good because &lt;em&gt;one&lt;/em&gt; person builds it; it gets good because many people use it and push exactly where it still gives. 😉&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>ai</category>
      <category>programming</category>
      <category>eventsourcing</category>
    </item>
    <item>
      <title>Append-only doesn't mean what you'd hope</title>
      <dc:creator>Norbert Rosenwinkel</dc:creator>
      <pubDate>Sat, 30 May 2026 15:51:07 +0000</pubDate>
      <link>https://dev.to/norbertrosenwinkel/append-only-doesnt-mean-what-youd-hope-41go</link>
      <guid>https://dev.to/norbertrosenwinkel/append-only-doesnt-mean-what-youd-hope-41go</guid>
      <description>&lt;p&gt;Event sourcing gets sold on immutability. You don't update, you don't delete, you only append, so the history is permanent.&lt;/p&gt;

&lt;p&gt;It mostly isn't. The events are immutable because your code agrees not to touch them, not because anything actually stops it. Underneath they're still rows in Postgres, and rows have a DBA with write access. A migration that "cleans up" old data. A 2 a.m. query run against the wrong connection. A backup restored with slightly different bytes in it.&lt;/p&gt;

&lt;p&gt;Change one of those rows and a replay won't blink. The aggregate rebuilds, the projections rebuild, everything looks fine. Usually the first person to notice is a customer whose balance is off, and by then the trail is cold.&lt;/p&gt;

&lt;h2&gt;
  
  
  Chain each event into the next
&lt;/h2&gt;

&lt;p&gt;The trick is small. Give every row two extra columns: a hash of its contents, and the hash of the row before it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frx8mi8pgtp1bcd3lctdo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frx8mi8pgtp1bcd3lctdo.png" alt=" "&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The hash is &lt;code&gt;SHA-256(previousHash || json(payload))&lt;/code&gt;. Nothing exotic.&lt;/p&gt;

&lt;p&gt;The point is that each hash depends on the one before it. Edit a payload and its hash stops matching. Rewrite that hash to cover for the edit, and now the next row's pointer is wrong. You can't fix one without breaking the next.&lt;/p&gt;

&lt;h2&gt;
  
  
  About forty lines of it
&lt;/h2&gt;

&lt;p&gt;Appending an event hashes it together with the previous one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;HashChainedEntry&lt;/span&gt; &lt;span class="nf"&gt;Append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;object&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;previousHash&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_entries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Count&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;GenesisHash&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;_entries&lt;/span&gt;&lt;span class="p"&gt;[^&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;Hash&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;hash&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ComputeHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;previousHash&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="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;HashChainedEntry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_entries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Count&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="m"&gt;1&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;previousHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;_entries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;internal&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="nf"&gt;ComputeHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;previousHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;object&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;payloadJson&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;JsonSerializer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SerializeToUtf8Bytes&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;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetType&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;combined&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;previousHash&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Length&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;payloadJson&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Length&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="n"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;BlockCopy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;previousHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;combined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;previousHash&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;BlockCopy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payloadJson&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;combined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;previousHash&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payloadJson&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;SHA256&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;HashData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;combined&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;Verifying is the same thing backwards. Walk the rows, recompute, and check two things on each one: the pointer and the hash.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;previousHash&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;32&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="c1"&gt;// genesis&lt;/span&gt;
&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Entries&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="nf"&gt;ByteArraysEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;previousHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PreviousHash&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;EventStreamCorruptedException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sequence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;"previous-hash pointer does not match the prior entry's hash"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;recomputed&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ComputeHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;previousHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entry&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="nf"&gt;ByteArraysEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;recomputed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Hash&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;EventStreamCorruptedException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sequence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;"stored hash does not match a fresh re-hash of the payload (payload was modified after commit)"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;previousHash&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Hash&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;Bump Alice's $50 deposit to $5,000 straight in the table, and the check stops you cold at the exact row:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Event stream tampering detected at sequence #2: stored hash does not
match a fresh re-hash of the payload (payload was modified after commit)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What that gets you
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Someone tries to…&lt;/th&gt;
&lt;th&gt;…and it shows up because&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Edit one event's payload&lt;/td&gt;
&lt;td&gt;the re-hash no longer matches the stored hash&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rewrite the stored hash to match&lt;/td&gt;
&lt;td&gt;the next row's pointer no longer matches&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delete a row from the middle&lt;/td&gt;
&lt;td&gt;the next row's pointer doesn't match its new neighbour&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slip in a forged row&lt;/td&gt;
&lt;td&gt;same thing, the pointer chain breaks at the seam&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The honest ceiling
&lt;/h2&gt;

&lt;p&gt;Here's the part people gloss over. That table assumes the attacker is lazy: edit a row, move on, leave the stale hash behind. Someone with full write access doesn't have to be lazy. They can edit the row and then recompute every hash after it. Now the chain is consistent again and the verifier has nothing to say.&lt;/p&gt;

&lt;p&gt;A hash chain is a checksum, not a signature. If you own both ends of it, so does anyone who owns your database. That's the honest ceiling of doing this inside your own four walls, and it's worth saying out loud before someone says it for you in the comments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting out of your own walls
&lt;/h2&gt;

&lt;p&gt;This is what anchoring is for, and it's the part I find actually interesting.&lt;/p&gt;

&lt;p&gt;Next to the per-stream chains, Stratara keeps a second table of anchors. Every so many events it writes down the head of the chain at that point. Each anchor row has a &lt;code&gt;BlockchainTxHash&lt;/code&gt; column, and that column is the hook: you take the anchor and commit it somewhere you don't control. A public blockchain. An RFC 3161 timestamp authority. An &lt;a href="https://opentimestamps.org/" rel="noopener noreferrer"&gt;OpenTimestamps&lt;/a&gt; calendar. A notary. Anything you trust that isn't you.&lt;/p&gt;

&lt;p&gt;Once an anchor lives somewhere out of your reach, the recompute attack falls apart. Your insider can rewrite every hash in the database and still can't touch the value you already pinned elsewhere. The question stops being "is this chain internally consistent" and becomes "does it still match what we committed outside." That second one is much harder to fake.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F78ipr0t94yws09abnwki.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F78ipr0t94yws09abnwki.png" alt=" "&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Let me be straight about what ships versus what you wire yourself. The anchor table, the worker that writes anchors, and the &lt;code&gt;BlockchainTxHash&lt;/code&gt; column are in the box. Actually pushing an anchor to your source of truth, and checking against it later, is the part you wire up. Stratara doesn't pick the chain for you, the same way it doesn't pick your message broker. The sample at the end runs the whole thing in memory so you can see the shape of it.&lt;/p&gt;

&lt;p&gt;One caveat, said plainly: if someone owns your database &lt;em&gt;and&lt;/em&gt; your anchoring pipeline, they can re-chain and re-anchor and it'll all look fine. The defense only holds if the thing you anchor to is genuinely out of their hands. That's the entire reason to put it outside.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the hashing happens, and where verifying does
&lt;/h2&gt;

&lt;p&gt;The hashing runs on a background worker, not inline on every append, so writes stay cheap. And cheap is measured, not hoped: hashing a typical event takes well under a microsecond — around 700 ns on a &lt;em&gt;fanless&lt;/em&gt; MacBook Air M4, riding the chip's hardware-accelerated SHA-256 — so the chain worker stays comfortably ahead of a brisk write rate. The chain gets filled in a beat behind the commit. Verifying is a separate thing you do on purpose: a scheduled job, or checking the external anchor. You don't want it on the read path, because that's a &lt;code&gt;SELECT … ORDER BY Sequence&lt;/code&gt; on every query and it ties each read to the integrity check.&lt;/p&gt;

&lt;p&gt;Worth being straight about: nothing in the framework wakes up and hunts for tampering on its own today. The hashes and the anchors are there so that &lt;em&gt;when&lt;/em&gt; you verify — on a schedule, during an audit, after an incident — the evidence is intact and a break lands on the exact row. For a SOC 2 or ISO 27001 audit, the worker's structured logs are the running record that the hashing happened across the period; the verification job is what proves the chain held.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this lives
&lt;/h2&gt;

&lt;p&gt;I build &lt;a href="https://github.com/yesbert/Stratara" rel="noopener noreferrer"&gt;Stratara&lt;/a&gt;, a CQRS and event-sourcing stack for .NET 10. The chaining is the &lt;code&gt;EventStreamHashing&lt;/code&gt; worker, running against Postgres. None of the idea is Stratara-specific though. If you've got an append-only table, you can bolt this on yourself.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/yesbert/Stratara/tree/main/samples/Stratara.Sample.TamperProof" rel="noopener noreferrer"&gt;&lt;code&gt;TamperProof&lt;/code&gt; sample&lt;/a&gt; is the whole story in zero-dependency, in-memory code, in three acts: a clean chain that verifies, a sloppy tamper caught at the exact row, and a full re-chain that sails past the local check but gets caught by an external anchor.&lt;/p&gt;

&lt;p&gt;Wiring it into a real app is more than one &lt;code&gt;dotnet add&lt;/code&gt; — you need the event store, the hashing worker, and a little DI — so the &lt;a href="https://docs.stratara.tech/getting-started/" rel="noopener noreferrer"&gt;getting-started guide&lt;/a&gt; walks the minimal setup. Full docs are at &lt;a href="https://docs.stratara.tech" rel="noopener noreferrer"&gt;https://docs.stratara.tech&lt;/a&gt;, and it's source-available under FSL-1.1-MIT (not OSI-approved OSS), which flips to plain MIT after two years.&lt;/p&gt;

&lt;p&gt;This is just one slice of Stratara, and honestly the easiest to show off. There's plenty more I want to write up — the tenant-aware encryption side especially, where a tenant's data is cryptographically bound to their own key — without cramming it all into one wall of text. So if this was your kind of thing, stick around: more coming.&lt;/p&gt;

&lt;p&gt;If you're already event sourcing: how would you actually prove to an auditor that nobody's touched the log? Genuinely curious what people are doing here.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>eventsourcing</category>
      <category>security</category>
    </item>
  </channel>
</rss>
