A Ledger Is Not a Database Table
By Arshad Ansari
For months, my finance agent read the first 200 characters of each email and called it accounting.
It was not lying, exactly. It genuinely did classify email, genuinely did track subscriptions, and genuinely did produce a monthly spend figure. The figure was just wrong, and nothing in the system was capable of noticing.
This is the second of three posts about rebuilding lanes of AEGIS, my self-hosted agent platform. The first was about alerting; the third is about knowledge. All three converged on the same rule, and this is the lane where getting it wrong shows up as a wrong number.
What "working" actually looked like
Measured on production on 2026-09-05, over the data since 1 July:
- The extractor never saw the email. The fetch pulled each message in full, then kept
snippet[:500]— Gmail's preview line, which runs to about 200 characters, so the cap never even bit. Downstream, that snippet was read back as if it were the message body. Of 61 emails judged to be receipts, 30 had an amount. Of 35 recurring-charge rows, 19 carriedamount_cents = 0. - The richest stream was discarded on purpose. A list of bank sender addresses existed to stop bank alerts minting fake subscriptions — correct for a subscription tracker, catastrophic for a finance agent. In 30 days, from one mailbox, that list threw away 46 UPI debit alerts, plus IMPS transfers, UPI credits, card spends, a credit-card statement and an inbound international remittance. None of it was recorded anywhere.
- Nothing dated reached me. Sitting unactioned in the inbox at that moment: a credit-card statement due in two days, an advance-tax instalment due in ten, a declined subscription payment with a fix-by date, an electricity bill, and an "AWS past due". Tasks created: zero.
- What it did send was noise. 73
Anomaly: ? Apple-shaped tasks in nine weeks, most with no amount. 27 chat pings in 30 days about the same four charges. The same "what is this vendor?" question asked six times, because vendor-name variants produced different dedupe keys. - The monthly total was fiction. One electricity account appeared as three vendors, so "total monthly burn" counted it three times. A client's own supplier invoices, sitting in a work mailbox, counted as my subscriptions.
The plumbing was in perfect health: 242 extraction calls in 30 days, zero failures, 212 completed workflow runs. Every dashboard was green and every number was wrong. That is the specific failure mode of agent systems, and it is why I now measure the output rather than the pipeline.
The decision: hledger is the record, Postgres is the index
The rebuild starts with one structural choice. The book of record is a plain-text double-entry journal — hledger — in a private git repo. Postgres holds an index over it for fast queries.
Three properties a table does not give you by default:
Every write is a diff. A journal entry is a few lines of text in a git commit. I can read it, a reviewer can read it, and git log is the audit trail I would otherwise have had to build.
The arithmetic is checked by something that isn't me. Every write runs hledger check --strict, and a failure reverts exactly the paths that write touched. An entry that does not balance, or that uses an account nobody declared, does not land. No amount of LLM confidence gets past a tool that does arithmetic.
The index is disposable. The rule in the codebase is blunt: never treat an amount in the index as authoritative — run hledger. That means an index bug is a display bug, not a financial one, and the whole index can be rebuilt from the journal whenever I want.
The mechanics are ordinary and worth stating anyway: core and worker share one checkout, serialised by a file lock, so every write goes through one module. A hand-rolled file write would skip the strict check and its revert — so there is exactly one writer, and the rest of the system asks it.
Deterministic first, model second
Bank alerts are templates. They vary by bank, not by message. So the parsers are plain Python, tried before the model ever runs, and the model is the fallback for things no parser recognises.
Then a gate in front of even that, which turned out to be the single highest-return change in the lane.
Once the rebuild shipped and every money email was read in full rather than in 200 characters, the volume went up with it. On its first real day the lane made 324 extraction calls costing 522,846 tokens — and tripped the spend governor's kill switch, which blocks every LLM call in AEGIS, email triage included. The cause was that triage was doing its job correctly: stock-exchange alerts, tax notices, brokerage digests and book-sales reports are all genuinely financial, so they all reached the money lane, and the lane spent about 1,600 tokens on each to conclude nothing.
The fix is a regex. Before paying for an extraction, check that the message contains a currency token at all. It turns away 71% of extractions and loses no real event.
Two properties of that regex are load-bearing, and both are comments in the code because they look like tidiness opportunities:
- It is case-sensitive. Under
IGNORECASE,Rsmatches inside "hours" and "years". The gate would pass everything. - It is deliberately permissive. There is no trailing word boundary after the currency codes, because AWS writes
INR2,068.12. One wasted call is much cheaper than one lost transaction, so the gate errs open by design.
That is the general shape: deterministic cheap check → deterministic parser → model only for the remainder. It is not a cost optimisation bolted on afterwards; it is what stops a spend spike from taking out unrelated parts of the system.
The only place money may interrupt you
Exactly one function may turn an email into a task: the one that decides a bill needs paying. Everything else an email produces is read in a weekly brief. The first day of real production mail put three guards into that function, and each is a small lesson:
A zero invoice is not a bill. Nothing can ever close it, because a payment matches a due on its amount and no ₹0 payment mail ever arrives.
Same amount, same currency, same due date is the same obligation, whatever it calls itself. Payment apps mirror billers, so the same electricity bill arrives twice under two names. Name-keyed dedupe sees two bills; economics-keyed dedupe sees one.
A mail that says the money moves by itself is a heads-up, not a chore. Autopay notifications get indexed and reach the brief, but raise no task.
That last one is only safe because of an explicit exception: a failed payment still raises a task. That is precisely what catches an automatic debit that did not go through. A guard whose whole justification rests on another guard is worth writing down before someone "simplifies" it.
And the important part: all three still index the event and still reach the weekly brief. They withhold the chore, not the record. Which brings me to the bug.
The bug that named the rule
Here is code that looks careful and is not:
# post_money_event, roughly
closed = True
if capture is not None and due["todoist_ref"]:
closed = await capture.complete_captured_task(due["todoist_ref"])
if closed:
await mark_due_paid(due["message_id"], msgid) # the bill is now settled
The intent is tidy: don't mark a bill paid unless you also closed the reminder, or you strand an open task nothing will revisit. In practice, mark_due_paid has exactly one caller and nothing re-drives it, so every close failure was permanent. A task referenced by a temporary id queues no completion and returns false. A task the user deleted is a permanent 4xx. Even a retryable failure returns false here while the outbox goes on to close the task later. In all three cases the payment never settled the due — and the bill sat in every "dues open" count forever, clearable only by hand.
Whether a task manager accepted a close request is not evidence about whether money moved.
So the payment settles the due, and the close is best-effort: an unclosed task logs a warning and a human can tick it. The general form, which is now a rule in two lanes:
A check about noise must never decide what the record says.
It is the same rule as the alerting post, where a deleted ticket was being allowed to define whether an incident existed. I found it in two lanes independently. That is usually how you know a rule is real.
From "the bank emailed me" to "the bank did this"
The books recorded what the banks emailed. Nothing checked them against what the banks actually did. Two consequences, both live:
- Incomplete. Cash withdrawals, bank charges, interest credits and silent auto-debits send no alert, so they simply did not exist. An
assets:unknownbucket held ₹53,774.56 — the largest rupee balance in the journal. - Overstated. The renderer hardcoded
*(cleared) on every entry, so all 34 transactions claimed to be bank-cleared although not one had ever been reconciled with a bank.hledger bal --clearedreturned everything, which means the flag carried no information at all.
A statement fixes both, because it is the complete record for one account and one period — the only artefact that can promote a guess to a fact. Entries are now written ! (pending) and promoted to * when a statement proves them. One closing-balance check per statement, not per row: a per-row assertion turns a single late email into a books-wide write outage.
Two design choices from that work I'd repeat:
Unmatched rows are a digest, not a card each. Around 40–60 a month arrive uncategorised. A card per row is a chore that gets abandoned by month two, so they group deterministically — one finding per account, not 959 tasks — and go through the problem hub so a row that later matches resolves itself.
Completing the task acknowledges the finding. Otherwise a persistent watchdog reopens the same finding on the next tick, forever, and you learn to ignore it.
Two details worth stealing
The tool allowlist is exact-match, never a deny-list. The ledger query tool lets a model run hledger with arguments. It polices those arguments with an enumerated list of permitted options. A deny-list is not a weaker version of this — it is unimplementable, because hledger bundles short flags (-Ef<path> reads a file, -No<path> writes one), abbreviates long ones (--fil=), and splices the contents of @argsfile in as further arguments. Every deny-prefix scheme is bypassable, usually by accident.
The idempotency key is a hash of the content, not a UUID. A chat tool validates a write; a durable workflow performs it. The message id on a journal entry is a SHA-256 of the rendered block. So a write that outlived the chat loop's timeout and got retried finds its own entry already there instead of posting the transaction twice. A uuid4() would look equally correct and would double-count every slow write.
The desk, and what a score can honestly claim
The newest piece is a paper trading desk: the trading system decides what to hold, and the finance agent decides how much, and whether — sizing whole-share orders against a capital figure, refusing to trade on data that looks wrong, filling on paper at the close and scoring monthly. It is switched on as I write this and has not yet placed its first paper order, so what follows is what the design commits to, not a result.
I want to be straight about what that score can and cannot do, because this is where quant projects usually start lying to themselves. The backtest implies roughly 6% a year over the benchmark. An edge that size needs about ten years of live results to reach a t-statistic of 2. Over months, the score cannot prove the strategy works.
What it can do is catch the pipeline when it breaks — and that pipeline has stopped producing decisions three times, each time with its own internal checks passing. An independent consumer that refuses stale or odd input is the cheapest alarm you can build, and it is worth building even if the returns question stays open for a decade.
Two rules in the desk are load-bearing for reasons that took a while to see:
- A stored closing price is never overwritten. Price vendors rewrite past closes after a split. The first fetch of a day is therefore the only raw price you will ever see, and overwriting it silently rewrites your own history to agree with whatever you believe today.
- A day's findings are stored with that day's plan. A rerun after the upstream recovers must not resolve a problem that was genuinely true for that day.
The agent is the bookkeeper, not the book
Everything above is one decision applied five or six times. The journal is the record; Postgres is an index I am free to throw away and rebuild. That single demotion is what makes the rest survivable. A bad parse is a line in a diff. A wrong account is one commit to revert. A bug in the index is a wrong number on a screen, not a wrong number in my accounts — because the accounts are not kept there.
It also changes what the model is allowed to be. It can read a thousand emails, propose a thousand entries, and still never be the thing that decides what I owe. hledger check --strict decides whether an entry is admissible, a bank statement decides whether it is true, and a git diff is where I go to disagree.
The agent is a very good bookkeeper. It does not get to be the book.
AEGIS is open source, MIT-licensed: github.com/hikmahtech/aegis — the design documents with all the measurements above are in the repo. The wider tour is at /aegis.
The rest of the series: your alerts have no identity and a pile of documents is not knowledge.
Common questions
- Should an AI agent store financial data in a database or a ledger file?
- Keep the ledger as the record and use the database as an index over it. A plain-text double-entry journal in git gives you three things a table does not: every write is diffable and reviewable by a human, the arithmetic is checked by a tool that refuses to accept an unbalanced entry, and you can rebuild the index from scratch at any time. The database is then free to be wrong without being dangerous — you re-derive it. Reverse the roles and every index bug becomes a financial error.
- How do you stop an LLM from burning tokens on emails with no data in them?
- Put a cheap deterministic gate in front of the expensive call. In my case a regex checking for any currency token before the extractor runs turned away 71% of the emails that reached the extractor — stock-exchange alerts, tax notices, newsletter digests, sales reports — and lost no real transaction. Keep the gate permissive: one wasted model call is far cheaper than one missed transaction, so it should err towards letting things through.
- Why does my automation keep creating duplicate tasks for the same bill?
- Usually because the payee name is doing the deduplication. The same obligation arrives under different names — a biller's own email, a payment app's mirror of it, a bank's debit alert — so name-keyed dedupe sees three bills. Key on the economics instead: same amount, same currency, same due date is the same obligation whatever it calls itself.
- Is it safe to let a language model run command line tools?
- Only behind an exact-match allowlist of permitted arguments, never a deny-list of forbidden ones. Real CLI tools bundle short flags, accept abbreviated long options, and often splice the contents of a file in as further arguments — so any prefix-matching deny-list can be walked around by an attacker or, more likely, stumbled past by the model itself. Enumerate what is allowed and reject everything else.
Get new posts by email
Data engineering notes like this one — pipelines, warehouse cost, and what actually breaks in production. A few a month, never padded to hit a schedule.
No sequence, no pitch deck. Reply 'stop' once and you're off — it reaches me, not a queue.
Want the whole playbook?
If this was useful, the long version is my book. Local-First Analytics — 314 pages, runnable code for every chapter — is the full build: DuckDB, Parquet and Arrow, from install to production. On Amazon, or request a free review copy.
Get the bookNot ready to buy? Read chapter 1 free — the whole chapter, no email required.
Rather talk it through? Book a free 30-minute call. No slot that suits your time zone? Email [email protected].