shuaige121

Postledger

Community shuaige121
Updated

Double-entry bookkeeping that assumes the bookkeeper is not trustworthy. Idempotent writes, append-only journal, DB-enforced balance, tamper-evident hash chain. Unix CLI + MCP server. Zero dependencies.

Postledger

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 overone SQLite file. It exists because AI agents retry: when a tool call times out and the model posts thesame journal entry again, most ledgers cheerfully create a duplicate. Postledger cannot — every writecarries 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 UPDATEand 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 hashchain, recomputes every balance from the journal, and re-hashes each archived source document.

14 MCP tools. 304 tests. Zero dependencies. Runs on Node 22.6+, needs no server, no daemon, and noaccount.

npx postledger --help

60 seconds, no signup

postledger 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.00

Now 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 reversal

Where this sits

There are several local-first double-entry MCP servers now. They mostly compete on how much youragent can do — budgets, reconciliation, VAT, securities, cash-flow forecasting. Postledger competes ona 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 projectlisted 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 toimport deduplication (re-importing a bank file doesn't duplicate rows, keyed on a natural key) or toMCP's idempotentHint protocol metadata. Postledger uses it in the strict sense: a caller-supplied keyon every write, claimed atomically before any work happens, where replay returns the original result.

The seven guarantees

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 sixsuites, including one that drives the real CLI and speaks real MCP over stdio.

Why the database and not the application layer

debits == credits in application code protects you from today's callers. In a trigger it protects youfrom every future one — a migration script, a cron job, a helpful contributor, an agent with direct SQLaccess. 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 isrejected.

Use it from Claude, ChatGPT, or any MCP client

{
  "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_entry requires an idempotency_key and an expected_total the caller computeditself. A hallucinated line item rarely arrives with a total that happens to balance.

  • Amounts are strings, never JSON numbers. JSON.parse turns 125.50 into an imprecise double beforeany validator could see it, so it is refused at the boundary with an explanation.

  • Unknown account? The error carries did_you_mean candidates 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 digitsswapped 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 thewrong 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 conversationtranscript — a copy of your ledger's fingerprint that lives outside the machine holding the ledger.

Look at the books in a browser

postledger serve                    # http://127.0.0.1:7777

A single self-contained page: overview, chart of accounts, balance sheet, income statement, journal, andthe 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 toCSRF and no session to steal), and it binds 127.0.0.1 unless you explicitly pass --host. Yourbooks should not become reachable because you left a tab open.

When an agent goes wrong

Every entry is signed with its author and nothing is ever deleted, so one actor's entire footprint can beundone:

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 were

It reverses, it does not delete. The books end up as if that actor never wrote, while the record ofwhat happened — what was posted, by whom, when it was undone and why — stays intact. Deleting woulddefeat the point of keeping an audit trail.

Re-running with the same batch key is safe: already-reversed entries are recognised and skipped, so aninterrupted cleanup resumes rather than double-reverting.

Statements

postledger balance-sheet --table
postledger income-statement --from 2026-01-01 --to 2026-03-31

The 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 norounding tolerance to hide behind — money is integer minor units, so a difference of one cent is a realdifference and means something is wrong. Profit for the period is shown as its own line inside equityrather than folded in silently, so retained earnings and this period's result stay distinguishable.

Your data is not held hostage

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.journal

Round-trip is lossless. Postledger's own facts (entry id, idempotency key, actor) ride along in tagcomments, 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 anexplicit side with a strictly positive amount; ledger-likes use a sign. Positive is debit, negative iscredit, 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 thesame invariants apply. Idempotency keys are derived from the file and position, so re-running an importis a no-op rather than a duplicate. Anything Postledger does not model (virtual postings, multi-commoditylegs, automated transaction rules) is rejected with the line number, never silently dropped: a toolthat quietly discards part of your file is worse than one that refuses it.

Statistical forensics

postledger audit

Benford first-digit distribution, round-number density, duplicate amounts, clustering just below approvalthresholds, and outliers by modified Z-score. Fabricated numbers have a fingerprint — people and languagemodels both favour uniform leading digits, round figures, and amounts sitting just under a limit. Realledgers do not.

These are indicators, not evidence. Deviation is not fraud and conformity is not innocence: a carefulfabricator can match Benford on purpose, and plenty of honest ledgers fail it (fixed contract prices, anatural floor or cap, or simply too few entries). Below 100 samples the tool refuses to draw a conclusionat all. The output repeats this caveat every time. It tells you which entries to pull the source documentfor. Nothing more.

Threat model, honestly

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 thetriggers, rewrite history, and recompute the chain so it verifies clean. There is a test in thisrepository that does exactly that and asserts local verification passes — because claiming otherwisewould be the dishonest choice.
  • Prove who did anything. Over stdio there is no authenticated identity. The actor field isself-declared, and the schema column is called claimed_actor so nobody mistakes it for proof. Goodfor 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 witnesses

An attacker can rewrite what is on your disk. They cannot rewrite the copy that already left it. The sametest that proves local verification is defeatable also proves the anchor check catches it. Anchorsomewhere you do not control — a remote repo, a colleague, another host — and the more places, the higherthe cost of forgery.

Design decisions

One book per file. A book is a file you can cp, tar, rsync, chmod, and sha256sum. Backup iscopy. Isolation is file permissions — which matters, because with no trustworthy identity in theapplication layer, the filesystem is the only real access control there is. Reporting across books is aseparate 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 byprefix. 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 dependencies. Node 22.6+ runs TypeScript directly and ships node:sqlite, so there is no buildstep and no supply chain. 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.

Deliberately not in v1: multi-currency, invoice/AR/AP state machines, period-close automation, a webUI, bank imports. None of them change whether an AI can keep books safely, which is the only thing thisis trying to be good at.

Install

Requires Node 22.6+ (native TypeScript and node:sqlite).

npx postledger --help          # no install
npm install -g postledger      # or install it

Or Docker — the image runs the full test suite at build time, so an image that exists is an image whoseinvariants held:

docker run -v "$PWD/books:/books" postledger \
  init /books/demo.db --name "Acme Co" --currency USD

As 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',
});

Exit codes

0 ok · 1 error · 2 validation failed · 3 idempotency conflict · 4 book problem ·5 integrity check failed

postledger verify || echo "the books need attention"

Tests

npm test

304 assertions across six suites: schema invariants, money arithmetic, the engine, forensics, reportsand journal interop, and an end-to-end pass that drives the real CLI and speaks real MCP over stdio.

License

MIT

MCP Server · Populars

MCP Server · New

    tenequm

    pond

    Lossless storage and search for AI agent sessions, across every agentic client.

    Community tenequm
    lineai-intelligence

    lineai-mcp-server

    An MCP Server to utilize Lineai's rich software dependency data in your AI programming assistant.

    mutonby

    OpenShorts.app

    Open source AI clip generator: turns long videos into viral 9:16 shorts with AI moment detection, face tracking, subtitles and dubbing. Self-host free with Docker (MIT), or use the cloud with GPU speed from $12/mo. MCP server and API for AI agents.

    Community mutonby
    legendaryvibecoder

    Gigabrain

    Local-first memory layer for OpenClaw, Codex App, and Codex CLI: capture, recall, dedupe, and native sync.

    Community legendaryvibecoder
    sysevol-ai

    Searchable codebase wikis and context for coding agents

    A multi-view data system for serving repository context to coding agents.

    Community sysevol-ai