J. Castillo
Language
← Back to all work

corebank

A complete digital bank: accounts, transfers between customers, transaction history, and an AI assistant you ask about your balances in plain language.

Status
Verifiable live
When
3–4 August 2026
My role
Design, backend, frontend and infrastructure

Architecture

A Go API between two stores: TigerBeetle holds the money and PostgreSQL holds everything else. There is no distributed transaction — the row uuid is the transfer id in the ledger.

  • TigerBeetle is the source of truth for balances and movements: accounts with debits and credits, amounts as integer cents. It stores no text and answers no ad-hoc queries.
  • PostgreSQL holds identity, credentials, account numbers, descriptions and the audit trail. It cannot guarantee accounting invariants.
  • The browser talks to the API over REST and server-sent events; the assistant runs on an in-process MCP server, and the Anthropic key is optional.
  • A transfer writes to both: the row lands pending first, then the ledger movement. If the process dies in between, a sweeper looks that id up in the ledger at boot and closes the row.

Backend Go 1.26 · chi · pgxLedger TigerBeetle 0.17.9Data PostgreSQL 17 · gooseFrontend React 19 · Vite 6 · TanStack Query · Tailwind 4Assistant MCP · Anthropic SDK for GoInfrastructure Docker Compose · nginx · GitHub Actions

What I take away

An invariant that really matters is not defended with a check: it is defended by removing the possibility. There is no balance column, so it cannot fall out of sync; the model cannot confirm, so a prompt injection does not scale into a loss; the spend cap compares and increments in one statement, so there is no race to win. In all three the cost was losing a convenience.

The full case — the decisions, the evidence and what is missing

A bank has exactly one obligation it cannot fail: money that leaves one account has to appear in another, and no balance may go negative. Almost every system I have read meets that with an if balance < amount and a balance column, and both are promises someone can forget: the check does not cover the route added next month, and the column drifts out of step with the history that is supposed to explain it.

This system has neither. Balance does not exist as a column anywhere in the schema, and an overdraft is rejected by the financial database itself, not by application code. What follows are the five decisions that were needed to be able to say that, and what each one cost.

Overdraft is impossible by construction, not by validation

What it costA mandatory equity account, and no ad-hoc SQL over balances

Situation

No operation could be allowed to leave an account negative. Not “make it hard”: make it so that no code path is capable of it, including the one I write next month without remembering this rule.

The decision

The account is opened in the ledger with the flag that forbids its debits from exceeding its credits. The rule lives in the financial database, not in the service.

What I rejected

An if balance < amount in the service layer, plus a balance column in Postgres. That is what almost everyone does, and it loses for a concrete reason: a check covers the routes that exist on the day it is written. The new route, the data-migration script, the internal adjustment endpoint — each is an opportunity to skip it. And the balance column introduces a second source of truth that can disagree with the history that supposedly explains it.

The consequence

No application path can skip the check because there is no check to skip. The cost is real and it is two things: it forces an equity account as the counterparty — deposits debit world, withdrawals credit it — and it gives up querying balances with ad-hoc SQL, because the balance is not in Postgres. When I want a balance, I ask the ledger.

It deliberately has NO balance column anywhere — balances are derived from TigerBeetle, which is the single source of truth for money. Two stores can never disagree about how much money exists if only one of them is allowed to answer the question.

api/migrations/00001_init.sql:1-8

The comment sits in the file that creates the schema, and it is not decorative: the last sentence is the whole reason for the decision. Only one store is allowed to answer the question of how much money exists, so two of them can never disagree about the answer.

Cents are integers, and parsing works on the client's text

What it costA hand-written UnmarshalJSON at the edge of the system

Situation

Amounts arrive as JSON, which does not distinguish an integer from a floating-point number. They had to be converted to the minor unit without losing a cent on the way.

What was breaking

float64(8.87) * 100 does not give 887. It gives 886, because 8.87 has no exact binary representation and the product lands just below. One cent per transaction, silently — and in a double-entry ledger a cent that does not reconcile is an entry that does not close.

The decision

The domain type is a 64-bit integer of cents, and UnmarshalJSON keeps the raw text the client sent so parsing happens on the digits, never on an intermediate float.

What I rejected

Decoding into float64 and rounding at the end, which is the default path of any JSON library. And a third-party decimal type: it fixes the arithmetic but not the edge that matters, because the float was already lost in the decoder before the decimal exists.

The consequence

There is a hand-written UnmarshalJSON that most people would consider unnecessary, and a test pinning the exact difference between 886 and 887 so nobody “simplifies” it later. The cost is that extra complexity at the edge of the system, concentrated in one file, in exchange for the rest of the code never having to think about it.

Two databases, one truth, and no distributed transaction

What it costA reconciliation sweeper exists and has to be understood

Situation

The ledger holds the movement of money; Postgres holds everything else — who the user is, what the account is called, what the movement’s description said. One operation writes to both, and no transaction spans them.

What was breaking

The classic problem: if the process dies between the two writes, what is left? A movement in the ledger with no row to explain it, or a row promising a movement that never happened.

The decision

The UUID of the Postgres row is the ledger’s transfer identifier. One value, generated once, used as the key on both sides.

What I rejected

A two-phase commit across the two engines, which requires both to support it and adds a coordinator that can also fall over. And an outbox with a worker, which is the right answer at scale but which I would expect to cost latency and one more moving part for a problem that a deterministic identifier solves here.

The consequence

Retries are safe by construction: retrying means writing the same identifier, and the ledger rejects the duplicate. And a reconciliation sweeper walks the rows whose outcome was never recorded, reads the ledger, and makes the row agree. It never moves money — it only records what the ledger already did. The cost is that this process exists and has to be understood: a bug there can misreport a movement, but it cannot lose or duplicate one.

The two-phase pending transfer is the AI boundary

What it costThe conversation has one more step: the model never completes alone

Situation

The system has an assistant that operates accounts in natural language: “withdraw 250 dollars”, “move 80 to my savings”. A language model deciding money movements is exactly the kind of thing that cannot go right by accident.

The decision

The model can only prepare. Preparing creates a transfer in a pending state, which holds the amount without settling it. Confirming it is a separate authenticated endpoint, under the transactions route and not under the chat route, because confirming is a banking operation and not a conversation.

What I rejected

Filtering the model’s output — inspecting what it asks for and blocking the dangerous cases — which is the usual answer and loses because it is a denylist: it protects against what you thought of, and prompt injection consists precisely of what you did not think of. And a pending table of my own with a cron to clear expired rows, which works but adds a process that can fail silently.

The consequence

The hold is expired by the ledger itself when its lifetime passes, so there is no cleanup process: the background job that could fall over does not exist. Prompt injection stops mattering, because the worst an attacker achieves is preparing an operation the account holder has to confirm with their own session. The cost is that the conversation has one more step: the assistant never completes anything on its own, and that is felt in the interface.

The AI spend ceiling was built like the ledger

What it cost“Is the assistant available?” stopped having a yes-or-no answer

Situation

Every message to the assistant costs real money. A public demo with open registration is an invitation for someone to spend my budget for me.

The decision

A single UPDATE that compares and increments in the same statement, with the ceiling in the WHERE clause. If the statement affects zero rows, the call is not made. Amounts are integers of micro-dollars, for the same reason cents are integers. There are three ceilings — lifetime, daily, and per user per day — and the narrowest one wins.

What I rejected

Reading the counter, comparing in Go, and writing the new value: the classic race, and with several concurrent requests the ceiling is exceeded. And a per-IP rate limit, which measures the wrong thing — I do not care how many requests someone makes, I care how much they cost.

The consequence

The budget holds under concurrency because the check and the increment are the same operation, and a test running with -race fires goroutines against a small ceiling to prove it. Because spend is reserved with a high estimate and settled with the real cost, the interface has four states instead of a boolean, and says which engine answered. A rules-based provider also exists, so the application works with an empty API key. The cost is that “is the assistant available?” stopped having a yes-or-no answer.

How you would check

corebank dashboard. At the top the available balance, 32,354.53 dollars, with a composition bar and the caption 'Nothing held: your whole balance is available'. Below, the savings account with its number. On the right, the assistant shows which model answered, a user question about how much money they have, an indicator that it queried the accounts, and the answer with the figure plus the note that no funds are held.
The balance does not come from a column: it is derived from the ledger. The bar shows its composition, and when an operation is prepared but unconfirmed the held segment appears. Captured from the live demo.
corebank transaction history. Filters by account, type and date range, a search box and an export-to-CSV button. The table groups by month with columns for day, concept, counterparty, debit and credit, figures aligned down each column.
The columns are debit and credit, not a single signed field: this is a double-entry book's view. The export ships with a BOM and CRLF line endings so Excel opens it without a fight.

Almost everything above can be verified without running anything. That there is no balance column is visible in the first migration, which says so in a comment and also does not contain one. That overdraft is rejected by the ledger is visible in the flag the account is opened with. The assistant’s six tools are in one file, and none of them accepts a user identifier: identity is injected from the token, outside the model’s reach.

What is worth running is the suite: go test -race ./... runs on a clean checkout, because the one test that needs a real TigerBeetle skips itself when it cannot find one. And the demo is live, with two test accounts documented in the repository.

There is no TigerBeetle in CI

The test that exercises the real ledger client calls t.Skip() if no instance is available, and in CI there is none. That keeps the suite green on any machine, and in exchange the ledger integration is not covered automatically: I check it by bringing the compose up by hand.

Bringing it up in CI costs one more service in the workflow, the memory that service reserves, and a formatting step before start-up. I would do it the day I touch the ledger client again; today the ratio between what it costs and what it covers does not convince me.

Images are rebuilt on the VPS

Deployment builds the images on the server instead of pulling them from a registry. It works, and I would expect it to be slower than pulling a published image, and it is the open item the project’s own README declares. Publishing them from CI is the obvious improvement and it is not done.

The seed dataset was inconsistent, and I picked a reading

The data the demo is seeded with came from a third party and does not agree with itself: depending on how the movements are interpreted, the same group of accounts ends with different balances, and several end negative under any reading. I analysed it before writing the importer, precisely so as not to discover it afterwards.

I picked the reading that makes the visible balances exact, and the accounts that end negative stay negative: they are historical movements recorded as audit data, not operations passing through the overdraft rule. I am not going to claim it is the only defensible reading. It is the one I picked, and the reason I picked it.

The 87 tests never touch the frontend

All 87 test functions are Go. The 9,676 lines of TypeScript have none: no unit tests, no component tests, and no end-to-end run that opens the interface and moves money through it. What CI does with the frontend is typecheck it and build it, which catches a type that no longer holds or an import that does not resolve, and says nothing about behaviour.

So the half of the system where money lives is the half under test, and the interface is checked by hand. I decided that with two days available, and it is the gap I would close first: one end-to-end pass over deposit, transfer and confirm is worth more than any number of component tests. It is not written.