DEV Community

A Spend Cap That Stops Counting Is Already Fail-Open

A Spend Cap That Stops Counting Is Already Fail-Open

Two of the five ways a spend cap can handle a missing price produce the exact same decision stream - same sha256, byte for byte. One of them is the thing everybody calls fail-open. The other is the thing everybody recommends instead of it: fall over to a free local model.

Let me say what that hash is and isn't before it does any work. It covers (seq, label, admitted, charge) and deliberately drops the human-readable reason strings, so two policies that print different words hash the same when they decide the same. Once you see that, the collision is a theorem rather than a discovery: a free fallback charges zero, fail-open charges zero, and a ledger built out of charges cannot tell them apart because there is nothing there to tell apart. The sha256 proves only that my implementation doesn't quietly cheat.

The reason it's still worth a post is that nobody ships them as the same policy. One is the bug you apologise for; the other is the fix you recommend in the thread. On the axis that matters they are one policy, and one of them has better branding.

AI disclosure

I wrote blind_spend_cap.py with AI assistance and ran it myself. Every number and hash below is pasted from a real run: offline, stdlib only, no network, no keys, no funds. The oracle is injected, so runs are deterministic. I ran it three times; the output was byte-identical each time. Code sha256 ddc42590โ€ฆ , output sha256 9ebe1b4aโ€ฆ . External figures are linked and labeled, and I say clearly which ones I did not reproduce.

TL;DR

A spend cap needs a cost-oracle to price the next action. The oracle has its own outage. The usual framing (fail-open vs fail-closed) is the wrong axis. The real split: does the ledger keep moving while the oracle is quiet? Strategies that charge a price the remaining budget can still absorb keep the ledger alive and re-trip the cap. Charge zero and spent freezes forever. Charge more than fits and you've written refuse with extra steps - the harness proves that one on itself.

A free local fallback charges zero. In my harness it produces a decision stream identical to plain fail-open: same sha256. But a moving ledger is a floor, not a certificate. Any positive fiction satisfies it - price a $0.05 call at $0.01 and your cap is quietly five times the one you configured. The real axis is the bias of your estimator; zero is just where that bias hits โˆ’100%.

The headline number you'd expect me to use here (34 extra actions) is arithmetic, not evidence. I take it apart below rather than sell it.

The branch nobody writes down

Every spend cap I've shipped has the same shape. Price the action, compare against a budget, allow or block. The pricing step assumes the oracle answers. It doesn't always. CoinGecko 429s. A usage endpoint times out. A token meter sits behind a gateway returning 526. In that moment your cap takes a decision that probably isn't in your code review notes, because it isn't in your code: what to do with an action it cannot price.

I know it's unwritten because I shipped it that way. On June 8, 2026 I published SpendGuard, a 40-line pre-execution cap. It works, and it never declared this branch. The oracle call sits inside cost_fn on line 121, and eth_price_usd() calls raise_for_status() before it returns anything. So on a 429 the exception blows straight past the gate and out of the wrapper. Accidentally fail-closed, by way of an uncaught exception that takes the caller down instead of returning a verdict you can count.

Copy the demo loop from that same post and you get the opposite. It prices once before the loop and reuses that number for every round. A mid-loop outage is invisible. Accidental fail-open. Same file, two wirings, two opposite behaviors, and I declared neither.

Five strategies, one fork

So I built the smallest thing that isolates the branch. blind_spend_cap.py runs one Analyzer/Verifier ping-pong through one budget gate, under five strategies that are identical everywhere except the quote is None block:

else:
    if strategy == "refuse":
        return _v(seq, "BLOCK", "no quote: refuse", False, 0, priced)
    if strategy == "admit-unpriced":
        return _v(seq, "ADMIT", "no quote: admit, charge 0 (ledger frozen)", True, 0, priced)
    if strategy == "stale":
        if last_known is None:
            return _v(seq, "BLOCK", "no quote and no last-known price: refuse", False, 0, priced)
        est, tag = last_known, "stale"
    elif strategy == "pessimistic":
        est, tag = per_action_cap, "pessimistic"
    elif strategy == "fallback":
        est, tag = fallback_cents, "fallback"

Two design choices worth stating, because both cut against the result I might have wanted:

  • A BLOCK does not stop the loop. A real runaway retries. My earlier harness gave the refusing policy a free break, which quietly handed it the win: it "stopped the runaway" because I wrote the loop that way. Here the gate stops the spend, not the work, and the loop keeps hammering. There's an --on-block halt flag for the single-shot shape, and I sweep both.
  • The budget has no clock, so I call it a per-run budget rather than a daily one. Calling it daily would be a lie in a file with no time in it.

The split isn't admit-vs-block. It's counting-vs-not.

Here's the outage run, oracle down from step 6, straight from output.txt:

SCENARIO B - oracle down from step 6, loop retries after a BLOCK

refuse          admitted=6   spent=$0.30  unpriced=0  unaccounted=0  ledger-moved=False  exit(conv)=1  exit(strict)=1
admit-unpriced  admitted=40  spent=$0.30  unpriced=34 unaccounted=34 ledger-moved=False  exit(conv)=0  exit(strict)=2
stale           admitted=10  spent=$0.50  unpriced=4  unaccounted=0  ledger-moved=True   exit(conv)=1  exit(strict)=2
pessimistic     admitted=6   spent=$0.30  unpriced=0  unaccounted=0  ledger-moved=False  exit(conv)=1  exit(strict)=1
fallback:0c     admitted=40  spent=$0.30  unpriced=34 unaccounted=34 ledger-moved=False  exit(conv)=0  exit(strict)=2
fallback:1c     admitted=26  spent=$0.50  unpriced=20 unaccounted=0  ledger-moved=True   exit(conv)=1  exit(strict)=2

Look at the spent column, not the admitted one. Exactly two strategies end at $0.50: stale and fallback:1c. That's the budget, tripped, doing its job. admit-unpriced and fallback:0c end at $0.30 and stay there - not because the run was cheap, but because after step 6 nothing was ever added to the ledger again.

Now the row that breaks the tidy version of this claim, which I had written as "every strategy that charges something ends at $0.50" until the table two lines above told me otherwise. pessimistic charges the most of anybody and still ends at $0.30. Charging something isn't sufficient. The something has to fit. pessimistic prices every un-priced call at the $0.25 per-action cap, only $0.20 of budget remains after step 6, so every un-priced call is blocked on arrival: admitted=6, unpriced=0.

Which changes how you read the unaccounted column. It counts admissions where the oracle gave no quote and nothing was charged - defined by the fact of a missing quote, not by the name of the policy, so it can accuse any strategy including the ones I like. stale and fallback:1c sit at zero because they kept counting. pessimistic and refuse sit at zero because they never admitted a blind call in the first place. Same number, two different stories, and I'd been reading the flattering one into both.

Push it to the limit and the failure gets loud. Oracle dead from step 0:

$ python3 blind_spend_cap.py --strategy admit-unpriced --oracle-fails-from 0
admit-unpriced  admitted=40  spent=$0.00  unpriced=40  unaccounted=40  ledger-moved=False  exit(conv)=0  exit(strict)=2

Forty actions admitted. Ledger says zero dollars. Exit code zero, under the mapping most gates actually ship. That's the thing worth internalizing. A blind cap doesn't report danger. It reports innocence.

The number I'm not putting in the headline

You'd expect the pitch to be "fail-open admitted 34 more actions." The tool does print it. I'm going to argue against it anyway, because I got burned by exactly this number last time.

M is the gap in admitted actions between admit-unpriced and refuse. In the run above it's 34. Sweep the outage step across the whole parameter space and you get this:

on-block=retry
K:      0   5   6   9  10  11  12  20  39  40
M:     40  35  34  31  30  29  28  20  10   0

(That's the retry half of the sweep with the unacc row dropped for now - under retry it's identical to M anyway. The full block, both loop shapes, is two sections down.)

M = WANTS โˆ’ K, exactly, everywhere. I picked WANTS = 40. If I'd picked 1000, the headline would read 994. It isn't a property of any policy, it's a property of how long I let the loop want things. A number I chose, dressed up as a number I found. Which is why the strategy table above leads with spent and unaccounted, and why M is buried in a scenario that tells you to go read the sweep before quoting it.

Pressing my own kill switch

The honest question isn't whether my metric works in the run I picked. It's where it stops working. So here's the same sweep under both loop shapes:

on-block=retry
K:      0   5   6   9  10  11  12  20  39  40
M:     40  35  34  31  30  29  28  20  10   0
unacc: 40  35  34  31  30  29  28  20  10   0
-> M = 0 in 1/41 of K (2%); unaccounted = 0 in 1/41 (2%)

on-block=halt
K:      0   5   6   9  10  11  12  20  39  40
M:     40  35  34  31  30   0   0   0   0   0
unacc: 40  35  34  31  30   0   0   0   0   0
-> M = 0 in 30/41 of K (73%); unaccounted = 0 in 30/41 (73%)

Under halt semantics, everything I've argued dies in 73% of the parameter space. Not weakens. Dies, to identically zero, both metrics at once.

The reason is dull and important. The budget is $0.50, a healthy call is $0.05, so a healthy run hits the cap at step 10. If the oracle only falls over at step 11 or later, the loop is already stopped by money. The un-priced branch is never reached. Nothing to measure, nothing to argue about.

So the applicability condition, which my previous draft never stated and which I'm stating plainly now: this post is about outages that start before your budget would have stopped the loop anyway - K <= budget // unit_cost, which is K <= 10 here - or about loops that retry after being refused. Outside those two cases the whole thing is a non-event.

That boundary was off by one in the tool until this morning: the summary line said the metrics collapse once K >= 10, when K = 10 is the last K where they're still alive and K = 11 is the first dead one. The sweep table underneath it was right the whole time. A decent argument for printing the table and not just the conclusion drawn from it.

I think retrying is the common case, because runaway agent loops are usually retry loops. That's a judgement about the world, not a measurement, and I'm flagging it as one.

A free fallback is fail-open with better branding

Now the equivalence. Same outage, and I hash the decision stream - the (seq, label, admitted, charge) tuples, deliberately excluding the human-readable reason strings, so two strategies that make the same decisions hash the same even when they print different words:

SCENARIO C - is a free fallback a distinct strategy, or a renamed fail-open?

admit-unpriced  sha=53b01d22a232f1fee833a76c7cd1ed810d1945da2e7620c8a1a17a9302b4df79
fallback:0c     sha=53b01d22a232f1fee833a76c7cd1ed810d1945da2e7620c8a1a17a9302b4df79

stale           sha=0dcbd560f59adcf2b1eec3ca111dc7f89c3e7ac08569fe165d88ec7af77b4311
fallback:5c     sha=0dcbd560f59adcf2b1eec3ca111dc7f89c3e7ac08569fe165d88ec7af77b4311

refuse          sha=7df1f34491edfde21648d36e9a8eda1db7306d12efda4c29364fa8f54ad3b04a
pessimistic     sha=7df1f34491edfde21648d36e9a8eda1db7306d12efda4c29364fa8f54ad3b04a

-> fallback:0c == admit-unpriced: True
-> fallback:5c == stale: True (needs >=1 real quote before the outage, and a constant oracle price)
-> pessimistic == refuse: True (at THESE caps: $0.25 never fits what is left of $0.50)

Three collisions in that block, and the third one costs me a third of my own recommendation.

fallback:0c == admit-unpriced is the headline, and - as I said up top - a theorem. Both charge zero, the hash covers the charge. I couldn't break it by moving the budget, the unit cost, the per-action cap, the loop shape or the outage step; it isn't a coincidence of the parameters I picked.

fallback:5c == stale is real but conditional, and I stated it as a general truth in an earlier pass. It needs two things I'd left unsaid. There has to be at least one successful quote before the outage - with --oracle-fails-from 0 there's no last-known price at all, stale refuses, and the equality collapses. And the oracle's price has to actually hold still; point the harness at a varying price and the two decision streams separate immediately. The honest version is narrower: a fallback pinned to the real rate is stale pricing, as long as the real rate isn't moving.

pessimistic == refuse was sitting in my own main table and I walked past it twice. Same 7df1f344โ€ฆ. A $0.25 estimate never fits the $0.20 left after step 6, so pessimistic blocks every un-priced call - which is refuse, decision for decision. Run --strategy fallback --fallback 25 --oracle-fails-from 6 and you get 7df1f344โ€ฆ as well. Three names, one behavior.

That forces an admission about my own advice. Further down I tell you to charge a stale price, or a conservatively biased estimate, or the per-action cap. At the parameters in my own demo that last one is bit-identical to the refusal I declined to recommend - which is why it now ships with that caveat attached instead of posing as a third independent door. It doesn't make the advice wrong. Refusing is defensible, and I spend a whole section on its bill.

The price sweep is where this gets concrete:

SCENARIO D - sweep the fallback price (the whole argument hangs on it)

fallback:0c  admitted=40  spent=$0.30  unpriced=34  unaccounted=34  ledger-moved=False  exit(conv)=0  exit(strict)=2
fallback:1c  admitted=26  spent=$0.50  unpriced=20  unaccounted=0   ledger-moved=True   exit(conv)=1  exit(strict)=2
fallback:2c  admitted=16  spent=$0.50  unpriced=10  unaccounted=0   ledger-moved=True   exit(conv)=1  exit(strict)=2
fallback:5c  admitted=10  spent=$0.50  unpriced=4   unaccounted=0   ledger-moved=True   exit(conv)=1  exit(strict)=2

Comments

No comments yet. Start the discussion.