FifeRouter

2 September 2026 · ledger money

Metering off the request path, and the overshoot we accept

Billing reads the log the router already writes. An account can end slightly below zero, and we wrote that down rather than discovering it.

Two things have to be true about billing. A request from an account with no credit must not reach a provider, and a request that did reach one must be debited.

The obvious implementation puts both on the request path: check the balance before dispatch, write the debit after. Two synchronous database round trips inside every routed request — against a 5% routing budget that exists to keep exactly that off the path, and putting the router back in the business of knowing about money, which is a separation it was deliberately given.

Where each half went instead

The gate moved to authentication. /api/internal/authenticate already runs on every paid request, already does one indexed lookup to validate the key, and already returns a non-2xx that the edge turns into a refusal. It now also reads the cached balance and answers 402 when there is none. No extra hop, and the router still knows nothing about balances.

Metering became a reconciler. The router writes a decision record per model attempt because BR-log-every-decision says it must, for reasons that have nothing to do with billing. So the billing data already exists. A loop reads that log and posts one debit per billable attempt, keyed so that re-reading the whole log is a no-op.

Nothing is on the request path. The debit happens seconds later.

The consequence we accept

Metering runs after the fact, so an account can cross zero while requests are already in flight. Those complete and are billed, and the balance ends slightly negative.

That is stated in PDR-0006 as an accepted consequence rather than found later in a support ticket, which is the only part of this worth arguing about. The alternatives are worse:

The bound is small: the requests in flight at the moment of crossing. For a single caller that is one; for a fan-out it is their concurrency. Both are recoverable, neither is a surprise, and the ledger records exactly what happened.

The gate is described in its own code as "a floor, not a forecast: it refuses once the balance is gone, and does not try to predict whether this request would exhaust it". Predicting means pricing a response that does not exist.

Idempotency you can re-run

The reconciler re-reads records it has already billed on every pass. That is fine because each debit carries a dedupe key derived from the record, and the ledger has a unique index on it:

CREATE UNIQUE INDEX ledger_dedupe_idx ON ledger (dedupe_key)
    WHERE dedupe_key IS NOT NULL;

Posting an existing key returns False rather than raising. Not an error — that is how the reconciler stays idempotent, and how a replayed Stripe webhook changes nothing. The property comes from a constraint in the database, not from the reconciler remembering where it got to.

Which means the log can be replayed from the beginning after an outage and the ledger is unchanged. That is worth more than a cursor, because a cursor is a second piece of state that can be wrong.

The silent failure it introduces

Asynchronous metering has one bad mode: the reconciler stops and nothing notices. Requests are served, providers are paid, and nobody is billed.

Nothing errors. Traffic looks healthy. The only symptom is revenue that quietly does not arrive.

So /api/health reports seconds_since_last_debit, and the twice-daily watchdog flags it as stalled when there has been billable traffic and no debit. The check has to be conditional, because the reconciler only writes when there is something to bill — silence is not automatically wrong, it is only wrong when there was work.

That is the general shape of moving something off the request path: you trade a loud, immediate failure for a quiet, delayed one, and you owe the system a monitor in exchange.


← All posts