Ledger errors should name the invariant they protect
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.