An AI agent with a wallet is a loop that spends money. Give it a budget — "$50/day, max
$5 per call" — and you'd think the hard part is done. It isn't. The hard part is making the
budget hold when the agent fires ten calls at once. That's where most spend guardrails
quietly fail, and the failure is the worst kind: it looks like it's working.
This is the story of the bug that shaped Bridle, an open-source spend guardrail for agentic
payments — and why we now don't trust a single line of money code that hasn't been run
against a real database under concurrency.
The setup: a budget that "works"
The naive design is obvious and wrong. You keep a ledger of what an agent has spent in a
rolling window. Before a payment, you check: does spent + amount exceed the limit? If
not, you insert the reservation and let the payment through.
check: SELECT sum(amount) FROM ledger WHERE agent = $1 AND window_active
decide: if sum + amount <= limit -> INSERT reservation; ALLOW
-> DENY
Write a unit test with a mocked store. Agent has spent $45 of $50, tries to spend $10 → deny.
Tries $4 → allow. Green. Ship it.
Now the agent — because it's an agent, not a human clicking one button — fires twenty
concurrent requests for $4 each. Every one of them runs the SELECT. Every one of them
reads spent = $45. Every one of them computes $45 + $4 = $49 <= $50 and says ALLOW.
Twenty payments go out. The agent just spent $80 against a $50 limit, and your guardrail
reported success twenty times.
The unit tests are still green. They will always be green, because a mock has no notion of
two things happening at once.
The two traps
The first instinct is "just add a lock." SELECT ... FOR UPDATE. But there are two traps
waiting, and we hit both.
Trap 1: you can't FOR UPDATE an aggregate. SELECT sum(amount) ... FOR UPDATE is not
valid — Postgres rejects locking with aggregate functions. So people lock the rows they're
summing. Which leads straight to:
Trap 2: locking rows doesn't protect the case with no rows. The dangerous scenario is a
new agent, or an agent on a default budget, with zero ledger rows yet. There's nothing
to lock. Twenty concurrent "first payments" all see an empty ledger, all pass, and you've
overcommitted before the agent has any history at all. Row locks protect existing rows; they
do nothing about the rows that are all about to be inserted simultaneously.
This is the trap that's invisible in a demo (you test with an agent that already has a
budget row) and lethal in production (every agent starts with none).
The fix: lock the decision, not the data
The guarantee we actually need is: for a given (agent, currency), the check-and-reserve
runs serially — even when no row exists. That's not a lock on data; it's a lock on the
logical entity. Postgres has exactly the right tool:
SELECT pg_advisory_xact_lock(hashtext('bridle:' || $agent || ':' || $currency));
-- ...now do the SELECT sum, the decision, and the INSERT...
-- lock releases automatically on COMMIT / ROLLBACK
An advisory lock keyed on the agent serializes the critical section regardless of whether any
row exists. Two concurrent reservations for the same agent take turns; the second one sees the
first one's insert. The budget holds. And critically, the same connection that takes the
lock must run the queries — a lock on one connection and a SELECT on another serializes
nothing (a subtlety that bites again when you wire this into a framework's transaction).
In Bridle this lives behind a single non-negotiable primitive on the storage interface:
withAgentLock(agentAddress, currency, fn): Promise<T>
The core only enters check-and-reserve inside withAgentLock. You can't forget to hold the
lock from a call site, because there is no other door.
The test that has to travel with the code
Here's the lesson that changed how we work: the bug is invisible to unit tests, so the test
that proves the fix must run against real Postgres, and it must ship with the adapter.
Bridle's concurrency test fires ≥20 concurrent reservations for one agent whose budget only
allows a single one, against a real Postgres, and asserts exactly one wins and the total
reserved equals the budget — including the no-row default case that caused the original bug.
It's gated so that in CI it fails (not skips) if there's no database: a green pipeline
means the guarantee was actually validated, not quietly skipped.
A mocked unit test that "covers" concurrency is worse than no test — it's a green light on the
one thing you most need to be red when it's broken.
The sequel: when the guardrail broke itself
There's a second story, because money code finds new ways to betray you. We added an audit
sink — a hook that records every allow/deny decision for the compliance trail. Best-effort,
fire-and-forget. Except the emit happened inside the locked transaction, right before the
reservation insert, and it wasn't wrapped in a try/catch.
So: a perfectly valid, in-budget payment comes in. The guard decides ALLOW. It calls the audit
sink. The host's sink throws (a transient logging error, say). The exception escapes the
transaction, Postgres rolls back — and a payment that was inside budget gets rejected. The
observability hook, the thing that was supposed to just watch, had become able to kill a
legitimate payment.
A review pass caught it because we now read money paths adversarially, asking "what breaks if
this dependency misbehaves?" — not because a test was red. The fix is boring (isolate the sink;
a best-effort hook can never break the flow) and it now has a test with a sink that always
throws. But the pattern is the point: on a spend path, every side effect is guilty until
proven harmless.
What we believe now
- Mocks lie about concurrency. For money code, a test that doesn't hit real infrastructure under real parallelism is decoration.
- The guarantee travels with the implementation. Bridle's storage contract ships its own concurrency test; any new adapter has to pass it. The promise isn't in a doc, it's in CI.
- Fail closed, always. No policy, missing context, a misbehaving hook — the answer is deny, never a silent allow. A guardrail that fails open is not a guardrail.
That's what Bridle is: pre-execution budget reservation with a concurrency guarantee you can
actually verify, a declarative policy engine, and an audit trail — framework-agnostic,
non-custodial, x402-ready. It sits above your wallet or rail and only does one job:
decide, correctly, whether an agent is allowed to spend — even when twenty requests arrive at
the same millisecond.
npm install @igarzatech/bridle
- Code & docs: https://github.com/IgarzaTech/bridle
- npm (published with provenance): https://www.npmjs.com/package/@igarzatech/bridle
If you're building agents that spend money, I'd genuinely love to know how you're handling the
concurrency problem — reply here or open an issue.
Bridle is Apache-2.0 and built in the open. The concurrency test, the advisory-lock adapter,
and the audit-sink fix described above are all in the repo.
Top comments (1)