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.
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.
Two readers, one string
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.
"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.
In ledger-core (https://github.com/polycratia/ledger-core) 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.
class InvariantViolated(ValueError):
"""A refused write, carrying the promise that refused it."""
protects: ClassVar[str] = "a rule of the ledger"
def __init__(self, detail: str) -> None:
super().__init__(f"{detail} \u2014 protects: {self.protects}")
self.detail = detail
class InsufficientFunds(InvariantViolated):
"""Raised when a reservation or a movement takes more than is available."""
protects: ClassVar[str] = (
"available balances stay at or above zero, so the same funds are never spent twice"
)
There are three of these: UnbalancedEntry, InsufficientFunds, ClosedAccount. Each overrides protects 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 .detail holding the reason without the promise appended.
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. .detail is the half that does not carry the editorial text, so it is the half you persist and forward.
Enforced at the one place it can be broken
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.
Journal.append asks only whether an entry balances. It is not a funds check and does not imply one. The funds check lives in Holds.spend, which computes -effect_on(entry, account), compares that against available(), raises InsufficientFunds if it exceeds it, and only then appends. Closure is enforced where a closed account can actually be touched: in Account._posting, and in Hold.__post_init__.
def _posting(self, amount: MoneyLike, side: Side) -> Posting:
if self.closed_at is not None:
raise ClosedAccount(
f"account {self.account_id} was closed at {self.closed_at}, "
f"{side.value} of {amount} is refused"
)
Putting it there has a consequence worth stating plainly: an Entry.transfer 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.
Branch on the class, store the detail
With the taxonomy on the classes, the worker's decision is a try block and nothing else:
from ledger_core import ClosedAccount, InsufficientFunds
def settle(holds, entry, account, requests):
try:
holds.spend(entry, account=account)
except InsufficientFunds as refused:
requests.park(entry.entry_id, reason=refused.detail)
return "retry_after_funding"
except ClosedAccount as refused:
requests.escalate(entry.entry_id, reason=refused.detail, alert=str(refused))
return "manual"
return "settled"
The two branches differ because the underlying facts differ. InsufficientFunds is a statement about a quantity that changes: a deposit lands and the same instruction succeeds, so retrying is meaningful. ClosedAccount is a statement about a terminal state, so retrying is guaranteed waste and a human needs to look. The alert carries str(refused), which includes the promise, because the person being woken up has not read this module. The parked row carries refused.detail, because repeating the same promise sentence across ten thousand rows is noise.
Not every refusal is an invariant
The holds module also raises CaptureMismatch, DuplicateHold, HoldNotOpen and UnknownHold. None of them subclass InvariantViolated, and that is deliberate.
Holds.capture 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 hold.amount.amount, raising CaptureMismatch 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 InsufficientFunds would make the retry decision wrong: one is worth re-reading a balance for, the other will fail identically forever.
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 protects 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.
The test that keeps it honest
Because protects 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:
@pytest.mark.parametrize("violation", [UnbalancedEntry, InsufficientFunds, ClosedAccount])
def test_every_violation_names_what_it_protects(violation: type[InvariantViolated]) -> None:
assert issubclass(violation, InvariantViolated)
assert violation.protects != InvariantViolated.protects
assert violation.protects in str(violation("refused"))
It asserts the override exists and that the sentence actually reaches the message. Since protects is a class attribute, it is readable without raising anything, which means documentation can be generated from the classes rather than maintained beside them.
What I would do differently
First, the default. A ClassVar 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 __init_subclass__ so a new invariant without a promise fails at import rather than in CI.
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.
Third, the gap a refusal cannot close. Holds.place and Holds.spend read available() and then write, as two steps, and the Storage protocol has no transaction, so the atomic pair is left to the backend. InsufficientFunds is then a true statement about what available() 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.
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.
Originally published on polycratia.com — where I write about payment systems, crypto rails and marketplace backends.
Top comments (2)
The split between the class as the branch key and
.detailas the stored half is the right call. One thing that bites it: the base__init__passes the joined message tosuper().__init__, soargsholds the full string and notdetail. I ran your two classes through pickle, which is what multiprocessing and Celery do to an exception crossing a process boundary. The rebuilt exception callscls(args[0]), so its message comes back as "need 50, have 20 — protects: available balances stay at or above zero — protects: available balances stay at or above zero", the promise twice..detailsurvives only because the instance dict is restored after construction, so it is correct but the alert text is not.A worker that parks
refused.detailis fine; a worker that logsstr(refused)after the exception returns from a pool is not. Callingsuper().__init__(detail)and building the joined text in__str__fixes both, sinceargsis then the bare reason and a round trip reproduces it. It also makes your base-class docstring rule structural: the string that carries the editorial sentence is never the one that gets stored or re-fed to a constructor.Does the funds check at
Holds.spendrun in the same process as the worker that catches it, or can a refusal cross a queue?You're right, and the bug sits in the line I wrote. The base
__init__hands the joined string tosuper().__init__, soargs[0]carries the reason plus the promise, and anything that rebuilds the exception fromargsappends the promise twice. Pickle across a process boundary is the easiest way to hit it, not the only one: retry wrappers and some tracing exporters reconstruct fromargsas well.Your fix has the right shape.
super().__init__(detail)with the join moved into__str__leavesargsas the bare reason, so a round trip is idempotent, and the editorial sentence then lives in one place nobody persists. It also turns the base-class rule into something structural instead of a docstring request, and I like that more than the pickle fix itself. I'd still assign.detailexplicitly so callers keep the attribute they already read.Worth a test next to the
protectsone: round-trip all three through pickle, assertstr()is unchanged anddetailcomes back. That goes on the list with the__init_subclass__check.On your question: in the
settleexamplespendis called inline, so the refusal is caught in the same process. The library can't assume that holds for every caller, which is why it matters. Worse than pickle is a JSON-serialized queue, where the instance dict doesn't cross at all and the exception gets rebuilt from type name and message, so.detailis gone and the doubled string is all that's left. I'd want to walk the module again first, to confirm nothing else leans on the joined form...