Double-entry bookkeeping that assumes the bookkeeper is not trustworthy.
Postledger is an idempotent, append-only double-entry ledger with a Unix CLI and an MCP server over one SQLite file. It exists because AI agents retry: when a tool call times out and the model posts the same journal entry again, most ledgers cheerfully create a duplicate. Postledger cannot — every write carries an idempotency key, and replaying that key returns the original entry and posts nothing.
Balance is enforced by SQLite triggers, not by application code. Entries and postings have no UPDATE
and no DELETE path at all; a mistake is corrected with a reversing entry. Money is bigint minor units,
so there is no float anywhere and no rounding tolerance to exploit. postledger verify walks the hash
chain, recomputes every balance from the journal, and re-hashes each archived source document.
14 MCP tools. 304 tests. Zero dependencies. Runs on Node 22.13+, needs no server, no daemon, and no account.
npx postledger --helppostledger init books/demo.db --name "Demo Co" --currency SGD
export POSTLEDGER_BOOK=books/demo.db
postledger account open Assets:Bank:Checking --type asset
postledger account open Income:Sales --type income
postledger post --key inv-001 --date 2026-08-08 --desc "Invoice 001" \
--leg "Assets:Bank:Checking debit 5000.00" \
--leg "Income:Sales credit 5000.00" \
--expect-total 5000.00Now try to break it:
# Replay the same key — returns the original entry, posts nothing
postledger post --key inv-001 ... # "replayed": true, still 1 entry
# Off by one cent — rejected, exit code 2
postledger post --key x --leg "Assets:Bank:Checking debit 100.00" \
--leg "Income:Sales credit 99.00" --expect-total 100.00
# Edit the books behind postledger's back — the database itself refuses
sqlite3 books/demo.db "UPDATE postings SET amount = 1"
# Error: postledger: postings are immutable
sqlite3 books/demo.db "DELETE FROM entries"
# Error: postledger: entries are append-only; correct with a reversalThere are several local-first double-entry MCP servers now. They mostly compete on how much your agent can do — budgets, reconciliation, VAT, securities, cash-flow forecasting. Postledger competes on a different axis: whether you can trust what the agent did.
Feature comparison, from reading the source of each project on 2026-08-08. Facts only; every project listed is doing something legitimate and several are more feature-rich than this one.
| Postledger | A | B | C | D | |
|---|---|---|---|---|---|
| Storage | SQLite | SQLite | SQLite | PostgreSQL | JSONL file |
| Money as integer minor units | ✅ | ✅ | ✅ | — | ✅ |
| General idempotency key on writes | ✅ | — | — | — | ✅ |
| Immutability enforced by DB triggers | ✅ all tables | partial | ✅ postings | — | — |
| Hash chain over entries | ✅ | — | — | — | — |
| External anchoring | ✅ | — | — | — | — |
| Bulk revert by actor | ✅ | — | — | — | — |
| Statistical fraud indicators | ✅ | — | — | — | — |
| Bookkeeper's error diagnostics | ✅ | — | — | — | — |
| Document archive + fingerprint check | ✅ | ✅ | ✅ | ✅ | — |
| Breadth of features | moderate | very high | high | moderate | minimal |
A = cloviscomputing/clovis · B = erikvankempen/bukio-cli · C = yuens1002/bookie · D = themusashimaru/ledgerkit-mcp
Worth knowing: "idempotent" means different things across these projects. In several it refers to
import deduplication (re-importing a bank file doesn't duplicate rows, keyed on a natural key) or to
MCP's idempotentHint protocol metadata. Postledger uses it in the strict sense: a caller-supplied key
on every write, claimed atomically before any work happens, where replay returns the original result.
| Guarantee | Enforced by | Where |
|---|---|---|
| Debits equal credits | RAISE(ABORT) in a trigger, at seal time |
schema.sql |
| Retries never double-post | Key claimed before the work — no check-then-act window | ledger.ts |
| Nothing is edited or deleted | BEFORE UPDATE/DELETE triggers on every table |
schema.sql |
| No floating point, ever | bigint minor units; there is deliberately no fromNumber() |
money.ts |
| The caller's own total must match | expected_total is cross-checked against the legs |
ledger.ts |
| Source documents stay verifiable | Content-addressed; verify re-hashes the file on disk |
ledger.ts |
| Tampering is detectable | Hash chain over entries and their postings | ledger.ts |
Each row has a test that goes red if you remove the mechanism. npm test runs 304 of them across six
suites, including one that drives the real CLI and speaks real MCP over stdio.
debits == credits in application code protects you from today's callers. In a trigger it protects you
from every future one — a migration script, a cron job, a helpful contributor, an agent with direct SQL
access. The rule outlives the code path that was meant to enforce it.
SQLite has no deferred constraints, so the write protocol is inverted to make that stop mattering:
1. INSERT all postings — the entry is unsealed, invisible to every read path
2. INSERT the entry header — a BEFORE INSERT trigger validates the whole entry at this instant
There is no window in which an unbalanced entry is visible, and appending a leg after sealing is rejected.
{
"mcpServers": {
"postledger": {
"command": "npx",
"args": ["-y", "postledger", "mcp", "--book", "/absolute/path/to/books/demo.db"]
}
}
}The tool surface is shaped so the model has little room to get it wrong:
-
postledger_post_entryrequires anidempotency_keyand anexpected_totalthe caller computed itself. A hallucinated line item rarely arrives with a total that happens to balance. -
Amounts are strings, never JSON numbers.
JSON.parseturns125.50into an imprecise double before any validator could see it, so it is refused at the boundary with an explanation. -
Unknown account? The error carries
did_you_meancandidates rather than leaving the model guessing. -
Unbalanced? The error runs the classic bookkeeper's checks and names the likely mistake:
debits 54.00 != credits 45.00 (off by 9.00)— the difference is divisible by 9, the classic signature of a transposition error — two digits swapped somewhere (e.g. 54 typed as 45). Re-read each amount against the source document.It also catches the two other classics: a difference that is exactly twice one leg (that leg is on the wrong side) and a difference that equals one leg exactly (its counterpart is missing).
Every write returns the current chain head. In an MCP session that value lands in the conversation transcript — a copy of your ledger's fingerprint that lives outside the machine holding the ledger.
postledger serve # http://127.0.0.1:7777A single self-contained page: overview, chart of accounts, balance sheet, income statement, journal, and
the forensics panel. No build step, no framework, no CDN — the HTML you can read is the HTML that runs,
and a CSP of default-src 'none' means the page cannot reach the network even if something got into it.
Two deliberate limits: it is read-only (writing stays with the CLI and MCP, so there is no form to
CSRF and no session to steal), and it binds 127.0.0.1 unless you explicitly pass --host. Your
books should not become reachable because you left a tab open.
Every entry is signed with its author and nothing is ever deleted, so one actor's entire footprint can be undone:
postledger revert-actor agent:rogue --key cleanup-1 --reason "malfunction" --dry-run
# → matched: 3, and exactly what each balance would become
postledger revert-actor agent:rogue --key cleanup-1 --reason "malfunction"
# → 3 reversing entries posted; balances back to where they wereIt reverses, it does not delete. The books end up as if that actor never wrote, while the record of what happened — what was posted, by whom, when it was undone and why — stays intact. Deleting would defeat the point of keeping an audit trail.
Re-running with the same batch key is safe: already-reversed entries are recognised and skipped, so an interrupted cleanup resumes rather than double-reverting.
postledger balance-sheet --table
postledger income-statement --from 2026-01-01 --to 2026-03-31The balance sheet asserts the accounting identity rather than assuming it:
assets = liabilities + equity + (income − expenses)
If that does not hold exactly it returns ok: false, prints the exact gap, and exits 5. There is no
rounding tolerance to hide behind — money is integer minor units, so a difference of one cent is a real
difference and means something is wrong. Profit for the period is shown as its own line inside equity
rather than folded in silently, so retained earnings and this period's result stay distinguishable.
postledger export --format journal > books.journal # hledger/ledger format
hledger -f books.journal balancesheet # someone else's tool, your data
postledger import books.journal --dry-run # see what would happen
postledger import books.journalRound-trip is lossless. Postledger's own facts (entry id, idempotency key, actor) ride along in tag comments, which ledger-likes preserve and ignore — so an export re-imports without inventing a dialect.
Direction is the one real difference between the formats and it is handled explicitly: Postledger uses an
explicit side with a strictly positive amount; ledger-likes use a sign. Positive is debit, negative is
credit, and the export writes that convention into the file header.
Import goes through the same post() path as everything else — an import is not a back door, and the
same invariants apply. Idempotency keys are derived from the file and position, so re-running an import
is a no-op rather than a duplicate. Anything Postledger does not model (virtual postings, multi-commodity
legs, automated transaction rules) is rejected with the line number, never silently dropped: a tool
that quietly discards part of your file is worse than one that refuses it.
postledger auditBenford first-digit distribution, round-number density, duplicate amounts, clustering just below approval thresholds, and outliers by modified Z-score. Fabricated numbers have a fingerprint — people and language models both favour uniform leading digits, round figures, and amounts sitting just under a limit. Real ledgers do not.
These are indicators, not evidence. Deviation is not fraud and conformity is not innocence: a careful fabricator can match Benford on purpose, and plenty of honest ledgers fail it (fixed contract prices, a natural floor or cap, or simply too few entries). Below 100 samples the tool refuses to draw a conclusion at all. The output repeats this caveat every time. It tells you which entries to pull the source document for. Nothing more.
What the audit chain detects
- Accidental corruption
- Any modification that did not go through Postledger
- Entries deleted from the middle or the end of the chain
- An archived source document swapped for a different file
What it cannot do
- Stop someone who owns the machine. With write access to the file, an attacker can drop the triggers, rewrite history, and recompute the chain so it verifies clean. There is a test in this repository that does exactly that and asserts local verification passes — because claiming otherwise would be the dishonest choice.
- Prove who did anything. Over stdio there is no authenticated identity. The
actorfield is self-declared, and the schema column is calledclaimed_actorso nobody mistakes it for proof. Good for tracing accidents, useless against an adversary.
What actually raises the bar
postledger anchor --line >> anchors.log # after each session
git -C anchors commit -am "anchor" && git push
postledger verify-anchors anchors.log # check the book against those witnessesAn attacker can rewrite what is on your disk. They cannot rewrite the copy that already left it. The same test that proves local verification is defeatable also proves the anchor check catches it. Anchor somewhere you do not control — a remote repo, a colleague, another host — and the more places, the higher the cost of forgery.
One book per file. A book is a file you can cp, tar, rsync, chmod, and sha256sum. Backup is
copy. Isolation is file permissions — which matters, because with no trustworthy identity in the
application layer, the filesystem is the only real access control there is. Reporting across books is a
separate read-only command, not a reason to put five companies in one file.
No account tree table. Hierarchy lives in the name (Expenses:Meals:Team) and reports aggregate by
prefix. That removes parent ids, closure tables, and subtree moves in one stroke.
One currency per book. Multi-currency drags in rates, translation, and revaluation — half a project. Need another currency? Open another book.
Zero runtime dependencies. The only thing the published package imports is node: builtins —
node:sqlite for storage, node:http for the web view, node:crypto for the chain. TypeScript is a
build-time dependency and nothing else. The MCP server is ~350 lines of newline-delimited JSON-RPC rather
than an SDK, because a financial tool people are asked to audit should be readable end to end.
(During development Node runs the .ts sources directly, so there is no build step in the loop. The
package is compiled for publication because Node deliberately refuses to strip types from anything under
node_modules — shipping .ts files would install cleanly and then crash on first run. CI installs the
real tarball into a clean directory and drives the binary, so that failure mode cannot come back.)
Deliberately not in v1: multi-currency, invoice/AR/AP state machines, period-close automation, a web UI, bank imports. None of them change whether an AI can keep books safely, which is the only thing this is trying to be good at.
Requires Node 22.13+ — that is where node:sqlite became available without a flag (measured, not guessed). Contributors running the TypeScript sources directly need 22.18+, where type stripping is on by default.
npx postledger --help # no install
npm install -g postledger # or install itOr Docker — the image runs the full test suite at build time, so an image that exists is an image whose invariants held:
docker run -v "$PWD/books:/books" postledger \
init /books/demo.db --name "Acme Co" --currency USDAs a library:
import { Ledger } from 'postledger';
const book = Ledger.open('books/demo.db');
book.post({
idempotencyKey: 'stripe_evt_1P9x…', // the real-world event id
date: '2026-08-08',
description: 'Stripe payout',
legs: [
{ account: 'Assets:Bank:Checking', side: 'debit', amount: '4820.15' },
{ account: 'Expenses:Fees', side: 'debit', amount: '179.85' },
{ account: 'Income:Sales', side: 'credit', amount: '5000.00' },
],
expectedTotal: '5000.00',
actor: 'agent:stripe-sync',
});0 ok · 1 error · 2 validation failed · 3 idempotency conflict · 4 book problem ·
5 integrity check failed
postledger verify || echo "the books need attention"npm test304 assertions across six suites: schema invariants, money arithmetic, the engine, forensics, reports and journal interop, and an end-to-end pass that drives the real CLI and speaks real MCP over stdio.
MIT