<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
  <title>Shipmind Labs — writing</title>
  <link>https://shipmindlabs.com/blog/</link>
  <description>Engineering notes from Shipmind Labs.</description>
  <language>en</language>
  <atom:link href="https://shipmindlabs.com/feed.xml" rel="self" type="application/rss+xml"/>
  <item>
    <title>Catching money bugs with ledger invariants, not error logs</title>
    <link>https://shipmindlabs.com/blog/catching-money-bugs-with-ledger-invariants-not-error-logs/</link>
    <guid isPermaLink="true">https://shipmindlabs.com/blog/catching-money-bugs-with-ledger-invariants-not-error-logs/</guid>
    <pubDate>Thu, 30 Jul 2026 09:00:00 +0000</pubDate>
    <description>A payout that credits the wrong sub-account returns HTTP 200. Nothing throws, the worker acknowledges the message, the error dashboards stay green, and the discrepancy surfaces days later when…</description>
    <content:encoded><![CDATA[<p>A payout that credits the wrong sub-account returns HTTP 200. Nothing throws, the worker acknowledges the message, the error dashboards stay green, and the discrepancy surfaces days later when someone reconciles a bank statement by hand.</p>
<p>That gap — between "the code ran without errors" and "the money is where it should be" — is where the hardest incidents in payment systems live. Retries, partial failures, and provider callbacks arriving out of order all produce states that are individually plausible and collectively wrong. Exception tracking cannot see them, because there is no exception.</p>
<p>We work on systems that move money: payment services on card rails and account-to-account flows, virtual bank-account ledgering with per-user attribution, custodial wallets, marketplace payout flows. Across all of them the same practice earns its keep. Write down the properties that must hold over the ledger, check them continuously, and treat a violation with the same weight as a spike of 500s.</p>
<h2 id="the-constraint-that-makes-this-hard">The constraint that makes this hard<a class="hlink" href="#the-constraint-that-makes-this-hard" title="Link to this section">#</a></h2>
<p>A ledger backed by external rails is legitimately inconsistent for a while. A transfer is submitted, the provider confirms asynchronously, the bank statement line arrives on the next value date. A check that ignores that timing produces false positives, and a team that gets paged for false positives stops reading pages. So an invariant is not just a boolean over the tables — it needs a <strong>settlement window</strong> and a <strong>severity</strong>.</p>
<p>The second constraint is operational: these are the tables you cannot lock or slow down. Checks must be read-only, run against a replica, and be cheap enough to repeat every few minutes.</p>
<h2 id="write-the-invariants-as-sql-not-as-prose">Write the invariants as SQL, not as prose<a class="hlink" href="#write-the-invariants-as-sql-not-as-prose" title="Link to this section">#</a></h2>
<p>We keep them in the repository next to the migrations, one file per invariant, under version control and code review like everything else. The contract is deliberately narrow: <strong>each query returns zero rows when the system is healthy, and one row per offending entity when it is not.</strong> Every file takes a single <code>:settlement</code> parameter cast to an interval.</p>
<p>Double entry, first. Every posted transaction must sum to zero:</p>
<figure class="code"><figcaption><span class="lang">sql</span></figcaption><pre><code><span class="c1">-- ledger/invariants/sql/0001_transaction_balances_to_zero.sql</span>
<span class="k">SELECT</span><span class="w"> </span><span class="n">t</span><span class="p">.</span><span class="n">id</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">entity_id</span><span class="p">,</span>
<span class="w">       </span><span class="k">SUM</span><span class="p">(</span><span class="n">p</span><span class="p">.</span><span class="n">amount_minor</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">residual_minor</span>
<span class="k">FROM</span><span class="w"> </span><span class="n">ledger_transaction</span><span class="w"> </span><span class="n">t</span>
<span class="k">JOIN</span><span class="w"> </span><span class="n">ledger_posting</span><span class="w"> </span><span class="n">p</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">p</span><span class="p">.</span><span class="n">transaction_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">t</span><span class="p">.</span><span class="n">id</span>
<span class="k">WHERE</span><span class="w"> </span><span class="n">t</span><span class="p">.</span><span class="n">posted_at</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="n">now</span><span class="p">()</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="k">CAST</span><span class="p">(:</span><span class="n">settlement</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="nb">interval</span><span class="p">)</span>
<span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">t</span><span class="p">.</span><span class="n">id</span>
<span class="k">HAVING</span><span class="w"> </span><span class="k">SUM</span><span class="p">(</span><span class="n">p</span><span class="p">.</span><span class="n">amount_minor</span><span class="p">)</span><span class="w"> </span><span class="o">&lt;&gt;</span><span class="w"> </span><span class="mi">0</span><span class="p">;</span></code></pre></figure>
<p>Next, the cached balance every read path uses must match the postings that produced it. This is the invariant that catches a balance updated outside a transaction, or updated twice by a retry:</p>
<figure class="code"><figcaption><span class="lang">sql</span></figcaption><pre><code><span class="c1">-- ledger/invariants/sql/0002_account_balance_matches_postings.sql</span>
<span class="k">SELECT</span><span class="w"> </span><span class="n">a</span><span class="p">.</span><span class="n">id</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">entity_id</span><span class="p">,</span>
<span class="w">       </span><span class="n">a</span><span class="p">.</span><span class="n">balance_minor</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">cached_minor</span><span class="p">,</span>
<span class="w">       </span><span class="k">COALESCE</span><span class="p">(</span><span class="k">SUM</span><span class="p">(</span><span class="n">p</span><span class="p">.</span><span class="n">amount_minor</span><span class="p">),</span><span class="w"> </span><span class="mi">0</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">derived_minor</span>
<span class="k">FROM</span><span class="w"> </span><span class="n">ledger_account</span><span class="w"> </span><span class="n">a</span>
<span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="n">ledger_posting</span><span class="w"> </span><span class="n">p</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">p</span><span class="p">.</span><span class="n">account_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">a</span><span class="p">.</span><span class="n">id</span>
<span class="k">WHERE</span><span class="w"> </span><span class="n">a</span><span class="p">.</span><span class="n">updated_at</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="n">now</span><span class="p">()</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="k">CAST</span><span class="p">(:</span><span class="n">settlement</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="nb">interval</span><span class="p">)</span>
<span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">a</span><span class="p">.</span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">a</span><span class="p">.</span><span class="n">balance_minor</span>
<span class="k">HAVING</span><span class="w"> </span><span class="n">a</span><span class="p">.</span><span class="n">balance_minor</span><span class="w"> </span><span class="o">&lt;&gt;</span><span class="w"> </span><span class="k">COALESCE</span><span class="p">(</span><span class="k">SUM</span><span class="p">(</span><span class="n">p</span><span class="p">.</span><span class="n">amount_minor</span><span class="p">),</span><span class="w"> </span><span class="mi">0</span><span class="p">);</span></code></pre></figure>
<p>Then attribution. In a virtual-account design, where each user pays into their own requisites and the bank reports movements on the parent account, incoming money has to be matched to exactly one user. Zero matches means funds are sitting unattributed; two matches means one payment was credited twice:</p>
<figure class="code"><figcaption><span class="lang">sql</span></figcaption><pre><code><span class="c1">-- ledger/invariants/sql/0003_inbound_lines_attributed_once.sql</span>
<span class="k">SELECT</span><span class="w"> </span><span class="n">l</span><span class="p">.</span><span class="n">id</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">entity_id</span><span class="p">,</span>
<span class="w">       </span><span class="k">count</span><span class="p">(</span><span class="n">p</span><span class="p">.</span><span class="n">id</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">attribution_count</span>
<span class="k">FROM</span><span class="w"> </span><span class="n">bank_statement_line</span><span class="w"> </span><span class="n">l</span>
<span class="k">LEFT</span><span class="w"> </span><span class="k">JOIN</span><span class="w"> </span><span class="n">ledger_posting</span><span class="w"> </span><span class="n">p</span><span class="w"> </span><span class="k">ON</span><span class="w"> </span><span class="n">p</span><span class="p">.</span><span class="n">statement_line_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">l</span><span class="p">.</span><span class="n">id</span>
<span class="k">WHERE</span><span class="w"> </span><span class="n">l</span><span class="p">.</span><span class="n">direction</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;inbound&#39;</span>
<span class="w">  </span><span class="k">AND</span><span class="w"> </span><span class="n">l</span><span class="p">.</span><span class="n">value_date</span><span class="w"> </span><span class="o">&lt;</span><span class="w"> </span><span class="k">current_date</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="k">CAST</span><span class="p">(:</span><span class="n">settlement</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="nb">interval</span><span class="p">)</span>
<span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">l</span><span class="p">.</span><span class="n">id</span>
<span class="k">HAVING</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="n">p</span><span class="p">.</span><span class="n">id</span><span class="p">)</span><span class="w"> </span><span class="o">&lt;&gt;</span><span class="w"> </span><span class="mi">1</span><span class="p">;</span></code></pre></figure>
<p>The rest of the set follows the same shape: no custody or escrow account below zero at any moment, no posting written to a closed account, the sum of user balances equal to the omnibus balance reported by the provider, no transaction in a non-terminal state older than its expected lifetime.</p>
<p>Two conventions do a lot of work here. Amounts are integers in minor units, never floats, so equality comparisons are meaningful. And every invariant returns an <code>entity_id</code>, so an alert points at rows an engineer can open rather than at a number that dropped.</p>
<h2 id="run-them-like-tests-on-a-schedule">Run them like tests, on a schedule<a class="hlink" href="#run-them-like-tests-on-a-schedule" title="Link to this section">#</a></h2>
<p>The runner is intentionally dull. It loads the SQL, executes it read-only, counts rows, samples a few for the alert payload, and reports how long it took.</p>
<figure class="code"><figcaption><span class="lang">python</span></figcaption><pre><code><span class="c1"># ledger/invariants/runner.py</span>
<span class="kn">import</span><span class="w"> </span><span class="nn">time</span>
<span class="kn">from</span><span class="w"> </span><span class="nn">dataclasses</span><span class="w"> </span><span class="kn">import</span> <span class="n">dataclass</span>
<span class="kn">from</span><span class="w"> </span><span class="nn">pathlib</span><span class="w"> </span><span class="kn">import</span> <span class="n">Path</span>

<span class="kn">from</span><span class="w"> </span><span class="nn">sqlalchemy</span><span class="w"> </span><span class="kn">import</span> <span class="n">text</span>

<span class="n">SQL_DIR</span> <span class="o">=</span> <span class="n">Path</span><span class="p">(</span><span class="vm">__file__</span><span class="p">)</span><span class="o">.</span><span class="n">parent</span> <span class="o">/</span> <span class="s2">&quot;sql&quot;</span>


<span class="nd">@dataclass</span><span class="p">(</span><span class="n">frozen</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
<span class="k">class</span><span class="w"> </span><span class="nc">Invariant</span><span class="p">:</span>
    <span class="n">name</span><span class="p">:</span> <span class="nb">str</span>
    <span class="n">severity</span><span class="p">:</span> <span class="nb">str</span>        <span class="c1"># &quot;page&quot; or &quot;ticket&quot;</span>
    <span class="n">settlement</span><span class="p">:</span> <span class="nb">str</span>      <span class="c1"># interval literal, e.g. &quot;5 minutes&quot;</span>
    <span class="n">sample_size</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="mi">20</span>


<span class="n">REGISTRY</span> <span class="o">=</span> <span class="p">(</span>
    <span class="n">Invariant</span><span class="p">(</span><span class="s2">&quot;0001_transaction_balances_to_zero&quot;</span><span class="p">,</span> <span class="s2">&quot;page&quot;</span><span class="p">,</span> <span class="s2">&quot;1 minute&quot;</span><span class="p">),</span>
    <span class="n">Invariant</span><span class="p">(</span><span class="s2">&quot;0002_account_balance_matches_postings&quot;</span><span class="p">,</span> <span class="s2">&quot;page&quot;</span><span class="p">,</span> <span class="s2">&quot;5 minutes&quot;</span><span class="p">),</span>
    <span class="n">Invariant</span><span class="p">(</span><span class="s2">&quot;0003_inbound_lines_attributed_once&quot;</span><span class="p">,</span> <span class="s2">&quot;ticket&quot;</span><span class="p">,</span> <span class="s2">&quot;2 days&quot;</span><span class="p">),</span>
    <span class="n">Invariant</span><span class="p">(</span><span class="s2">&quot;0004_custody_never_negative&quot;</span><span class="p">,</span> <span class="s2">&quot;page&quot;</span><span class="p">,</span> <span class="s2">&quot;0 seconds&quot;</span><span class="p">),</span>
<span class="p">)</span>


<span class="k">def</span><span class="w"> </span><span class="nf">run</span><span class="p">(</span><span class="n">invariant</span><span class="p">,</span> <span class="n">session</span><span class="p">):</span>
    <span class="n">sql</span> <span class="o">=</span> <span class="p">(</span><span class="n">SQL_DIR</span> <span class="o">/</span> <span class="sa">f</span><span class="s2">&quot;</span><span class="si">{</span><span class="n">invariant</span><span class="o">.</span><span class="n">name</span><span class="si">}</span><span class="s2">.sql&quot;</span><span class="p">)</span><span class="o">.</span><span class="n">read_text</span><span class="p">()</span>
    <span class="n">started</span> <span class="o">=</span> <span class="n">time</span><span class="o">.</span><span class="n">monotonic</span><span class="p">()</span>
    <span class="n">rows</span> <span class="o">=</span> <span class="n">session</span><span class="o">.</span><span class="n">execute</span><span class="p">(</span>
        <span class="n">text</span><span class="p">(</span><span class="n">sql</span><span class="p">),</span> <span class="p">{</span><span class="s2">&quot;settlement&quot;</span><span class="p">:</span> <span class="n">invariant</span><span class="o">.</span><span class="n">settlement</span><span class="p">}</span>
    <span class="p">)</span><span class="o">.</span><span class="n">mappings</span><span class="p">()</span><span class="o">.</span><span class="n">all</span><span class="p">()</span>
    <span class="k">return</span> <span class="p">{</span>
        <span class="s2">&quot;name&quot;</span><span class="p">:</span> <span class="n">invariant</span><span class="o">.</span><span class="n">name</span><span class="p">,</span>
        <span class="s2">&quot;severity&quot;</span><span class="p">:</span> <span class="n">invariant</span><span class="o">.</span><span class="n">severity</span><span class="p">,</span>
        <span class="s2">&quot;violations&quot;</span><span class="p">:</span> <span class="nb">len</span><span class="p">(</span><span class="n">rows</span><span class="p">),</span>
        <span class="s2">&quot;sample&quot;</span><span class="p">:</span> <span class="p">[</span><span class="nb">dict</span><span class="p">(</span><span class="n">r</span><span class="p">)</span> <span class="k">for</span> <span class="n">r</span> <span class="ow">in</span> <span class="n">rows</span><span class="p">[:</span> <span class="n">invariant</span><span class="o">.</span><span class="n">sample_size</span><span class="p">]],</span>
        <span class="s2">&quot;duration_s&quot;</span><span class="p">:</span> <span class="n">time</span><span class="o">.</span><span class="n">monotonic</span><span class="p">()</span> <span class="o">-</span> <span class="n">started</span><span class="p">,</span>
    <span class="p">}</span></code></pre></figure>
<p>The scheduled task wraps it, pins the session to a read-only transaction on the replica, and publishes one gauge per invariant:</p>
<figure class="code"><figcaption><span class="lang">python</span></figcaption><pre><code><span class="c1"># ledger/invariants/tasks.py</span>
<span class="kn">from</span><span class="w"> </span><span class="nn">sqlalchemy</span><span class="w"> </span><span class="kn">import</span> <span class="n">text</span>

<span class="kn">from</span><span class="w"> </span><span class="nn">app.celery</span><span class="w"> </span><span class="kn">import</span> <span class="n">app</span>
<span class="kn">from</span><span class="w"> </span><span class="nn">app.db</span><span class="w"> </span><span class="kn">import</span> <span class="n">replica_session</span>
<span class="kn">from</span><span class="w"> </span><span class="nn">app.metrics</span><span class="w"> </span><span class="kn">import</span> <span class="n">gauge</span>
<span class="kn">from</span><span class="w"> </span><span class="nn">app.alerts</span><span class="w"> </span><span class="kn">import</span> <span class="n">emit</span>
<span class="kn">from</span><span class="w"> </span><span class="nn">ledger.invariants.runner</span><span class="w"> </span><span class="kn">import</span> <span class="n">REGISTRY</span><span class="p">,</span> <span class="n">run</span>


<span class="nd">@app</span><span class="o">.</span><span class="n">task</span><span class="p">(</span><span class="n">name</span><span class="o">=</span><span class="s2">&quot;ledger.run_invariants&quot;</span><span class="p">)</span>
<span class="k">def</span><span class="w"> </span><span class="nf">run_invariants</span><span class="p">():</span>
    <span class="k">with</span> <span class="n">replica_session</span><span class="p">()</span> <span class="k">as</span> <span class="n">session</span><span class="p">:</span>
        <span class="n">session</span><span class="o">.</span><span class="n">execute</span><span class="p">(</span><span class="n">text</span><span class="p">(</span><span class="s2">&quot;SET TRANSACTION READ ONLY&quot;</span><span class="p">))</span>
        <span class="k">for</span> <span class="n">invariant</span> <span class="ow">in</span> <span class="n">REGISTRY</span><span class="p">:</span>
            <span class="n">result</span> <span class="o">=</span> <span class="n">run</span><span class="p">(</span><span class="n">invariant</span><span class="p">,</span> <span class="n">session</span><span class="p">)</span>
            <span class="n">tags</span> <span class="o">=</span> <span class="p">[</span><span class="sa">f</span><span class="s2">&quot;invariant:</span><span class="si">{</span><span class="n">invariant</span><span class="o">.</span><span class="n">name</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">]</span>
            <span class="n">gauge</span><span class="p">(</span><span class="s2">&quot;ledger.invariant.violations&quot;</span><span class="p">,</span> <span class="n">result</span><span class="p">[</span><span class="s2">&quot;violations&quot;</span><span class="p">],</span> <span class="n">tags</span><span class="p">)</span>
            <span class="n">gauge</span><span class="p">(</span><span class="s2">&quot;ledger.invariant.duration_s&quot;</span><span class="p">,</span> <span class="n">result</span><span class="p">[</span><span class="s2">&quot;duration_s&quot;</span><span class="p">],</span> <span class="n">tags</span><span class="p">)</span>
            <span class="n">gauge</span><span class="p">(</span><span class="s2">&quot;ledger.invariant.last_run_ts&quot;</span><span class="p">,</span> <span class="n">time</span><span class="o">.</span><span class="n">time</span><span class="p">(),</span> <span class="n">tags</span><span class="p">)</span>
            <span class="k">if</span> <span class="n">result</span><span class="p">[</span><span class="s2">&quot;violations&quot;</span><span class="p">]:</span>
                <span class="n">emit</span><span class="p">(</span><span class="n">result</span><span class="p">)</span></code></pre></figure>
<p>The third gauge matters more than it looks. A checker that stopped running produces exactly the same picture as a perfectly healthy ledger: no violations, no alerts. So the monitoring has two rules per invariant — one on the violation count, and one on the age of <code>last_run_ts</code>. Without the staleness rule, the whole mechanism can fail silently, which is the failure mode it exists to prevent.</p>
<h2 id="the-same-invariants-in-the-test-suite">The same invariants in the test suite<a class="hlink" href="#the-same-invariants-in-the-test-suite" title="Link to this section">#</a></h2>
<p>Because the checks are plain SQL against the schema, the test suite can run them too — with a zero settlement window, since a test controls time and has no in-flight provider callbacks. Our money-path tests end with the ledger being asserted clean, not just with the response body being asserted correct:</p>
<figure class="code"><figcaption><span class="lang">python</span></figcaption><pre><code><span class="c1"># tests/conftest.py</span>
<span class="kn">import</span><span class="w"> </span><span class="nn">pytest</span>

<span class="kn">from</span><span class="w"> </span><span class="nn">ledger.invariants.runner</span><span class="w"> </span><span class="kn">import</span> <span class="n">REGISTRY</span><span class="p">,</span> <span class="n">run</span>


<span class="nd">@pytest</span><span class="o">.</span><span class="n">fixture</span>
<span class="k">def</span><span class="w"> </span><span class="nf">assert_ledger_clean</span><span class="p">(</span><span class="n">db_session</span><span class="p">):</span>
    <span class="k">def</span><span class="w"> </span><span class="nf">_assert</span><span class="p">(</span><span class="n">exclude</span><span class="o">=</span><span class="p">()):</span>
        <span class="n">failures</span> <span class="o">=</span> <span class="p">[]</span>
        <span class="k">for</span> <span class="n">invariant</span> <span class="ow">in</span> <span class="n">REGISTRY</span><span class="p">:</span>
            <span class="k">if</span> <span class="n">invariant</span><span class="o">.</span><span class="n">name</span> <span class="ow">in</span> <span class="n">exclude</span><span class="p">:</span>
                <span class="k">continue</span>
            <span class="n">result</span> <span class="o">=</span> <span class="n">run</span><span class="p">(</span><span class="n">invariant</span><span class="p">,</span> <span class="n">db_session</span><span class="p">)</span>
            <span class="k">if</span> <span class="n">result</span><span class="p">[</span><span class="s2">&quot;violations&quot;</span><span class="p">]:</span>
                <span class="n">failures</span><span class="o">.</span><span class="n">append</span><span class="p">((</span><span class="n">invariant</span><span class="o">.</span><span class="n">name</span><span class="p">,</span> <span class="n">result</span><span class="p">[</span><span class="s2">&quot;sample&quot;</span><span class="p">]))</span>
        <span class="k">assert</span> <span class="ow">not</span> <span class="n">failures</span><span class="p">,</span> <span class="n">failures</span>

    <span class="k">return</span> <span class="n">_assert</span></code></pre></figure>
<figure class="code"><figcaption><span class="lang">python</span></figcaption><pre><code><span class="c1"># tests/payouts/test_reversal.py</span>
<span class="k">def</span><span class="w"> </span><span class="nf">test_failed_payout_reverses_without_residual</span><span class="p">(</span>
    <span class="n">api_client</span><span class="p">,</span> <span class="n">payout_factory</span><span class="p">,</span> <span class="n">provider_stub</span><span class="p">,</span> <span class="n">assert_ledger_clean</span>
<span class="p">):</span>
    <span class="n">payout</span> <span class="o">=</span> <span class="n">payout_factory</span><span class="p">(</span><span class="n">state</span><span class="o">=</span><span class="s2">&quot;submitted&quot;</span><span class="p">)</span>
    <span class="n">provider_stub</span><span class="o">.</span><span class="n">reject</span><span class="p">(</span><span class="n">payout</span><span class="o">.</span><span class="n">reference</span><span class="p">,</span> <span class="n">reason</span><span class="o">=</span><span class="s2">&quot;account_closed&quot;</span><span class="p">)</span>

    <span class="n">api_client</span><span class="o">.</span><span class="n">post</span><span class="p">(</span><span class="s2">&quot;/internal/payouts/poll&quot;</span><span class="p">)</span>
    <span class="n">api_client</span><span class="o">.</span><span class="n">post</span><span class="p">(</span><span class="s2">&quot;/internal/payouts/poll&quot;</span><span class="p">)</span>  <span class="c1"># duplicate poll, must be idempotent</span>

    <span class="n">assert_ledger_clean</span><span class="p">()</span></code></pre></figure>
<p>This is also what our review gate looks for. A pull request that introduces a new money path — a new transfer type, a new reversal branch, a new provider — is expected either to be covered by an existing invariant or to bring a new one with it. Reviewers ask the same question every time: which property of the ledger would be violated if this code is wrong, and what checks it? Answering that in review costs minutes. Answering it during a reconciliation incident costs a week and a lot of trust.</p>
<h2 id="what-this-costs-to-run">What this costs to run<a class="hlink" href="#what-this-costs-to-run" title="Link to this section">#</a></h2>
<p>The frequent invariants are aggregates over the postings table, so they live or die on indexes: <code>ledger_posting(transaction_id)</code>, <code>ledger_posting(account_id)</code>, and a timestamp index supporting the settlement filter. Past a certain table size a full sweep every few minutes stops being free, and the fix is to split the cadence:</p>
<table>
<thead>
<tr>
<th>Mode</th>
<th>Cadence</th>
<th>Scope</th>
</tr>
</thead>
<tbody>
<tr>
<td>Incremental</td>
<td>minutes</td>
<td>entities touched since the last watermark</td>
</tr>
<tr>
<td>Full sweep</td>
<td>nightly, off-peak</td>
<td>the whole table set</td>
</tr>
</tbody>
</table>
<p>Incremental mode adds one predicate to the same file, driven by a stored watermark:</p>
<figure class="code"><figcaption><span class="lang">sql</span></figcaption><pre><code><span class="k">AND</span><span class="w"> </span><span class="n">t</span><span class="p">.</span><span class="n">posted_at</span><span class="w"> </span><span class="o">&gt;</span><span class="w"> </span><span class="k">CAST</span><span class="p">(:</span><span class="n">since</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">timestamptz</span><span class="p">)</span></code></pre></figure>
<p>The full sweep still matters, because incremental checks only see rows the application touched. Corruption introduced by a manual fix, a bad backfill, or a restore lands outside the watermark, and only the sweep finds it.</p>
<p>Operationally it is a replica, one scheduled task, a handful of gauges, and two alert rules per invariant. No new infrastructure, no vendor.</p>
<h2 id="close">Close<a class="hlink" href="#close" title="Link to this section">#</a></h2>
<p>Error rates tell you whether the code ran. Invariants tell you whether the money is right, and those are different questions. Since the ledger is the source of truth for every balance a user sees and every figure that ends up in a reconciliation report, it should also be the thing under continuous assertion — in production and in the test suite, with the same SQL doing both jobs.</p>]]></content:encoded>
  </item>
</channel>
</rss>
