Every paid request already passes through one place that does a database lookup:
the authentication hop. Caddy calls forward_auth against
/api/internal/authenticate, which validates the API key and returns the
account id in a header.
That hop is where the spend gate went, and the reasoning generalises past billing.
The candidates
In the router, before dispatch. The natural place — it is the thing about to spend money. It means the router queries a balance, which puts a database round trip on the request path against a 5% routing budget, and puts the router back in the business of knowing about accounts. PDR-0005 had just separated those two concerns; this would rejoin them.
In the edge, as a rule. Caddy would need to know balances. It does not have a database and should not get one.
At authentication. One indexed lookup is already happening. Reading one more column from the row you already fetched costs nothing measurable, and the hop already returns a non-2xx that the edge turns into a refusal.
The third is the only one that adds no round trip and no new knowledge to a component that did not have it.
What it looks like
if not await ledger.has_credit(row["account_id"]):
return JSONResponse(
{"error": {"type": "insufficient_credit",
"message": "No credit remaining. Top up at fiferouter.com/dashboard."}},
status_code=402,
)
402 Payment Required and not 401. The key is fine; the balance is not, and a
caller can tell those apart and do different things about them. Conflating them
would mean a customer who ran out of credit gets told their key is invalid, and
goes looking for the wrong problem.
has_credit is a floor, not a forecast
async def has_credit(account_id: str) -> bool:
return await balance(account_id) > 0
It refuses once the balance is gone. It does not try to predict whether this request would exhaust it, and that is deliberate — predicting means pricing a response nobody has generated yet, and erring cautiously means refusing requests the customer could afford.
The consequence is an overshoot: requests already in flight when the balance crosses zero will complete and be billed, so an account can end slightly negative. Written down in PDR-0006 as accepted rather than discovered later.
> 0 also does the right thing for a genuinely negative balance — an account
that refunded a payment after spending the credit. Not != 0, which would treat
debt as credit, and there is a test asserting exactly that, because the two
comparisons look equally reasonable at a glance.
The property this buys
There is one gate. Not a gate in the router and a gate at auth that could disagree, and not a second check added later "for safety" that drifts from the first.
That mattered when email verification arrived. The obvious design was to block unverified accounts from making requests — a second gate, with its own conditions. Instead verification gates only the grant, so an unverified account simply has no credit, and the existing gate refuses it for the reason it refuses everyone else.
One gate, one reason, one place to look when somebody asks why a request was refused.
Where it does not reach
The gate is per-account and per-request. It says nothing about a single request
that is enormously expensive, which is what FIFEROUTER_SPEND_CAP is for — a
per-process breaker that refuses rather than downgrading, because a budget
control that silently swaps in a cheaper model is an invisible quality change.
Two different questions: can this account pay and has this process spent an alarming amount. They are enforced in different places because they are asked by different people about different things.