<?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: polycratia</title>
    <description>The latest articles on DEV Community by polycratia (@polycratia).</description>
    <link>https://dev.to/polycratia</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%2F4054706%2Fac002c30-6c04-4e99-a7e2-1373af5f4833.png</url>
      <title>DEV Community: polycratia</title>
      <link>https://dev.to/polycratia</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/polycratia"/>
    <language>en</language>
    <item>
      <title>A testnet xpub under a mainnet encoder: addresses nobody scans</title>
      <dc:creator>polycratia</dc:creator>
      <pubDate>Sat, 24 Oct 2026 13:27:50 +0000</pubDate>
      <link>https://dev.to/polycratia/a-testnet-xpub-under-a-mainnet-encoder-addresses-nobody-scans-1ao1</link>
      <guid>https://dev.to/polycratia/a-testnet-xpub-under-a-mainnet-encoder-addresses-nobody-scans-1ao1</guid>
      <description>&lt;p&gt;A deposit service pairs an extended public key with an address encoder, and there is exactly one way to get that pair wrong that produces no error at any layer. A testnet key under a mainnet encoder parses cleanly, derives cleanly, and hands back a well-formed mainnet address with a valid checksum, while the signer holding the seed watches a different chain and never sees the deposit. The defect is not in the key. It is not in the address either. It sits in the pair, which is why the pair is the only place a check can live.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure that reports success
&lt;/h2&gt;

&lt;p&gt;Three steps stand between an account-level key and an address a customer pastes into a wallet, and each one has a narrow, honest contract.&lt;/p&gt;

&lt;p&gt;Parsing validates structure. In &lt;code&gt;bip32.py&lt;/code&gt;, &lt;code&gt;ExtendedPublicKey.parse&lt;/code&gt; rejects a payload that is not 78 bytes, rejects the six private versions by name, and rejects any version not in &lt;code&gt;PUBLIC_VERSIONS&lt;/code&gt;; &lt;code&gt;__post_init__&lt;/code&gt; then checks depth, fingerprint length, chain code length and the 33-byte compressed point. A &lt;code&gt;tpub&lt;/code&gt; passes all of it, because a &lt;code&gt;tpub&lt;/code&gt; is a perfectly valid extended public key.&lt;/p&gt;

&lt;p&gt;Derivation is arithmetic. A chain code and a curve point go in, a child point comes out. There is no network anywhere in that computation: the same scalar addition serves every chain that runs on secp256k1.&lt;/p&gt;

&lt;p&gt;Encoding is a lookup and a string. The &lt;code&gt;AddressEncoder&lt;/code&gt; protocol in &lt;code&gt;addresses.py&lt;/code&gt; has two members, a &lt;code&gt;name&lt;/code&gt; and &lt;code&gt;encode(public_key) -&amp;gt; str&lt;/code&gt;. &lt;code&gt;P2WPKH&lt;/code&gt; takes a 33-byte compressed key, hashes it, and emits bech32 under its own &lt;code&gt;hrp&lt;/code&gt;. It does not ask where the key came from, and that is what the protocol buys you: code holding an encoder never has to know which chain it is serving.&lt;/p&gt;

&lt;p&gt;So the mismatch is invisible to every step on its own. It is also invisible to address validation after the fact, and that part is worth sitting with. Hand the resulting string to &lt;code&gt;validate_address&lt;/code&gt; with &lt;code&gt;network="mainnet"&lt;/code&gt; and it comes back valid, because it genuinely is a well-formed mainnet address. Nothing is wrong with the address. The wrong part is that no signer on mainnet derived it, so no scanner on mainnet is looking for it. Validating the output cannot catch a defect that exists only in the relationship between two inputs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where each network is written down
&lt;/h2&gt;

&lt;p&gt;Both facts are present, just sitting on different objects.&lt;/p&gt;

&lt;p&gt;On the key side, the fact is the version prefix. &lt;code&gt;PUBLIC_VERSIONS&lt;/code&gt; maps the six serialisation versions to &lt;code&gt;xpub&lt;/code&gt;, &lt;code&gt;ypub&lt;/code&gt;, &lt;code&gt;zpub&lt;/code&gt;, &lt;code&gt;tpub&lt;/code&gt;, &lt;code&gt;upub&lt;/code&gt; and &lt;code&gt;vpub&lt;/code&gt;, and &lt;code&gt;gap.py&lt;/code&gt; maps those names to networks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;_KEY_NETWORKS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;xpub&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Network&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MAINNET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ypub&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Network&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MAINNET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;zpub&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Network&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MAINNET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tpub&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Network&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TESTNET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;upub&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Network&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TESTNET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vpub&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Network&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TESTNET&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;On the encoder side, the fact is the name. The registry is flat and the names carry their own scope (&lt;code&gt;bitcoin-p2wpkh&lt;/code&gt;, &lt;code&gt;bitcoin-testnet-p2wpkh&lt;/code&gt;, &lt;code&gt;evm&lt;/code&gt;), so you can read the network straight off the string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_encoder_network&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;encoder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AddressEncoder&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Network&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;encoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_BITCOIN_PREFIX&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;Network&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ANY&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;encoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_TESTNET_PREFIX&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;Network&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TESTNET&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Network&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MAINNET&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_check_networks_agree&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ExtendedPublicKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encoder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AddressEncoder&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;prefix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;PUBLIC_VERSIONS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;key_network&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_KEY_NETWORKS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;encoder_network&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_encoder_network&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;encoder&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;encoder_network&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;Network&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ANY&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;encoder_network&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;key_network&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;LedgerError&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;PUBLIC_VERSIONS&lt;/code&gt; lookup is unguarded on purpose: &lt;code&gt;__post_init__&lt;/code&gt; has already refused any version that is not in that table, so by the time a key reaches the ledger its prefix is known to exist. An invariant established at construction is worth spending, not re-checking.&lt;/p&gt;

&lt;p&gt;The message on that &lt;code&gt;LedgerError&lt;/code&gt; names the prefix, both networks, and the encoder. That is not padding. A network mismatch is a configuration mistake, and whoever fixes it is reading a log line while deciding whether to roll back. &lt;code&gt;tpub&lt;/code&gt; alone does not say what it was paired with, and &lt;code&gt;network mismatch&lt;/code&gt; says nothing at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Networks, not script types
&lt;/h2&gt;

&lt;p&gt;The comparison stops at networks, deliberately. A &lt;code&gt;zpub&lt;/code&gt; is allowed to drive &lt;code&gt;bitcoin-p2pkh&lt;/code&gt;, and that is not an oversight.&lt;/p&gt;

&lt;p&gt;Version bytes describe the address format the exporting signer expects, not the derivation. The chain code and the point under a &lt;code&gt;zpub&lt;/code&gt; are the same chain code and point you would get under an &lt;code&gt;xpub&lt;/code&gt; at the same path; the prefix is a hint about the signer's preference, carried along in the serialisation. A key can produce every format in the table, and sometimes it has to. A signer that only exports one prefix, or a service still issuing legacy addresses for an old integration, is a legitimate setup rather than a mistake. Refusing it would turn the check into an obstacle, and an obstacle that refuses correct configurations is the kind of check people learn to route around.&lt;/p&gt;

&lt;p&gt;Network is a different sort of claim. It is not a preference about formatting, it is a statement about which chain the watch-only wallet on the other side is scanning. Get it wrong and you do not get an ugly address, you get an unscanned one.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Network.ANY&lt;/code&gt; is the same reasoning applied to EVM chains. The &lt;code&gt;evm&lt;/code&gt; encoder takes the key's x and y coordinates and produces EIP-55 hex; nothing in that path distinguishes one EVM network from another, so there is no claim to contradict and either key is accepted. &lt;code&gt;validate.py&lt;/code&gt; treats the same format the same way: EIP-55 hex belongs to &lt;code&gt;Network.ANY&lt;/code&gt; and passes any network check.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check runs before the first derivation
&lt;/h2&gt;

&lt;p&gt;In &lt;code&gt;AddressLedger.__init__&lt;/code&gt; the refusals are ordered, and the order is the design:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;gap_limit&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;LedgerError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gap limit must be at least 1, got &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;gap_limit&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;_check_index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;next_index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;next index&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;last_used&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;_check_index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;last_used&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;last used index&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;last_used&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;next_index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;LedgerError&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
&lt;span class="n"&gt;resolved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_encoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;encoder&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;encoder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;encoder&lt;/span&gt;
&lt;span class="nf"&gt;_check_networks_agree&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resolved&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;_check_hardened_above&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;branch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_branch_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;account&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;branch&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;child&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;branch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Policy arithmetic comes first, because it is free. Then &lt;code&gt;get_encoder&lt;/code&gt; resolves a string name, raising &lt;code&gt;AddressError&lt;/code&gt; for one it does not know: the network check needs a real encoder with a real &lt;code&gt;name&lt;/code&gt;, so resolution has to come before agreement. Then the two refusals that are about safety rather than bookkeeping, networks agree and the key sits below a hardened step. Only after all of that does &lt;code&gt;account.child(branch)&lt;/code&gt; run, which is the first moment any key material is touched.&lt;/p&gt;

&lt;p&gt;The effect is that an &lt;code&gt;AddressLedger&lt;/code&gt; cannot exist in a state where it would mint addresses nobody scans. No caller has to remember a check, no code path can skip it, and the failure arrives at the line that built the object, in configuration-loading code, with a stack trace pointing at the configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  The snapshot is a second door into the same room
&lt;/h2&gt;

&lt;p&gt;A ledger that hands out addresses in order has state worth persisting: &lt;code&gt;next_index&lt;/code&gt;, &lt;code&gt;last_used&lt;/code&gt;, the gap limit, the branch, the account key, the format. So there is a restore path, and restore paths are where constructor invariants usually go to die. The tempting shape is to allocate the object and assign the fields back one at a time, which is fast, total, and bypasses every check above.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;restore()&lt;/code&gt; instead requires &lt;code&gt;account&lt;/code&gt; and &lt;code&gt;format&lt;/code&gt; in the snapshot, raising &lt;code&gt;LedgerError&lt;/code&gt; that lists whatever is missing, and then calls the constructor with the remaining values defaulted. Every refusal runs again on the way back in.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;snapshot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;account&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;account_xpub&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;format&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bitcoin-p2wpkh&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;next_index&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;41&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;last_used&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;37&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;book&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AddressLedger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;restore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Snapshots get edited apart from the code that wrote them. Someone copies a staging row into production, someone fixes a format by hand during an incident, someone restores a backup taken before a key rotation. Each of those can produce a stored pair that was never possible through the constructor, and a restore that trusts its input accepts all of them. With the check on the load path, a snapshot that disagrees with itself fails at startup. Without it, the first symptom is a customer deposit at an address no scanner reaches, found days later with nothing left to do about it.&lt;/p&gt;

&lt;p&gt;All of this lives in &lt;a href="https://github.com/polycratia/chain-addresses" rel="noopener noreferrer"&gt;chain-addresses&lt;/a&gt;, in &lt;code&gt;gap.py&lt;/code&gt; alongside the gap-limit bookkeeping.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;p&gt;In earlier custodial work the network was a deployment variable. One environment variable selected the key, another selected the address format, and nothing compared them, because on any single environment they were correct anyway: staging configured for testnet, production for mainnet, and the mismatch was a state no running process ever occupied. That is the shape of bug no test on one environment finds. Both halves are right, and only the combination is wrong.&lt;/p&gt;

&lt;p&gt;What I would do earlier is make the network a property derived from the artifacts rather than declared next to them. The &lt;code&gt;tpub&lt;/code&gt; already knows what it is. The encoder name already knows what it is. Once those two get read off the objects and compared, the mismatched pair stops being a configuration that happens to be correct today and becomes a configuration that cannot load.&lt;/p&gt;

&lt;p&gt;The narrowing probably matters as much as the check. Comparing networks and not script types keeps the refusal aimed at the one disagreement that silently loses money, and leaves the formatting choices with the caller who knows which addresses the integration on the other end can read.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://polycratia.com/r/mdv/a-testnet-xpub-under-a-mainnet-encoder-addresses-nobody-scans" rel="noopener noreferrer"&gt;polycratia.com&lt;/a&gt; — where I write about payment systems, crypto rails and marketplace backends.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>crypto</category>
      <category>blockchain</category>
      <category>fintech</category>
    </item>
    <item>
      <title>No Patch Is Coming: PHP 7.4, 8.0 and 8.1 After PHP2Shell</title>
      <dc:creator>polycratia</dc:creator>
      <pubDate>Sat, 24 Oct 2026 10:55:20 +0000</pubDate>
      <link>https://dev.to/polycratia/no-patch-is-coming-php-74-80-and-81-after-php2shell-i4o</link>
      <guid>https://dev.to/polycratia/no-patch-is-coming-php-74-80-and-81-after-php2shell-i4o</guid>
      <description>&lt;p&gt;On 21 October 2026 CISA issued an alert and CERT-EU published advisory 2026-018, covering two remote code execution flaws in PHP core: CVE-2026-109618 and CVE-2026-109619, both rated CVSS 9.2. An unauthenticated attacker can run code on a web-facing PHP runtime. Emergency releases exist for PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35. For PHP 7.4, 8.0 and 8.1 there is no fix, and there will not be one. That second sentence is the whole story, and it is not a security story.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened
&lt;/h2&gt;

&lt;p&gt;Both flaws sit in core, not in an extension and not in a framework. Per the php.net release notes and the two advisories, they reach every mainstream way of serving PHP over HTTP: mod_php, PHP-FPM, php-cgi, FrankenPHP, LiteSpeed. No configuration turns a web-facing runtime into a non-web-facing one, which is why CISA and CERT-EU wrote the same sentence: patch immediately. Exploit attempts have been reported.&lt;/p&gt;

&lt;p&gt;Two days later, on 23 October, a third flaw was disclosed as CVE-2026-115560. It has no patch yet. The only published mitigation is one line of configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;; php.ini
&lt;/span&gt;&lt;span class="py"&gt;session.upload_progress.enabled&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One piece of noise worth naming, because it wasted people's morning: details leaked on Hacker News before the official release, carrying an inflated CVSS 10.0. The official rating is 9.2. Operationally that difference is nothing. Unauthenticated remote code execution at 9.2 and unauthenticated remote code execution at 10.0 produce the same day of work. Argue about the decimal later, since the decimal has not once changed what you have to do.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do today if you stay on PHP
&lt;/h2&gt;

&lt;p&gt;Four things, in this order.&lt;/p&gt;

&lt;p&gt;Move to a patched build. The only versions carrying the fix are 8.5.12, 8.4.27, 8.3.36 and 8.2.35. If you are on 8.2 or newer, this is a package upgrade and a restart, and you should be done before lunch.&lt;/p&gt;

&lt;p&gt;Set &lt;code&gt;session.upload_progress.enabled = 0&lt;/code&gt; for the third CVE, then verify the setting actually landed in the SAPI that serves traffic. The CLI ini file is frequently not the FPM ini file, and the mitigation in the wrong file is worth exactly zero.&lt;/p&gt;

&lt;p&gt;Verify what your hosting vendor patched. Not the status page, the running binary. A status page saying 'PHP updated' is a marketing artifact: the version of the process answering port 443 is evidence. Ask for it explicitly, and ask which SAPI.&lt;/p&gt;

&lt;p&gt;Then inventory every box, including the ones nobody calls a web server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# each SAPI reports its own build; the web one is the one that gets exploited&lt;/span&gt;
php &lt;span class="nt"&gt;-v&lt;/span&gt;
php-fpm &lt;span class="nt"&gt;-v&lt;/span&gt;
php-cgi &lt;span class="nt"&gt;-v&lt;/span&gt; 2&amp;gt;/dev/null

&lt;span class="c"&gt;# the running process, not the package manager's opinion&lt;/span&gt;
&lt;span class="nv"&gt;pid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;systemctl show &lt;span class="nt"&gt;-p&lt;/span&gt; MainPID &lt;span class="nt"&gt;--value&lt;/span&gt; php8.2-fpm&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;readlink&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /proc/&lt;span class="nv"&gt;$pid&lt;/span&gt;/exe

&lt;span class="c"&gt;# confirm the mitigation in the ini set that FPM actually loads&lt;/span&gt;
php-fpm &lt;span class="nt"&gt;-i&lt;/span&gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; upload_progress
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-RIn&lt;/span&gt; &lt;span class="s1"&gt;'upload_progress'&lt;/span&gt; /etc/php/

&lt;span class="c"&gt;# the hosts people forget: cron, queue workers, build images&lt;/span&gt;
crontab &lt;span class="nt"&gt;-l&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; php
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-RIn&lt;/span&gt; &lt;span class="s1"&gt;'php'&lt;/span&gt; /etc/cron.d/
docker ps &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{.Image}}'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A cron box running an unpatched build is still an unpatched build. It is less exposed, not safe, and it is usually the one holding credentials for everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that matters more than the score
&lt;/h2&gt;

&lt;p&gt;If you are on 7.4, 8.0 or 8.1, none of the above finishes the job. You can apply the one-line mitigation. You cannot apply the fix, because the fix was never built for your branch.&lt;/p&gt;

&lt;p&gt;Here is what I want to be precise about: an unpatchable branch is not a security incident. It is a stack decision, made years ago, by not making it. Nobody approved staying on 7.4. The branch went out of support while the service kept working, and working software generates no tickets. PHP2Shell did not create that position. It published it.&lt;/p&gt;

&lt;p&gt;And it will happen again. The structural fact about an end-of-life runtime is not that it has one unfixed hole today; it is that every future CVE lands on it with no upstream answer. You are no longer in a patch cycle. You are in a sequence of one-off mitigations, each one narrower than the last, each one depending on whether a workaround happens to exist for that particular bug. This one has a config flag. The next one may not.&lt;/p&gt;

&lt;p&gt;So the honest question today is not 'how do I patch this'. It is: version jump, or stack jump?&lt;/p&gt;

&lt;h2&gt;
  
  
  Version jump or stack jump
&lt;/h2&gt;

&lt;p&gt;Four signals, and you can read all four in an afternoon.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Size.&lt;/strong&gt; Not line count, surface. How many HTTP entry points, scheduled jobs and external integrations does the service own? A service with twelve endpoints and one payment provider is a different animal from one with two hundred endpoints accumulated over eight years.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Glue versus rules.&lt;/strong&gt; This is the decisive one. How much of the code is framework scaffolding, and how much is business logic that only exists here? Glue is cheap to replace: it is a solved shape, and you are rewriting something whose behaviour is documented. Business rules get expensive, because the rules are not written down anywhere except in the code, and half of them are bug-compatible with a client's expectations.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# rough shape: where do the lines actually live&lt;/span&gt;
find &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'*.php'&lt;/span&gt; &lt;span class="nt"&gt;-not&lt;/span&gt; &lt;span class="nt"&gt;-path&lt;/span&gt; &lt;span class="s1"&gt;'./vendor/*'&lt;/span&gt; &lt;span class="nt"&gt;-print0&lt;/span&gt; | xargs &lt;span class="nt"&gt;-0&lt;/span&gt; &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
find &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-path&lt;/span&gt; &lt;span class="s1"&gt;'./vendor/*'&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'*.php'&lt;/span&gt; &lt;span class="nt"&gt;-print0&lt;/span&gt; | xargs &lt;span class="nt"&gt;-0&lt;/span&gt; &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;

&lt;span class="c"&gt;# controllers and views are glue; count them separately from the rest&lt;/span&gt;
find &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-path&lt;/span&gt; ./vendor &lt;span class="nt"&gt;-prune&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'*Controller*.php'&lt;/span&gt; &lt;span class="nt"&gt;-print&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
find &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-path&lt;/span&gt; ./vendor &lt;span class="nt"&gt;-prune&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'*.tpl'&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'*.phtml'&lt;/span&gt; &lt;span class="nt"&gt;-print&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;

&lt;span class="c"&gt;# every place money, auth or an external party is touched&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-RIl&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*.php'&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'curl_init'&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'password_'&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'session_start'&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; vendor | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Test coverage.&lt;/strong&gt; Not the percentage. Whether any executable description of correct behaviour exists at all. With a test suite, a migration is mechanical and verifiable. Without one, the only available oracle is the old service itself, which changes the plan (see below).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who still knows it.&lt;/strong&gt; If the people who wrote the business rules are gone and the rules are undocumented, a version jump on an unsupported branch buys you very little: you are paying migration cost to land on another runtime that will also age out, with nobody able to confirm you preserved behaviour.&lt;/p&gt;

&lt;p&gt;Two strong branches and a staffed team: jump the version, stay on PHP, get back into a patch cycle. Mostly glue, thin rules, no owner left: moving off the branch entirely is probably cheaper than nursing it, and it ends the recurring exposure instead of deferring it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an honest rewrite looks like
&lt;/h2&gt;

&lt;p&gt;Scope first. A day of reading a service tells you what it costs and how many weeks it takes. That is a real deliverable, and it is the only part that can be done quickly. Anyone quoting a rewrite without reading the code is quoting a different service than the one you have.&lt;/p&gt;

&lt;p&gt;Strangler, not big bang. The old service keeps serving traffic. New endpoints come up behind the same entry point and take routes one group at a time, and each cutover is individually reversible. A big-bang rewrite has exactly one integration test, on the worst possible day.&lt;/p&gt;

&lt;p&gt;Data model, then payment and auth paths. The schema is the hard constraint everything else gets shaped around, and the money and identity paths are where a silent behaviour difference costs real money rather than a bug report. Do them while attention is highest, not last.&lt;/p&gt;

&lt;p&gt;Keep the old service running as the reference until parity. With no test suite, the legacy code is your specification: you run both, compare outputs on real traffic shapes, and the differences you find are the undocumented rules. That is how you discover the rounding rule nobody remembers writing.&lt;/p&gt;

&lt;p&gt;Timelines, honestly: a small service with clear boundaries moves in days. A large one moves in planned weeks, endpoint group by endpoint group, with traffic shifted gradually. Nothing meaningful gets rewritten in a day. What takes a day is knowing the number.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;p&gt;I would make the support window a build-time fact rather than a thing someone remembers. A pipeline check that fails when the runtime branch has fewer than six months of upstream support left turns this from an emergency into a scheduled quarter of work. The cost of moving a service does not grow because the code got worse. It grows because the distance to a supported branch gets longer every year you stand still, and because the people who understood the rules keep leaving.&lt;/p&gt;

&lt;p&gt;The branches that got patched this week will be unsupported too, on a published schedule. The question that matters is whether you meet that date deliberately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting a number
&lt;/h2&gt;

&lt;p&gt;If you are sitting on 7.4, 8.0 or 8.1 right now, the useful next step is not a debate about risk appetite. It is a cost and a timeline. Send me the service and within a day you will have both: what moving it onto a supported, modern stack costs, and how many weeks it takes. I have done inherited-system rewrites and major runtime migrations before, including monolith-to-services splits where the old system stayed in production as the reference the whole way.&lt;/p&gt;

&lt;p&gt;Apply the mitigation today either way.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://polycratia.com/r/mdv/no-patch-is-coming-php-7-4-8-0-and-8-1-after-php2shell" rel="noopener noreferrer"&gt;polycratia.com&lt;/a&gt; — where I write about payment systems, crypto rails and marketplace backends.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>fintech</category>
      <category>architecture</category>
      <category>backend</category>
    </item>
    <item>
      <title>Cite the row, or say unknown: a checkable crypto classifier</title>
      <dc:creator>polycratia</dc:creator>
      <pubDate>Fri, 23 Oct 2026 13:22:54 +0000</pubDate>
      <link>https://dev.to/polycratia/cite-the-row-or-say-unknown-a-checkable-crypto-classifier-38aa</link>
      <guid>https://dev.to/polycratia/cite-the-row-or-say-unknown-a-checkable-crypto-classifier-38aa</guid>
      <description>&lt;p&gt;A cryptography inventory that prints &lt;code&gt;RSA  quantum_vulnerable&lt;/code&gt; is asking to be believed. Whether you can check it instead comes down to two design decisions that have nothing to do with scanning: where the published source of a verdict lives, and what the tool does with an algorithm name it has never seen.&lt;/p&gt;

&lt;p&gt;I ran into both while building cbomscope (&lt;a href="https://github.com/polycratia/cbomscope" rel="noopener noreferrer"&gt;https://github.com/polycratia/cbomscope&lt;/a&gt;), which reads the cryptography out of a Go repository and off a live TLS handshake and writes the result as a CycloneDX 1.6 cryptography bill of materials. Scanning is the easy half. The hard half is that the document outlives the run.&lt;/p&gt;

&lt;p&gt;A posture is a claim about cryptanalysis, not about the code&lt;/p&gt;

&lt;p&gt;What an inventory asserts about an algorithm is not a property of the codebase. Shor's algorithm solves factoring and discrete logarithms in polynomial time, so RSA and ECDSA fall; Grover's quadratic speedup halves the effective strength of a symmetric key, so AES-128 becomes about 64 bits of quantum work. The RSA key in a test helper and the one signing production tokens carry the same posture. I separated those on purpose: which of the two you should fix first is a different question, and in cbomscope it belongs to &lt;code&gt;plan&lt;/code&gt; rather than to the classifier.&lt;/p&gt;

&lt;p&gt;It also means every posture in the output restates somebody's published result. Six months after the scan, the person reading the file is usually not the person who ran it. If the line says &lt;code&gt;quantum_vulnerable&lt;/code&gt; and nothing else, they can trust the tool or redo the literature search. Both are bad outcomes for a document that is supposed to open a migration discussion.&lt;/p&gt;

&lt;p&gt;So the verdict travels with its source.&lt;/p&gt;

&lt;p&gt;The citation belongs in the row&lt;/p&gt;

&lt;p&gt;Classification in cbomscope is a table. Each row is a family, a posture, a rationale you can read, and the citation the verdict was read from.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Rule is one row of the classification table.&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Rule&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;// Family is matched as a prefix against the uppercased algorithm name. The&lt;/span&gt;
    &lt;span class="c"&gt;// longest matching family wins, so SHA-1 is not read as SHA.&lt;/span&gt;
    &lt;span class="n"&gt;Family&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="c"&gt;// Kind narrows the row: a protocol row is never applied to an algorithm,&lt;/span&gt;
    &lt;span class="c"&gt;// nor the other way round.&lt;/span&gt;
    &lt;span class="n"&gt;Kind&lt;/span&gt; &lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Kind&lt;/span&gt;
    &lt;span class="c"&gt;// Posture is empty on rows whose verdict depends on a size.&lt;/span&gt;
    &lt;span class="n"&gt;Posture&lt;/span&gt;   &lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Posture&lt;/span&gt;
    &lt;span class="n"&gt;Rationale&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Citation&lt;/span&gt;  &lt;span class="kt"&gt;string&lt;/span&gt;

    &lt;span class="n"&gt;match&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
    &lt;span class="n"&gt;size&lt;/span&gt;  &lt;span class="n"&gt;sizeRule&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// PostureLabel is the verdict for display. A row settled by a size says so&lt;/span&gt;
&lt;span class="c"&gt;// rather than showing an empty column.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="n"&gt;Rule&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;PostureLabel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Posture&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;"by size"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Posture&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;The citations themselves are constants at the top of the file, which matters more than it sounds like it should. When they are string literals scattered through branches, nobody reads them as a set, and the same source gets paraphrased three different ways. As a block, they are an audit surface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;shor&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Shor 1997, SIAM J. Comput. 26(5): polynomial-time factoring and discrete logarithms"&lt;/span&gt;
    &lt;span class="n"&gt;grover&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Grover 1996, STOC: a quadratic speedup for unstructured search"&lt;/span&gt;
    &lt;span class="n"&gt;bht&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Brassard, Hoyer and Tapp 1998, LATIN: quantum collision search in about 2^(n/3)"&lt;/span&gt;
    &lt;span class="n"&gt;ir8547&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"NIST IR 8547: transition to post-quantum cryptography standards"&lt;/span&gt;
    &lt;span class="n"&gt;fips203&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"NIST FIPS 203: ML-KEM, module-lattice key encapsulation"&lt;/span&gt;
    &lt;span class="n"&gt;sp800131a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"NIST SP 800-131A Rev. 2: transitioning cryptographic algorithms and key lengths"&lt;/span&gt;
    &lt;span class="n"&gt;rfc6151&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"RFC 6151: MD5 and HMAC-MD5 security considerations"&lt;/span&gt;
    &lt;span class="n"&gt;shattered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Stevens et al., CRYPTO 2017: the first SHA-1 collision"&lt;/span&gt;
    &lt;span class="n"&gt;sweet32&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Bhargavan and Leurent, CCS 2016: birthday attacks on 64-bit block ciphers"&lt;/span&gt;

    &lt;span class="c"&gt;// What the attack costs is one half of the answer; what a migration is&lt;/span&gt;
    &lt;span class="c"&gt;// expected to do about it is the other.&lt;/span&gt;
    &lt;span class="n"&gt;shorMigration&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;shor&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"; "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;ir8547&lt;/span&gt;
    &lt;span class="n"&gt;groverMigration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;grover&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"; "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;ir8547&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last pair is the part I would defend hardest. A citation that only names the attack explains why an algorithm is doomed and says nothing about what anyone expects you to do instead. Pair the result with the transition guidance and the row answers both halves of the question you actually have.&lt;/p&gt;

&lt;p&gt;The table is data so the gaps stay visible&lt;/p&gt;

&lt;p&gt;The other reason this is a table rather than a chain of conditions: a chain hides its own holes. A &lt;code&gt;switch&lt;/code&gt; with no case for a family looks identical to a &lt;code&gt;switch&lt;/code&gt; that was never asked about it. A list of rows can be counted and printed, and a missing row is a visible shape rather than an absent branch.&lt;/p&gt;

&lt;p&gt;It also makes room for the families prefix matching cannot reach. A hybrid TLS group is spelled as a concatenation, and both of its halves would otherwise match rows of their own, so rows carrying a &lt;code&gt;match&lt;/code&gt; function are tried before any prefix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;postQuantumHalves&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"MLKEM"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"ML-KEM"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"KYBER"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;classicalHalves&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"X25519"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"X448"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"SECP"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"P256"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"P-256"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"P384"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"P-384"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"P521"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"P-521"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"ECDH"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;Table&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;Rule&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;Family&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;    &lt;span class="s"&gt;"X25519MLKEM768"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Posture&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;   &lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Hybrid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Rationale&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"classical and post-quantum key exchange combined: secure if either half holds"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Citation&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;  &lt;span class="n"&gt;hybridTLS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;match&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;bool&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;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"HYBRID"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
                &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;containsAny&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;postQuantumHalves&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;containsAny&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;classicalHalves&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="c"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the match rows, the longest matching family prefix wins, and that is the rule keeping SHA-1 from being read as SHA.&lt;/p&gt;

&lt;p&gt;Print the table without running a scan&lt;/p&gt;

&lt;p&gt;If the citations sit in the rows, the cheapest useful command in the whole tool is the one that prints the rows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;tw&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;tabwriter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewWriter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&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="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sc"&gt;' '&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;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fprintln&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"FAMILY&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s"&gt;VERDICT&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s"&gt;SOURCE"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;classify&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Table&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"%s&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s"&gt;%s&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s"&gt;%s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Family&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PostureLabel&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Citation&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;&lt;code&gt;cbomscope table&lt;/code&gt; takes no arguments, reads nothing, and ends with the line &lt;code&gt;n row(s). A family with no row here is reported as unknown.&lt;/code&gt; If you disagree with a verdict in a report you can go read where it came from without pointing the tool at anything, and &lt;code&gt;-json&lt;/code&gt; emits the same rows through &lt;code&gt;Rule.MarshalJSON&lt;/code&gt; for whoever wants to diff two versions of the table instead of two scans.&lt;/p&gt;

&lt;p&gt;Unknown is an answer, and it has to cost something&lt;/p&gt;

&lt;p&gt;Now the second decision. A classifier handed a name no row knows has three options: guess, pass it, or admit it. Guessing is wrong. Passing is the one that looks reasonable and isn't, because a clean report and an incomplete report become the same document.&lt;/p&gt;

&lt;p&gt;In cbomscope, no row matching returns &lt;code&gt;PostureUnknown&lt;/code&gt; with an empty &lt;code&gt;Asset.Citation&lt;/code&gt;. The empty citation is load-bearing: a source printed beside an answer nobody has would make the gap look checked.&lt;/p&gt;

&lt;p&gt;An unknown that merely sits in the output still loses, though, because it sorts somewhere near the bottom and nobody reads it. So the ranking treats it as work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;postureFactor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Posture&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Factor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QuantumSafe&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Hybrid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NotApplicable&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Factor&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Broken&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;brokenNow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QuantumVulnerable&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;shorFalls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QuantumReduced&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;resizable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;true&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;unjudged&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;false&lt;/code&gt; return is reserved for postures that genuinely imply no migration, and those assets are listed as settled rather than dropped, since a report that omits what it checked reads exactly like one that never looked. Everything else, including an asset that never reached the classifier at all, falls through to &lt;code&gt;unjudged&lt;/code&gt;: ranked above the posture that is merely resizable, because an algorithm nobody has judged is a worse position to be in than a 256-bit hash with a known answer. Silence is not a clean bill of health.&lt;/p&gt;

&lt;p&gt;The citation has to leave the tool&lt;/p&gt;

&lt;p&gt;The last step makes the terminal output irrelevant. CycloneDX 1.6 has no field for a quantum posture, so writing one into a standard field would make the document lie to anything that reads it by the spec. Instead the posture, the rationale and the citation go out as namespaced properties beside the component:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Property&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Name&lt;/span&gt;  &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"name"`&lt;/span&gt;
    &lt;span class="n"&gt;Value&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"value"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cbom.Build&lt;/code&gt; writes &lt;code&gt;cbomscope:citation&lt;/code&gt; next to the posture property. What you get is a file that can be attached to a release and read a year later by someone who has never run the tool, where every claim about an algorithm carries the paper or the standard it came from, and the entries nobody could judge say so in the same vocabulary.&lt;/p&gt;

&lt;p&gt;What I would do differently&lt;/p&gt;

&lt;p&gt;A citation is a string. That is enough for you to go find the document and not enough for anything automated to notice when the document has moved. &lt;code&gt;NIST SP 800-131A Rev. 2&lt;/code&gt; already encodes a revision in prose, which is a hint that the field wants structure: a document identifier, a revision, and optionally a URL, so a row whose source has been superseded can be found by a tool rather than by someone rereading the constants block.&lt;/p&gt;

&lt;p&gt;The second thing I would look at again is &lt;code&gt;PostureLabel&lt;/code&gt; returning &lt;code&gt;by size&lt;/code&gt;. Honest in a terminal column, but &lt;code&gt;MarshalJSON&lt;/code&gt; emits the same label, so a program consuming the JSON table sees &lt;code&gt;by size&lt;/code&gt; in a field that otherwise holds postures. Display logic leaked into the data representation, and the fix is for the JSON to expose the size-dependence as its own field instead of a magic string.&lt;/p&gt;

&lt;p&gt;Neither of those changes the shape of the design. The shape is that a verdict without a printable source is an opinion, and an inventory of opinions is what this kind of tool exists to replace.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://polycratia.com/r/mdv/cite-the-row-or-say-unknown-a-checkable-crypto-classifier" rel="noopener noreferrer"&gt;polycratia.com&lt;/a&gt; — where I write about payment systems, crypto rails and marketplace backends.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>security</category>
      <category>compliance</category>
      <category>fintech</category>
    </item>
    <item>
      <title>Validate VEX on the way out, not on the way in</title>
      <dc:creator>polycratia</dc:creator>
      <pubDate>Thu, 22 Oct 2026 13:22:54 +0000</pubDate>
      <link>https://dev.to/polycratia/validate-vex-on-the-way-out-not-on-the-way-in-1cbm</link>
      <guid>https://dev.to/polycratia/validate-vex-on-the-way-out-not-on-the-way-in-1cbm</guid>
      <description>&lt;p&gt;A tool that both reads and writes VEX documents ends up with one struct carrying two opposite obligations. A document I issue is a claim somebody downstream will act on, so anything ambiguous has to be refused before it reaches disk. A document a supplier issued last quarter is a fact, and refusing to parse it hides exactly the statements someone needs in order to fix them. The mistake is serving both duties with one validator called from both sides.&lt;/p&gt;

&lt;p&gt;I ran into this building &lt;a href="https://github.com/polycratia/vexdesk" rel="noopener noreferrer"&gt;vexdesk&lt;/a&gt;, a small Go tool that sits between a bill of materials and a VEX document: it reads what syft or trivy already wrote, works out which OSV advisories could touch the components, and turns my decisions into OpenVEX. From 11 September 2026 the EU Cyber Resilience Act expects manufacturers to answer the "is my product affected" question in writing, quickly, for every product they ship. That changes what these files are. They stop being an artefact of an internal process and become something a customer receives, stores, and compares against the one before it.&lt;/p&gt;

&lt;p&gt;That last word carries the whole argument. Comparison is the only reason to be lenient on read, and it is a sufficient one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading: refuse only what is not a document
&lt;/h2&gt;

&lt;p&gt;The read path has exactly one job: decode. Whether the document obeys the rules is information, not a gate.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Parse reads a document that has already been issued. It does not validate: a&lt;/span&gt;
&lt;span class="c"&gt;// document published last quarter is a fact, and refusing to read it would hide&lt;/span&gt;
&lt;span class="c"&gt;// exactly the statements someone needs to see in order to fix them. Writing&lt;/span&gt;
&lt;span class="c"&gt;// stays strict; reading does not.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;Parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&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="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Document&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="n"&gt;Document&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unmarshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"openvex: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&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="n"&gt;doc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Statements&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"openvex: not a VEX document: it has neither @context nor statements"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The single check left in there is not a conformance check. A payload with neither &lt;code&gt;@context&lt;/code&gt; nor &lt;code&gt;statements&lt;/code&gt; is not a malformed VEX document, it is some other JSON file, almost always because the wrong path was typed on the command line. Telling you that is helpful. Telling you that a vendor's &lt;code&gt;not_affected&lt;/code&gt; is missing a justification code, and then refusing to open the file, is not: the finding you wanted to look at is inside the file you just declined to read.&lt;/p&gt;

&lt;p&gt;The payoff shows up in the &lt;code&gt;diff&lt;/code&gt; command, which is what a recipient of a new document actually wants:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;vexdesk diff released/vex-2.0.0.json vex.json
&lt;span class="go"&gt;3 change(s), 4 claim(s) unchanged

CHANGE       ADVISORY      PRODUCT                                      DETAIL
new          FIXTURE-0005  pkg:npm/cogwheel@4.1.0                       under_investigation
restated     FIXTURE-0004  pkg:npm/cogwheel@4.1.0                       affected → fixed
rejustified  FIXTURE-0001  pkg:golang/github.com/example/widget@v1.2.3  justification vulnerable_code_not_in_execute_path → component_not_present
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The comparison runs claim by claim, one vulnerability against one product. For that to work, both sides have to be loadable, including the old one I issued before I tightened my own rules, and including the one from a supplier whose generator is sloppier than mine. A strict parser would make the second document in that pipeline unreadable precisely when it matters most.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing: refuse anything that leaves a reader guessing
&lt;/h2&gt;

&lt;p&gt;On the way out the posture inverts. &lt;code&gt;Validate&lt;/code&gt; is called from exactly one place, the constructor, and it is unforgiving.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Document&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Validate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;errs&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;error&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TrimSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Author&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;errs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;errs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"document: author is required"&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="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Statements&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&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;errs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;errs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"document: a document with no statements says nothing"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Statements&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Validate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;errs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;errs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"statement %d: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;errs&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details there are deliberate. Errors are joined rather than returned on the first failure, because somebody fixing a decisions file wants the whole list, not one problem per run. And each statement error is wrapped with its index, because "vulnerability name is required" is useless without knowing which of forty statements it belongs to.&lt;/p&gt;

&lt;h2&gt;
  
  
  not_affected is where the rule earns its keep
&lt;/h2&gt;

&lt;p&gt;The statement-level check that matters most is the one on &lt;code&gt;not_affected&lt;/code&gt;, since that is the verdict nobody rechecks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;NotAffected&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="c"&gt;// One of the closed set of codes, always. Prose explains a decision to&lt;/span&gt;
    &lt;span class="c"&gt;// the person reading this document; only the code lets a reviewer, a&lt;/span&gt;
    &lt;span class="c"&gt;// diff or another tool compare it against every other not_affected.&lt;/span&gt;
    &lt;span class="c"&gt;// An impact statement is welcome alongside it, never instead of it.&lt;/span&gt;
    &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Justification&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TrimSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ImpactStatement&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;errs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;errs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"not_affected has an impact_statement but no justification: prose does not stand in for one of %v"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;justifications&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Justification&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;errs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;errs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"not_affected requires a justification, one of %v"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;justifications&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;slices&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;justifications&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Justification&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;errs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;errs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"justification %q is not one of %v"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Justification&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;justifications&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;The first case took a real argument with myself to keep. A sentence like "the affected parser is only reached from the admin importer, which this build does not include" is genuinely more informative to a human than the code &lt;code&gt;vulnerable_code_not_in_execute_path&lt;/code&gt;. It is also unusable by the diff above. A reviewer putting this quarter's document next to last quarter's can see that a code changed; nobody can tell whether two paragraphs of prose express the same conclusion. So the code is mandatory and the prose is encouraged next to it, and the error message lists the accepted codes rather than sending the author off to find the spec.&lt;/p&gt;

&lt;p&gt;The symmetric rules follow from the same test: can you act on this without asking me a question. &lt;code&gt;affected&lt;/code&gt; requires an &lt;code&gt;action_statement&lt;/code&gt;, because telling a customer they are affected and nothing else gives them no move to make. A justification on any status other than &lt;code&gt;not_affected&lt;/code&gt; is refused outright: a code attached to &lt;code&gt;fixed&lt;/code&gt; looks like it means something and does not.&lt;/p&gt;

&lt;p&gt;Note that none of this is enforced on read. A supplier document that breaks every one of these rules parses fine and diffs fine, and that is what splitting the paths buys.&lt;/p&gt;

&lt;h2&gt;
  
  
  The identifier comes from the statements
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// New assembles a document and validates it. The identifier is derived from the&lt;/span&gt;
&lt;span class="c"&gt;// statements, so the same decisions produce the same document id — a rebuild in&lt;/span&gt;
&lt;span class="c"&gt;// CI does not look like a new document every time.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;author&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;at&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tooling&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;statements&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;Statement&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Document&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;Document&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Author&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;     &lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Timestamp&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;  &lt;span class="n"&gt;at&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UTC&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RFC3339&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;Version&lt;/span&gt;&lt;span class="o"&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;Tooling&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;tooling&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Statements&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;statements&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;deriveID&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;statements&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Validate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&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;doc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;deriveID&lt;/code&gt; is a hash of the marshalled statements, nothing else. A random UUID would have been one line shorter and would have made every CI run produce a document that looks new. The timestamp moves on every build and should; identity should not. If my decisions did not change, the &lt;code&gt;@id&lt;/code&gt; does not change, and a recipient automating against these files can tell a reissue from a revision without reading the body.&lt;/p&gt;

&lt;p&gt;That makes the placement of the validation matter in a way that is easy to miss: the ID is assigned before &lt;code&gt;Validate&lt;/code&gt; runs, so a rejected document never escapes with a plausible-looking identifier attached. There is no partially-constructed &lt;code&gt;Document&lt;/code&gt; lying around for a caller to grab and write out anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  No issuer, no document
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;vex&lt;/code&gt; command refuses to run without &lt;code&gt;-author&lt;/code&gt;. The library would refuse too, a few hundred milliseconds later, after reading the SBOM and the advisory directory, so the flag check is redundant in the strictest sense. I keep it because the reason is not about flags. A VEX document without an issuer cannot be trusted by its reader: an anonymous &lt;code&gt;not_affected&lt;/code&gt; is a claim with nobody behind it, which is worth less than no claim at all. Checking it at the edge says that out loud, and checking it again in &lt;code&gt;Validate&lt;/code&gt; means it holds no matter who calls the package.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;p&gt;Leniency on read currently means silence on read. The information is available, since &lt;code&gt;Validate&lt;/code&gt; is exported and I could run it against a parsed supplier document, but nothing in the read path surfaces it, so a document with a bare &lt;code&gt;impact_statement&lt;/code&gt; and no code passes through unremarked. I would add that as a report rather than a refusal: parse, diff, and separately say "this document breaks three rules I would not have let myself emit." Accepting a flawed document and failing to mention the flaws are two different decisions, and I conflated them.&lt;/p&gt;

&lt;p&gt;Two smaller things. &lt;code&gt;Version&lt;/code&gt; is hardcoded to &lt;code&gt;1&lt;/code&gt; while the &lt;code&gt;@id&lt;/code&gt; moves with the statements, which leaves two identity-shaped fields following different rules. And because &lt;code&gt;deriveID&lt;/code&gt; hashes only the statements, the same claims issued under a different author produce the same identifier, defensible enough since the ID names a set of decisions and the author is a separate field, but it is a property I arrived at rather than chose...&lt;/p&gt;

&lt;p&gt;The general shape has held up, though, and I think it is not specific to VEX. Any format you both consume and produce wants the validator on one side only. Strict where you are the author and responsible for what the reader can do with it; permissive where somebody else already made their choices and all you can do is read them.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://polycratia.com/r/mdv/validate-vex-on-the-way-out-not-on-the-way-in" rel="noopener noreferrer"&gt;polycratia.com&lt;/a&gt; — where I write about payment systems, crypto rails and marketplace backends.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>security</category>
      <category>compliance</category>
      <category>fintech</category>
    </item>
    <item>
      <title>Routing a withdrawal is a value, not an if inside the sender</title>
      <dc:creator>polycratia</dc:creator>
      <pubDate>Wed, 21 Oct 2026 13:29:18 +0000</pubDate>
      <link>https://dev.to/polycratia/routing-a-withdrawal-is-a-value-not-an-if-inside-the-sender-3o07</link>
      <guid>https://dev.to/polycratia/routing-a-withdrawal-is-a-value-not-an-if-inside-the-sender-3o07</guid>
      <description>&lt;p&gt;A withdrawal to an address you already custody does not have to touch a chain: crediting it is a ledger move, with no fee, no confirmations and nothing to wait for. A withdrawal to anyone else is a transaction you cannot take back. In most payout code I have been handed, the choice between those two lives inside the sender, as an &lt;code&gt;if&lt;/code&gt; a few lines above the broadcast, which means that after the fact nobody can say which path a given withdrawal took, or why.&lt;/p&gt;

&lt;p&gt;That is the whole problem. The branch itself is trivial. What is not trivial is that the branch decides the fee and the latency, and it also decides what the word &lt;em&gt;settled&lt;/em&gt; means, and then throws that decision away.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two rails do not disagree about states, they disagree about settlement
&lt;/h2&gt;

&lt;p&gt;In the withdrawal pipeline I keep at &lt;a href="https://github.com/polycratia/withdrawals" rel="noopener noreferrer"&gt;https://github.com/polycratia/withdrawals&lt;/a&gt;, both rails walk the same state chart: &lt;code&gt;requested&lt;/code&gt;, &lt;code&gt;approved&lt;/code&gt;, &lt;code&gt;sending&lt;/code&gt;, &lt;code&gt;sent&lt;/code&gt;, &lt;code&gt;confirmed&lt;/code&gt;, with &lt;code&gt;rejected&lt;/code&gt; and &lt;code&gt;failed&lt;/code&gt; as the two ways out. I did not fork the machine per rail, and I would push back hard on anyone who wanted to. What differs between the rails is not the shape of the lifecycle: it is what the reference on the request means, and when the request is allowed to confirm. On an internal route, the thing that moved is a ledger entry against an account I own, and it is done once the credit is booked. On an external route, &lt;code&gt;sent&lt;/code&gt; means a transaction exists somewhere and &lt;code&gt;confirmed&lt;/code&gt; means the chain agreed to keep it.&lt;/p&gt;

&lt;p&gt;A boolean living in a local variable inside the sender cannot carry that. The settlement rule has to travel with the withdrawal, because the code that decides whether to wait for confirmations runs minutes or hours after the code that picked the rail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decide once, return the decision, log it before you hand it back
&lt;/h2&gt;

&lt;p&gt;So routing is its own function, and it returns a value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;WithdrawalRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;custody&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Custody&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Route&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Decide which rail `request` travels on and log the decision.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;account&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;custody&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;account_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;account&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;rail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Rail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EXTERNAL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;destination is not an address we custody&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;rail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Rail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;INTERNAL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;destination is custodied by account &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;withdrawal %s routed %s to %s: %s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;,&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;decision&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details in there are load-bearing, and the &lt;code&gt;if&lt;/code&gt; is not one of them.&lt;/p&gt;

&lt;p&gt;The first is &lt;code&gt;reason&lt;/code&gt;. The rail alone tells you what happened; the reason tells you what the system believed at the time. Months later, when someone asks why a payout to a known customer address cost a network fee, the answer you need is not "it went external", since the fee already told you that. The answer you need is "custody did not recognise that address at 11:42", which is a statement about the state of your own address book, not about the chain.&lt;/p&gt;

&lt;p&gt;The second is that the log line happens before the return, unconditionally, on both branches. That sounds like a style point and is not. An internal transfer leaves no trace on any chain, so if your only durable record of a payout is the explorer, every internal withdrawal you ever processed is invisible to you. Logging at the decision point, rather than inside whichever sender was eventually chosen, is what makes the two populations countable afterwards. I have had to reconstruct that population retroactively on a custodial system once, from ledger entries alone, and it is not an afternoon's work...&lt;/p&gt;

&lt;h2&gt;
  
  
  Each rail's rules belong in the value, not in the caller
&lt;/h2&gt;

&lt;p&gt;The decision is a frozen dataclass that refuses to exist in a shape that makes no sense:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slots&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;The routing decision for one withdrawal, with the reason behind it.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="n"&gt;rail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Rail&lt;/span&gt;
    &lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__post_init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;destination must be a non-empty string&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reason must be a non-empty string&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rail&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;Rail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;INTERNAL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
                &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;an internal route must name the account it credits&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;account&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;an external route has no internal account&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nd"&gt;@property&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;is_internal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;True when the transfer stays on our own books.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rail&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;Rail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;INTERNAL&lt;/span&gt;

    &lt;span class="nd"&gt;@property&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;needs_confirmations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;True when the transfer only settles once the chain confirms it.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rail&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;Rail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EXTERNAL&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An internal route must name the account it credits. An external route must not carry one. That second rule is the interesting half: it is there because the bug I expect is not a missing account, it is a stale one, a decision that says external while still holding an account value that came from an earlier lookup, or a retry, or a copy-paste in a test fixture. A credit booked against that account is money created out of nothing. Making the combination unconstructible costs four lines and removes the whole class.&lt;/p&gt;

&lt;p&gt;Note also that &lt;code&gt;is_internal&lt;/code&gt; and &lt;code&gt;needs_confirmations&lt;/code&gt; are derived from &lt;code&gt;rail&lt;/code&gt; rather than stored beside it. There is exactly one field that can be wrong. Anything a later stage wants to know about settlement it asks the decision, so the sender, the confirmation watcher and the reconciliation job cannot drift apart on what external means.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lookup is where the money actually goes wrong
&lt;/h2&gt;

&lt;p&gt;Once routing is a value, the branch is boring and the risk moves entirely into the custody lookup. That lookup is asymmetric in a way worth saying out loud: a destination that &lt;em&gt;is&lt;/em&gt; mine but fails to match sends real funds through a real rail and pays a real fee for something that should have been a free ledger entry, and you cannot undo it. The reverse error, matching an address that is not mine, credits an internal account for a payout that did not go anywhere. Neither is a crash. Both are quiet.&lt;/p&gt;

&lt;p&gt;Which is why normalisation is the implementation's job, and it happens on both sides of the comparison:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;InMemoryCustody&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Custody backed by a mapping of deposit address to internal account.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="n"&gt;__slots__&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_accounts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapping&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_accounts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;account&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
                &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;custody entries need an address and an account&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_accounts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;_lookup_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;account&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;account_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_accounts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;_lookup_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_lookup_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&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;destination&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;casefold&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A padded or mixed-case destination still finds its account, and an empty address or account is refused at construction rather than sitting in the map as a key that matches nothing. The test table is the specification here, and it is deliberately boring:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;DESTINATIONS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="n"&gt;pytest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;param&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;THEIRS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Rail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EXTERNAL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;an-address-we-do-not-know&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;pytest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;param&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;THEIRS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;Rail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EXTERNAL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;case-does-not-make-it-ours&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;pytest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;param&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OURS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Rail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;INTERNAL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;acct-77&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;an-address-we-custody&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;pytest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;param&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OURS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;Rail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;INTERNAL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;acct-77&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;upper-case&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;pytest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;param&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;OURS&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Rail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;INTERNAL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;acct-77&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;surrounding-space&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;case-does-not-make-it-ours&lt;/code&gt; row is the one I would keep if I could keep only one. Normalisation widens what matches. It must not widen &lt;em&gt;whose&lt;/em&gt; it is.&lt;/p&gt;

&lt;p&gt;One consequence of doing it this way: the decision records the destination exactly as it was asked for, spaces and all, while the match runs on the normalised key. The test asserts that directly. The log should show what the user submitted, because that is the string you will be comparing against a support ticket, not your internal key.&lt;/p&gt;

&lt;h2&gt;
  
  
  One place picks the sender
&lt;/h2&gt;

&lt;p&gt;With the decision in hand, selecting a sender stops being a branch at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;withdraw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;custody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;holds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rails&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;ensure_approved&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;custody&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;holds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;place&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Hold&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;send&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rails&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rail&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;submissions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;broadcast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;send&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;holds&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;&lt;code&gt;rails[decision.rail]&lt;/code&gt; is the entire dispatch. The ledger sender contains no chain code and the chain sender contains no ledger code, so neither of them can take the wrong path under a bad condition: the worst they can do is fail at what they are for.&lt;/p&gt;

&lt;p&gt;Custody itself is a one-method runtime-checkable &lt;code&gt;Protocol&lt;/code&gt;, &lt;code&gt;account_for(destination) -&amp;gt; str | None&lt;/code&gt;. In production that is a database query against the deposit-address table. In the tests it is a dictionary, and one test supplies a class that knows exactly one address, to pin down that any lookup will do. Routing has no opinion about where the address book lives, which is the only reason the decision can be unit-tested without a chain, a ledger or a clock.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;needs_confirmations&lt;/code&gt; is a boolean, and a boolean answers &lt;em&gt;whether&lt;/em&gt;, not &lt;em&gt;how deep&lt;/em&gt;. Confirmation depth is a per-asset number, so the boolean is probably best read as a floor ("this one waits"), and the depth still has to come from somewhere else. If I were starting again I would think harder about whether the route is the right carrier for that number, so that the settlement rule travels as one value rather than two.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;reason&lt;/code&gt; is prose. That is correct for the reader I care about most, the person reading a log line at an awkward hour, and wrong for the other reader: you cannot aggregate over a sentence. The structured half of an internal decision is the account, and that is what I would group by if I had to report how much moved on my own books. I would not replace the sentence with a code. I would stop pretending the sentence is queryable.&lt;/p&gt;

&lt;p&gt;And &lt;code&gt;_lookup_key&lt;/code&gt; is a module-level helper, which quietly assumes one normalisation is right for every address space a withdrawal might target. The &lt;code&gt;Custody&lt;/code&gt; protocol gets this right by leaving normalisation to the implementation. The in-memory implementation just happens to pick one, and that convenience is the kind of thing that later gets reused by a custody backend where it does not fit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Close
&lt;/h2&gt;

&lt;p&gt;The sender's job is to send. The decision about which sender, and the reason behind it, is one function call older than the send, and it needs to outlive it by the length of your log retention. Written down as a value, a branch nobody remembers taking turns into a fact you can answer questions about.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://polycratia.com/r/mdv/routing-a-withdrawal-is-a-value-not-an-if-inside-the-sender" rel="noopener noreferrer"&gt;polycratia.com&lt;/a&gt; — where I write about payment systems, crypto rails and marketplace backends.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>crypto</category>
      <category>blockchain</category>
      <category>fintech</category>
    </item>
    <item>
      <title>A paywall is a table of what is sold</title>
      <dc:creator>polycratia</dc:creator>
      <pubDate>Tue, 20 Oct 2026 13:29:17 +0000</pubDate>
      <link>https://dev.to/polycratia/a-paywall-is-a-table-of-what-is-sold-1if5</link>
      <guid>https://dev.to/polycratia/a-paywall-is-a-table-of-what-is-sold-1if5</guid>
      <description>&lt;p&gt;Any HTTP payment gate has to answer one question: what happens to a request it has no price for. Most middleware answers that by accident rather than by design. Everything behind the gate is paid, so a path nobody priced gets a challenge built from some fallback amount, or a 500, or an empty &lt;code&gt;accepts&lt;/code&gt; array that no client can act on. I built &lt;a href="https://github.com/polycratia/paygate402" rel="noopener noreferrer"&gt;paygate402&lt;/a&gt; the other way around. The price table is the entire statement of what is sold, and a route the table does not name is served free, with no challenge at all.&lt;/p&gt;

&lt;p&gt;That is one of two defaults in this gate worth arguing about. The other one is an ordering: the free tier is spent before the &lt;code&gt;X-PAYMENT&lt;/code&gt; header is even read. Five lines each. Both of them decide what the thing is.&lt;/p&gt;

&lt;h2&gt;
  
  
  An unpriced route is not an error
&lt;/h2&gt;

&lt;p&gt;A gate sitting in front of a single resource can carry its terms directly in &lt;code&gt;Accepts&lt;/code&gt;. A gate in front of a whole API declares a price per route instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;gate&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;paygate402&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Gate&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Prices&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;paygate402&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Prices&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;Terms&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;paygate402&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Requirements&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;Scheme&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;            &lt;span class="s"&gt;"exact"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;Network&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;           &lt;span class="s"&gt;"base"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;PayTo&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;             &lt;span class="n"&gt;merchantAddress&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;Asset&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;             &lt;span class="n"&gt;usdcAddress&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;MaxTimeoutSeconds&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;Routes&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;paygate402&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Route&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Pattern&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"/report"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Amount&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"10000"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Description&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"One generated report"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Pattern&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"/reports/{id}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Amount&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"2000"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Pattern&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"POST /jobs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Amount&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"50000"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;BaseURL&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"https://api.example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;Facilitator&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;facilitator&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Terms&lt;/code&gt; holds what does not change from route to route (the scheme, the network, the asset, the recipient), and each route supplies the amount. The lookup is a compiled &lt;code&gt;http.ServeMux&lt;/code&gt; of the priced patterns, so method matching, wildcards and precedence come from the standard library instead of a second dialect invented inside a payment package. &lt;code&gt;/reports/summary&lt;/code&gt; beats &lt;code&gt;/reports/{id}&lt;/code&gt; here for the same reason it would anywhere else in a Go server.&lt;/p&gt;

&lt;p&gt;What happens on a miss is where the design decision sits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;accepts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;accepts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Is&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ErrNoPrice&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="c"&gt;// The table is the whole statement of what is sold here, so a route&lt;/span&gt;
    &lt;span class="c"&gt;// missing from it is free rather than closed.&lt;/span&gt;
    &lt;span class="n"&gt;next&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ServeHTTP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusInternalServerError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ErrNoPrice&lt;/code&gt; is a sentinel the lookup returns for a path no &lt;code&gt;Route&lt;/code&gt; declares, and the gate reads it as &lt;em&gt;this costs nothing&lt;/em&gt;, not as &lt;em&gt;this failed&lt;/em&gt;. A health check needs no line in the table saying it is free. Neither does &lt;code&gt;/favicon.ico&lt;/code&gt;, nor the OpenAPI document, nor whatever the load balancer probes.&lt;/p&gt;

&lt;p&gt;The opposite default (fail closed, charge for anything not explicitly exempt) sounds like the safe one, and it is the one I would expect a security reviewer to ask for. I think it is wrong for this component, for a reason that has nothing to do with safety: it stops the price table from being the statement. You would need a second table, of exemptions, and the two would be disagreeing inside a month. Every route added to the API would have to land on one list or the other before the service could answer a probe, and the failure mode of forgetting is a 402 on an endpoint whose caller has no wallet and no idea what a facilitator is. Ask a paywall what it sells, and it should answer by printing one list. It can only do that if the list is exhaustive by construction.&lt;/p&gt;

&lt;p&gt;The cost of this default is honest and should be stated: a typo in a pattern makes a paid route free rather than broken. That is a real risk, and it is why the two failures that &lt;em&gt;can&lt;/em&gt; be caught are caught loudly. A malformed or repeated pattern makes &lt;code&gt;http.ServeMux&lt;/code&gt; panic, and registration turns that panic into an error the gate reports as a 500, like the rest of its misconfiguration. &lt;code&gt;Gate.validate&lt;/code&gt; refuses a gate with both &lt;code&gt;Accepts&lt;/code&gt; and &lt;code&gt;Prices&lt;/code&gt; set, because those are two ways of saying the same thing, and the alternative is a precedence rule nobody would remember at three in the morning.&lt;/p&gt;

&lt;h2&gt;
  
  
  The free tier is spent before the payment is read
&lt;/h2&gt;

&lt;p&gt;The second default is nothing but the order of two statements in &lt;code&gt;Handler&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// The free tier is spent before the payment is even read: a client that&lt;/span&gt;
&lt;span class="c"&gt;// does not have to pay yet is not asked to, and a payment it sent anyway&lt;/span&gt;
&lt;span class="c"&gt;// is left unspent rather than taken.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;left&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;granted&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Free&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;take&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;granted&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;serveFree&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;next&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;left&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;DecodePayment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PaymentHeader&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Turn those around and nothing visibly breaks. The gate still serves five free requests, and the tests that count responses still pass. What changes is the client that sends a payment it did not have to send: an agent that attaches one to every request, because that is simpler than tracking an allowance. Decode first, and that payment gets matched, verified, recorded against replay and settled, and the free request is consumed on top of it. The client paid for something advertised as free and has no way to tell, because the response looks exactly like the paid one.&lt;/p&gt;

&lt;p&gt;With the check in this order, the payment header is not read at all. The facilitator is not called. The test asserts the absence rather than the outcome, which is the only way to assert it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;TestAGateWithFixedTermsCanAlsoHaveAFreeTier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;testing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;facilitator&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;StaticFacilitator&lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;gate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;facilitator&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Free&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;Allowance&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Requests&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Code&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusOK&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fatalf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"status = %d, want the free request served"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Code&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="n"&gt;second&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;second&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Code&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusPaymentRequired&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"status = %d, want 402 once the allowance is gone"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;second&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Code&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="n"&gt;facilitator&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Trail&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"calls = %q: a free request was put to the facilitator"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;facilitator&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Trail&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every free response also carries how many are left:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;X-PAYMENT-ALLOWANCE: 4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;AllowanceHeader&lt;/code&gt; is this package's own: x402 does not define it. A client that can watch the allowance running down can have a payment ready, instead of finding the boundary by being refused and having to replay the request.&lt;/p&gt;

&lt;h2&gt;
  
  
  A free request that produced nothing is given back
&lt;/h2&gt;

&lt;p&gt;The paid path in this gate does not charge for work it did not deliver: a handler that answers non-2xx gets the payment released rather than left spent. The free path owes the client the same courtesy, for the same reason. &lt;code&gt;serveFree&lt;/code&gt; runs the handler on the house, and when the handler answers outside the 2xx range it hands the request back through &lt;code&gt;Allowance.give&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The in-process counter does that with a decrement, and with the floor that matters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Return gives one free request back.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;AllowanceCounter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Return&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mu&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Lock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mu&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unlock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;used&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;used&lt;/span&gt; &lt;span class="o"&gt;&amp;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;entry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;used&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;entry.used &amp;gt; 0&lt;/code&gt; guard is not defensive clutter. Without it, a duplicated return (a retry, a buggy caller, a store that saw the release twice) drives the count negative and quietly hands out an unbounded free tier.&lt;/p&gt;

&lt;p&gt;The tally resets over &lt;code&gt;Every&lt;/code&gt;, and the sweep runs on write instead of in a goroutine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// sweep drops the clients whose period has closed. It runs on write, on the&lt;/span&gt;
&lt;span class="c"&gt;// requests that matter, so no background goroutine has to exist. A lifetime&lt;/span&gt;
&lt;span class="c"&gt;// allowance has no period and is never swept — that is what makes it a&lt;/span&gt;
&lt;span class="c"&gt;// lifetime one.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;AllowanceCounter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;sweep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;used&lt;/span&gt; &lt;span class="p"&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;entry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resets&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsZero&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;After&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resets&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nb"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;used&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Every == 0&lt;/code&gt; means the allowance does not reset, and that falls out of the same branch rather than from a special case: an entry with no reset time is never swept, so it is a lifetime allowance by omission.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who counts as a client
&lt;/h2&gt;

&lt;p&gt;Clients are told apart by &lt;code&gt;ClientIP&lt;/code&gt;, which reads &lt;code&gt;RemoteAddr&lt;/code&gt; and nothing else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;ClientIP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;net&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SplitHostPort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RemoteAddr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&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;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RemoteAddr&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;host&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No forwarded headers. Anyone can set one, and a free tier that counts a header the client controls counts nothing. The consequence is that behind a proxy the default is useless, since every request arrives from one address and the whole world shares five requests, so &lt;code&gt;Allowance&lt;/code&gt; takes a &lt;code&gt;Client&lt;/code&gt; function: you give it the header your own proxy sets, or you key the tier on something a client states on purpose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Free&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;Allowance&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Requests&lt;/span&gt;&lt;span class="o"&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;Client&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;   &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;string&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;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"authorization"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a courtesy and not a rate limit, and it should not be sold as one. Before anyone has paid, the only thing a request carries is the address it came from, and a new address is a new allowance, so the tier is worth exactly what a fresh address costs. It exists so an agent can try an endpoint before paying for it. The tally also lives in one process unless &lt;code&gt;Counts&lt;/code&gt; is set, which is why it is an interface: several replicas mean several allowances.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;AllowanceStore&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Take&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;window&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;Return&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I would change
&lt;/h2&gt;

&lt;p&gt;The inconsistency I would fix first is about trusting the request. The allowance refuses to read a forwarded header because the client controls it. But when neither &lt;code&gt;BaseURL&lt;/code&gt; nor a route &lt;code&gt;Resource&lt;/code&gt; is set, the resource named in the 402 gets built from the request's own scheme and host, which are just as much the client's to assert and are wrong behind a proxy that terminates TLS. Two parts of one package answer the same question in opposite directions. The free tier is the side that got it right. The resource fallback should be a configuration error instead of a guess.&lt;/p&gt;

&lt;p&gt;The rest I would keep. A paywall whose table is the whole statement of what is sold, and a free tier spent before any money is read: those are choices you get to make once, because every later line of code assumes one answer or the other.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://polycratia.com/r/mdv/a-paywall-is-a-table-of-what-is-sold" rel="noopener noreferrer"&gt;polycratia.com&lt;/a&gt; — where I write about payment systems, crypto rails and marketplace backends.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>crypto</category>
      <category>blockchain</category>
      <category>fintech</category>
    </item>
    <item>
      <title>The ledger core takes no lock, and the protocol says whose job it is</title>
      <dc:creator>polycratia</dc:creator>
      <pubDate>Mon, 19 Oct 2026 13:22:41 +0000</pubDate>
      <link>https://dev.to/polycratia/the-ledger-core-takes-no-lock-and-the-protocol-says-whose-job-it-is-506c</link>
      <guid>https://dev.to/polycratia/the-ledger-core-takes-no-lock-and-the-protocol-says-whose-job-it-is-506c</guid>
      <description>&lt;p&gt;Placing a hold is a read followed by a write: you ask what is available on an account, refuse if the amount does not fit, store the hold. Two callers doing that at the same moment can both read the same figure, both find room, and both write, and the account now reserves more than it holds. A ledger core cannot close that gap on its own, because it owns no records to lock. So you put the gap in the interface and name the side responsible for closing it.&lt;/p&gt;

&lt;p&gt;That is the shape I settled on in &lt;a href="https://github.com/polycratia/ledger-core" rel="noopener noreferrer"&gt;ledger-core&lt;/a&gt;, a double-entry core for custodial balances. The core promises the arithmetic. The store promises that a check and the write it guards land as one step. Both halves are written down, and the second one is a method on a protocol rather than a paragraph in a README, which (as I will get to) is the difference between a contract and a wish.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two steps
&lt;/h2&gt;

&lt;p&gt;Here is the whole of it, unedited:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;place&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;hold_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Account&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;MoneyLike&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;placed_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;memo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapping&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Hold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Reserve funds. Refused if they are not there to reserve.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;atomic&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hold&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hold_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;DuplicateHold&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hold &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;hold_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; is already placed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;hold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Hold&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;hold_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;hold_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;placed_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;placed_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;memo&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;memo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;available&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;available&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;hold&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;available&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;InsufficientFunds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hold &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;hold_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; reserves &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                    &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;available&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; is available on &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put_hold&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hold&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;hold&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two guarded writes are stacked in that method, not one. The duplicate check reads a hold by id, then writes a hold under that id. The funds check reads &lt;code&gt;available(account)&lt;/code&gt; and then writes. Strip the &lt;code&gt;with&lt;/code&gt; line and the method still reads as correct to a reviewer going top to bottom, and it is wrong in the only sense that matters in production.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Holds.spend&lt;/code&gt; has the same shape for a movement that was never reserved:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;spend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&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;Entry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Account&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Entry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Write a movement out of an account, refused if the funds are not free.

        An entry answers to its own rules only, and balancing says nothing about
        whether what it takes is still there once open holds are counted. This
        asks that question first and appends only if the answer holds.
        &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;atomic&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="n"&gt;taken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nf"&gt;effect_on&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;account&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;available&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;available&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;taken&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;available&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;InsufficientFunds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;entry &lt;/span&gt;&lt;span class="si"&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;entry_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; takes &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;taken&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; from &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                    &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;available&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; is available&lt;/span&gt;&lt;span class="sh"&gt;"&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_journal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The arithmetic the core actually owns
&lt;/h2&gt;

&lt;p&gt;What the core guarantees with no help from anyone is narrow, and worth stating precisely: people assume it covers more than it does.&lt;/p&gt;

&lt;p&gt;An entry is checked for balance when it is built, so a one-sided movement never becomes an object. &lt;code&gt;Journal.append&lt;/code&gt; asks whether the entry balances and nothing else: it has no opinion about whether the account can afford it. A balance is folded from postings rather than kept as a number, so there is no stored total for two writers to read and write back over each other, and that whole class of lost update does not exist here. &lt;code&gt;available&lt;/code&gt; is &lt;code&gt;balance&lt;/code&gt; less &lt;code&gt;held&lt;/code&gt;, both derived.&lt;/p&gt;

&lt;p&gt;None of that says anything about two &lt;code&gt;spend&lt;/code&gt; calls racing on one account. Each one balances. Each one gets a truthful answer from &lt;code&gt;available()&lt;/code&gt; at the moment it asks. The arithmetic is sound in both and the account still ends up overdrawn. Correct arithmetic over an unstable read is still a wrong answer, and no amount of invariant checking inside the core fixes it, because the instability sits in the store.&lt;/p&gt;

&lt;h2&gt;
  
  
  The seam has a name
&lt;/h2&gt;

&lt;p&gt;So the seam is declared:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@runtime_checkable&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Storage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Protocol&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;atomic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;AbstractContextManager&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;One unit of writing: everything under the block, or none of it.

        A guarded write is two steps — asking whether the funds are free or the
        key already settled, and then writing — and a refusal in the second must
        not leave the first behind. Writes made under the block land together;
        leaving it by an exception undoes them. A block opened inside another
        joins it rather than settling on its own, so a guarded write called from
        inside a wider one widens to it.
        &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reason this is a protocol method and not prose is mechanical: you cannot implement &lt;code&gt;Storage&lt;/code&gt; without writing &lt;code&gt;atomic&lt;/code&gt;. A type checker raises it, and &lt;code&gt;isinstance(InMemoryStorage(), Storage)&lt;/code&gt; is asserted in the test suite. Whoever adapts this core to their database gets forced to answer the question at the moment they are best placed to answer it, since they are the only person who knows what their store can promise.&lt;/p&gt;

&lt;p&gt;An earlier shape of this package had no &lt;code&gt;atomic&lt;/code&gt; at all. &lt;code&gt;Storage&lt;/code&gt; declared &lt;code&gt;append_entries&lt;/code&gt;,&lt;code&gt;put_hold&lt;/code&gt;,&lt;code&gt;put_snapshot&lt;/code&gt; and &lt;code&gt;put_operation&lt;/code&gt;, and the README said the atomic check-then-write pair belonged to the backend. True words, and useless ones. The core performed the check and the core performed the write; the backend was handed two unrelated method calls with no indication that they belonged together. There was nothing for a transaction to attach to. The split was documented and unimplementable at the same time, which is the specific failure I want to name, because it reads like diligence. A division of responsibility that gives the other side no place to stand has not been divided. It has been dropped.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the reference store does
&lt;/h2&gt;

&lt;p&gt;The in-memory store has no transaction of its own, so it builds one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;    &lt;span class="nd"&gt;@contextmanager&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;atomic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Iterator&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Writes under this block land together, or not at all.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_lock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_depth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_depth&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
                &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="k"&gt;yield&lt;/span&gt;
                &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_depth&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt;
            &lt;span class="c1"&gt;# records are values, so a shallow copy of each shelf is a whole one
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The outermost block keeps a copy of each shelf and puts it back if the block is left by an exception, and the &lt;code&gt;RLock&lt;/code&gt; admits one writer at a time. Rollback is cheap for the same reason balances cannot drift: records are values, so copying the dicts copies everything that could change.&lt;/p&gt;

&lt;p&gt;For a dict-backed double this is exactly right. As a mental model for production it misleads you in one direction: it serialises every writer in the process, not the writers touching one account. Which brings me to the part that actually bites.&lt;/p&gt;

&lt;h2&gt;
  
  
  A transaction is not the whole promise
&lt;/h2&gt;

&lt;p&gt;For a database-backed store,&lt;code&gt;atomic&lt;/code&gt; is the transaction you already have, and that is usually where people stop. Not enough. The read inside the block is &lt;code&gt;available(account)&lt;/code&gt;: the account's balance folded from the journal, resumed from a snapshot, minus the sum of its open holds. Under read-committed isolation, two transactions can both read that pre-state, both insert their hold row, and both commit. No constraint is violated; a hold is just a row, and nothing in the schema knows it was meant to fit inside a figure that was true a moment ago. The block was atomic. It was never serialised against the other writer.&lt;/p&gt;

&lt;p&gt;So the promise a backend owes is more specific than "wrap it in a transaction": the read the core makes inside the block and the writes that follow must not interleave with another writer on the same account. Two ordinary ways to get there: serialisable isolation with a retry on conflict, or an explicit lock on the account row taken before the read. Neither is exotic. What takes the work is noticing you need one, and that is what a declared method with a docstring, sitting in front of the implementer, buys you.&lt;/p&gt;

&lt;p&gt;One thing you do not have to guard: the snapshot cache. A snapshot is only ever replaced by one that reaches further, and one claiming more entries than the journal holds is refused, so a stale snapshot costs you the replay of what it is missing and never a wrong answer. The guarded set is holds and entries. Effort spent locking the cache is effort not spent on the thing that can actually go wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Blocks nest, and that is why capture is one step
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Holds.capture&lt;/code&gt; appends the settling entry and then writes the settled hold: two writes, and &lt;code&gt;Journal.append&lt;/code&gt; opens a block of its own for the first of them. If that inner block committed independently, a refusal in the second half would leave the entry in the journal with the hold still open, and the ledger would claim the money both moved and is still reserved. The joining rule is what lets me describe capture as a single step rather than a sequence that is usually fine.&lt;/p&gt;

&lt;p&gt;It works outward too. An application that wants "settle the hold and mark the payout row paid" in one commit opens its own transaction and calls &lt;code&gt;capture&lt;/code&gt; inside it; the core's block widens to the caller's instead of closing early. Width belongs to whoever has the broader unit of work, and the core does not assume it is that party.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prove the rollback, do not assert it
&lt;/h2&gt;

&lt;p&gt;The second half of a guarded write is the half nobody exercises, so the suite has a store you can tell to refuse:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Refusing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;InMemoryStorage&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;A store that can be made to refuse one kind of record.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;super&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;refuses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;put_hold&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Hold&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hold&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;super&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;put_hold&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hold&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;put_operation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;operation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Operation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;operation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;super&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;put_operation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;operation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;refuses&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;the store refused the &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is how you get to assert the property that matters, a store that refuses the hold leaves no entry behind, instead of hoping it holds. There is a &lt;code&gt;tests/test_races.py&lt;/code&gt; next to it for the overlapping-call side of the same question.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;p&gt;Ship &lt;code&gt;atomic&lt;/code&gt; in the first version. The release that documented the split without providing it was worse than one that said plainly "this core is single-writer, do not run two processes against it": an honest limitation is actionable, a contract the other side cannot fulfil is not.&lt;/p&gt;

&lt;p&gt;Write the isolation requirement into the protocol in verifiable terms. "How wide that promise is belongs to the store" is true and leaves the sharp edge unnamed. "The read made inside the block and the writes that follow must not interleave with another writer on the same account" is something a backend author can check their isolation level against.&lt;/p&gt;

&lt;p&gt;And I would probably be more careful about the reference store setting expectations. A test double stricter than production is how a race survives a green suite: the in-memory store's one-writer-at-a-time lock makes the concurrency tests pass for a reason no database will reproduce. The double proves the rollback semantics. It cannot prove the isolation, and the suite should say so where someone will read it.&lt;/p&gt;

&lt;p&gt;A core that takes no lock is a reasonable core. A core that takes no lock and does not say so is a race condition with documentation.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://polycratia.com/r/mdv/the-ledger-core-takes-no-lock-and-the-protocol-says-whose-job-it-is" rel="noopener noreferrer"&gt;polycratia.com&lt;/a&gt; — where I write about payment systems, crypto rails and marketplace backends.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>crypto</category>
      <category>blockchain</category>
      <category>fintech</category>
    </item>
    <item>
      <title>A post-quantum migration plan should print its arithmetic</title>
      <dc:creator>polycratia</dc:creator>
      <pubDate>Sun, 18 Oct 2026 13:22:37 +0000</pubDate>
      <link>https://dev.to/polycratia/a-post-quantum-migration-plan-should-print-its-arithmetic-1hkl</link>
      <guid>https://dev.to/polycratia/a-post-quantum-migration-plan-should-print-its-arithmetic-1hkl</guid>
      <description>&lt;p&gt;A cryptographic inventory lists what you have. A migration plan claims what to do first, and that claim is an opinion wearing a number. If you cannot recompute the score sitting next to a line, you have two moves available: accept the ordering, or ignore the tool. In my experience you ignore the tool.&lt;/p&gt;

&lt;p&gt;I hit this building cbomscope (&lt;a href="https://github.com/polycratia/cbomscope" rel="noopener noreferrer"&gt;https://github.com/polycratia/cbomscope&lt;/a&gt;), which reads Go source and a live TLS handshake and writes a CycloneDX cryptography bill of materials. The inventory part was uncontroversial. A posture says what a quantum computer would do to an algorithm, which is a property of the algorithm, and nobody argues with a list. The plan command was different: it ranks the same assets by the work they imply, and the first version printed a sorted list with a score column and nothing else. It fell over the first time someone disagreed with it. They could not tell me which part they disagreed with, only that an RSA call in a test helper should not be sitting above the signature algorithm on a public endpoint. That is not feedback about the result. It is feedback about a weight, and the output gave them no way to say so.&lt;/p&gt;

&lt;p&gt;The constraint&lt;/p&gt;

&lt;p&gt;A migration plan gets read by people who have the authority to reorder it and information the tool does not have. This service is being decommissioned next quarter. That endpoint carries data with a ten-year confidentiality requirement. This dependency is pinned because the upgrade breaks an API nobody here controls. None of that is visible to a program that parses syntax trees and completes handshakes.&lt;/p&gt;

&lt;p&gt;So the goal is not to be right about the order. It is to be legible enough that you can overrule one factor without throwing away the whole ordering. A hidden score forces an all-or-nothing judgement on the tool's credibility. A printed one shrinks the argument into something much smaller and much more productive: you think exposure should outweigh lifetime here, and now the conversation is about a number instead of about trust.&lt;/p&gt;

&lt;p&gt;Small scales, declared&lt;/p&gt;

&lt;p&gt;The weights are not literals buried in a scoring function. Each one is a named Factor (a label, a weight, and a reason), and the scoring functions do nothing but choose a row.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// postureFactor reports the weight of a posture, and false where the posture&lt;/span&gt;
&lt;span class="c"&gt;// implies no migration at all. Anything it does not recognise — including an&lt;/span&gt;
&lt;span class="c"&gt;// asset that never reached the classifier — is ranked as unjudged rather than&lt;/span&gt;
&lt;span class="c"&gt;// settled: silence is not a clean bill of health.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;postureFactor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Posture&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Factor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QuantumSafe&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Hybrid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NotApplicable&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Factor&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Broken&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;brokenNow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QuantumVulnerable&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;shorFalls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QuantumReduced&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;resizable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;true&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;unjudged&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;exposureFactor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Asset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Factor&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Location&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Host&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;atEndpoint&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Module&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;inDependency&lt;/span&gt;
    &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;firstParty&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;lifetimeFactor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Asset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Factor&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Primitive&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;KEM&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;KeyAgree&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PKE&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;keyExchange&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Signature&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;signature&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;storedData&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three questions, three scales, and the scales are small on purpose. Key exchange weighs 3 and a signature weighs 1, because the two run on different clocks: what is recorded off the wire today can be decrypted years from now, while a signature is forged at the moment it is verified. That is a deadline rather than a leak, and a deadline you can still move. A one-to-three range is a thing you can hold an opinion about. A zero-to-a-hundred range is not. It only looks more precise, and the extra digits are where fabricated confidence hides.&lt;/p&gt;

&lt;p&gt;Why posture multiplies and the rest adds&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;exposure&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lifetime&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;exposureFactor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;lifetimeFactor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Items&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Item&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Asset&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Score&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;posture&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Weight&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exposure&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Weight&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;lifetime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Weight&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;Posture&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;  &lt;span class="n"&gt;posture&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Exposure&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;exposure&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Lifetime&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;lifetime&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;The shape of the expression is itself a claim, and it should be one you can defend out loud. Exposure and lifetime add because they are independent reasons to move sooner: being reachable from the internet and protecting long-lived secrets are separate problems, and an asset with one of them is still work. Posture multiplies because it scales both. A classically broken primitive makes every other property worse, and a sound one makes them moot. Addition there would let a pile of mild factors out-rank something already broken, which is the failure mode people notice and do not forgive.&lt;/p&gt;

&lt;p&gt;The expression goes next to the line&lt;/p&gt;

&lt;p&gt;This is the whole argument in one method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Arithmetic is the score written out, so the number in a column can be checked&lt;/span&gt;
&lt;span class="c"&gt;// against the line that carries it.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="n"&gt;Item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Arithmetic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kt"&gt;string&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;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"%s (%d) × [%s (%d) + %s (%d)]"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Posture&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Posture&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Weight&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Exposure&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Exposure&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Weight&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Lifetime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Lifetime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Weight&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;Every ranked line carries the expression that produced its number, in named terms rather than bare integers. You do not have to find the source, and you do not have to take my word for the ordering: you can see that this line scored where it did because it was found at an endpoint and because it is a key agreement, and you can decide that in your deployment the second term is wrong. The factors also survive the JSON output, along with the formula itself on the report, so a plan that travels into a ticket arrives with its reasoning attached instead of a column of integers somebody has to reverse-engineer later.&lt;/p&gt;

&lt;p&gt;The same instinct runs one layer down. The classification table prints on its own, with the published source each verdict was read from, because a reviewer who wants to check a posture should not have to read the source to find out where the verdict came from. And a family missing from that listing is the honest shape of a gap.&lt;/p&gt;

&lt;p&gt;Settled, not dropped&lt;/p&gt;

&lt;p&gt;The first version filtered out the assets with nothing to do. That is the natural implementation and it is a quiet lie:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Settled are the assets with no migration work: post-quantum, hybrid, or&lt;/span&gt;
&lt;span class="c"&gt;// carrying no posture of their own. They are counted rather than dropped,&lt;/span&gt;
&lt;span class="c"&gt;// because a report that omits what it checked reads exactly like one that&lt;/span&gt;
&lt;span class="c"&gt;// never looked.&lt;/span&gt;
&lt;span class="n"&gt;Settled&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;asset&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Asset&lt;/span&gt; &lt;span class="s"&gt;`json:"settled"`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A plan listing four items and a plan listing four items out of sixty are different documents, and the difference matters most to you when you are deciding whether to believe the first number. Post-quantum algorithms, hybrid key exchange, and protocol versions with no posture of their own all go into Settled with their locations intact. The hybrid line earns its place in particular: on a deployment that has already moved its key exchange, the fact that something was checked and found to need nothing is the single most useful sentence in the report.&lt;/p&gt;

&lt;p&gt;The weight I expect to defend&lt;/p&gt;

&lt;p&gt;Anything the classifier did not recognise, including an explicit unknown posture, ranks at 2, above the Grover-reduced assets at 1. People read that as a bug. It is the one weight I most want printed, because it is the one most worth arguing with: an asset nobody has judged is not a settled asset, and a ranking that sorts unknowns to the bottom turns a gap in the table into a clean bill of health. The same refusal shows up elsewhere in the tool. A key size of zero means not determined rather than small, the classical security level returns zero rather than invent a number, modulus sizes between the published pairs are left out rather than interpolated, and a citation stays empty on an unknown so that a printed source does not make a gap look checked.&lt;/p&gt;

&lt;p&gt;If you decide unjudged should sit below resizable for your codebase, you are now making an informed local decision. That is a better outcome than agreement, I think, and it is only reachable because the number is shown in parts.&lt;/p&gt;

&lt;p&gt;What I would do differently&lt;/p&gt;

&lt;p&gt;The facts the tool cannot see still have nowhere to go. Today someone who knows a service is being decommissioned has to apply that knowledge in their head, after the sort. I would rather it entered as an exposure override on the input side, so the adjustment shows up inside the printed expression like every other factor. A plan corrected by a human should still be a plan whose arithmetic you can read, not one with a silent post-processing step bolted to the end.&lt;/p&gt;

&lt;p&gt;I would also hold the line hard on scale size. Every request to make the ranking smarter arrives as a request for one more factor, and three factors with reasons beat seven with none. The moment the expression stops fitting beside the line it describes, it stops being checkable, and an unchecked score is back to being an opinion.&lt;/p&gt;

&lt;p&gt;The product is the expression. The number is just what it evaluates to.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://polycratia.com/r/mdv/a-post-quantum-migration-plan-should-print-its-arithmetic" rel="noopener noreferrer"&gt;polycratia.com&lt;/a&gt; — where I write about payment systems, crypto rails and marketplace backends.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>security</category>
      <category>compliance</category>
      <category>fintech</category>
    </item>
    <item>
      <title>A date is not a version: strict parsing under OSV ranges</title>
      <dc:creator>polycratia</dc:creator>
      <pubDate>Sat, 17 Oct 2026 13:22:37 +0000</pubDate>
      <link>https://dev.to/polycratia/a-date-is-not-a-version-strict-parsing-under-osv-ranges-3mai</link>
      <guid>https://dev.to/polycratia/a-date-is-not-a-version-strict-parsing-under-osv-ranges-3mai</guid>
      <description>&lt;p&gt;A vulnerability range is a claim about ordering, and it is worth no more than the version parser sitting underneath it. Walk an OSV range's events in whatever order they happen to sit in the file, compare them against leniently parsed versions, and you get two silent failures: verdicts that flip when the advisory is re-serialised, and a component calling itself &lt;code&gt;2023-08-01&lt;/code&gt; that lands outside every affected range forever.&lt;/p&gt;

&lt;p&gt;Neither failure announces itself, and both land on the one verdict nobody rechecks. An &lt;code&gt;affected&lt;/code&gt; finding gets read, argued about, and then either fixed or justified in writing. &lt;code&gt;not_affected&lt;/code&gt; gets counted. So a comparator that quietly produces &lt;code&gt;not_affected&lt;/code&gt; out of an ordering it invented is worse than one that refuses to answer. A good part of the design work in the &lt;code&gt;internal/match&lt;/code&gt; package of &lt;a href="https://github.com/polycratia/vexdesk" rel="noopener noreferrer"&gt;vexdesk&lt;/a&gt; (the tool I wrote to turn an SBOM and a pile of OSV advisories into an OpenVEX document) goes into making the refusal the cheap path rather than the embarrassing one.&lt;/p&gt;

&lt;h2&gt;
  
  
  not_affected is version arithmetic and nothing else
&lt;/h2&gt;

&lt;p&gt;Worth pinning down what the verdict means before I defend it. When the matcher says &lt;code&gt;not_affected&lt;/code&gt;, it says this version sits outside every range the advisory gives. It is not saying the vulnerable code is unreachable in the product. That is an engineering judgement, it belongs to a person, and it arrives later as a decision file: anything still undecided leaves the document as &lt;code&gt;under_investigation&lt;/code&gt;, not as &lt;code&gt;not_affected&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The narrowness is deliberate. A claim about arithmetic over an ordering is the kind of claim you can get exactly right, and the kind that is indefensible to get wrong, because there is nothing subjective left to hide behind. So the ordering has to be real, and where you cannot establish it, the honest output is &lt;code&gt;unknown&lt;/code&gt; with the reason attached.&lt;/p&gt;

&lt;h2&gt;
  
  
  A lenient parser invents an ordering
&lt;/h2&gt;

&lt;p&gt;vexdesk's &lt;code&gt;parseSemver&lt;/code&gt; demands exactly &lt;code&gt;major.minor.patch&lt;/code&gt; and refuses leading zeros. &lt;code&gt;1.2&lt;/code&gt; is an error. &lt;code&gt;2023-08-01&lt;/code&gt; is an error. Both come out as &lt;code&gt;unknown&lt;/code&gt; with the parse error as the reason, and the first reaction you hear is that this is pedantry inflating the unknown count. It runs the other way: this is the one place where pedantry carries load.&lt;/p&gt;

&lt;p&gt;Think about what a tolerant parser does with a date. Split on non-digit separators, coerce the parts, and &lt;code&gt;2023-08-01&lt;/code&gt; turns into something that orders as &lt;code&gt;2023.8.1&lt;/code&gt;, a version above every real version anyone will ever publish. Now measure it against a perfectly ordinary range, &lt;code&gt;introduced: 0&lt;/code&gt;, &lt;code&gt;fixed: 2.1.4&lt;/code&gt;. The date sits above the fix, so it falls outside the interval, so the component is &lt;code&gt;not_affected&lt;/code&gt;. Permanently, for every advisory, for a version string that carries no ordering information at all. The tolerant parser did not fail to compare: it succeeded at comparing a number it made up.&lt;/p&gt;

&lt;p&gt;The leading-zero refusal is the same argument at smaller scale. Accept &lt;code&gt;1.08.0&lt;/code&gt; and read it numerically, and now two spellings of one version exist, equality and ordering stop agreeing with each other, and whether a component sits inside a range starts depending on which spelling reached you. Cheaper to have no answer than an answer that depends on that.&lt;/p&gt;

&lt;p&gt;Everything the comparison cannot establish ends up in one list, and the list is kept whole:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;unknown&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;aff&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Ranges&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;osv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RangeSemver&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
            &lt;span class="c"&gt;// fall through to comparison&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;osv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RangeEcosystem&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;eco&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;semverOrdered&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;unknown&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="s"&gt;"ECOSYSTEM range for %s needs that ecosystem's own version ordering, which is not implemented"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;eco&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;osv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RangeGit&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;unknown&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"GIT range: commit ranges cannot be compared against a version string"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;unknown&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"range type %q is not recognised"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;inSemverRange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;unknown&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;hit&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;Affected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"version falls inside the advisory's affected range"&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="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c"&gt;// Every reason survives: the person clearing this finding needs the&lt;/span&gt;
        &lt;span class="c"&gt;// whole list, not the first item of it.&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Unknown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"; "&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;ECOSYSTEM&lt;/code&gt; branch is the same discipline applied to a whole ecosystem instead of one string. An &lt;code&gt;ECOSYSTEM&lt;/code&gt; range means "use this ecosystem's own version ordering", and only a few ecosystems in the tool are marked &lt;code&gt;semverOrdered&lt;/code&gt;: golang, npm, cargo. Anything else leaves the range &lt;code&gt;unknown&lt;/code&gt;, carrying the reason, rather than quietly compared with semver rules that do not apply to it. Here is what a PyPI component looks like on the way out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;vexdesk match &lt;span class="nt"&gt;-sbom&lt;/span&gt; sbom.cyclonedx.json &lt;span class="nt"&gt;-advisories&lt;/span&gt; ./advisories
&lt;span class="go"&gt;4 component(s) compared against the advisory set

STATUS    ADVISORY      COMPONENT        VERSION  REASON
affected  FIXTURE-0001  widget           1.2.3    version falls inside the advisory's affected range
affected  FIXTURE-0004  cogwheel         4.0.0    version is in the advisory's affected version list
unknown   FIXTURE-0003  Example_Fixture  3.1.2    ECOSYSTEM range for PyPI needs that ecosystem's own version ordering, which is not implemented
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  File order is not a sort order
&lt;/h2&gt;

&lt;p&gt;The second failure is subtler, because nothing about it looks like a parsing problem.&lt;/p&gt;

&lt;p&gt;An OSV range is a JSON array of events: &lt;code&gt;introduced&lt;/code&gt; opens an interval, &lt;code&gt;fixed&lt;/code&gt; and &lt;code&gt;last_affected&lt;/code&gt; close it. JSON preserves array order, so walking the array and flipping a boolean as you go feels natural. The trouble: the schema's meaning does not live in the array order. A well-formed range can list its events in an order that has nothing to do with their version order, and any tool that reads, filters, merges or re-emits an advisory feed is free to hand you the same claim with the array arranged differently.&lt;/p&gt;

&lt;p&gt;This range is the same claim whichever way round you print it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"affected"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"package"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ecosystem"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Go"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"github.com/example/widget"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"ranges"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SEMVER"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"events"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"introduced"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1.5.0"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"fixed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2.0.0"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"introduced"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"fixed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1.2.0"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Walked in array order, &lt;code&gt;1.1.0&lt;/code&gt; opens at &lt;code&gt;1.5.0&lt;/code&gt;, closes at &lt;code&gt;2.0.0&lt;/code&gt;, opens again at &lt;code&gt;0&lt;/code&gt;, closes at &lt;code&gt;1.2.0&lt;/code&gt;, and whether it comes out inside depends entirely on how you carried that state. Shuffle the four lines and the same advisory gives you a different answer. Not a wrong answer: an unstable one, which is harder to notice and much harder to explain to someone who has already shipped a document built on it.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;inSemverRange&lt;/code&gt; sorts the events before walking them. Two rules do the work. First, &lt;code&gt;"introduced": "0"&lt;/code&gt; sorts ahead of everything: it is the schema's sentinel for "from the beginning", it is not a parseable &lt;code&gt;major.minor.patch&lt;/code&gt; triple, and it needs its own case rather than a parser loose enough to swallow it. Second, at equal versions the closing event sorts before the &lt;code&gt;introduced&lt;/code&gt; event that reopens. That pair is a real shape and not a hypothetical: a branch gets closed at a version and a new line of releases opens at the same version. Sorted that way the walk closes and then reopens, so the interval is open at that version, and the verdict holds regardless of the order the file used. &lt;code&gt;internal/match/range_order_test.go&lt;/code&gt; exists to hold that property down: the same range, shuffled, one verdict.&lt;/p&gt;

&lt;h2&gt;
  
  
  The strongest verdict wins across entries
&lt;/h2&gt;

&lt;p&gt;One more place where order would otherwise leak in. Advisories routinely split a single claim across several &lt;code&gt;affected&lt;/code&gt; entries: one per branch, one per range type, one per distribution. Stating each separately would put several statements in the document for one claim, and a claim-by-claim reader keeps only the last one it sees, which puts the output back at the mercy of entry order. So the entries fold into one verdict, and the verdict that asks most of a person wins:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;aff&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Affected&lt;/span&gt; &lt;span class="p"&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;samePackage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;aff&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Package&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Ecosystem&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;aff&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Package&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;eco&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;aff&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;eco&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;stated&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;stated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reasons&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;slices&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reasons&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;reasons&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reasons&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// rank orders verdicts by how much they ask of a person.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;Affected&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;Unknown&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="m"&gt;0&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;&lt;code&gt;unknown&lt;/code&gt; outranking &lt;code&gt;not_affected&lt;/code&gt; is the thesis of this whole post compressed into one integer. An entry that could not be compared does not get cleared by a sibling entry that could. And because &lt;code&gt;match&lt;/code&gt; exits 1 when anything needs attention, an unknown stops a pipeline instead of sitting as a line in a report nobody opens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where leniency does belong
&lt;/h2&gt;

&lt;p&gt;Strictness is not a general virtue here, and it is worth being precise about where it stops.&lt;/p&gt;

&lt;p&gt;An explicit affected version list is exact set membership, not ordering, and there I do apply one normalisation: the leading &lt;code&gt;v&lt;/code&gt; gets trimmed on both sides, because Go components carry it and OSV lists mostly do not, and a string mismatch there would clear a version that is plainly on the list. Safe, because nothing is being ordered, and deliberately nowhere near the comparator.&lt;/p&gt;

&lt;p&gt;The same split runs through the document side of the tool. &lt;code&gt;openvex.New&lt;/code&gt; validates what it builds, and &lt;code&gt;Statement.Validate&lt;/code&gt; is severe about it: a &lt;code&gt;not_affected&lt;/code&gt; with an &lt;code&gt;impact_statement&lt;/code&gt; and no justification code gets refused, because prose cannot be compared against every other &lt;code&gt;not_affected&lt;/code&gt; the way a code can. But &lt;code&gt;openvex.Parse&lt;/code&gt; never calls &lt;code&gt;Validate&lt;/code&gt;, so documents already issued are read leniently, including ones that break the rules the tool enforces on write. Refusing them would hide statements someone needs to read.&lt;/p&gt;

&lt;p&gt;The rule is not "be strict everywhere". It is: never let leniency manufacture a fact. A tolerant read of a document's shape loses nothing. A tolerant read of a version string produces an ordering that was not in the input, and then a verdict that was not in the input either.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;p&gt;I would stop treating the unknown count as a defect to engineer down. My first instinct here was the wrong one: a long unknown list reads like the tool failing, and the obvious fix looks like a cleverer parser that gets an answer out of &lt;code&gt;2023-08-01&lt;/code&gt; somehow. That instinct comes from the wrong domain. In payment systems I have spent years on the opposite reflex: an unsettled transfer is not a successful one, and the unknown state is the expensive one precisely because you cannot collapse it into either neighbour without lying. Vulnerability matching has the same shape. &lt;code&gt;unknown&lt;/code&gt; with a reason attached is a work item for a named person; &lt;code&gt;not_affected&lt;/code&gt; derived from a guess is a liability with a signature under it.&lt;/p&gt;

&lt;p&gt;And I would write the order-independence test before the comparator, not after it. The property (same range, any permutation, one verdict) runs about ten lines, it fails immediately against a naive in-order walk, and it is probably the cheapest way to find out that your evaluator's answer depends on something as fragile as how a feed happened to serialise an array.&lt;/p&gt;

&lt;p&gt;The parser is not a detail underneath the range. It is the range.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://polycratia.com/r/mdv/a-date-is-not-a-version-strict-parsing-under-osv-ranges" rel="noopener noreferrer"&gt;polycratia.com&lt;/a&gt; — where I write about payment systems, crypto rails and marketplace backends.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>security</category>
      <category>compliance</category>
      <category>fintech</category>
    </item>
    <item>
      <title>Stuck withdrawals: wait, bump or cancel as a pure function</title>
      <dc:creator>polycratia</dc:creator>
      <pubDate>Fri, 16 Oct 2026 13:22:37 +0000</pubDate>
      <link>https://dev.to/polycratia/stuck-withdrawals-wait-bump-or-cancel-as-a-pure-function-473i</link>
      <guid>https://dev.to/polycratia/stuck-withdrawals-wait-bump-or-cancel-as-a-pure-function-473i</guid>
      <description>&lt;p&gt;A withdrawal that has sat unconfirmed for an hour is not in one state. It is in one of three: too early to touch, worth replacing at a higher fee, or past the point where the only useful move left is to cancel it and hand the money back. Code that models this as a single timeout has to pick one of those three, and it will be wrong about the other two.&lt;/p&gt;

&lt;p&gt;I keep running into the same shape in payout systems. A job wakes up, loads everything that is &lt;code&gt;sent&lt;/code&gt;, compares a timestamp against a constant, and rebroadcasts. Small constant: it replaces transactions that were about to confirm on their own, and pays the fee twice for nothing. Large constant: support answers the customer by hand hours before the code does anything. Either way the interesting part, what should happen to this one transaction right now, is smeared across a cron expression, a config value and a branch inside a worker that is also holding an RPC connection.&lt;/p&gt;

&lt;p&gt;The fix is not a better constant. You want a decision, and the decision should be a value that a function returns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three outcomes need two thresholds
&lt;/h2&gt;

&lt;p&gt;One threshold gives you two answers. Three answers need two thresholds, plus a budget for how often you are willing to pay for a replacement.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;withdrawals&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;StuckPolicy&lt;/span&gt;

&lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;StuckPolicy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;bump_after&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;minutes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;cancel_after&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hours&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;bumps&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&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;&lt;code&gt;bump_after&lt;/code&gt; is where patience ends. &lt;code&gt;cancel_after&lt;/code&gt; is where hope ends. &lt;code&gt;bumps&lt;/code&gt; is how many replacements the transaction is worth before you stop paying for them.&lt;/p&gt;

&lt;p&gt;The constructor refuses an incoherent pair:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nc"&gt;StuckPolicy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bump_after&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hours&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;cancel_after&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;minutes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="c1"&gt;# ValueError: cancel_after cannot come before bump_after
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That check earns its line. An inverted policy does not crash anything: with the cancel condition tested first, it quietly makes the bump path unreachable, and nobody notices until someone asks why no transaction has ever been replaced. The same constructor refuses a policy that allows fewer than one bump. A pipeline that never bumps is spelled by choosing &lt;code&gt;cancel_after&lt;/code&gt;, not by hollowing out the middle action.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision is a value, not an effect
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;decimal&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;withdrawals&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;WithdrawalRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;review&lt;/span&gt;

&lt;span class="n"&gt;sent_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tzinfo&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;WithdrawalRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;w-1041&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;25.00&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;USDC&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0x5c69bee701ef814a2b6a3edd4b1652cb9cc5aa6f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;approve&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start_sending&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mark_sent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0xdeadbeef&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;review&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sent_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sent_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sent_at&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;minutes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;Action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WAIT&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;waited&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;minutes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;review&lt;/code&gt; takes the request, the moment it went out, a policy and the current time, and returns a &lt;code&gt;Recovery&lt;/code&gt;: an action, the reason behind it, and how long the transaction has waited. It opens no connection, reads no clock it was not given, and writes nothing down. &lt;code&gt;now&lt;/code&gt; is optional and defaults to the real clock. Tests pass it, so every branch is reachable without sleeping.&lt;/p&gt;

&lt;p&gt;The reason is not decoration. It is prose carrying the numbers that produced the decision: unconfirmed for this long, against that deadline. A &lt;code&gt;Recovery&lt;/code&gt; has two readers. The dispatch reads &lt;code&gt;action&lt;/code&gt;, and the person who gets paged at three in the morning reads &lt;code&gt;reason&lt;/code&gt;. Both are served by the same value.&lt;/p&gt;

&lt;p&gt;Dispatch then fits in one function with no policy in it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;withdrawals&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;bump&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;return_funds&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;act_on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sent_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;holds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;replacement&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;review&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sent_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sent_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_wait&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;request&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;Action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BUMP&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;bump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;replacement&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;return_funds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;holds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The list of replaced references is already the counter
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;review&lt;/code&gt; counts bumps as &lt;code&gt;len(request.replaced)&lt;/code&gt;. There is no &lt;code&gt;bump_count&lt;/code&gt; field, and I would not add one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;bumped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;bump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0xfeedface&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;bumped&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reference&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0xfeedface&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;bumped&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;replaced&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0xdeadbeef&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,)&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;bump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bumped&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0xfeedface&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;bumped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You keep the replaced references anyway, for a reason that has nothing to do with counting: any one of them can be the one that gets mined. When a confirmation finally arrives carrying a reference that is no longer the current one, you still have to recognise it as yours. Once that list exists, a separate integer is a second source of truth: you can increment it without replacing anything, or replace without incrementing, and the two will disagree exactly when the transaction is already in trouble.&lt;/p&gt;

&lt;p&gt;The same list makes the replacement step total. Bumping to the reference that is already current gives back the same request. Bumping back to one that has been replaced is a conflict, not a no-op:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;bump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bumped&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0xdeadbeef&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# InvalidTransition: withdrawal w-1041 already replaced transaction 0xdeadbeef
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the request itself refuses to exist in a state where a reference is current and replaced at once, so the counter cannot be inflated by resubmitting the same hash.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two budgets that run out independently
&lt;/h2&gt;

&lt;p&gt;Time and attempts are not the same resource, and either one running out means the same thing: stop.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;StuckPolicy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;bump_after&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;minutes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;cancel_after&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hours&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;bumps&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;review&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;bumped&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sent_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sent_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sent_at&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;minutes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;Action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CANCEL&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Thirty minutes is comfortably inside the two hours. This transaction is cancelled because the attempt budget is gone, not the time budget.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;sent_at&lt;/code&gt; comes from the caller, because the package stores nothing and owns no clock. That makes the definition of "waited" a design decision at the call site, so it is worth saying out loud: I pin it to the first broadcast, not to the latest bump. Reset it on every bump and a transaction that gets bumped becomes immortal. Each action the system takes pushes its own deadline out, and the only requests that ever reach &lt;code&gt;cancel_after&lt;/code&gt; are the ones nobody touched. The customer's clock does not restart when you rebroadcast.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cancelling twice has to release nothing twice
&lt;/h2&gt;

&lt;p&gt;The cancel path is the one that moves money back, so it is the one that gets retried by a crash, a redelivered queue message, or an operator clicking twice.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;failed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;return_funds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unconfirmed past its deadline&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;holds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;again&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;return_funds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;failed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unconfirmed past its deadline&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;holds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;again&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;failed&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;holds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;held&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;w-1041&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things make that hold. Failing a request that has already failed with the same reason goes through the replay path and returns the identical value; failing it with a different reason raises &lt;code&gt;InvalidTransition&lt;/code&gt; instead of quietly overwriting the first explanation, which is the one that is true. And releasing a hold that is not there reports that nothing was released rather than inventing a credit.&lt;/p&gt;

&lt;p&gt;The packaged &lt;code&gt;cancel&lt;/code&gt; puts the replacement in front of the stuck transaction and then returns the funds. Because bumping to an unchanged reference hands back the same request, running that sequence twice does not manufacture a second replacement or a second release.&lt;/p&gt;

&lt;h2&gt;
  
  
  What review refuses to answer
&lt;/h2&gt;

&lt;p&gt;Anything that is not a transaction waiting on the chain raises &lt;code&gt;InvalidTransition&lt;/code&gt;. A request still in &lt;code&gt;sending&lt;/code&gt; has no reference and an unknown outcome, so there is nothing to replace and nothing to measure. A confirmed or failed one is terminal.&lt;/p&gt;

&lt;p&gt;That is deliberately a refusal and not a &lt;code&gt;WAIT&lt;/code&gt;. &lt;code&gt;WAIT&lt;/code&gt; means the chain still deserves the benefit of the doubt. A request that may never have reached the chain is a different problem with a different owner, usually reconciliation, and answering it with "wait" is how it stays invisible for a day.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;p&gt;In the payout systems I built before I wrote any of this down, the recovery rule lived inside the worker, and the first production surprise was the same every time: someone needs different patience for a different asset or a different rail, and the threshold is global. The signature here allows a policy per call, which I think is the right shape. It only takes the discipline not to add one module-level default policy and reintroduce the problem in a nicer font.&lt;/p&gt;

&lt;p&gt;The other boundary I would keep exactly where it is: nothing in the decision function knows what the replacement should cost. Fee strategy belongs to the rail. &lt;code&gt;review&lt;/code&gt; says only that a replacement is due, and leaves the caller to produce one.&lt;/p&gt;

&lt;p&gt;The decision function is the smallest piece of a withdrawal pipeline, and the first one I would transplant into a new system. It lives in &lt;code&gt;recovery.py&lt;/code&gt; in &lt;a href="https://github.com/polycratia/withdrawals" rel="noopener noreferrer"&gt;https://github.com/polycratia/withdrawals&lt;/a&gt;, next to the holds and the broadcast path it answers to.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://polycratia.com/r/mdv/stuck-withdrawals-wait-bump-or-cancel-as-a-pure-function" rel="noopener noreferrer"&gt;polycratia.com&lt;/a&gt; — where I write about payment systems, crypto rails and marketplace backends.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>crypto</category>
      <category>blockchain</category>
      <category>fintech</category>
    </item>
    <item>
      <title>Ledger errors should name the invariant they protect</title>
      <dc:creator>polycratia</dc:creator>
      <pubDate>Thu, 15 Oct 2026 13:22:37 +0000</pubDate>
      <link>https://dev.to/polycratia/ledger-errors-should-name-the-invariant-they-protect-3474</link>
      <guid>https://dev.to/polycratia/ledger-errors-should-name-the-invariant-they-protect-3474</guid>
      <description>&lt;p&gt;A payout worker catches an exception from the ledger and has to decide, with no human in the loop, whether to retry the request, park it, or escalate. If the refusal says "invalid entry", it cannot decide any of those, so the request goes to a dead-letter queue and the string goes to the logs, where a person reads it an hour later and learns exactly as little. A refusal is part of a ledger's interface, and in most systems it is the one part nobody designed.&lt;/p&gt;

&lt;p&gt;I have been building payment and custodial systems since 2018, and the error taxonomy is reliably the piece written last and read most. The balance code gets reviewed twice. The exception that stops a write from happening gets a string literal and a shrug. That is backwards: the write that succeeds is self-documenting, since there is an entry you can read. The write that was refused leaves nothing behind except whatever the message said.&lt;/p&gt;

&lt;p&gt;Two readers, one string&lt;/p&gt;

&lt;p&gt;The message has two audiences with incompatible requirements. The machine wants something narrow and stable: a key it can branch on, which does not move when someone improves the wording. The human wants the opposite, enough context to know whether money is at risk, and whether refusing was the right call.&lt;/p&gt;

&lt;p&gt;"Insufficient funds" serves the machine and leaves the human guessing what the ledger was protecting. "The customer's available balance would have gone below zero, so the write was refused" serves the human and is a terrible thing to pattern-match. The usual compromise is a short code plus a wiki page that explains it, and the wiki page is stale within two quarters because nothing fails when it drifts.&lt;/p&gt;

&lt;p&gt;In ledger-core (&lt;a href="https://github.com/polycratia/ledger-core" rel="noopener noreferrer"&gt;https://github.com/polycratia/ledger-core&lt;/a&gt;) I split it differently. The exception class is the branch key. The promise it protects is a class attribute on that class. The message is the two of them joined at raise time, and the bare reason stays reachable on its own.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;InvariantViolated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;A refused write, carrying the promise that refused it.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="n"&gt;protects&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ClassVar&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a rule of the ledger&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;super&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="se"&gt;\u2014&lt;/span&gt;&lt;span class="s"&gt; protects: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;protects&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;detail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;InsufficientFunds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;InvariantViolated&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Raised when a reservation or a movement takes more than is available.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="n"&gt;protects&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ClassVar&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;available balances stay at or above zero, so the same funds are never spent twice&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are three of these: UnbalancedEntry, InsufficientFunds, ClosedAccount. Each overrides &lt;code&gt;protects&lt;/code&gt; with one sentence written from the ledger's point of view, not the caller's. What reaches a log line is the specific reason and the general promise, separated by an em dash so the two halves stay legible as two halves. What reaches code is the class, plus &lt;code&gt;.detail&lt;/code&gt; holding the reason without the promise appended.&lt;/p&gt;

&lt;p&gt;That last part matters more than it looks. The promise sentence is editorial copy. Someone will reword it, because it is the sort of thing that gets reworded in review. If callers are storing the full message as a rejection reason on a request row, every rewording changes historical data and any accumulated string matching. &lt;code&gt;.detail&lt;/code&gt; is the half that does not carry the editorial text, so it is the half you persist and forward.&lt;/p&gt;

&lt;p&gt;Enforced at the one place it can be broken&lt;/p&gt;

&lt;p&gt;A promise is only worth printing if it holds where the caller forgot to check. So each of the three is enforced at the single point where it could be violated, and nowhere else pretends to cover it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Journal.append&lt;/code&gt; asks only whether an entry balances. It is not a funds check and does not imply one. The funds check lives in &lt;code&gt;Holds.spend&lt;/code&gt;, which computes &lt;code&gt;-effect_on(entry, account)&lt;/code&gt;, compares that against &lt;code&gt;available()&lt;/code&gt;, raises &lt;code&gt;InsufficientFunds&lt;/code&gt; if it exceeds it, and only then appends. Closure is enforced where a closed account can actually be touched: in &lt;code&gt;Account._posting&lt;/code&gt;, and in &lt;code&gt;Hold.__post_init__&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_posting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;MoneyLike&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;side&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Side&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Posting&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;closed_at&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ClosedAccount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;account &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; was closed at &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;closed_at&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;side&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; of &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; is refused&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Putting it there has a consequence worth stating plainly: an &lt;code&gt;Entry.transfer&lt;/code&gt; against a closed account raises before the journal appears anywhere in the call stack. The refusal happens while the write is still a value. Same for a one-sided movement: an unbalanced entry never becomes an object, so it cannot be discovered later by something reading the journal. And closing an account refuses new movement without erasing anything, since a closed account's balance still folds from the entries it already has.&lt;/p&gt;

&lt;p&gt;Branch on the class, store the detail&lt;/p&gt;

&lt;p&gt;With the taxonomy on the classes, the worker's decision is a &lt;code&gt;try&lt;/code&gt; block and nothing else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;ledger_core&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ClosedAccount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;InsufficientFunds&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;settle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;holds&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;account&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;holds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;spend&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;account&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;InsufficientFunds&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;refused&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;park&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;entry_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;refused&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;retry_after_funding&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;ClosedAccount&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;refused&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;escalate&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;entry_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;refused&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;alert&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;refused&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;manual&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;settled&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two branches differ because the underlying facts differ. &lt;code&gt;InsufficientFunds&lt;/code&gt; is a statement about a quantity that changes: a deposit lands and the same instruction succeeds, so retrying is meaningful. &lt;code&gt;ClosedAccount&lt;/code&gt; is a statement about a terminal state, so retrying is guaranteed waste and a human needs to look. The alert carries &lt;code&gt;str(refused)&lt;/code&gt;, which includes the promise, because the person being woken up has not read this module. The parked row carries &lt;code&gt;refused.detail&lt;/code&gt;, because repeating the same promise sentence across ten thousand rows is noise.&lt;/p&gt;

&lt;p&gt;Not every refusal is an invariant&lt;/p&gt;

&lt;p&gt;The holds module also raises &lt;code&gt;CaptureMismatch&lt;/code&gt;, &lt;code&gt;DuplicateHold&lt;/code&gt;, &lt;code&gt;HoldNotOpen&lt;/code&gt; and &lt;code&gt;UnknownHold&lt;/code&gt;. None of them subclass &lt;code&gt;InvariantViolated&lt;/code&gt;, and that is deliberate.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Holds.capture&lt;/code&gt; skips the funds check entirely: the funds were reserved when the hold was placed, and the reservation is what the balance has been standing behind since. What capture checks is that the amount taken equals &lt;code&gt;hold.amount.amount&lt;/code&gt;, raising &lt;code&gt;CaptureMismatch&lt;/code&gt; if it does not. That is not a money problem. It is the caller's instruction disagreeing with the caller's own reservation, and collapsing it into &lt;code&gt;InsufficientFunds&lt;/code&gt; would make the retry decision wrong: one is worth re-reading a balance for, the other will fail identically forever.&lt;/p&gt;

&lt;p&gt;So the dividing question is this: does this refusal protect a promise the ledger makes to everyone, or does it report that the instruction disagrees with state the caller named? The first gets a &lt;code&gt;protects&lt;/code&gt; sentence. The second gets a precise message and a plain exception type. That is the only taxonomy rule I have found that keeps working when a new error case shows up six months later.&lt;/p&gt;

&lt;p&gt;The test that keeps it honest&lt;/p&gt;

&lt;p&gt;Because &lt;code&gt;protects&lt;/code&gt; has a default, a subclass that forgets to override it inherits "a rule of the ledger", which is worse than silence, since it looks like a promise and says nothing. One parametrised test closes that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@pytest.mark.parametrize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;violation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;UnbalancedEntry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;InsufficientFunds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ClosedAccount&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_every_violation_names_what_it_protects&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;violation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;InvariantViolated&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;issubclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;violation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;InvariantViolated&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;violation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;protects&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;InvariantViolated&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;protects&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;violation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;protects&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;violation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;refused&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It asserts the override exists and that the sentence actually reaches the message. Since &lt;code&gt;protects&lt;/code&gt; is a class attribute, it is readable without raising anything, which means documentation can be generated from the classes rather than maintained beside them.&lt;/p&gt;

&lt;p&gt;What I would do differently&lt;/p&gt;

&lt;p&gt;First, the default. A &lt;code&gt;ClassVar&lt;/code&gt; default makes the base class convenient and makes forgetting silent, and a test is a weaker guard than a definition-time failure. I would check the override in &lt;code&gt;__init_subclass__&lt;/code&gt; so a new invariant without a promise fails at import rather than in CI.&lt;/p&gt;

&lt;p&gt;Second, I would write down, in the base class docstring, that the message is for humans and the class is for code. Without that sentence, someone eventually matches on the full string, and then the promise sentence is load-bearing text that cannot be edited.&lt;/p&gt;

&lt;p&gt;Third, the gap a refusal cannot close. &lt;code&gt;Holds.place&lt;/code&gt; and &lt;code&gt;Holds.spend&lt;/code&gt; read &lt;code&gt;available()&lt;/code&gt; and then write, as two steps, and the &lt;code&gt;Storage&lt;/code&gt; protocol has no transaction, so the atomic pair is left to the backend. &lt;code&gt;InsufficientFunds&lt;/code&gt; is then a true statement about what &lt;code&gt;available()&lt;/code&gt; was a moment before the append, not a guarantee about what it was during it. Where more than one writer posts to one account, making that pair a single step belongs to the store underneath. A message that names its invariant is honest about the rule, and it cannot be honest about a race the layer below allows. That belongs in the exception's own docstring, next to the promise, so nobody reads the promise as stronger than it is.&lt;/p&gt;

&lt;p&gt;A ledger earns trust by what it refuses, and a refusal nobody can act on is barely better than a write nobody checked. Making the promise a class attribute and putting it in the message costs about four lines in one module. What you get for that is that the retry logic and the person on call are reading the same refusal and reaching the same conclusion about it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://polycratia.com/r/mdv/ledger-errors-should-name-the-invariant-they-protect" rel="noopener noreferrer"&gt;polycratia.com&lt;/a&gt; — where I write about payment systems, crypto rails and marketplace backends.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>fintech</category>
      <category>architecture</category>
      <category>backend</category>
    </item>
    <item>
      <title>A 402 is a bill: what an x402 gate owes a client when things fail</title>
      <dc:creator>polycratia</dc:creator>
      <pubDate>Wed, 14 Oct 2026 13:21:09 +0000</pubDate>
      <link>https://dev.to/polycratia/a-402-is-a-bill-what-an-x402-gate-owes-a-client-when-things-fail-58m</link>
      <guid>https://dev.to/polycratia/a-402-is-a-bill-what-an-x402-gate-owes-a-client-when-things-fail-58m</guid>
      <description>&lt;p&gt;An HTTP 402 gate sits between a client's money and a service that can fail on either side of it. The handler can fail. The payment network can fail. Those two failures need opposite answers: a failed handler must charge nothing, while a failed facilitator must tell the client the gate is broken, not that it owes more. Nearly every bug I have hit in paid-request plumbing is one of those two answers given in the other's place.&lt;/p&gt;

&lt;p&gt;In ordinary HTTP a status code is a statement about the request. On a paid request it is also a statement about money, and that changes what you are allowed to say. A 402 means: you have not paid for this, here are the terms, come back with a payment. Return it because your own facilitator stopped answering and you have just told a client to sign a second authorisation to cover your outage. Settle before the handler has finished and you have billed someone for a 503.&lt;/p&gt;

&lt;p&gt;I wrote paygate402 (&lt;a href="https://github.com/polycratia/paygate402" rel="noopener noreferrer"&gt;https://github.com/polycratia/paygate402&lt;/a&gt;) around that single asymmetry. The protocol part of x402 is short: unpaid request, 402 with terms, repeat with an X-PAYMENT header, verify, settle, serve. The happy path is not where the work is. It is the four or five places where something fails and the gate has to decide whose problem it is.&lt;/p&gt;

&lt;p&gt;Two failures, two answers&lt;/p&gt;

&lt;p&gt;The handler side is the easy half, as long as you commit to it early. The client's money must only move for work that was delivered. So settlement cannot happen before the handler has run, and the handler's output cannot reach the client before settlement has succeeded. Both constraints point at the same implementation: the response goes into a buffer.&lt;/p&gt;

&lt;p&gt;The payment side is the half that gets written wrong, because the wrong version looks like idiomatic Go.&lt;/p&gt;

&lt;p&gt;The facilitator answers on two channels&lt;/p&gt;

&lt;p&gt;A facilitator is the seam where the chain lives: signature recovery, allowance checks, the actual transfer. From the server's point of view it is an HTTP call that can fail, and that gives it two distinct ways of saying no:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Facilitator verifies payments and settles them. This is the seam where the&lt;/span&gt;
&lt;span class="c"&gt;// chain lives: signature recovery, allowance checks and the actual transfer all&lt;/span&gt;
&lt;span class="c"&gt;// happen behind it.&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Facilitator&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;// Verify reports whether a payment is good for the given terms. A returned&lt;/span&gt;
    &lt;span class="c"&gt;// error means the question could not be asked; a Verification with&lt;/span&gt;
    &lt;span class="c"&gt;// Valid=false means it was asked and answered no.&lt;/span&gt;
    &lt;span class="n"&gt;Verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payment&lt;/span&gt; &lt;span class="n"&gt;Payment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;terms&lt;/span&gt; &lt;span class="n"&gt;Requirements&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Verification&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c"&gt;// Settle moves the money and reports the transaction.&lt;/span&gt;
    &lt;span class="n"&gt;Settle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payment&lt;/span&gt; &lt;span class="n"&gt;Payment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;terms&lt;/span&gt; &lt;span class="n"&gt;Requirements&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Settlement&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Verification&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Valid&lt;/span&gt;  &lt;span class="kt"&gt;bool&lt;/span&gt;   &lt;span class="s"&gt;`json:"isValid"`&lt;/span&gt;
    &lt;span class="n"&gt;Reason&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"invalidReason,omitempty"`&lt;/span&gt;
    &lt;span class="n"&gt;Payer&lt;/span&gt;  &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"payer,omitempty"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The error is "the question could not be asked". The &lt;code&gt;Valid&lt;/code&gt; field is "it was asked and answered". Those are not degrees of the same thing, and the line that treats them as one is the bug:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Wrong, and it reads fine.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Facilitator&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;terms&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Valid&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;challenge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;accepts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"payment was not accepted"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;||&lt;/code&gt; converts a network timeout into a demand for money. The client, especially an automated one, does exactly what the status code tells it to: signs another payment and retries into the same outage. The correct shape keeps the channels apart, and the comment is in the repository because it is the reason the branch exists at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;verification&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Facilitator&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;terms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;// The facilitator is unreachable. This is the server's problem, not&lt;/span&gt;
    &lt;span class="c"&gt;// the client's: answering 402 would tell them to pay again.&lt;/span&gt;
    &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"payment verification is unavailable"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusBadGateway&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;
&lt;span class="p"&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;verification&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Valid&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;challenge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;accepts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;orDefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;verification&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"payment was not accepted"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One more detail about &lt;code&gt;Verification.Payer&lt;/code&gt;: inside the handler, &lt;code&gt;Payer(r.Context())&lt;/code&gt; returns the address the facilitator said paid. It is the facilitator's claim, not the web layer's finding. A web layer cannot honestly check a payer, an amount, an asset or a recipient, so it should not pretend to. It should record whose claim it is relaying.&lt;/p&gt;

&lt;p&gt;Settlement comes after the handler&lt;/p&gt;

&lt;p&gt;The handler runs into a recorder, and the buffered body is only written out once money has actually moved:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;recorder&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;recorder&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusOK&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;next&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ServeHTTP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;recorder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;withPayer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;verification&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Payer&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;recorder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;recorder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="m"&gt;300&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;// Nothing was delivered, so nothing is charged.&lt;/span&gt;
    &lt;span class="n"&gt;recorder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;settlement&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Facilitator&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Settle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;terms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"settlement call failed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"payment settlement is unavailable"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusBadGateway&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;
&lt;span class="k"&gt;case&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;Success&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"settlement declined"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"reason"&lt;/span&gt;&lt;span class="p"&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;ErrorReason&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;challenge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;accepts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;orDefault&lt;/span&gt;&lt;span class="p"&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;ErrorReason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"payment could not be settled"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the two cases of that switch next to each other. A settlement call that errored is a 502 and a log line. A settlement the facilitator declined is a 402 carrying the facilitator's own reason. Same function, same return site, opposite audiences: the first is for an operator, the second is for a client who can act on it.&lt;/p&gt;

&lt;p&gt;The non-2xx branch is the part that earns the buffer. A 503 from the upstream the handler depends on, a 404 for a report that does not exist, a panic turned into a 500: none of those produce a charge, because none of them produced a response. The cost of that guarantee is real and worth saying plainly. A buffered response is a response held in memory, and you cannot stream a paid body while also promising it was never delivered unsettled. Buffering is choosing the promise over the streaming. For priced API responses that is the right trade; for large downloads it is not, and that boundary is a design decision rather than an implementation detail.&lt;/p&gt;

&lt;p&gt;The log line matters too. Settlement failures are the events an operator most needs to see, because they are the only ones where the work was done and the money did not arrive. Everything else you can read off the status code distribution.&lt;/p&gt;

&lt;p&gt;Where the rule bends, and why&lt;/p&gt;

&lt;p&gt;There is one place where "charge nothing for failed work" does not fully hold, and pretending otherwise would be worse than admitting it. The replay key for a payment is recorded after verification passes, and once recorded the payment stays spent even if settlement then fails:&lt;/p&gt;

&lt;p&gt;Recording at first sight would burn a payload the facilitator rejected, because a client that fixes its allowance will legitimately resend the same signed payment. But once settlement has been attempted, that attempt may already have reached the chain. The server does not know. The ambiguous case is resolved against the possibility of a double charge, which means the client gets a 502 and the old payload is refused if it comes back.&lt;/p&gt;

&lt;p&gt;So a client that hits a settlement outage has to produce a fresh payment instead of retrying the stale one. That is friction, and it is deliberate: the alternative is a path where the same authorisation can be settled twice because a timeout looked like a failure.&lt;/p&gt;

&lt;p&gt;The same "whose failure is it" question decides a smaller case. When receipts are enabled and a receipt fails to sign, the failure is logged and the paid response still goes out. The money moved, the service was rendered, and withholding the body would punish the client for a problem with the server's signing key.&lt;/p&gt;

&lt;p&gt;Free requests follow the same logic&lt;/p&gt;

&lt;p&gt;The free tier is spent before the X-PAYMENT header is even read:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;left&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;granted&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Free&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;take&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;granted&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;serveFree&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;next&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;left&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A client with free requests remaining is not charged, even if it sent a payment anyway: the payment is left unspent rather than taken. And &lt;code&gt;serveFree&lt;/code&gt; calls &lt;code&gt;Allowance.give&lt;/code&gt; when the handler returns a non-2xx, giving the free request back. Work that was not delivered is not billed, whether the unit of billing is a stablecoin transfer or a slot in a courtesy allowance. A route that the price table does not mention is served unpaid for the same kind of reason: the table is the whole statement of what is sold, so a health check needs no line saying it costs nothing.&lt;/p&gt;

&lt;p&gt;Testing the refusals, not just the success&lt;/p&gt;

&lt;p&gt;The distinction is only real if there is a test that fails when someone collapses it. Two fakes exist for that: &lt;code&gt;StaticFacilitator&lt;/code&gt;, which answers from a fixed script and records its calls, and &lt;code&gt;MockFacilitator&lt;/code&gt;, which keeps balances in memory and exposes &lt;code&gt;VerifyErr&lt;/code&gt; and &lt;code&gt;SettleErr&lt;/code&gt; to fail the way an unreachable facilitator does.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;TestFacilitatorOutageIsNotTheClientsProblem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;testing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;facilitator&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;StaticFacilitator&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;VerifyErr&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"dial tcp: connection refused"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
    &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;Gate&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Accepts&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;accepts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Facilitator&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;facilitator&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;httptest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewRecorder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;httptest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MethodGet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"https://api.example.com/report"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PaymentHeader&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encoded&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;paid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ServeHTTP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Code&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusBadGateway&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"status = %d, want 502 when the facilitator cannot be reached"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Code&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="n"&gt;facilitator&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Trail&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;"verify"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"calls = %q: settlement ran after verification could not be asked"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;facilitator&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Trail&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;Trail&lt;/code&gt; assertion is the one I would not drop. A status code test tells you the client was answered correctly. The call trail tells you the gate did not quietly attempt to move money on a question it never got an answer to.&lt;/p&gt;

&lt;p&gt;What I would do differently&lt;/p&gt;

&lt;p&gt;The two unavailability responses are prose: "payment verification is unavailable" and "payment settlement is unavailable". For a human operator reading logs that is fine. For the agent clients this protocol is aimed at, it means string matching to tell a transient server fault from a permanent one, and I would rather hand them a small machine-readable body with a stable code. Related: x402 defines a shape for "you must pay" and no shape at all for "come back shortly", so a 502 from this gate carries no retry guidance beyond what HTTP already gives you. A &lt;code&gt;Retry-After&lt;/code&gt; on the facilitator-outage path costs nothing and is the only hint a client can act on without guessing.&lt;/p&gt;

&lt;p&gt;I would also make the buffer's ceiling explicit instead of leaving it implicit in the host's memory, and I would surface the settlement ambiguity in the response rather than only in the log. A client that is told "this payment is spent, sign a new one" can act correctly, while a client that is told only "unavailable" will reasonably retry the payload that will now be refused.&lt;/p&gt;

&lt;p&gt;None of this changes the rule the gate is built around. A 402 is a bill, and a bill is only honest when the service was delivered and the payment was actually judged and declined. Everything else the server has to own in its own status code.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://polycratia.com/r/mdv/a-402-is-a-bill-what-an-x402-gate-owes-a-client-when-things-fail" rel="noopener noreferrer"&gt;polycratia.com&lt;/a&gt; — where I write about payment systems, crypto rails and marketplace backends.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>fintech</category>
      <category>architecture</category>
      <category>backend</category>
    </item>
  </channel>
</rss>
