<?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: Miguel Shinyenyi</title>
    <description>The latest articles on DEV Community by Miguel Shinyenyi (@miguel_shinyenyi_e2291c8c).</description>
    <link>https://dev.to/miguel_shinyenyi_e2291c8c</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%2F2292980%2F4251fa04-8b57-4cec-bc4a-88c1a8ac7a2a.jpg</url>
      <title>DEV Community: Miguel Shinyenyi</title>
      <link>https://dev.to/miguel_shinyenyi_e2291c8c</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/miguel_shinyenyi_e2291c8c"/>
    <language>en</language>
    <item>
      <title>What Becomes True When Both Stay</title>
      <dc:creator>Miguel Shinyenyi</dc:creator>
      <pubDate>Tue, 29 Sep 2026 22:54:22 +0000</pubDate>
      <link>https://dev.to/miguel_shinyenyi_e2291c8c/what-becomes-true-when-both-stay-11k2</link>
      <guid>https://dev.to/miguel_shinyenyi_e2291c8c/what-becomes-true-when-both-stay-11k2</guid>
      <description>&lt;h2&gt;
  
  
  The curtain of silence
&lt;/h2&gt;

&lt;p&gt;Sapiens describes the evidence for prehistoric life as a Rorschach test. A burial site with&lt;br&gt;
ornaments on a skeleton might mean hierarchy. It might mean something else entirely. A&lt;br&gt;
fracture on an ancient bone might mean violence. It might not. The book calls this its&lt;br&gt;
Curtain of Silence, evidence thin enough that even careful scholarship can't get a clean&lt;br&gt;
answer through it. Scholars keep asking anyway, because the past still shaped what came&lt;br&gt;
after it, but the honest version of that inquiry holds its answers loosely.&lt;/p&gt;

&lt;p&gt;That's a claim about archaeology. It turned out to be useful somewhere closer to home.&lt;/p&gt;
&lt;h2&gt;
  
  
  Two truths instead of one
&lt;/h2&gt;

&lt;p&gt;Dialectical thinking, how my mind works?,&lt;br&gt;
it generates a position and its counter-argument at the same time. Useful when pointed&lt;br&gt;
outward. Corrosive when pointed inward, because the counter-argument doesn't sit next to the&lt;br&gt;
first position, it tries to erase it. Grateful for the past, but it cost me something real,&lt;br&gt;
failing a job interview among the costs. Forgiving someone, but not reconciled with them.&lt;br&gt;
The old move was picking one of those as the real feeling and discarding the other as noise.&lt;/p&gt;

&lt;p&gt;The question that actually helps isn't which one is true. Both are. The question is what&lt;br&gt;
becomes true if I stop forcing a choice between them.&lt;/p&gt;
&lt;h2&gt;
  
  
  A system that already does this
&lt;/h2&gt;

&lt;p&gt;The settlement engine I built has a state called&lt;br&gt;
&lt;code&gt;UNKNOWN&lt;/code&gt;. It's not a bug, it's a deliberate third option, used when a payment might have&lt;br&gt;
gone through or might not have, and the system genuinely doesn't have enough information yet&lt;br&gt;
to say which.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Reused across three different situations: a real gateway failure,&lt;/span&gt;
&lt;span class="c1"&gt;// exhausted retries after a database deadlock, and a hard crash that&lt;/span&gt;
&lt;span class="c1"&gt;// left a settlement stuck with no way to confirm what happened.&lt;/span&gt;
&lt;span class="n"&gt;settlementTransactions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;finalizeSettlement&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;settlementId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;GatewayResult&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SettlementOutcome&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;UNKNOWN&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tempting design is to force an answer immediately: retry until you get a clean yes or no,&lt;br&gt;
because "unknown" feels like a failure to decide. The actual design holds &lt;code&gt;UNKNOWN&lt;/code&gt; as a&lt;br&gt;
first-class state, moves on, and lets a separate reconciliation process resolve it later,&lt;br&gt;
once there's real information to resolve it with. Forcing a premature CONFIRMED or FAILED&lt;br&gt;
would be lying to the rest of the system, papering over uncertainty that was still genuinely&lt;br&gt;
open. The honest version of "I don't know yet" turns out to be more useful than a fast, fake&lt;br&gt;
certainty. That's the same shape as forgiving without reconciling. Not every open state needs&lt;br&gt;
collapsing into a resolved one on a deadline. Some of them get resolved later, correctly,&lt;br&gt;
once there's enough to resolve them with. Some of them are just allowed to be two things at&lt;br&gt;
once, permanently, the way gratitude and grief can both be true about the same period without&lt;br&gt;
either one cancelling the other.&lt;/p&gt;
&lt;h2&gt;
  
  
  Where this gets tested, not just described
&lt;/h2&gt;

&lt;p&gt;I've also been carrying a specific belief:&lt;br&gt;
that my anxiety comes from a need to control everything. It's a real, documented idea in&lt;br&gt;
general, intolerance of uncertainty, anxiety tracking unpredictability rather than actual&lt;br&gt;
danger, controlling behavior as an attempt to shrink that unpredictability back down. But a&lt;br&gt;
documented general pattern isn't the same as a verified account of one specific person, and&lt;br&gt;
right now I don't have an instance attached to it, just the label.&lt;/p&gt;

&lt;p&gt;There's a code parallel here too, and it's not a flattering one. The same idempotency work&lt;br&gt;
this week turned up two concurrency races, both caused by the system trying to guarantee&lt;br&gt;
too much certainty upfront.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Two requests, same key, same instant.

Option A: lock everything before
either proceeds. Force certainty
immediately.

Option B: let both proceed. The
database's unique constraint decides
who wins. The loser recovers by
reading the winner's result.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The actual code takes option B. Locking everything upfront to force immediate certainty is&lt;br&gt;
exactly what produces a deadlock later, two transactions each holding what the other one&lt;br&gt;
wants, both stuck, because the system tried to control too much of the outcome up front&lt;br&gt;
instead of accepting a controlled version of not-knowing-yet and resolving it after the&lt;br&gt;
fact. The fix that shipped this week for the crash-recovery gap follows the same shape: not&lt;br&gt;
tighter control at the moment of failure, a sweep that runs later, accepts the outcome was&lt;br&gt;
genuinely unknown for a while, and resolves it once enough time has passed to trust the&lt;br&gt;
answer.&lt;/p&gt;

&lt;p&gt;I don't know yet whether "addiction to control" is actually what's driving the anxiety, or&lt;br&gt;
just the label that was closest at hand when I needed one. That's not resolved in this&lt;br&gt;
piece, on purpose. If forcing a system to decide before it has enough information produces a&lt;br&gt;
deadlock, forcing a belief about myself into a tidy answer before I've actually tested it&lt;br&gt;
probably isn't any more reliable. The honest state to hold it in, for now, is &lt;code&gt;UNKNOWN&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took from it
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Two things can both be true about the same period. Stop asking which one is real.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;UNKNOWN&lt;/code&gt; is an honest state. A fast, fake certainty is worse than a slow, correct answer.&lt;/li&gt;
&lt;li&gt;Forcing certainty upfront is what caused the deadlocks. That applies to beliefs about
myself too.&lt;/li&gt;
&lt;li&gt;"My anxiety comes from control" stays a hypothesis until a real instance tests it.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;Originally published on &lt;a href="https://miguel-shinyenyi.github.io/miguel-site/philosophy/what-becomes-true-when-both-stay/" rel="noopener noreferrer"&gt;my site&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>sapiens</category>
      <category>selfobservation</category>
      <category>concurrency</category>
    </item>
    <item>
      <title>Knowing and being able to explain are not the same skill</title>
      <dc:creator>Miguel Shinyenyi</dc:creator>
      <pubDate>Tue, 29 Sep 2026 22:53:50 +0000</pubDate>
      <link>https://dev.to/miguel_shinyenyi_e2291c8c/knowing-and-being-able-to-explain-are-not-the-same-skill-4bng</link>
      <guid>https://dev.to/miguel_shinyenyi_e2291c8c/knowing-and-being-able-to-explain-are-not-the-same-skill-4bng</guid>
      <description>&lt;h2&gt;
  
  
  Two different skills
&lt;/h2&gt;

&lt;p&gt;There's a difference between recognizing the shape of a solution and being able to derive it&lt;br&gt;
without the shape already in front of you. The first is pattern matching. The second is&lt;br&gt;
understanding. Most technical education optimizes for the first, because it's faster to teach&lt;br&gt;
and easier to test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the gap shows
&lt;/h2&gt;

&lt;p&gt;The gap only shows up when someone asks "why does it work that way" instead of "does it work."&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took from it
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Recognizing a solution and deriving it are separate skills, and practicing one doesn't
train the other.&lt;/li&gt;
&lt;li&gt;"Does it work?" tests recognition. "Why does it work that way?" tests understanding. Ask
myself the second one.&lt;/li&gt;
&lt;li&gt;A gap that only appears under a specific question is worth testing directly, not assuming.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;Originally published on &lt;a href="https://miguel-shinyenyi.github.io/miguel-site/philosophy/knowing-vs-explaining/" rel="noopener noreferrer"&gt;my site&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>learning</category>
      <category>firstprinciples</category>
    </item>
    <item>
      <title>Claim, Argument, and the Discipline of Not Fooling Yourself</title>
      <dc:creator>Miguel Shinyenyi</dc:creator>
      <pubDate>Tue, 29 Sep 2026 22:53:19 +0000</pubDate>
      <link>https://dev.to/miguel_shinyenyi_e2291c8c/claim-argument-and-the-discipline-of-not-fooling-yourself-33nm</link>
      <guid>https://dev.to/miguel_shinyenyi_e2291c8c/claim-argument-and-the-discipline-of-not-fooling-yourself-33nm</guid>
      <description>&lt;h2&gt;
  
  
  The trap
&lt;/h2&gt;

&lt;p&gt;I did a set of exercises this morning to raise my energy, then showered, because I've learned&lt;br&gt;
I need to feel physically settled before my mind is any good to me. Only afterward, reading a&lt;br&gt;
chapter about hunter-gatherer instincts colliding with modern life, did I have language for&lt;br&gt;
why. The insight came after the action, not before it. That ordering turned out to matter&lt;br&gt;
more than the insight itself.&lt;/p&gt;

&lt;p&gt;Here's the trap I nearly walked into. I read a compelling argument, that a mismatch between&lt;br&gt;
old wiring and new environments explains a lot of modern unease, and I started treating it as&lt;br&gt;
settled. Not because the evidence demanded it, but because the writing was good. A well-made&lt;br&gt;
argument and a demonstrated fact produce the same feeling of certainty in a reader. That&lt;br&gt;
feeling is not evidence. It's craft.&lt;/p&gt;

&lt;h2&gt;
  
  
  The distinction that actually matters
&lt;/h2&gt;

&lt;p&gt;A claim is something shown to be true, checked against reality, falsifiable and unfalsified.&lt;br&gt;
An argument is something &lt;em&gt;made&lt;/em&gt; to be believed, structured, sequenced, persuasive by design.&lt;br&gt;
Good nonfiction is full of arguments dressed in the confidence of claims, and the better the&lt;br&gt;
writing, the harder that becomes to notice. The specific idea I was reading, that suppressed&lt;br&gt;
movement contributes to depression, is a real argument worth taking seriously. It is not a&lt;br&gt;
settled account of what causes depression, which research treats as a tangle of contributing&lt;br&gt;
factors, not a single mechanism. Losing that distinction wouldn't just cost me an accurate&lt;br&gt;
view of one book. It's the kind of thing that quietly reshapes how you read your own career&lt;br&gt;
decisions, your own dissatisfaction, your own choices, once a persuasive frame is sitting&lt;br&gt;
unexamined in the back of your head.&lt;/p&gt;

&lt;p&gt;There's a second version of the same trap, closer in: I'd summarized what I'd read, in my own&lt;br&gt;
words, and then treated my own summary as if it were a faithful account of the source. It's&lt;br&gt;
entirely possible I misread the chapter. My understanding of an idea and a verified account of&lt;br&gt;
that idea are not the same thing, and conflating them means any misreading I make gets&lt;br&gt;
laundered into something that looks like the author's authority instead of my own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I'd already been doing this right
&lt;/h2&gt;

&lt;p&gt;The clearest working example of separating claim from argument that I have access to isn't&lt;br&gt;
philosophical at all. It's a settlement-engine system I built myself, with Claude Code, and&lt;br&gt;
am now restudying deliberately, not to relearn it from scratch, but to make sure I actually&lt;br&gt;
know it well enough to explain it, in passing or in detail, on demand. That gap, between&lt;br&gt;
having built something and being able to explain it under pressure, is one I've caught in&lt;br&gt;
myself before. This restudy is a direct test of it.&lt;/p&gt;

&lt;p&gt;What I found worth studying closely is a documentation habit built into the project itself: a&lt;br&gt;
decisions log that doesn't just record what was decided, it records &lt;em&gt;why&lt;/em&gt;, one sentence per&lt;br&gt;
decision, stated plainly enough to be checked. "Foreign key validation failures return 400,&lt;br&gt;
not 404, because the bad ID is client input error, not a missing resource being fetched&lt;br&gt;
directly." That's not a conclusion asserted with confidence. That's a reasoned claim, sitting&lt;br&gt;
next to the reasoning that produced it, inviting disagreement rather than discouraging it.&lt;/p&gt;

&lt;p&gt;Compare that to a decisions log that just says "we chose 400." Both read as authoritative if&lt;br&gt;
you don't look closely. Only one of them actually lets you check the reasoning instead of&lt;br&gt;
just trusting the confidence of the person who wrote it. That's the whole trick. Confidence is&lt;br&gt;
cheap. Showing your work is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this looks like day to day
&lt;/h2&gt;

&lt;p&gt;In reading: hold two separate questions for anything persuasive. What is being claimed, and&lt;br&gt;
what is being argued for. Notice which one produced your certainty. If it's the second, the&lt;br&gt;
certainty is borrowed from the writing, not earned from the evidence.&lt;/p&gt;

&lt;p&gt;In relationships and self-observation: distinguish what happened from what you concluded it&lt;br&gt;
meant. A single explanation that fits doesn't mean it's the only one, or the right one, it&lt;br&gt;
means it's plausible enough to feel finished. Plausible and finished are not the same&lt;br&gt;
condition.&lt;/p&gt;

&lt;p&gt;In code, and in any documentation that describes a system: treat a decisions log entry, a&lt;br&gt;
comment, or a design doc the way you'd treat a persuasive book, with the same two questions.&lt;br&gt;
Does this line state a checkable reason, or does it just assert a conclusion with confidence?&lt;br&gt;
A codebase full of confident assertions with no reasoning attached is tunnel vision waiting to&lt;br&gt;
happen, for whoever reads it next, including the person who wrote it, six months later,&lt;br&gt;
having forgotten why. That's precisely the risk I'm restudying my own system to guard&lt;br&gt;
against, having built it doesn't guarantee I could still explain it cold.&lt;/p&gt;

&lt;p&gt;The actual discipline isn't suspicion of everything. It's narrower than that: know which of&lt;br&gt;
your beliefs are standing on demonstrated ground and which are standing on good writing, good&lt;br&gt;
confidence, or your own possibly-mistaken summary of something else. Most of the time you can&lt;br&gt;
hold both kinds of belief at once, loosely, without needing to resolve which is which&lt;br&gt;
immediately. The problem only starts when you stop checking.&lt;/p&gt;

&lt;h2&gt;
  
  
  No tidy ending
&lt;/h2&gt;

&lt;p&gt;I don't have a tidy ending for this one, and I'm treating that as correct rather than&lt;br&gt;
unfinished. The whole point was to stop mistaking a well-argued position for a demonstrated&lt;br&gt;
one. Reaching a neat conclusion here would be exactly the thing I just spent this piece&lt;br&gt;
arguing against doing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took from it
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Certainty from good writing feels the same as certainty from evidence. Check which one I'm
standing on.&lt;/li&gt;
&lt;li&gt;My own summary of a source is not the source. Mark it as mine.&lt;/li&gt;
&lt;li&gt;A decision is only checkable when its reason is written next to it, in code and in life.&lt;/li&gt;
&lt;li&gt;Not every belief needs resolving now. The problem starts when I stop checking.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;Originally published on &lt;a href="https://miguel-shinyenyi.github.io/miguel-site/philosophy/claim-versus-argument/" rel="noopener noreferrer"&gt;my site&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>reading</category>
      <category>reasoning</category>
      <category>documentation</category>
    </item>
    <item>
      <title>A Mind That Lies to You Too</title>
      <dc:creator>Miguel Shinyenyi</dc:creator>
      <pubDate>Tue, 29 Sep 2026 22:52:46 +0000</pubDate>
      <link>https://dev.to/miguel_shinyenyi_e2291c8c/a-mind-that-lies-to-you-too-1h69</link>
      <guid>https://dev.to/miguel_shinyenyi_e2291c8c/a-mind-that-lies-to-you-too-1h69</guid>
      <description>&lt;h2&gt;
  
  
  What sapiens traded time for
&lt;/h2&gt;

&lt;p&gt;Every other species that reached a new environment got there the slow way: fins, blubber, a&lt;br&gt;
nose built as a snorkel, millions of years of the body itself changing to fit the place. When&lt;br&gt;
sapiens reached Australia, the body that arrived was the same one that had walked out of&lt;br&gt;
Africa. No gills, no fur suited to a new climate. What made the crossing possible was a mind&lt;br&gt;
good enough to understand the sea and build something that could cross it, in one lifetime,&lt;br&gt;
not a species-length of them.&lt;/p&gt;

&lt;p&gt;That's a real trade, not a free upgrade. Every other animal is capped by what its body allows&lt;br&gt;
it to take from an environment. A mind with a plan has no such cap, and it showed almost&lt;br&gt;
immediately. Sapiens gives the number: 23 of 24 Australian animal species over 50kg were gone&lt;br&gt;
within a few thousand years of that crossing. The local megafauna had never seen anything&lt;br&gt;
like this arrival and had no instinct to fear it. In Africa, animals evolved alongside early&lt;br&gt;
humans for close to two million years and learned caution the slow way. Everywhere sapiens&lt;br&gt;
reached quickly instead, that warning never had time to happen.&lt;/p&gt;

&lt;p&gt;The same trade explains something closer to us than a wombat the size of a hippo. Before&lt;br&gt;
sapiens spread, several other human species were alive at the same time, Neanderthals,&lt;br&gt;
Homo erectus, others. Sapiens is the only one left. Whatever let a mind out-hunt giant&lt;br&gt;
kangaroos also let it outcompete or eliminate every other kind of human. The mind that solved&lt;br&gt;
the sea didn't stop at animals.&lt;/p&gt;
&lt;h2&gt;
  
  
  The same tool turns on you
&lt;/h2&gt;

&lt;p&gt;Here's the part that doesn't stay in the past. A mind capable of outthinking millions of&lt;br&gt;
years of evolution is not automatically trustworthy about itself. The same faculty that plans&lt;br&gt;
a boat also generates a justification for whatever you've already decided, and does it just&lt;br&gt;
as fluently. It doesn't announce which mode it's in. A conclusion produced by looking at&lt;br&gt;
evidence and a conclusion produced by protecting a belief you're attached to feel identical&lt;br&gt;
from the inside.&lt;/p&gt;

&lt;p&gt;That's not a flaw sapiens escaped by getting smarter. It's the same trade, still running. A&lt;br&gt;
mind good enough to reshape a continent is also good enough to reshape your own account of&lt;br&gt;
yourself, and it will do that for free, without being asked, unless something is actually&lt;br&gt;
checking it.&lt;/p&gt;
&lt;h2&gt;
  
  
  A short list for checking it
&lt;/h2&gt;

&lt;p&gt;I've been trying to internalize a set of tools for exactly that, and grouping them, not as&lt;br&gt;
ten separate items but as four jobs:&lt;/p&gt;

&lt;p&gt;Catching a thought as it forms, and catching the pattern behind thoughts that keep repeating.&lt;br&gt;
One is metacognition, the other is naming your own bias, and they're the same muscle aimed at&lt;br&gt;
two grain sizes.&lt;/p&gt;

&lt;p&gt;A test to apply once something's been caught: what evidence would actually prove this wrong?&lt;br&gt;
If the honest answer is nothing, it's stopped being a belief and become something being&lt;br&gt;
protected instead.&lt;/p&gt;

&lt;p&gt;The same discipline aimed at feelings instead of thoughts: a feeling is real, but it isn't&lt;br&gt;
automatically information about the world. "This feels like it will fail" is a fact about&lt;br&gt;
right now, not a fact about the plan.&lt;/p&gt;

&lt;p&gt;And a decision-making set for anything aimed outward: every choice costs the other choices&lt;br&gt;
you didn't make, small repeated choices compound into large outcomes over time, and a good&lt;br&gt;
decision can still produce a bad outcome, so judge the decision, not just how it turned out.&lt;/p&gt;

&lt;p&gt;I don't know yet whether these four groupings hold up as anything more than a convenient way&lt;br&gt;
to remember ten words. That's not settled here on purpose. It's a framework being tried, not&lt;br&gt;
a conclusion being reported.&lt;/p&gt;
&lt;h2&gt;
  
  
  One place it got tested this week
&lt;/h2&gt;

&lt;p&gt;The clearest test so far wasn't abstract. I explained the ledger in this system's settlement&lt;br&gt;
engine cold, described it as checked double-entry bookkeeping, the kind where a balance is&lt;br&gt;
provable, not just trusted. Then I looked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;LedgerEntryRepository&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;JpaRepository&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;LedgerEntry&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;UUID&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing in the actual code ever read those entries back. The check I'd described as already&lt;br&gt;
existing didn't exist. That's falsifiability doing its job in real time, not as a concept I&lt;br&gt;
was reading about but as a belief about my own work that had a real, checkable answer, and&lt;br&gt;
the answer was no. The honest move afterward wasn't to feel bad about being wrong. It was to&lt;br&gt;
notice that the explanation had felt just as confident either way, right or wrong, which is&lt;br&gt;
exactly the warning the concept is supposed to be. Confidence was never the signal. Whether&lt;br&gt;
there was a way to be proven wrong was.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took from it
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;A mind good enough to outthink evolution isn't automatically trustworthy about itself.
That's the same trade, not a different one.&lt;/li&gt;
&lt;li&gt;Confidence in an explanation isn't evidence for it. Whether it could be proven wrong is.&lt;/li&gt;
&lt;li&gt;The four groupings are a framework being tested, not a conclusion. One real test this week
went the way the framework predicts; that's one data point, not a pattern yet.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;Originally published on &lt;a href="https://miguel-shinyenyi.github.io/miguel-site/philosophy/a-mind-that-lies-to-you-too/" rel="noopener noreferrer"&gt;my site&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>sapiens</category>
      <category>cognitivebias</category>
      <category>selfobservation</category>
    </item>
    <item>
      <title>The Outbox Held, the Ledger Didn't</title>
      <dc:creator>Miguel Shinyenyi</dc:creator>
      <pubDate>Tue, 29 Sep 2026 22:42:43 +0000</pubDate>
      <link>https://dev.to/miguel_shinyenyi_e2291c8c/the-outbox-held-the-ledger-didnt-k7g</link>
      <guid>https://dev.to/miguel_shinyenyi_e2291c8c/the-outbox-held-the-ledger-didnt-k7g</guid>
      <description>&lt;h2&gt;
  
  
  Explain it cold, then check
&lt;/h2&gt;

&lt;p&gt;Same method as idempotency: explain the mechanism from memory, then check it against the&lt;br&gt;
actual code, not the other way around. The point isn't to be right on the first try. It's&lt;br&gt;
that a claim about your own system should be falsifiable. If I can't say what I'd expect to&lt;br&gt;
find in the code that would prove my explanation wrong, I'm not really explaining it, I'm&lt;br&gt;
just repeating a shape that sounds right.&lt;/p&gt;

&lt;p&gt;Two subsystems, two different results. The outbox explanation held up once checked. The&lt;br&gt;
ledger explanation didn't, not because the mechanism I described was wrong, but because the&lt;br&gt;
part of it I assumed existed simply wasn't built yet.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the outbox is actually for
&lt;/h2&gt;

&lt;p&gt;The instinct is to describe it as a receipt: proof you sent something, so you can resend it&lt;br&gt;
if it doesn't land. That's close, but backward. The real problem is that a database commit&lt;br&gt;
and a message published to Kafka are two separate systems, and you can't make them one atomic&lt;br&gt;
operation. Publish first, then save to the database, and a crash in between means you told&lt;br&gt;
the world something happened that your own database never recorded. Save first, then&lt;br&gt;
publish, and a crash in between means your database says it happened but nobody downstream&lt;br&gt;
ever hears about it. Either order leaves a gap where the two systems can disagree.&lt;/p&gt;

&lt;p&gt;The fix is to make the event write local, so it can share a transaction with the fact it's&lt;br&gt;
describing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Transactional&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;SettlementExecutionRequest&lt;/span&gt; &lt;span class="nf"&gt;createPendingSettlement&lt;/span&gt;&lt;span class="o"&gt;(...)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="o"&gt;...&lt;/span&gt;
    &lt;span class="n"&gt;settlementRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;settlement&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;outboxWriter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;write&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;AGGREGATE_TYPE_SETTLEMENT&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;settlementId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;KafkaTopics&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SETTLEMENT_REQUESTED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;SettlementRequestedEvent&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;settlementId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
                    &lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="o"&gt;()));&lt;/span&gt;
    &lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same transaction, same database. The settlement and the event to announce it commit together&lt;br&gt;
or not at all. A separate process then polls for unpublished rows and sends them on, only&lt;br&gt;
marking a row done once the broker actually confirms it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;kafkaTemplate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;send&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getTopic&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAggregateId&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;toString&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getPayload&lt;/span&gt;&lt;span class="o"&gt;()).&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;markPublished&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that send fails, the row just sits there, unpublished, and gets tried again next poll.&lt;br&gt;
What this buys isn't proof after the fact. It's a guarantee that the event and the thing it&lt;br&gt;
describes can never come apart: if the settlement never happened, no event exists to send. If&lt;br&gt;
it did happen, the event goes out eventually, whatever crashes in between.&lt;/p&gt;
&lt;h2&gt;
  
  
  What double-entry is supposed to guarantee, and what wasn't there
&lt;/h2&gt;

&lt;p&gt;Double-entry means every transaction writes two sides, a debit somewhere and a credit&lt;br&gt;
somewhere else, and across any transaction the two must net to zero. The point of doing it&lt;br&gt;
this way is that a balance stops being something you just trust. It becomes something you can&lt;br&gt;
prove, by summing the entries that produced it.&lt;/p&gt;

&lt;p&gt;The code writes both sides correctly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;debit&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;settlement&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAmount&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;credit&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;settlement&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAmount&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="n"&gt;ledgerEntryRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;LedgerEntry&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;UUID&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;randomUUID&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;settlementId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
        &lt;span class="nc"&gt;EntryType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;DEBIT&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;settlement&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAmount&lt;/span&gt;&lt;span class="o"&gt;()));&lt;/span&gt;
&lt;span class="n"&gt;ledgerEntryRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;LedgerEntry&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;UUID&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;randomUUID&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;settlementId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
        &lt;span class="nc"&gt;EntryType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CREDIT&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;settlement&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAmount&lt;/span&gt;&lt;span class="o"&gt;()));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But &lt;code&gt;balance&lt;/code&gt; is also a stored column, mutated directly by &lt;code&gt;debit()&lt;/code&gt;/&lt;code&gt;credit()&lt;/code&gt; in that same&lt;br&gt;
method. And this is the whole repository interface for the entries just written:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;LedgerEntryRepository&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;JpaRepository&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;LedgerEntry&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="no"&gt;UUID&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No query beyond what comes free. Nothing anywhere sums those entries back and checks them&lt;br&gt;
against the balance. The entries were being written correctly and never read. A ledger that&lt;br&gt;
nobody checks isn't really double-entry, it's an audit trail sitting next to a number&lt;br&gt;
everyone just trusts.&lt;/p&gt;
&lt;h2&gt;
  
  
  Deciding what happens when it's wrong
&lt;/h2&gt;

&lt;p&gt;Finding the gap is the easy part. The harder question is what a mismatch should actually do&lt;br&gt;
once the system can see one.&lt;/p&gt;

&lt;p&gt;The tempting answer is to auto-correct: recompute the balance from the entries and overwrite&lt;br&gt;
it. That's the wrong incentive to build into the system. A mismatch means you don't yet know&lt;br&gt;
which number is wrong, the stored balance or an entry. Auto-fixing assumes an answer you&lt;br&gt;
don't have, and it means a real bug gets silently absorbed instead of surfaced, which is&lt;br&gt;
exactly how a system keeps producing the same kind of failure: nothing about the mismatch&lt;br&gt;
ever reaches whoever could actually fix the cause. &lt;code&gt;docs/reconciliation.md&lt;/code&gt; already states&lt;br&gt;
this as policy for a different case, mismatches with an external gateway: manual review by&lt;br&gt;
default, no silent auto-resolution. That's not a rule specific to talking to a bank. It's the&lt;br&gt;
right rule for any internal disagreement a system can detect but can't safely resolve on its&lt;br&gt;
own, so it's now written that way, a general rule with two implementations rather than one&lt;br&gt;
rule that only happens to apply once.&lt;/p&gt;

&lt;p&gt;The fix: every account gets an opening entry so its balance always equals the sum of its own&lt;br&gt;
history, and a check runs on every read. On a disagreement, it records the mismatch, once,&lt;br&gt;
durably, and still refuses to hand back a number it doesn't trust:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LedgerAccount&lt;/span&gt; &lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="n"&gt;computed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ledgerEntryRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;sumNetByAccountId&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="n"&gt;stored&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getBalance&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stored&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;compareTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;computed&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ledgerMismatchRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findByAccountIdAndResolutionStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="no"&gt;OPEN&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;isEmpty&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;ledgerMismatchRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;LedgerMismatch&lt;/span&gt;&lt;span class="o"&gt;(...));&lt;/span&gt;
    &lt;span class="o"&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;LedgerInconsistencyException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;stored&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;computed&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Resolving that record later never touches the balance either. It records that a human looked&lt;br&gt;
at it and made a decision, the same as the existing gateway-mismatch workflow. Verified&lt;br&gt;
against real data before calling it done: forty-six settlements moved through the actual API,&lt;br&gt;
every untouched account's backfilled history matched its original balance exactly, and one&lt;br&gt;
account whose balance was hand-edited to disagree with its own history was caught,&lt;br&gt;
flagged, and resolved without its balance being touched by the fix itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took from it
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;A claim about your own system should be falsifiable. If nothing in the code could prove
it wrong, it isn't really an explanation.&lt;/li&gt;
&lt;li&gt;The outbox isn't proof you sent something. It's a guarantee that an event and the fact it
describes can never come apart.&lt;/li&gt;
&lt;li&gt;Entries that are only ever written and never read aren't a checked ledger, whatever they're
called.&lt;/li&gt;
&lt;li&gt;When a system finds its own mistake, the honest move is to record it and ask, not to
quietly fix it and move on.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;Originally published on &lt;a href="https://miguel-shinyenyi.github.io/miguel-site/tech/outbox-and-ledger/" rel="noopener noreferrer"&gt;my site&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>springboot</category>
      <category>postgres</category>
      <category>kafka</category>
      <category>concurrency</category>
    </item>
    <item>
      <title>Idempotency keys, under load</title>
      <dc:creator>Miguel Shinyenyi</dc:creator>
      <pubDate>Tue, 29 Sep 2026 22:42:41 +0000</pubDate>
      <link>https://dev.to/miguel_shinyenyi_e2291c8c/idempotency-keys-under-load-1a2c</link>
      <guid>https://dev.to/miguel_shinyenyi_e2291c8c/idempotency-keys-under-load-1a2c</guid>
      <description>&lt;h2&gt;
  
  
  The textbook version
&lt;/h2&gt;

&lt;p&gt;An idempotency key is a client-generated identifier attached to a request, so that if the&lt;br&gt;
same request arrives twice, the server recognizes it and returns the original result instead&lt;br&gt;
of processing it again. That's the textbook version. The real version, the one that shows up&lt;br&gt;
once you put a system under actual concurrent load, has two more layers to it.&lt;/p&gt;
&lt;h2&gt;
  
  
  The guarantee is "answer it correctly"
&lt;/h2&gt;

&lt;p&gt;The guarantee isn't "block the duplicate," it's "answer it correctly." A retry of an&lt;br&gt;
in-flight request gets told the process is underway. A retry of a &lt;em&gt;finished&lt;/em&gt; request gets&lt;br&gt;
back the exact same result the first one got, replayed from a stored snapshot, not a fresh&lt;br&gt;
answer. That second case is the actual point. A caller retrying after a timeout isn't told&lt;br&gt;
"processing," they're told what genuinely happened.&lt;/p&gt;

&lt;p&gt;Here's the check, in the real code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;Optional&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;IdempotencyKey&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;settlementTransactions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findExisting&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;idempotencyKey&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isPresent&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;handleExisting&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;requestHash&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;executionRequest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;settlementTransactions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;createPendingSettlement&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;idempotencyKey&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requestHash&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DataAccessException&lt;/span&gt; &lt;span class="n"&gt;raceLost&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;IdempotencyKey&lt;/span&gt; &lt;span class="n"&gt;winner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;settlementTransactions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findExisting&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;idempotencyKey&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;orElseThrow&lt;/span&gt;&lt;span class="o"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;raceLost&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;handleExisting&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;winner&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requestHash&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Race one: same key, same instant
&lt;/h2&gt;

&lt;p&gt;Check-then-write always&lt;br&gt;
has a gap. Two requests can both see nothing during &lt;code&gt;findExisting&lt;/code&gt;, before either has&lt;br&gt;
written anything. Rather than closing that gap with an upfront lock, the code lets both&lt;br&gt;
proceed, and leans on a database-level unique constraint to guarantee only one insert&lt;br&gt;
survives. The other throws, and gets recovered by re-reading whoever won.&lt;/p&gt;

&lt;p&gt;What's not obvious from reading the code: that failure doesn't always look the same. Under&lt;br&gt;
Postgres, the identical race sometimes surfaces as a clean unique-constraint violation, and&lt;br&gt;
sometimes as an outright deadlock between the two competing index insertions, depending on&lt;br&gt;
timing. That's not something you'd predict from the schema. It's something you'd only find&lt;br&gt;
by actually running concurrent load against it and watching what comes back.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;t1  A      findExisting(key) -&amp;gt; empty
t1  B      findExisting(key) -&amp;gt; empty
t2  A      INSERT idempotency_keys
t2  B      INSERT idempotency_keys
t3  DB     unique constraint on key:
           only one INSERT wins
t3  loser  fails as EITHER
           - a unique-violation, or
           - a deadlock
             (CannotAcquireLock)
           depending on index timing
t4  loser  catch(DataAccessException)
           -&amp;gt; findExisting(key)
           -&amp;gt; return winner's result
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Race two: the winner deadlocks
&lt;/h2&gt;

&lt;p&gt;Race two is a different race, on the winner. The losing threads above, in their own&lt;br&gt;
doomed transactions, are inserting into &lt;code&gt;settlements&lt;/code&gt;, which has a foreign key back to&lt;br&gt;
&lt;code&gt;idempotency_keys&lt;/code&gt;. Validating that foreign key takes a shared lock on the referenced row.&lt;br&gt;
If those losing transactions haven't rolled back yet when the winner tries to &lt;code&gt;UPDATE&lt;/code&gt; that&lt;br&gt;
same row to mark it complete, the update deadlocks against locks held for an unrelated&lt;br&gt;
reason.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1  Winner  createPendingSettlement()
           commits
2  Loser   INSERT INTO settlements
           (FK -&amp;gt; idempotency_keys)
           takes a shared lock on
           that row
3  Loser   rolling back, lock still
           held
4  Winner  finalizeSettlement():
           UPDATE idempotency_keys
           SET status = COMPLETED
           -&amp;gt; blocked on that lock
5  DB      detects deadlock,
           aborts the UPDATE
6  Winner  catches
           TransientDataAccessException
           retries finalizeSettlement
           only (already PENDING;
           rerunning the whole flow
           would see its own key as
           a conflict)
7  Loser   rollback completes,
           lock released
8  Winner  retry succeeds
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five bounded retries, short backoff. If all five are exhausted, still under heavy&lt;br&gt;
contention, the system doesn't leave the settlement stuck. It falls back to a second,&lt;br&gt;
independent retry budget that finalizes the settlement as &lt;code&gt;UNKNOWN&lt;/code&gt;, the same "we genuinely&lt;br&gt;
don't know, let reconciliation resolve it later" state used when the external call itself&lt;br&gt;
fails outright. Both concurrency behaviors were found by actually running load and chaos&lt;br&gt;
tests against the system, not by design review.&lt;/p&gt;

&lt;h2&gt;
  
  
  A third failure mode: the crash
&lt;/h2&gt;

&lt;p&gt;This one was found by studying the first two closely enough to explain them. Neither race above covers what happens if the process hard-crashes between the two&lt;br&gt;
transactions entirely, after the settlement is written as &lt;code&gt;PENDING&lt;/code&gt; but before it's ever&lt;br&gt;
finalized. No thread survives to catch anything in that case, and the settlement has no&lt;br&gt;
external reference yet, since that's only set during finalization, which made it invisible&lt;br&gt;
to reconciliation's own query. The idempotency key would stay &lt;code&gt;IN_PROGRESS&lt;/code&gt; indefinitely,&lt;br&gt;
409-ing every retry, forever. That was a real, verified gap, confirmed against the actual&lt;br&gt;
repository before writing it down here, not assumed.&lt;/p&gt;

&lt;p&gt;It's fixed now. A scheduled sweep, &lt;code&gt;StalePendingSettlementSweepService&lt;/code&gt;, runs the same way&lt;br&gt;
reconciliation already does: it finds settlements stuck &lt;code&gt;PENDING&lt;/code&gt; with no external reference&lt;br&gt;
past a grace period (300 seconds by default), and finalizes each one as &lt;code&gt;UNKNOWN&lt;/code&gt;, reusing&lt;br&gt;
the exact same "we don't know, reconciliation resolves it later" path the two races above&lt;br&gt;
already fall back to. No new state-machine logic, just the same escape hatch applied to a&lt;br&gt;
case that previously had no path to it at all. Covered by its own unit and integration&lt;br&gt;
tests, the way everything else in this system is.&lt;/p&gt;

&lt;p&gt;The interesting part was never the key itself. It's every place where "the same request&lt;br&gt;
happening twice" turns out to have more than one way of actually happening, and every place&lt;br&gt;
"finished" turns out to have more than one way of actually finishing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took from it
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;An idempotency key's job is to answer a retry correctly, not just to block it.&lt;/li&gt;
&lt;li&gt;Let the database's unique constraint decide the race instead of locking upfront.&lt;/li&gt;
&lt;li&gt;The same race can fail two different ways. Only real concurrent load showed that.&lt;/li&gt;
&lt;li&gt;Every path needs an exit to &lt;code&gt;UNKNOWN&lt;/code&gt;, including a crash nobody survives to catch.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;Originally published on &lt;a href="https://miguel-shinyenyi.github.io/miguel-site/tech/idempotency-keys/" rel="noopener noreferrer"&gt;my site&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>springboot</category>
      <category>postgres</category>
      <category>concurrency</category>
    </item>
  </channel>
</rss>
