LedgerMind — Java 21 / Spring Boot payments core
Append-only double-entry ledger, idempotent transfers, safe concurrency (Testcontainers-proven), exposed to an AI agent through an OAuth 2.1 MCP server.
Built by Juan Francisco Paez — Top 8, Anthropic × Kaszek Hackathon (Buenos Aires, April 2026).
A payments core in Java/Spring Boot: an append-only double-entry ledger, idempotency by unique key (exactly-once) and concurrency control with optimistic locking + retry (proven with a spike of 50 concurrent transfers against real Postgres), exposed to an AI agent through an MCP server with OAuth 2.1, on top of an auditability ladder that ends in a post-quantum signature (ML-DSA / FIPS 204) of the journal.
What it is
LedgerMind is the accounts-and-movements engine of a fintech: it records money as append-only double-entry postings, makes it structurally impossible (a UNIQUE constraint in the DB) for a retry to duplicate a charge (idempotency), and prevents money from being created or lost under concurrency (optimistic locking + retry). It is pure backend.
On top of the ledger there are two things that set it apart from a CRUD app:
- An MCP server (Model Context Protocol) that lets an AI agent query and audit the ledger — never move money — protected with OAuth 2.1: every tool enforces its scope with
@PreAuthorize. The four query and audit tools are read-only and share the singleledger.readscope; two operator tools (list_frozen_accounts,unfreeze_account) requireledger.adminand do not move money either (see the granularity note in Scope and honest limits). - An auditability ladder that makes the journal tamper-evident against the edit or deletion of postings already covered by a signed checkpoint (not against an insert, edit or delete, with the balance counters adjusted, on postings not yet chained, or on chained postings after the last checkpoint when the editor also recomputes the links; and a burst of legitimate traffic leaves coverage degraded (
coverageDegraded), not a tamper alarm — see Scope and honest limits): hash-chain → checkpoint signed with post-quantum cryptography → an MCP tool with which the agent itself verifies integrity.
It is built to be defensible: every decision has its rationale and its limits are written down, not hidden (see Scope and honest limits).
Demo
The reliable path is to run it locally (1 command, see How to run).
BASE=http://localhost:8080 # or your own deployed instance
KEY_HEADER="X-API-Key: $LEDGERMIND_API_KEY" # a key whose sha256 line is in LEDGERMIND_API_KEYS_FILE
# Create two accounts and transfer (the transfer is idempotent by idempotencyKey)
curl -s -XPOST $BASE/api/accounts -H "$KEY_HEADER" -H 'Content-Type: application/json' \
-d '{"address":"wallet:ana","asset":"ARS","allowNegative":true}'
curl -s -XPOST $BASE/api/accounts -H "$KEY_HEADER" -H 'Content-Type: application/json' \
-d '{"address":"wallet:beto","asset":"ARS"}'
curl -s -XPOST $BASE/api/transfers -H "$KEY_HEADER" -H 'Content-Type: application/json' \
-d '{"debitAddress":"wallet:ana","creditAddress":"wallet:beto","amount":50000,"idempotencyKey":"demo-1"}'
# Audit the integrity of the journal (the same data the agent sees over MCP)
curl -s -H "$KEY_HEADER" $BASE/api/journal/audit
# OAuth2.1 → MCP flow (demo profile): request a token and call /mcp with the Bearer
TOKEN=$(curl -s -u mcp-client:secret -d grant_type=client_credentials -d scope=ledger.read \
$BASE/oauth2/token | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
curl -s -o /dev/null -w '%{http_code}\n' $BASE/mcp # no token → 401
curl -s -o /dev/null -w '%{http_code}
' $BASE/mcp -H "Authorization: Bearer $TOKEN" # with token → no longer 401 (a bare GET gets 400; MCP clients POST a JSON-RPC initialize)
In the demo the token carries only the
ledger.readscope. The operator toolslist_frozen_accountsandunfreeze_accountrequireledger.admin, which the demo Authorization Server does not issue: they cannot be used in the demo.
Architecture
flowchart TB
U["REST client"]
AG["AI agent<br/>(MCP client)"]
U -->|HTTP| API["REST API<br/>/api/**"]
AG -->|"MCP · OAuth2.1 Bearer"| MCP["MCP Server<br/>/mcp · per-tool scope<br/>ledger.read: 4 read-only tools<br/>ledger.admin: 2 operator tools"]
API --> SVC["LedgerService"]
MCP --> SVC
SVC --> DOM["Ledger domain<br/>Account · Posting · TransferService<br/>double-entry · idempotency · @Version + retry"]
DOM -->|"writes postings (append-only)"| PG[("PostgreSQL<br/>schema owned by Flyway")]
subgraph AUD["Auditability — two INDEPENDENT @Scheduled jobs (async, off the hot path)"]
CH["JournalChainer<br/>SHA-256 hash-chain"]
CHK["JournalCheckpointService<br/>ML-DSA-65 signature (FIPS 204)"]
end
CH -.->|"polls postings and chains them"| PG
CHK -.->|"signs the head of the chain"| PG
MCP -->|"verify_journal_integrity"| CHK
It is a modular monolith (Spring Modulith): today a single ledger bounded context with REST (ledger.web) and MCP (ledger.mcp) adapters, designed to be split as it grows. The schema is owned by Flyway; Hibernate only validates.
The auditability ladder
The signature demonstrates crypto-agility (a swappable
JournalSignerinterface) and signed tamper-evidence — it is not prevention and it does not replace access control. The limits are in Scope and honest limits.
How to run
Requirements: JDK 21 and Docker (for Postgres and for the Testcontainers tests).
# 1) Local Postgres
docker compose up -d
# 2) API keys file, required in EVERY profile. An existing empty file = no keyed callers: under the demo profile only
# the five anonymous /api/demo/* endpoints work; under the default profile every /api call gets 401.
mkdir -p ~/.ledgermind && touch ~/.ledgermind/api-keys.txt && export LEDGERMIND_API_KEYS_FILE=~/.ledgermind/api-keys.txt
# Add a key (after the line above, which creates the folder and the file):
# export LEDGERMIND_API_KEY=$(openssl rand -hex 32); echo "me sha256:$(printf %s "$LEDGERMIND_API_KEY" | sha256sum | cut -d' ' -f1)" >> ~/.ledgermind/api-keys.txt
# 3) The app (demo profile = includes an embedded Authorization Server to issue MCP tokens)
./mvnw spring-boot:run -Dspring-boot.run.profiles=demo
# Tests (concurrency spike, real ML-DSA runtime, scope enforcement, reconciliation...). Only needs Docker:
./mvnw verify
Upgrading an existing database (operators). Flyway applies
V5__overdraft_sweep.sqlon the next start. It only creates tables and an index (overdraft_sweep_state,account_derived_total,overdraft_flagwith one active flag per account,journal_chainer_state) and seeds the single sweep-state row; it does not alter existing tables. The sweep-state row starts at watermark 0, so the first sweep after the upgrade re-derives every account that has postings from its full journal (one REPEATABLE READ transaction whose cost grows with the whole history) and freezes any existing account whose derived balance already violates its overdraft rule: its transfers then get423 Lockeduntil an operator runsunfreeze_account(scopeledger.admin). If you ran a pre-release build whose V5 differed, Flyway refuses to start with a checksum mismatch: that only affects development volumes, so recreate the local Postgres volume (e.g.docker compose down -v, which deletes its data).
API and MCP tools
REST (/api) — every row needs the X-API-Key header, in every profile.
MCP (/mcp, OAuth 2.1 — per-tool scope + audience validation: ledger.read for the 4 query and audit tools, ledger.admin for the 2 operator tools; none of them moves money)
In the demo the token carries only
ledger.read:list_frozen_accountsandunfreeze_account(scopeledger.admin) cannot be used there, because the demo Authorization Server does not issue that scope.
The /mcp endpoint requires a valid JWT Bearer; without a token → 401. Under the demo profile, an embedded Spring Authorization Server issues tokens (client_credentials) at /oauth2/token.
Design decisions (with their rationale)
- Money as integers (cents /
BIGINT), neverdouble— the ledger has to balance to the cent. - Balance derived from accumulated counters (
posted_debits/credits) — O(1) reads, append-only journal. - Optimistic locking (
@Version) + retry outside the transaction — prevents lost updates without serializing everything. - Idempotency by unique
idempotency_key— a network retry never duplicates a movement. - Flyway owns the schema, Hibernate only validates — no silent destructive changes from the ORM.
- Testcontainers (real Postgres, not H2) — the tests exercise Postgres's real locking.
- Everything in UTC (JVM + Hibernate + DB) — money timestamps with no ambiguity.
The most relevant formal decisions are recorded as ADRs in docs/adr/.
Tests
The suite (./mvnw verify, against real Postgres) includes, among others:
- Concurrency spike — 50 parallel transfers over an account with a limited balance: money conservation, no overdraft and global double-entry are all verified.
- Exactly-once idempotency — 24 requests with the same key in parallel receive the same posting (a replay, not a 500); reusing the key with different parameters → 409.
- ML-DSA runtime — proves that BouncyCastle actually signs and verifies (not just that it compiles), and rejects tampered data and foreign keys.
- MCP security — scope enforcement proven by invoking the tool, not just by the annotation being present: the audit tool runs with
ledger.readand is denied withSCOPE_other(AccessDeniedException); the operator toolunfreeze_accountis denied withledger.readand runs withledger.admin+ the SAS issuesaud=ledgermind-mcp(audience validation). - Tamper-evidence — edits a posting via direct SQL and verifies that the chain detects it and the signature comes apart.
- Reconciliation — a deterministic matcher that balances and classifies the 4 discrepancies (including duplicate PSP references, which are aggregated rather than falsely matched).
Scope and honest limits
This is a demonstration project; the limits are written down on purpose (they are part of the engineering judgement):
- Simulated money — it is a demonstration of capability, not a system with certified compliance.
- Ephemeral signing key — generated at startup. In production the private key lives in an HSM/KMS and the public key is anchored outside the DB; signature verification proves message integrity, not signer authenticity, without that anchor.
- Tamper-EVIDENCE, not prevention — an actor with full write access to the DB can consistently rewrite content + chain + checkpoint; what raises the cost and makes it detectable is anchoring externally (HSM + transparency log + WORM). The audit cannot by itself detect truncation of the tail without an external high-water-mark.
- What the audit covers, measured — it detects the edit or deletion of a posting covered by the last signed checkpoint, even if the editor recomputes the links (that changes the signed head), unless the checkpoint is rewritten too. It does not detect, when the same DB writer also adjusts the balance counters: (a) the insertion of a posting — with a recent timestamp it is indistinguishable from a legitimate posting just written inside the chainer's normal window (
unchainedGraceMs: 60 s by default, never less than 3 chainer cycles), and with any timestamp it gets chained as legitimate as soon as the chainer runs; (b) the edit, offset (+x/−x) or not, of a posting not yet chained; (c) the deletion of a posting not yet chained; (d) the edit or deletion of a posting already chained but later than the last checkpoint, recomputing the links (the link is an unkeyed SHA-256): the next checkpoint signs the forged version. The audit counts unchained postings (unchainedPostings) and those older than that window (staleUnchainedPostings), and it does flag one of those as tamper when it was written by a transaction that started after the last confirmed chainer pass and its timestamp is earlier than that pass minus the window; but this is transient: a posting inserted with an old timestamp was flagged, and 2.9 s later the production chainer had already chained it and it stopped being flagged. That flag also depends on evidence the same DB writer can forge: the last confirmed chainer pass is read from thejournal_chainer_staterow, so deleting that row, or raising itspass_xid, makes the next audit report the same inserted posting ascoverageDegraded=true(ATRASADO/DETENIDO) withtamperDetected=false(pinned byChainerStateForgeryDowngradeTest). Anchoring the head externally is not enough (the next anchor would cover the forged posting): closing this needs provenance created at the app's write that the DB cannot forge, e.g. a MAC with a key the DB does not hold, and for deletion also binding the order. It is a pending design decision. - Overdraft re-derived from the journal (watermarked sweep + freeze) — the overdraft
CHECKlooks at the cached counters, which are never recomputed. A job (ledgermind.overdraft.sweep-delay-ms, 10 s by default) picks ONLY the accounts touched by new postings since the last watermark (persisted in the DB: it survives a restart), re-derives each one from all of its journal postings (not from an incremental total), and freezes the account whose derived balance violates its overdraft rule. An overdraft is detected within ≤ the sweep interval + the duration of the sweep, and the account is then frozen (the cost of a pass grows with the full history of the touched accounts, not only with the new postings). Frozen = transfers are rejected, as source or destination, with423 Lockedand a dedicated ProblemDetail, until an operator unfreezes it with the MCP toolunfreeze_account(scopeledger.admin; who, from the token, and why are recorded). The transfer only adds one indexed read; it re-derives nothing. It does not prevent the first transfer after the tampering (it freezes afterwards). An edit to an already-swept posting (below the watermark) is seen on the first pass after the account receives a new posting; until then only the audit (counters) and the hash-chain, if the posting is chained, see it. It does not detect a posting inserted out-of-band with an id below the watermark (e.g.id = -1withOVERRIDING SYSTEM VALUE) on an account that never moves again: a documented decision, pinned by a test. If two sweeps run at the same time (several instances), the loser fails with a serialization error and rolls back, without retry: the next pass covers its share. - Under load: degraded coverage, not an alarm — with the production configuration, 4,000 legitimate transfers in 30 s left the chainer behind (it drains ~200 postings every 5 s):
staleUnchainedPostingsreached 1,170 and cleared by itself after ~120 s. That comes out ascoverageDegraded=truewithcoverageReason=ATRASADO(chainer behind) andtamperDetected=false:tamperDetectedis reserved for confirmed evidence. An old unchained posting counts as an out-of-band insert only if it was written by a transaction that started after the last confirmed chainer pass (itsxminagainst the xid that pass takes when it starts and stores in the same transaction as its links) and its timestamp is earlier than that pass minus the window; a slow legitimate transaction that was still open when the chainer ran does not meet the first condition. The other false alarm (the balance replay read the journal and the counters in separate statements under READ COMMITTED) is closed: the audit runs in one REPEATABLE READ snapshot. Measured inLiveBurstFloorGraceTest(clean 8 s burst, window at its 900 ms floor) andLiveBurstProductionDefaultsTest(production values): zero out-of-band flags, zeroTAMPER DETECTEDverdicts, zerotamperDetected. Limit: an out-of-band insert whose transaction was already open when the chainer ran, or whose timestamp is later than that pass minus the window, cannot be told apart from a legitimate one. - A signal that exists and the audit does not use — every legitimate transfer bumps
account.versionandaccount.updated_aton both accounts; a naive 3-statement SQL mint (the posting INSERT plus the two counters) does not (measured:versionends 1 below the number of postings touching the account, against 0 under legitimate traffic, andupdated_atends older than the last posting). It is a cheap signal against an attacker who did not read the schema, and one who also bumps those two columns defeats it at no cost. The audit does not look at it today; wiring it in is a possible extension. - Money conservation is implied, not checked separately — the audit has no separate
sum(posted_debits) == sum(posted_credits)check. It does not need one to catch a counter edit: every posting row carries one positive amount, one debit account and a different credit account (both foreign keys toaccount), so the journal is conserved by construction, andbalancesConsistent=truemeans every account's counters equal its journal sums, which makes the counters conserved too. A counter edit that breaks conservation therefore also makesbalancesConsistent=false(pinned byPendingCountersAndConservationTest). - No holds:
pending_debits/pending_creditsare always 0 — the schema,Account.availableBalance()and the overdraft sweep carry a pending (hold) term reserved for a future two-phase flow, but no code path writes it: both columns stay 0, the available balance is posted credits minus posted debits, and the balance replay does not look at them (pinned byPendingCountersAndConservationTest). - One scope for the four read tools, a separate one for the operator tools — among the four read-only tools the granularity is one of enforcement (each tool checks its own
@PreAuthorize), not of privilege: all four ask for the sameledger.read, because a single scope is enough for read-only access. The two operator tools (list_frozen_accounts;unfreeze_account, which changes an account's state but moves no money) require the separateledger.admin. The documented next step for real least-privilege among the read tools (e.g. a second, less-trusted client that must audit but not reconcile) is still to splitledger.readintoledger.audit/ledger.reconcile. Today that would be ceremony. /apirequires an API key; the audit endpoint is still expensive —/apirequires an API key in every profile: send it in theX-API-Keyheader; without it the answer is401(auth_missing; a key that matches nothing getsauth_invalid). Keys never live in the repository: the server reads<key_id> sha256:<hex>lines from the file named byLEDGERMIND_API_KEYS_FILEand refuses to start without it (an existing empty file is valid and means no keyed callers). Only under thedemoprofile, anonymous callers can reach exactly five endpoints, all under/api/demo/:POST reset,POST idempotency,POST tamper,POST reconcileandGET audit; the idempotency demo runs server-side with one fixed key, so an anonymous caller cannot grow the journal: the first idempotency call after a reset adds one posting and every later call replays it, except after the demo tamper has edited that posting (the tamper edits the latest chained posting; the chainer links a new posting within one cycle, 5 s by default): then later idempotency calls get409(the stored amount no longer matches) and still add no posting, until the next reset. The anonymous reset and tamper endpoints do wipe, re-seed and edit the demo ledger, including data written with a key./mcpstays behind OAuth (with audience validation), not behind the API key, and the rate limit does not cover it: the MCP audit toolverify_journal_integrityruns the same full audit with no rate limit, and under thedemoprofile any caller can get a token with the demo client credential published above. Every audit call reads the whole journal: the hash-chain recompute loads every chained posting through JPA (in batches of 200) and the balance replay aggregates every posting in one SQL statement, so its cost grows with the journal. The rate limit is counted per caller: by default 30 requests per fixed 10 s window (ledgermind.rate-limit.max-per-window,ledgermind.rate-limit.window-ms; a window resets on the first request that arrives more than 10 s after it opened, so up to 60 fit in a 10 s span that straddles a window boundary, pinned byRateLimitFilterClockTest) for each API key, and one window shared by all anonymous demo callers, on every request to/api/demo/**and/api/journal/**(nested paths such as/api/journal/checkpoint/verifyincluded) that passes authentication (a401is not counted), matched on the decoded, normalized path the server routes (so a percent-encoded path such as/api/%6Aournal/auditis counted too, pinned byRateLimitEncodedPathTest). One key using up its window does not limit another key, and the anonymous demo cannot put keyed callers in429(pinned byRateLimitPerCallerTest); anonymous demo callers share one window, so they can still limit each other./api/transfers,/api/accountsand/api/reconciliationare not rate limited. Matching on the handler Spring resolves, instead of the routed path, is not built. The five/api/demo/*endpoints (reset, idempotency, tamper, reconcile, audit) exist only under thedemoprofile. Outside thedemoprofile,/actuator/prometheusalso needs anX-API-Key(a metrics scraper must send one; pinned byActuatorExposureTest);/actuator/healthstays anonymous in every profile, and underdemothe metrics stay anonymous for the bundled observability stack.- Deploy precondition: who may reset the public demo — under the
demoprofile anyone can callPOST /api/demo/reset, which truncates the whole database (keyed data included) and re-seeds it. Before any public deploy, whoever deploys it must decide whether to gate that endpoint or to accept, in writing, that any visitor can reset the demo; no gate or captcha is built. - 64-bit range — A journal sum above the 64-bit range is reported as tamper, never as an error (the balance replay and the overdraft sweep both sum exactly; the sweep freezes a non-negative account whose debit sum overflows and stores the evidence saturated at the 64-bit limits); a transfer that would overflow a counter is rejected with
422, and so is a reconciliation whose per-reference sum, total or difference falls outside the range (the sums are exact, so the order of the rows never matters: only the final value is checked). verify()is O(n) — at real scale, the next step is Merkle + incremental verification from the last checkpoint. Two hardening items are descoped for now (2026-09-28): keying the rate limit on the handler Spring resolves (the decoded-path match already counts percent-encoded paths) and the bounded request-time audit of ADR 0004 D4 (the REST audit endpoints are rate limited per caller).- Reconciliation against a simulated feed — matching is by exact reference + amount (no tolerance, no T+N window, no multi-currency); in production the feed would come from the PSP's real file and be reconciled by window. It assumes
idempotencyKey == the client's order idas the correlation axis.
Stack
Java 21 · Spring Boot 3.5 · Spring Modulith · Spring AI 1.1 (MCP server) · Spring Security / OAuth 2.1 · PostgreSQL 16 + Flyway · BouncyCastle (ML-DSA / FIPS 204) · Testcontainers · Actuator + Micrometer/Prometheus · Docker · GitHub Actions.
LedgerMind es un backend de pagos en Java/Spring Boot: un ledger de doble entrada append-only con dinero como entero exacto, idempotencia exactly-once y control de concurrencia explícito (optimistic locking + retry), probado con un test de concurrencia sobre Testcontainers. Expone herramientas MCP (Spring AI) a un agente de IA detrás de un resource server OAuth 2.1: cada herramienta enforza su scope con @PreAuthorize; las cuatro de consulta y auditoría son de solo lectura y comparten el único scope ledger.read (enforcement por herramienta, un solo privilegio), y dos herramientas de operador (list_frozen_accounts, unfreeze_account) exigen ledger.admin y tampoco mueven dinero (no se pueden usar en la demo, cuyo token solo lleva ledger.read); más validación de audiencia (defensa contra el confused deputy). Encima hay una escalera de auditabilidad de tres capas: una hash-chain SHA-256 (tamper-evidence), una firma post-cuántica ML-DSA / FIPS 204 de la cabeza de la cadena (Signed Tree Head) y una herramienta MCP (verify_journal_integrity) con la que el agente audita el journal por su cuenta. Un módulo de reconciliación cuadra de forma determinista un feed de liquidación del PSP contra el ledger y clasifica los descuadres (la IA solo los narra; el código decide). Las limitaciones (clave de demo efímera, tamper-evidence vs prevención, lo que la auditoría no detecta —inserciones y ediciones sobre asientos aún sin encadenar, o posteriores al último checkpoint si además se recomputan los eslabones—, verificación O(n), feed simulado) están documentadas a propósito. El porqué de cada decisión está en docs/adr/.
License
Apache License 2.0 — Copyright 2026 Juan Francisco Paez. See LICENSE.