September 2026 · 13 min read
Extracting the Lumo ledger, and the race it had been hiding
tenant-ledger is the accounting core of Lumo, taken out of the application and put on npm as its own package. Charges, payments, allocations, standing credit, credit notes and their reversal on one side, cash in and cash out on the other, one append-only event log under both. It exists because a studio desk does not pay the way a form expects: a parent pays for two children in one transfer, a payment arrives before the invoice does, a card bounces three weeks later, an owner types 200 instead of 20 and needs to fix it without losing the record that they did. None of that is hard to write down once. It is hard to keep one answer to "what does this account owe" that every screen and every month-end report agree on. I did not extract it because anyone needed a package. I extracted it because the core had been written under one rule and I wanted to know whether the rule was real, and then, once the ledger stood on its own, reviewing it found a race that had been in production the whole time, kept from happening only by the way one person at a desk uses the product.
- Role
- Design, the extraction brief, and the review. A coding agent did the mechanical work against the brief
- Package
- TypeScript · no runtime dependencies · ES modules and CommonJS · in-memory and Postgres stores · one behavioural contract suite
- Status
- 1.0.0 on npm. 287 tests, five of them against a real Postgres on two connections. CI on Postgres 16 and 18
- Source
- github.com/robertciudica/tenant-ledger, MIT.
npm install tenant-ledger
This is what a payment looks like from the outside. It is the first four of
the nine steps of npm run demo, which drives the package against Postgres
compiled to WebAssembly and reads every figure back out of the database
after the step that produced it.
1. Bill the customer for January and February
2026-01 €50.00 paid €0.00 OVERDUE (stored says PENDING)
2026-02 €50.00 paid €0.00 PENDING
Today is 3 February. January is late and February is not, and no code
path ever wrote OVERDUE: it falls out of the due date when you read it.
2. They say they will send €70. Where would it land?
2026-01 €50.00 of €50.00 outstanding -> PAID
2026-02 €20.00 of €50.00 outstanding -> PARTIALLY_PAID
left over as credit: €0.00 (nothing was written)
3. The €70 arrives
allocated €70.00, credit €0.00
2026-01 €50.00 paid €50.00 PAID
2026-02 €50.00 paid €20.00 PARTIALLY_PAID
4. The bank webhook fires again with the same key
IdempotencyError: Operation already processed: bank-2026-02-03-a1b2
payments recorded: 1
The rest of this is about the decisions behind those steps, what was wrong with them when the ledger came out of Lumo, and how I know they hold now.
The boundary survived extraction
The rule the codebase calls the golden rule says that inside the core there are no ORM imports, no HTTP, no framework types and no file I/O, that storage goes through one interface injected into the constructor, and that every read and write on that interface takes the studio id as an explicit argument. I wrote it for testability and so that tenant identity stays visible at every call site. Extraction was never the plan. But a rule like that is a hypothesis with a cheap test, because if the boundary was real then taking the ledger out should be a rename and not a rewrite, so before any code moved I had an estimate written down: eight source files, about 135 identifier renames, 131 existing tests of which about 110 would port with renames only and six would be dropped along with a product feature, no adapters, no stubs.
That is what happened, and the estimate sits in docs/extraction.md next to
the outcome. studentId became accountId and the entity behind it kept an
id and a tenant and nothing else; a fixed six-role matrix became a permission
set the caller supplies; the studio's chart of accounts became a constructor
argument; five foreign keys into product tables became one optional
reference string that nothing in the ledger reads; and the storage port
shrank from the whole product to the 29 methods the ledger actually used.
What stayed behind is everything that decides an amount: pricing, enrolment,
billing periods, and the monthly summary that replays the event log. The
ledger takes an amount and does not ask where it came from.
That proves one narrow thing: the boundary the rule described was where the dependencies actually were. It does not prove the ledger was correct, and it was not. The defects inside the boundary survived extraction exactly as well as the boundary did. What extraction gave me was a place where I could review the subsystem without the application around it making its assumptions safe.
The question the review kept asking
The extraction asked one question of the code: can this behaviour be preserved outside Lumo. The review asked a different one, and it turned out to be the same question every time: which layer can actually make this guarantee true? None of the failures were algorithmic. The waterfall allocated correctly, the status derivation computed the right state, the idempotency check caught the ordinary duplicate. Each failure was a guarantee sitting in a layer that could not own it, and every correction moved it down to the lowest layer that could, leaving the layers above to express the meaning rather than imitate the guarantee.
Authority in the wrong place
Problem
Obvious answer
Why it breaks
What I built
VOID is the only value taken from storage, because it is the one state a person sets and the rows cannot derive. Everything else is computed from the amount, the allocations and the due date, and the column is kept for filtering only.Why
export function computeEffectiveStatus(dbStatus, amount, paidAmount, dueDate, now) {
if (dbStatus === 'VOID') return 'VOID'
const balance = computeBalance(amount, paidAmount)
if (balance <= 0) return 'PAID'
if (paidAmount > 0) return 'PARTIALLY_PAID'
if (dueDate.getTime() < now.getTime()) return 'OVERDUE'
return 'PENDING'
}
Overdue had the same shape. Nothing in Lumo ever wrote it, so an account
three weeks late looked identical to one due at month end and four features
that filtered on overdue matched nothing. It is a function of a date the
caller has already loaded, so now it is computed. And a method called
calculateBalance returned standing credit, not a balance; inside Lumo its
one caller knew that, so nobody else had to. It is calculateStandingCredit
now with the formula untouched.
One consequence of facts being immutable needs stating precisely, because it is the part a financial systems engineer will push on. When a payment is reversed, its allocations are deleted rather than flagged, because the authority for "is this charge paid" is the sum of allocations for one charge id at about twenty read sites, and a flag would need all twenty, and every site written later, to remember an exclusion. Deleting makes every site correct without being touched, and the reversal writes one event whose payload snapshots every allocation it removed, so the history is kept in the log rather than in the table. That is a choice about where a fact lives, not a claim that it never was one.
Money follows the same principle. Amounts are integers in minor units, and until the review the guards only checked that an amount was an integer, which is the wrong check, because above 2^53 a JavaScript integer stops being exact and two different amounts compare equal. Every entry point now requires a safe integer and every sum goes through one function that throws rather than rounds, so the check sits where the arithmetic happens.
Idempotency: the read is a fast path, the constraint is the guarantee
Problem
Obvious answer
Why it breaks
What I built
(organization_id, idempotency_key) on the event log, written in the same transaction as the payment, the allocations and the cash row, so a rejected key rolls everything back together and a failed transaction rolls the key back with it. The pre-flight read stays as the fast path. The store throws DuplicateIdempotencyKeyError, and the services translate that into the one IdempotencyError a caller ever sees.Why
That translation is where my first correction was wrong. At the extraction
commit a lost race surfaced as the driver's own error, and my first fix had
the services recognise it by comparing the Postgres constraint name, which
put the relational schema on the wrong side of the interface whose whole
purpose was to hide it. The translation lives in the adapter now. And one of
the seven mutating methods, applyCreditNote, had no pre-flight read at all;
the constraint still stopped the duplicate, only the error type was wrong,
and it had never mattered because the constraint had never fired.
The race
Two payments arrive for the same account at about the same time. Each, in its own transaction, reads the account's open charges and how much has landed on each, plans where its money goes, and writes allocations.
A: read outstanding on the charge 100
B: read outstanding on the charge 100
A: allocate 100, commit
B: allocate 100, commit
charge holds 200 against 100 owed, and no error anywhere
Each transaction is locally valid: it read a charge that was open and covered
it. The final state is globally wrong. This was the behaviour in Lumo and in
the extraction commit, and nothing had shown it, because the unit tests run
one operation at a time against a store where every await resolves in
order, so there is no second caller to interleave. The idempotency key does
not help, because the two payments are genuinely different. READ COMMITTED
does not help either: the allocation inserts do not conflict, and the two
status updates on the same charge row do, but the second simply waits for the
first to commit and then proceeds, by which point both transactions have
already decided what to allocate from the same stale reading. The conflict
the database sees arrives after the decision it would have needed to prevent.
The obvious reading is that the review found a concurrency bug. The truer one is that the model had an invariant nobody had written down, allocation for one account must be serialised, and the only thing enforcing it had ever been that in a studio one person records payments by hand, so the window was milliseconds wide and there was never a second caller in it. That is absence of failure, not prevention of it, and I had been treating the two as the same thing.
The waterfall was correct given a stable read, so the fix went around the
read and the write and not inside the algorithm: a row lock on the account,
taken as the first statement of every transaction that allocates and held
until commit. B now waits for A and then reads the state A left, and nothing
in the waterfall changed. The lock is one method on the storage interface,
lockAccount, with a documented obligation that a real store holds it to the
end of the transaction, so the invariant is part of the port rather than an
assumption about callers. I wrote the test that shows the overpayment before
the fix, because I wanted to see it happen rather than reason that it could.
It runs against a real Postgres on two connections, with a counting latch
rather than a timed pause, so the interleaving is a certainty and not a
probability.
20,000
Allocated against a 10,000 charge with the lock degraded to a plain read and each transaction held after its read until the other had read too
test/postgres-concurrency.test.ts, Postgres 16 and 18 in CI
10,000 and 0
What the two payments allocate with the lock in place: the second waits, then reads the state the first left
same test file
The same lock has since gone back into Lumo, where account-level serialisation now runs in production. The extraction was meant to produce a package. What it also produced was a place where a production assumption could be challenged without the application around to make it safe.
One operation serving two events
The ledger had one correction for a payment: reverse every payment that
landed on a charge, entered from the charge's side. That is right when a
transfer bounces. It is wrong when the charge itself should not have been
raised, because then the money did arrive, and clearing the charge declared
it nonexistent and reopened every other charge it had covered. Nothing could
even set a charge to void. Inside Lumo the two situations were rare enough,
and handled by the same person, that the distinction never surfaced. They are
two operations now because they are two accounting events. reversePayment
says the money never arrived: it voids the payment whole, removes every
allocation it funded and re-projects every charge it touched. voidInvoice says the charge was
wrong: it marks it void, releases its allocations and leaves the payments
alone, so their money becomes standing credit. Both take the account lock,
because both change what is allocated.
Contracts that were only prose
The storage interface describes shape. The ledger depends on behaviour that shape cannot express, and at the extraction commit those rules were comments: voided payments must not come back from the account's payment list, or a reversed payment reappears as credit; the event log must reject a duplicate key, or every idempotency test is theatre; rows with no tenant column of their own must be reached through a parent that has one. The comments were correct, and with one implementation written by the person who wrote them that was tolerable. With two it was not.
In the review the rules became a suite that takes a factory for any store and runs
29 cases: tenant isolation per read, allocations reached only through a
parent, voided rows excluded from the reads that spend them, the event log as
idempotency anchor, locking and transactions, and round trips of amounts,
instants and nulls. Run against the two stores that ship with the package, it
found four defects. My in-memory store filtered allocations by id alone,
ignoring the tenant, which is the exact failure the interface header warns
about, in the reference implementation written by the person who wrote the
header, and every service test had passed because every service test uses one
tenant. The Postgres schema made account ids globally unique, so two tenants
could not both have a customer-1. Charges created in one transaction got
the same timestamp, because now() is transaction time, so oldest first had
nothing to order by. And the Postgres store's runTransaction constructed
the class by name, so a subclass's overrides were silently dropped inside
transactions. The interface compiled in every case and the implementation was
wrong in every case. The same review found that a payment could settle a
charge in another currency at face value, through four entry points, because
each of Lumo's studios runs one currency and nothing had ever needed to
check. Preview and commit now take the currency and refuse a mismatch.
Tenant identity is where explicitness and enforcement divide. Every read and
write on the port takes organizationId, with no ambient state, and what
that repetition across thirty signatures buys is inspectability: when a query
is wrong, the wrong id is in the arguments of the call that made it. It does
not buy correctness, and the in-memory store proved that, because the type
checker guarantees the argument is present and not that it is right. Explicit
identity made the contract inspectable, the suite made it enforceable, and
neither alone would have found the leak. The rule came from an incident, when
Lumo leaked data across tenants through a table with no tenant column of its
own. In this package that table is allocations, and the absence is
deliberate: an allocation joins a payment and a charge that each carry the
tenant, so a column would be a second copy of the same fact, and copies can
disagree.
Permissions are the same division at the host boundary. The ledger checks membership in a set of four permissions the caller supplies, and at the extraction commit one method had no check at all because its check had lived in a Lumo server action. It has one now. But who a person is and what they may do belongs to the host, and the ledger's checks are only as good as what it is handed.
How to check it
Nothing above is estimated. Each claim has a command in the repository.
- The rules hold
npm test: 287 tests, each named for the rule it covers, on Postgres compiled to WebAssembly- The lock holds under contention
DATABASE_URL=... npm test: two connections, the same race, 20,000 without the lock and 10,000 with it- A store is checked, not trusted
- The contract suite runs against both shipped stores inside the same
npm test - Cost does not grow with the account
npm run bench: nine queries per payment whether one charge is open or a hundred- The estimate held
docs/extraction.md, written before the work, next to what happened
What I did not build
A neutral role model. Owner, manager, clerk, so the package has authorization out of the box. The matrix would be a product decision the ledger has no business making, every host would map its real roles onto an invented set anyway, and it would imply a security boundary the ledger cannot provide. Four permissions, supplied by the host, keep the guarantee where it can be made.
ORM types, an HTTP layer or an ambient tenant in the core. Each removes
repetition: the mapping layer, the need for a host, thirty organizationId
parameters. Each would have made the extraction a rewrite, and the ambient
tenant would have removed the property that made the leak findable.
What I would do differently
The concurrency test before the extraction, not after. It is one test on two connections, and it did not exist because I had never written the invariant down, so there was nothing to test. And the contract suite on the day the second store appeared, because the in-memory store was wrong from its first commit and every green run since had been a run with one tenant.
A coding agent did the mechanical extraction against a brief I wrote, and later the implementation work of the review, and I reviewed every diff, every test and every change to the shape of the thing; the README says so. The extraction was mechanical because the boundary was real, since a core that imports nothing but its own storage interface gives a literal reader nothing to untangle, and that is one example supporting one narrow claim.
The pattern across every finding is the same. Each assumption had survived because a fact about the host covered for it: one caller, one currency, one operator, one implementer. Each is now an invariant in the code, a case in the contract, or a responsibility stated as the host's. So I argue a subsystem's correctness without reference to how its current callers behave, and if the argument needs "but nobody calls it that way", the guarantee that sentence protects has not been placed anywhere yet. That sentence is the thing I look for when I review now, because every time I have found it, it was standing where a guarantee should have been.