A double-entry payments ledger whose correctness guarantees are enforced by the database, and a read-only MCP server that lets an AI agent investigate it without being able to damage or leak it.
Most ledger examples store a mutable balance column and call it done. This one
treats the ledger as append-only and derives balances from entries, which is how
real payment systems work and why they can be audited. The MCP layer then
demonstrates the harder half: giving an agent genuine analytical access without
handing it the database.
These are the product. Everything else is scaffolding.
| # | Invariant | Enforced by | Where |
|---|---|---|---|
| 1 | Every transaction's legs sum to zero | DEFERRABLE INITIALLY DEFERRED constraint trigger |
002-zero-sum-invariant.sql |
| 2 | An idempotency key maps to at most one transaction, ever | UNIQUE constraint, plus insert-and-catch in the service |
001-core-tables.sql, PostingService |
| 3 | Entries are append-only; corrections are reversing entries | BEFORE UPDATE OR DELETE trigger that always raises |
003-append-only-invariant.sql |
| 4 | A balance is always derived, never stored | There is no balance column to store one in | 004-derived-balance-views.sql |
| 5 | A transaction commits all legs or none | One database transaction, checked at commit | TransactionWriter |
Note the "Enforced by" column. Not one of these says "application code". That is the whole argument: a rule that lives only in a service method binds the service method. A rule that lives in the schema binds the service, the migration script, the bulk importer, the intern with a psql prompt at 2am, and every future component nobody has written yet.
flowchart LR
subgraph agent["AI agent"]
A["Claude / any MCP client"]
end
subgraph mcp["MCP server · Python"]
T1["get_balance"]
T2["list_transactions"]
T3["trace_transaction"]
T4["find_imbalances"]
end
subgraph svc["Ledger service · Java 21 / Spring Boot 3"]
W["POST /transactions<br/>POST /accounts"]
R["GET /balance<br/>GET /transactions<br/>GET /audit/integrity"]
end
subgraph db["PostgreSQL"]
direction TB
TBL["account · ledger_transaction · ledger_entry"]
INV["zero-sum trigger · unique idempotency key<br/>append-only triggers · derived views"]
TBL --- INV
end
A -- "stdio, MCP" --> mcp
mcp -- "HTTPS, GET only" --> R
W --> TBL
R --> TBL
style INV fill:#fff3cd,stroke:#d39e00
style mcp fill:#e7f1ff,stroke:#2a6fb0
The dashed boundary worth noticing is the one the MCP server does not cross. It has no database credentials and no write path; it reaches the ledger through the same public REST API any other consumer would use. The most an agent can do with it is read things it was already allowed to read.
Requires Docker (for Postgres and Testcontainers), JDK 21, and Python 3.10+. The
Maven wrapper (./mvnw) is checked in, so a separate Maven install isn't
required.
# 1. Database
docker compose up -d
# 2. Ledger service - Liquibase creates the schema, triggers and seed accounts on boot
cd ledger-service
./mvnw spring-boot:run
# 3. MCP server, in a second terminal
cd mcp-server
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
LEDGER_API_BASE_URL=http://localhost:8080 ledger-mcp./mvnw spring-boot:run blocks the terminal it runs in — that's the running
server, not a hang. Check it came up before moving on:
curl -s http://localhost:8080/actuator/health
# {"status":"UP"}To wire the MCP server into Claude Desktop instead of running it standalone,
see mcp-server/README.md.
To seed a realistic scenario instead of posting by hand, see Worked example below.
Both services are tested against the real thing, not a stand-in. This matters more here than in most projects — see Tests run against real PostgreSQL, never H2 below for why an in-memory database would test a different system.
cd ledger-service
./mvnw verifyThis runs two JUnit 5 classes end to end, each spinning up its own disposable PostgreSQL 16 container via Testcontainers, with Liquibase applying the full changelog on boot:
InvariantContractTestattacks each invariant through raw SQL, going around the service entirely — because "the application prevents it" and "the system prevents it" are different claims, and only the second one is worth putting on a README.ConcurrentPostingTestfires 32 simultaneous duplicate requests through aCyclicBarrierand asserts exactly one201, thirty-one200s, one row, two legs, and one movement of money — the idempotency race, forced to actually happen instead of being taken on faith.
A clean run looks like:
Tests run: 8, Failures: 0, Errors: 0, Skipped: 0 -- in com.ledgerlab.InvariantContractTest
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 -- in com.ledgerlab.ConcurrentPostingTest
BUILD SUCCESS
macOS + Docker Desktop: if ./mvnw verify fails immediately with
Could not find a valid Docker environment, it's almost always one of two
Docker Desktop quirks, not a real problem with the project:
- Socket path. Docker Desktop on macOS doesn't put its socket at
/var/run/docker.sockunless you've explicitly enabled "Allow the default Docker socket to be used" in Settings → Advanced. Point Testcontainers at the real one instead:export DOCKER_HOST="unix://$HOME/.docker/run/docker.sock"
- API version. Recent Docker Desktop releases raised their minimum
supported Docker API version. The
docker-javaclient version pinned by Spring Boot's Testcontainers BOM can predate that floor and skip version negotiation, so every request comes back400 Bad Requestwith an all-blank body. Force a version above the floor:./mvnw verify -Dapi.version=1.41
Both are read-only environment flags — nothing about the project or the test
code changes. Run docker version first if you want to confirm your daemon's
own MinAPIVersion before picking a number.
cd mcp-server
source .venv/bin/activate # from the Running it setup above
pytest -q
ruff check .pytest covers the read-only guarantee (no tool the server exposes can reach
a write endpoint) and the request/response shape of each tool without needing
the ledger service running — HTTP calls are mocked with respx. A clean run:
7 passed in 0.02s
All checks passed!
For an end-to-end check that exercises both services together against a real ledger service (not mocks), start everything per Running it, then in a third terminal:
./docs/scenario-off-by-250.shThis posts four real transactions over HTTP and prints the resulting balance. See Worked example for what it sets up and why.
Every response below is real output from this project, captured by running the
commands shown against a freshly seeded database — not hand-written examples.
Amounts are in minor units (paise); LIABILITY:MERCHANT:M001 starts this
section already seeded by docs/scenario-off-by-250.sh.
curl -X POST localhost:8080/api/v1/accounts \
-H 'Content-Type: application/json' \
-d '{"code": "LIABILITY:MERCHANT:M998", "name": "Merchant M998 payable", "type": "LIABILITY", "currency": "INR"}'HTTP/1.1 201
Location: /api/v1/accounts/LIABILITY:MERCHANT:M998
{"id":"ead1e36d-36b0-4e08-b6d6-886eb63e76ee","code":"LIABILITY:MERCHANT:M998","name":"Merchant M998 payable","type":"LIABILITY","currency":"INR","createdAt":"2026-09-13T11:39:54.020629Z"}
created_at is insertable = false — the database fills it in from its own
DEFAULT now(), not the application — and Account is @Immutable, so
AccountService.create can't just re-read the row it just saved inside the
same transaction: Hibernate's session would hand back the same cached,
pre-insert object rather than the one the database actually wrote.
create is deliberately not @Transactional; save() and the follow-up
findByCode each get their own transaction and their own Hibernate session,
so the second one reads the real row — the same two-phase shape
PostingService.post
already uses for the same reason.
curl -X POST localhost:8080/api/v1/accounts \
-H 'Content-Type: application/json' \
-d '{"code": "ASSET:BAD:MISMATCH", "name": "Mismatched type test", "type": "LIABILITY", "currency": "INR"}'HTTP/1.1 422
{"code":"ACCOUNT_CODE_TYPE_MISMATCH","message":"The database refused this account: Batch entry 0 insert into account (code,currency,name,type,id) values (('ASSET:BAD:MISMATCH'),('INR'),('Mismatched type test'),('LIABILITY'),('357cdbcc-1a33-4821-b26f-fe27153b7c46'::uuid)) was aborted: ERROR: new row for relation \"account\" violates check constraint \"account_code_prefix_matches_type\"\n Detail: Failing row contains (357cdbcc-1a33-4821-b26f-fe27153b7c46, ASSET:BAD:MISMATCH, Mismatched type test, LIABILITY, INR, 2026-09-13 18:13:30.13883+05:30). Call getNextException to see other errors in the batch.","at":"2026-09-13T12:43:30.154791Z","detail":null}
A malformed code (wrong characters, wrong length) never reaches the database —
@Pattern on the request DTO catches that as a plain 400. This one can't be
caught the same way: it's a relationship between two fields, code and type
together, which a single-field validator can't see. So it surfaces exactly like
Post something unbalanced does — the database's
own refusal, translated to a 422 rather than left to fall through as a bare
500. See
Account codes carry their own type.
A payment is not one movement, it is three, and the chart of accounts is shaped to record each one separately.
| Step | What actually happens | Posting |
|---|---|---|
| Capture | The card is charged. The money is real, but the card network still holds it. | DEBIT ASSET:RECEIVABLE:NETWORK · CREDIT LIABILITY:MERCHANT:M001 · CREDIT REVENUE:FEES:PROCESSING |
| Settlement | A day later the network pays. The promise becomes cash. | DEBIT ASSET:BANK:SETTLEMENT · CREDIT ASSET:RECEIVABLE:NETWORK |
| Payout | We pay the merchant what we owe them. | DEBIT LIABILITY:MERCHANT:M001 · CREDIT ASSET:BANK:SETTLEMENT |
ASSET:RECEIVABLE:NETWORK exists for the gap between the first two rows. For those
hours the money is ours and not in our hands, and a ledger that cannot express
that has to either pretend the cash arrived early or pretend the sale had not
happened yet. Both lie.
Notice what each step does not touch. Settlement moves value between two asset
accounts and never touches the merchant payable or revenue: nothing was earned
or owed there, money only changed form. That is the sort of distinction a single
mutable balance column cannot make at all.
The obligation and the fee are recognised at capture, not at settlement —
the merchant made the sale then, and we did the work then. If the network never
settles, ASSET:RECEIVABLE:NETWORK sits there not shrinking, which is exactly the
operational alarm you want.
Codes are TYPE:CATEGORY:IDENTIFIER - LIABILITY:MERCHANT:M001, REVENUE:FEES:PROCESSING.
The first segment repeats the type column, which is redundant on purpose: it
makes a code readable wherever it appears alone, with no lookup.
INVARIANT_VIOLATION: transaction abc-123 legs net to 5000 minor units, expected 0
DEBIT 25000 ASSET:BANK:SETTLEMENT
CREDIT 20000 LIABILITY:MERCHANT:M001
At 2am that readability is worth the duplication - but only if the duplicate
cannot drift. Until
006-account-code-conventions.sql
nothing stopped an account coded ASSET:... carrying type = 'LIABILITY', and a
duplicated fact that can disagree with itself is worse than no duplication at
all. So the convention became a constraint:
CONSTRAINT account_code_prefix_matches_type CHECK (code LIKE type || ':%')The same file also renames ASSET:PSP:RECEIVABLE to ASSET:RECEIVABLE:NETWORK.
Every account in this ledger belongs to the PSP, so that segment said nothing,
while the party the account is actually about - the card network that owes us the
money - went unnamed. It is a new changeset rather than an edit to 005, because
005 has already run and Liquibase checksums exist precisely so that rewriting
applied history is noisy rather than silent.
A CHECK constraint refusing a row is a DataIntegrityViolationException by
default, which AccountService.create was not translating — it fell through to
a bare 500, the one place in the API where the database's refusal was not
yet a decent HTTP response. It is now: CHECK_VIOLATION becomes a 422 ACCOUNT_CODE_TYPE_MISMATCH, the same shape PostingService.post already used
for the zero-sum trigger. See
Create an account whose code lies about its type
for the real response.
docs/scenario-off-by-250.sh posts the full
capture → settlement → refund sequence and leaves the books at ₹500.00 cash,
₹485.00 owed and ₹15.00 earned — assets equalling liabilities plus equity, which
falls out of the zero-sum rule rather than being asserted anywhere. The inline
example below is the shortened one-step version, crediting the bank directly, to
keep the request readable.
A capture of ₹250.00 with a ₹5.00 fee, three legs:
curl -X POST localhost:8080/api/v1/transactions \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: capture_ord_88410' \
-d '{
"description": "Card capture 250.00, fee 5.00",
"legs": [
{"accountCode": "ASSET:BANK:SETTLEMENT", "direction": "DEBIT", "amountMinor": 25000},
{"accountCode": "LIABILITY:MERCHANT:M001", "direction": "CREDIT", "amountMinor": 24500},
{"accountCode": "REVENUE:FEES:PROCESSING", "direction": "CREDIT", "amountMinor": 500}
]
}'HTTP/1.1 201
Location: /api/v1/transactions/9d27868d-178c-43c0-9bee-8b84218447c6
{"id":"9d27868d-178c-43c0-9bee-8b84218447c6","idempotencyKey":"capture_ord_88410","description":"Card capture 250.00 for order 88410, fee 5.00","postedAt":"2026-03-01T09:12:00Z","createdAt":"2026-09-13T11:32:25.972996Z","reversesTransactionId":null,"reversedByTransactionId":null,"netMinor":0,"legs":[{"entryId":"2046d921-0ef3-42f2-aa3d-954928637fde","accountCode":"ASSET:BANK:SETTLEMENT","accountName":"Settlement bank account","accountType":"ASSET","direction":"DEBIT","amountMinor":25000,"signedAmountMinor":25000,"currency":"INR","amountDisplay":"INR 250.00"},{"entryId":"51715139-832c-4d47-a97e-0a377d8c81d4","accountCode":"REVENUE:FEES:PROCESSING","accountName":"Processing fee revenue","accountType":"REVENUE","direction":"CREDIT","amountMinor":500,"signedAmountMinor":-500,"currency":"INR","amountDisplay":"INR 5.00"},{"entryId":"a121658d-73c2-40c9-a288-3810fc35a132","accountCode":"LIABILITY:MERCHANT:M001","accountName":"Merchant M001 payable","accountType":"LIABILITY","direction":"CREDIT","amountMinor":24500,"signedAmountMinor":-24500,"currency":"INR","amountDisplay":"INR 245.00"}]}
netMinor is 0 — that's invariant 1, visible in the response, not just
enforced invisibly.
Same Idempotency-Key, same body:
HTTP/1.1 200
Idempotent-Replay: true
{"id":"9d27868d-178c-43c0-9bee-8b84218447c6", ...same transaction...}
200, not 201 — same transaction id, same legs, no second posting. The
Idempotent-Replay header is how a caller distinguishes "this succeeded just
now" from "this succeeded when you called it the first time."
curl -X POST localhost:8080/api/v1/transactions \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: capture_ord_88410' \
-d '{"description": "Card capture 500.00 for order 88410", "legs": [
{"accountCode": "ASSET:BANK:SETTLEMENT", "direction": "DEBIT", "amountMinor": 50000},
{"accountCode": "LIABILITY:MERCHANT:M001", "direction": "CREDIT", "amountMinor": 50000}
]}'HTTP/1.1 409
{"code":"IDEMPOTENCY_KEY_REUSED","message":"Idempotency key capture_ord_88410 was already used for a different request","at":"2026-09-13T11:32:44.821771Z","detail":{"existingDescription":"Card capture 250.00 for order 88410, fee 5.00","existingTransactionId":"9d27868d-178c-43c0-9bee-8b84218447c6"}}
409, not a silent replay of the original ₹250 posting — see
A reused idempotency key with a different body is a conflict.
curl -X POST localhost:8080/api/v1/transactions \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: bad-posting-1' \
-d '{"description": "Debit 250, credit 200 - 50 short", "legs": [
{"accountCode": "ASSET:BANK:SETTLEMENT", "direction": "DEBIT", "amountMinor": 25000},
{"accountCode": "LIABILITY:MERCHANT:M001", "direction": "CREDIT", "amountMinor": 20000}
]}'HTTP/1.1 422
{"code":"UNBALANCED_TRANSACTION","message":"The database refused this posting: ERROR: INVARIANT_VIOLATION: transaction 63bfe0fc-2117-4444-98e4-9888630c647c legs net to 5000 minor units, expected 0\n Where: PL/pgSQL function assert_transaction_balanced() line 31 at RAISE","at":"2026-09-13T11:32:44.856268Z","detail":null}
The message is the database trigger's own text, surfaced rather than
paraphrased — that's what SET CONSTRAINTS ... IMMEDIATE in TransactionWriter
buys, explained in
The zero-sum rule is checked by the database.
curl localhost:8080/api/v1/accounts/LIABILITY:MERCHANT:M001/balance{
"accountId": "11111111-1111-4111-8111-111111111103",
"code": "LIABILITY:MERCHANT:M001",
"name": "Merchant M001 payable",
"type": "LIABILITY",
"currency": "INR",
"balanceMinor": -48500,
"normalBalanceMinor": 48500,
"balanceDisplay": "INR 485.00",
"entryCount": 4,
"lastEntryAt": "2026-09-13T11:32:26.081320Z",
"derivedAt": "2026-09-13T11:32:26.103799Z"
}balanceMinor is signed (credits negative, per accounting convention);
normalBalanceMinor flips it to the account's own normal side so a caller
doesn't have to know the sign convention to read it. derivedAt is not
lastEntryAt — it's recomputed on this exact request, which is the whole
point of invariant 4.
curl "localhost:8080/api/v1/transactions?accountCode=LIABILITY:MERCHANT:M001&limit=2"Returns an array, most recent first, each with every leg inline — the counterparty of any movement is visible without a second call:
[
{
"id": "48ec212d-b420-4e99-8768-a0e3d8fe69eb",
"idempotencyKey": "refund_ord_88412",
"description": "Refund 250.00 for order 88412 - customer disputed",
"postedAt": "2026-03-03T14:22:00Z",
"netMinor": 0,
"legs": [
{"accountCode": "LIABILITY:MERCHANT:M001", "direction": "DEBIT", "amountMinor": 25000, "amountDisplay": "INR 250.00"},
{"accountCode": "ASSET:BANK:SETTLEMENT", "direction": "CREDIT", "amountMinor": 25000, "amountDisplay": "INR 250.00"}
]
}
]curl -X POST localhost:8080/api/v1/transactions/6352a699-8805-4999-a902-528d97647526/reversal \
-H 'Content-Type: application/json' \
-d '{"reason": "Captured against the wrong merchant"}'HTTP/1.1 201
{"id":"ad6fe9c7-9f45-4bc8-8760-b3f2e3595ecc","idempotencyKey":"reversal:6352a699-8805-4999-a902-528d97647526","description":"Reversal of 6352a699-8805-4999-a902-528d97647526: Captured against the wrong merchant","reversesTransactionId":"6352a699-8805-4999-a902-528d97647526","reversedByTransactionId":null,"netMinor":0,"legs":[{"accountCode":"REVENUE:FEES:PROCESSING","direction":"DEBIT","amountMinor":500,"amountDisplay":"INR 5.00"},{"accountCode":"LIABILITY:MERCHANT:M001","direction":"DEBIT","amountMinor":24500,"amountDisplay":"INR 245.00"},{"accountCode":"ASSET:BANK:SETTLEMENT","direction":"CREDIT","amountMinor":25000,"amountDisplay":"INR 250.00"}]}
Every direction is flipped from the original, and reversesTransactionId
points back at it. Both the mistake and the fix are now on the record,
permanently — there is no PATCH or DELETE on either endpoint; the database
would refuse it if there were.
curl localhost:8080/api/v1/audit/integrity{
"checkedAt": "2026-09-13T11:32:49.632911Z",
"healthy": true,
"imbalancedTransactions": [],
"trialBalance": [
{"currency": "INR", "totalDebitsMinor": 125000, "totalCreditsMinor": 125000, "netMinor": 0, "transactionCount": 5, "entryCount": 14}
],
"note": "Every transaction's legs net to zero and total debits equal total credits in every currency."
}imbalancedTransactions should always be empty — the zero-sum trigger makes it
unreachable through the API. It's exposed anyway, because a guarantee nobody
can check is a guarantee nobody should believe.
These are the exact JSON strings the four tools return to an MCP client,
captured by calling them directly against a running ledger service. Every
result shares one envelope: source (the exact HTTP request that produced
data), data, and — where there's something worth saying in one line —
summary.
get_balance(account_code="LIABILITY:MERCHANT:M001")
{
"source": "GET http://localhost:8080/api/v1/accounts/LIABILITY:MERCHANT:M001/balance",
"data": { "code": "LIABILITY:MERCHANT:M001", "balanceMinor": -24000, "balanceDisplay": "INR 240.00", "entryCount": 5, "...": "..." },
"summary": "LIABILITY:MERCHANT:M001 (LIABILITY) holds INR 240.00 on its normal credit side, derived from 5 entries."
}trace_transaction(transaction_id=...) — on a reversal, data gains a
followUps array pointing back at the original posting, and summary renders
every leg as a readable line:
{
"source": "GET http://localhost:8080/api/v1/transactions/ad6fe9c7-...",
"data": {
"reversesTransactionId": "6352a699-8805-4999-a902-528d97647526",
"followUps": ["This posting IS a reversal; trace 6352a699-... to see what it undid."],
"...": "..."
},
"summary": "2026-09-13T11:32:49Z Reversal of 6352a699-...: Captured against the wrong merchant\n REVERSAL of 6352a699-...\n DEBIT INR 5.00 REVENUE:FEES:PROCESSING (REVENUE)\n DEBIT INR 245.00 LIABILITY:MERCHANT:M001 (LIABILITY)\n CREDIT INR 250.00 ASSET:BANK:SETTLEMENT (ASSET)\n net = 0 minor units"
}find_imbalances()
{
"source": "GET http://localhost:8080/api/v1/audit/integrity",
"data": { "healthy": true, "imbalancedTransactions": [], "trialBalance": [ { "currency": "INR", "netMinor": 0, "...": "..." } ] },
"summary": "Ledger is internally consistent: every transaction nets to zero and debits equal credits in every currency."
}A failed call — get_balance on an account code that doesn't exist. There
is no exception for the agent to catch; the error comes back as readable
content it can act on:
{
"error": "Not found",
"status": 404,
"source": "GET http://localhost:8080/api/v1/accounts/LIABILITY:MERCHANT:DOES_NOT_EXIST/balance",
"hint": "Check the account code or transaction id, or call find_imbalances to confirm the ledger is reachable."
}| Method | Path | Purpose |
|---|---|---|
POST |
/api/v1/accounts |
Create an account |
GET |
/api/v1/accounts |
List the chart of accounts |
GET |
/api/v1/accounts/{code}/balance |
Balance, derived at read time |
POST |
/api/v1/transactions |
Post a transaction (Idempotency-Key header honoured) |
GET |
/api/v1/transactions |
Recent postings, optionally filtered by account |
GET |
/api/v1/transactions/{id} |
One posting with every leg |
POST |
/api/v1/transactions/{id}/reversal |
Correct a posting by mirroring it |
GET |
/api/v1/balances |
Every balance |
GET |
/api/v1/audit/integrity |
Imbalance check and trial balance |
See Workflows and responses above for a worked example of each.
amount_minor BIGINT, with a CHECK (amount_minor > 0) and the sign carried by
a separate direction column. signed_amount_minor is a generated column,
so the sign can never disagree with the direction even if someone writes rows by
hand.
Cost: every consumer has to know the currency's exponent. The API sends both
amountMinor and a formatted string, and the MCP layer is explicitly instructed
never to parse money out of the string.
There is no if (sum != 0) throw anywhere in PostingService. On purpose. The
service's job is to translate the database's refusal into a decent 422, not to
re-implement the rule and create a second place where it can be wrong.
The trigger is DEFERRABLE INITIALLY DEFERRED, because a per-row check would
reject every balanced posting on its first leg. That leaves one ergonomic
problem: a deferred constraint fires during COMMIT, outside the service method,
where Spring surfaces it as an opaque TransactionSystemException. So
TransactionWriter issues SET CONSTRAINTS ledger_entry_zero_sum IMMEDIATE
once every leg is inserted, forcing the check to happen where the original
SQLSTATE and the trigger's own message are still intact.
Cost: one extra round trip per posting, and an error path that depends on
reading SQLSTATE rather than catching a typed exception.
The obvious implementation — look up the key, and insert if nothing is there — has a race between the lookup and the insert wide enough for two concurrent retries of the same payment to both find nothing and both post. Under a retry storm that is not a theoretical window; it is Tuesday.
So every posting attempts the insert, and a unique-constraint violation is the
answer: the database picks a winner, and the loser re-reads the winner's row in
a fresh transaction. TransactionWriter is a separate bean precisely so that
recovery can run in a new transaction — once a constraint fires, the current one
is poisoned and cannot read anything back. Self-invocation would go around the
proxy and quietly break that.
ConcurrentPostingTest
fires 32 simultaneous duplicate requests through a CyclicBarrier and asserts
exactly one 201, thirty-one 200s, one row, two legs, and one movement of
money.
Each transaction stores request_fingerprint, the SHA-256 of the canonicalised
request. A key replayed with different content returns 409, not the original
transaction. Answering it with the original would tell a caller that their
₹500 payment succeeded when a ₹250 one did — a bug that stays invisible until
reconciliation, which is the worst possible time to find it.
Cost: legs are sorted before hashing, so leg order is not semantically meaningful; if it ever needs to be, the fingerprint has to change.
account_balance is a view that sums entries on every read. No column, no
materialised view, no Redis.
Cost: this is O(entries) per account and will not survive a hot account with
millions of entries. The honest fix is a periodic snapshot table —
(account_id, as_of, balance_minor, last_entry_id) — with reads computing
snapshot + entries since snapshot. That keeps invariant 4 intact, because the
snapshot is a cache that can be rebuilt from entries rather than a source of
truth. It is out of scope here because adding it before it is needed would
obscure the property the project exists to demonstrate.
UPDATE and DELETE on ledger_entry and ledger_transaction raise
APPEND_ONLY_VIOLATION, and TRUNCATE has its own statement-level guard
because it bypasses row triggers entirely. POST /transactions/{id}/reversal
posts the mirror image, so both the mistake and the fix stay on the record —
which is exactly the property that makes the ledger auditable.
Cost: the test suite has to disable those triggers explicitly to clean up
between tests, and do so on a connection with autocommit forced on — the app's
own pool runs with hikari.auto-commit: false, which is correct for
request handling (Spring's transaction manager commits explicitly) but means an
un-managed DISABLE TRIGGER from a test never actually takes effect before the
next statement unless the test says so explicitly. That's visible in
AbstractLedgerIntegrationTest
rather than hidden by weakening the trigger for everyone.
A cross-currency posting would net to zero in raw minor units while being
economically meaningless. The zero-sum trigger therefore also rejects any
transaction whose legs span more than one currency, and a declarative FK on
(account_id, currency) keeps an entry's currency locked to its account's.
An un-enforced scope boundary is just a bug waiting for a Friday.
Every invariant in this project is a constraint trigger, a deferred constraint,
or a generated column — features an in-memory stand-in either lacks or fakes.
Testing them anywhere but the engine that enforces them would be testing a
different system. InvariantContractTest attacks each invariant through raw
SQL, going around the service entirely, because "the application prevents
it" and "the system prevents it" are different claims.
Four tools: get_balance, list_transactions, trace_transaction,
find_imbalances. Real output from each is in
Workflows and responses above; every result comes
back in the same envelope:
{
"source": "GET http://localhost:8080/api/v1/accounts/LIABILITY:MERCHANT:M001/balance",
"summary": "LIABILITY:MERCHANT:M001 (LIABILITY) holds INR 485.00 on its normal credit side, derived from 7 entries.",
"data": { "...": "..." }
}source is the exact request that produced data. It is there so an agent
asked to justify a conclusion can quote the call rather than reconstruct it from
memory — the difference between an audit trail and a plausible story.
This is the most important decision in the project, and it is an argument rather than an omission.
A raw SQL tool is genuinely tempting. It is one function, it answers every question the four tools answer and every question they don't, and it makes the demo look powerful. It is also wrong here, for four separate reasons, any one of which would be sufficient:
- It defeats the read-only guarantee at the only layer that can hold it.
"Read-only" would become a string check on the query text, and query-text
parsing is a losing game: CTEs with
INSERT ... RETURNING,SELECT pg_read_file(...),COPY ... TO PROGRAM, a function with a side effect. A read-only role would close the write hole and leave the others. - It makes the ledger's invariants irrelevant to the agent. The four tools
return
direction,amountMinorand the account's normal side, because that is the vocabulary in which the ledger is correct. An agent writing its ownSUM(amount)will eventually sum debits and credits together and produce a confident, wrong number. - It has no natural blast radius.
list_transactionscaps its rows.SELECT * FROM ledger_entrydoes not, and one careless question fills the context window — or the database's memory. - It moves the trust boundary to the wrong place. With four tools, the
dangerous input is a tool name and typed arguments. With
run_sql, the dangerous input is an arbitrary program written by a language model that read the ledger's own data, and that data may contain text written by someone hostile. Prompt injection stops being a content problem and becomes a code-execution problem.
The right escape hatch, when the four tools genuinely aren't enough, is a fifth named tool with typed parameters — not a general-purpose one. Every new tool is then a deliberate widening of the boundary, reviewed on its own merits.
Cost, stated plainly: some questions need a code change rather than a clever prompt. That is the trade, and it is the right one for a system that moves money.
./docs/scenario-off-by-250.shThree card captures of ₹250.00 each with a ₹5.00 processing fee, then one refund of ₹250.00 that operations didn't know about. Merchant M001 is expected to be owed ₹735.00 and actually shows ₹485.00 — the ₹250.00 discrepancy an agent is asked to explain, using only the four MCP tools above.
docs/agent-transcript.md walks the investigation
tool call by tool call, with every query cited, so the transcript is
reproducible rather than illustrative.
Authentication and user management. Multi-currency and FX. Any UI. Real payment rails or settlement. A raw SQL tool on the MCP side.
The out-of-scope list matters as much as the feature list. A small ledger with airtight guarantees is worth more than a sprawling one with soft ones.
- Balance reads are
O(entries). The snapshot design above is the fix; it is not implemented. - No authentication anywhere. Both services assume a trusted network. In production the MCP server would carry a scoped read token, not ambient access.
- Two-decimal assumption.
Moneyreads the currency's exponent from the JDK where it can, but the MCP formatter assumes two places. Correct for INR, wrong for JPY and KWD. transaction_imbalanceshould always be empty. It is exposed anyway, because a guarantee nobody can check is a guarantee nobody should believe.
ledger-service/ Java 21, Spring Boot 3, PostgreSQL, Liquibase
src/main/resources/db/changelog/ the invariants, as DDL
src/test/java/ the invariants, as tests
mcp-server/ Python, official MCP SDK, read-only
docs/ worked example and seed script
MIT.