20 min

Money is a list, not a thing

Most money is not an object but an entry on a ledger; module 0 said it as a story, this lesson puts the record in code, and a payment becomes two edits to two lists.

Where you are. Module 0 left you holding the two-tier map and a fifty-line toy that pushed one payment across it, conservation assertions standing guard. Lesson 1 scaffolded the miniledger package this module grows into a library - an empty Ledger class waiting for rules, one passing check - and mapped which lesson feeds it which piece. This lesson delivers the first piece. It takes the toy’s dict-and-hope design and adds the two rules that make a list of balances begin to behave like a ledger: edits arrive in pairs, and refusal comes before either edit.

The toy that cannot say no

Reopen your finished first_payment.py from module 0. It works: four edits on three ledgers, conservation green, exit 0. Now rerun the experiment lesson 10 proposed: change the amount from 100 to 600 and run it again. Alice holds 500. The script does not blink. Alice lands on -100, Bob collects the full 600, both assertions stay green, and the exit code is still 0. Your toy has just paid out money nobody has, and by its own lights nothing went wrong: every debit found its credit, and both tiers conserved their totals. The toy can witness a payment; it cannot refuse one. This lesson is where your code learns to say no - and, just as important, where in the function it must say it.

The idea in one paragraph

Money is a list. Module 0 said that as a story - the notched stick, the goldsmith’s ledger, the deposit as a row wearing a primary key - and lesson 6’s chase ended at a dict entry because there was nowhere further to go. But a bare list is not yet a ledger, because a ledger is a list with rules, and this lesson installs the first two. First: a payment is two edits that stand or fall together, a debit and a credit of the same amount, two legs or none. Second: the function that makes the edits checks everything first and refuses, with a raise, before it has touched anything - because a refusal after the first edit leaves the list in a state worse than either completing the payment or never starting it. Every rule the miniledger later enforces is a generalisation of the four-line pay() you write here.

Two rows are an economy

The exercise strips the toy back to its smallest interesting core: one bank, two customers, the same round stylised numbers module 0 used.

balances = {"alice": 500, "bob": 200}

Those two rows are the whole economy. There is nowhere else for money to be. A payment of 100 is two edits: alice 500 to 400, bob 200 to 300. The total never moves - 700 before, 700 after - because the debit and the credit are the same 100 wearing opposite signs. This is the same-bank world from module 0’s project: two edits, one ledger, tier 1 asleep. The cross-bank version, four edits on three ledgers, returns in lesson 8, leg by leg on the finished miniledger.

The 600 experiment showed that conservation is not the missing rule. The overdraft conserved the total perfectly: 600 left Alice’s row as 600 arrived at Bob’s. What no rule asked is whether Alice’s row could cover it. Conservation polices pairing; solvency is a precondition. And a precondition has a natural home: the top of the function, before anything is edited.

Two legs or none

The exercise’s pay() has this signature:

def pay(payer: str, payee: str, amount: int) -> None:

and its body has three jobs, in an order that is the whole lesson. First the check:

if balances[payer] < amount:
    raise ValueError(f"{payer} holds {balances[payer]}, cannot cover {amount}")

Then, and only then, the two legs: debit the payer’s entry, credit the payee’s. If the check raises, the function exits before the first edit and the list is exactly as it was - the payment did not half-happen, it did not happen at all. If the check passes, both edits run to completion on the next two lines. Two legs or none.

The state between the edits

Read the two edit lines again and ask what the world looks like between them. Alice has been debited; Bob has not yet been credited. The list reads {"alice": 400, "bob": 200}; the total reads 600. For that instant, 100 does not exist anywhere.

In straight-line code that has already passed its check, the instant is harmless: nothing observes it, and the credit lands on the very next line. The danger is every path that exits between the edits. An exception raised there does not roll the first edit back; Python unwinds the stack and leaves the dict exactly as the crash found it. Torn.

before alice 500 bob 200 total 700 torn alice 400 bob 200 total 600 after alice 400 bob 300 total 700 leg 1 alice -100 leg 2 bob +100 pay() checks the payer here; refuse and nothing has moved after leg 1 and before leg 2: 100 gone; this state must never escape both legs applied; total conserved at 700
A payment as two paired edits: the before and after tables joined by two gold legs, with the crossed-out torn state between them, its total down to 600

Wider than the screen; scroll it sideways.

A refusal that cannot be ignored

Notice that pay() refuses by raising, not by returning False. A returned False is a refusal the caller is free to never read; the line after a forgotten check runs as if the payment happened, and somewhere a warehouse ships goods nobody paid for. A raise cannot be skipped: either somebody handles it deliberately or the program stops. For a function whose entire job is moving money, loud is the only acceptable failure mode.

There is one more way to tear this list, and your check does not cover it.

The lesson generalises: every rule needs a check, and every check runs before the first edit. That generalisation is where this module goes next. Lesson 3 gives the paired edits their proper name and makes the pairing structural, so an unbalanced entry cannot even be expressed; lesson 4 re-checks the whole book after every entry rather than trusting the caller; and lesson 7 splits your single check-then-edit into two named phases, submit and settle, so a payment can be validated, queued and refused as a first-class object. Module 0 promised that the miniledger could refuse a payment instead of merely witnessing one. The refusal you write today is that promise in its smallest form.

Review

A ledger is a list with rules

Money is a list. The notched stick, the goldsmith’s ledger, the deposit as a row wearing a primary key: chase any balance far enough and it ends at an entry, because there is nowhere further to go. But a bare list is not yet a ledger, because a ledger is a list with rules, and the first two are the whole lesson. A payment is two edits that stand or fall together, a debit and a credit of the same amount, two legs or none. And the function that makes the edits checks everything first and refuses before it has touched anything, because a refusal after the first edit leaves the list in a state worse than either completing the payment or never starting it. Every rule the ledger later enforces is a generalisation of those four lines.

Check yourself

1. The 600 overdraft left both of module 0’s conservation assertions green. What rule was missing, and why could conservation never catch it?

Solvency: nobody asked whether Alice’s row covered the amount. Conservation checks that edits arrive in cancelling pairs, and the overdraft’s did - 600 out of alice, 600 into bob, total untouched. A negative balance conserves totals as happily as a positive one. Solvency is a precondition on one row before the edits, not a property of the edits, so it needs its own check, run first.

2. Why must the check come before the first edit rather than between the two?

Because a raise between the edits leaves exactly the torn state the check exists to prevent: the payer debited, the payee not credited, the total short by the amount. Checked first, a refusal exits while the list is untouched, and a passed check leaves nothing between the legs but the next line of code. The check’s position, not its existence, is what guarantees two-legs-or-none.

3. pay() refuses by raising ValueError rather than returning False. What does the raise buy?

A refusal the caller cannot ignore. A returned False must be checked voluntarily, and the code after a forgotten check runs as if the payment succeeded. A raise either gets handled deliberately or stops the program. The miniledger keeps this stance for every rule it enforces: every violation raises, none is swallowed.

4. The solution’s check still lets pay("alice", "carol", 50) tear the list. What happened, and what is the general lesson?

The funds check passes, alice is debited to 350, and the credit raises KeyError because carol has no row - a torn state caused by a rule (the payee must exist) that no check stated. One check covers one rule; a mutation is only safe when every rule it depends on is checked before the first edit. That is the shape the miniledger adopts: validate every leg of an entry, then apply every leg.

Do this

Work from module-01-money-at-rest. Open code/paired_edits.py: the two-row economy, a harness that pays 100 and then attempts 10,000, and one TODO(you) inside pay(). Run it as shipped:

python code/paired_edits.py

and it dies at NotImplementedError - an unwritten pay() fails loudly rather than pretending.

Write the body: the check first, raising ValueError if the payer cannot cover the amount, then the debit, then the credit. The harness asserts the exact balances after the first payment, asserts conservation, and demands the 10,000 attempt be refused. Success prints a refused: line carrying whatever message your ValueError was given - the solution names the holder, the balance and the amount - and then, exactly:

paired edits, conserved total, refusal before any edit

Then break it once, deliberately. Move your check between the two edits and rerun: the script still exits 0, because the harness catches the ValueError wherever it is raised. Now add print(balances) as the last line and look: alice sits at -9600. A green run, a refusal on screen, and a torn ledger underneath - the tear happened where no assert was looking. Restore the check to the top and take the point with you: callers checking afterwards is a policing model that misses things, which is why lesson 4 moves the invariant into the book itself, re-asserted after every entry. The completed version is in solutions/paired_edits.py; check yours against it when you are done, not before.

What you can now do. You can state the two rules that begin to turn a list into a ledger - edits arrive in pairs, and refusal comes before the first edit - and you can enforce both in four lines of Python whose line order carries the whole guarantee. You can name the torn state those lines exist to prevent, produce it on demand by moving one of them, and recognise it as the thing databases call a torn write. This pay() is the seed the whole library grows from: lesson 3 makes the pairing structural, lesson 4 moves the checking into the book itself, and by lesson 7 your single check-then-edit has split into submit and settle - a payment that can be validated, queued, refused and made final, exactly as module 0 promised.

What you can now do

You can turn module 0's dict toy into paired, refusing edits - the seed the whole library grows from.