An ATM spends cash it owns against a balance it does not: two ledgers, one irreversible motor, and a network link that can drop mid-transaction.
The three decisions
- Model the session as an explicit state machine so the uncancellable region becomes nameable.
- Plan notes with bounded DP, not greedy, which silently misses plans that exist.
- Journal the intent before actuating, settle what was counted, so a jam is a reconciliation, not a dispute.
Objects and ownership
- No actor owns two of: balance (bank), cash + record of intent (ATM), physical arbitration (servicer).
- The ATM never decrements a balance; it is a remote actuator with a local durable log.
- Lifted out of
ATMfor a reason:Session(guarded lifecycle),CashInventory(servicer mutates it),Journal(must survive power loss),BankGateway(remote, fails). NotePlanneris a Strategy (interchangeable algorithm); one machine, not a Singleton.
Session state machine
IDLE → AUTHENTICATING → SELECTING → AUTHORIZING → DISPENSING → SETTLING → PRINTING → EJECTING- Uncancellable region:
AUTHORIZINGthroughSETTLINGhave no cancel event; machine is committed. - Guards: withdraw needs
amount % 1000 == 0(minor units, so multiples of $10); AUTHORIZING → DISPENSING fires journal INTENT + fsync. - 3 wrong PINs retains the card;
pin_trieslives onSession(a UI convenience), the issuer’s counter is the real security control. - Jam →
SUSPENDED, a state of the machine (in_service = False), outliving the session. RECOVERYruns at boot, not a session state; replays unsettled journal entries.
Counting notes: bounded knapsack
- Greedy is only correct for canonical sets (full US bill set is one); an ATM stocks a non-canonical subset.
- Greedy fails two ways: bad denomination set (
$130from{100,50,20}), and low inventory ($80from1×$50, 4×$20). - Bounded: each denomination used at most
stock[d]times; one DP layer per denomination. step = gcd(amount, *stock)collapses the state space:$130at a $10 step is 13 states, not 13,000.- Fewest notes ≠ operator goal:
MinNotesdrains one cassette (half the cash stranded in days);BalancedDrainpenalises depleted cassettes. - Plan first, hold second.
plan_notesreturningNonemust decline while nothing is reserved or journalled. - Concurrency: the other writer is the servicer at the replenishment door, not a second customer. Plan and decrement in one locked step (
reserve);reservedecrements at planning time.
Dispense failure: bracket the motor
Debit-first loses the customer’s money on a jam; dispense-first loses the bank’s and is repeatable (an exploit). Two non-atomic steps can’t fake atomicity by reordering.
1. Hold (reserve, no money moves) -> auth_id
2. Journal INTENT + fsync
3. Actuate (note counter reads `presented`)
4. Journal outcome (DISPENSED / SHORT)
5. Settle hold for `presented`, keyed by auth_id
settleis idempotent (keyed byauth_id); replay settles, never re-dispenses.- Settle the counted amount; a short dispense settles short, never more than the hold.
recover()at boot: for entries stuck at INTENT readdisp.counted; settle is idempotent so replay is free.- Every crash resolves without a human except the jam (notes physically stuck in the transport).
| Crash point | Bank | Recovery |
|---|---|---|
| Before hold | untouched | nothing happened |
| After hold, before INTENT | held | hold ages out, auto-releases |
| After INTENT, before motor | held | replay settles 0, hold released |
| Mid-dispense (jam) | held | settle n, retract rest, out of service |
| After motor, before settle | held | replay settles n by auth_id |
| After settle | captured | done |
Gotchas
- Money is an integer count of minor units (
80_00, never80.0); denominations are integers too, so the planner is exact. A float produces a plan that doesn’t sum, read as a phantom short dispense. in_serviceis stickyTrue → False; only a servicer clears it.Journal.appendcopies the plan dict;unsettled()walks backwards, latest state pertxn_idwins.- Retracted notes go to a locked reject bin, not back into the cassette, so they stay a term in the servicer’s reconciliation.
Extensions
- Deposits: same bracketed action, sign flipped (field is
presented, notdispensed); adds an escrow stage and a real cancel point; credit is provisional until the servicer’s count agrees. - Multi-currency: extra cassette set is nearly free; the cost is a
Moneyvalue object touching every signature, and capturing the FX rate in the journal at authorization time. - Daily limit across ATMs: shared state, must live at the issuer, checked and incremented in the same atomic step as the funds check (
hold); consumed by the settled amount, released when a hold expires.