<?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.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>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 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>
