How an AI assistant reaches a double-entry ledger
A message becomes a journal entry through five stages: the assistant chooses a tool, the server authenticates the key, opens one transaction scoped to that user, dispatches the call, and the database accepts or rejects the resulting entry. Refusal is possible at every stage, and the last one cannot be talked out of it.
It is worth following a single request the whole way down, because the interesting parts of this architecture are the places where it can say no.
What is actually enforced
- One RPC endpoint handles every tool call, and it opens the transaction itself.
- The current user id is set inside the transaction; every RLS policy reads it.
- Tool schemas are strict at every level of nesting, checked when the tool is defined rather than per tool file.
- Each bank account gets its own general-ledger child account so own-account transfers stay visible.
- A bank transaction has two states, unposted and posted; reversing its entry releases it back to unposted.
- Journal entries carry no state, because the journal is append-only and a correction is a separate entry.
- Nothing is stored as reconciled — reconciliation is computed against a balance supplied at the time.
- All four ledger invariants were verified from raw SQL with the service layer bypassed.
The path a request takes
One endpoint, one door. Every tool call arrives at the same handler, which is what makes the audit log complete rather than best-effort.
- Your assistant decides a tool is relevant and produces arguments for it, from the tool descriptions it read when it connected.
- The request arrives with a bearer API key. The key is hashed and compared against stored hashes; nothing is stored in plain text.
- The handler opens one transaction and sets the current user id inside it. Every row-level security policy reads that setting, so the scope of the request is established before any query runs.
- The tool's arguments are parsed against a strict schema. Unrecognised arguments are rejected rather than dropped, so a schema and its validator cannot disagree about what was accepted.
- The tool runs. If it writes a journal entry, database triggers decide whether the entry is allowed to exist.
- The result comes back with amounts as both cents and formatted strings, and the call is written to the audit log.
Where a request can be refused
Four independent refusals, in increasing order of how hard they are to argue with.
- The schema: malformed or unrecognised arguments never reach the handler.
- The service: a preview that shows what would happen, because confirm defaults to false.
- Row-level security: a query for someone else's book returns nothing, because the policy is on the table rather than in the query.
- The triggers: an unbalanced entry, a single-line entry, an update to history, or a posting into a locked period raises an exception. This one is not negotiable by any caller, including us.
Deterministic constraints for a probabilistic caller
The reason to put the rules in the database rather than in the service layer is not tidiness. It is that a rule in application code is a rule that holds for the paths someone remembered to route through it.
A test that bypasses the service layer entirely and writes raw SQL is the only way to know which is which. Every invariant here was verified that way — by trying to insert bad data directly, with the application code out of the picture, and checking that the database refused.
The practical result is that the correctness of your books does not depend on the correctness of the code that happens to be deployed today. It depends on constraints that would have to be deliberately dropped in a migration to stop holding.
The states a transaction can actually be in
Descriptions of accounting systems usually offer a tidy lifecycle — draft, posted, reconciled, closed. This one is narrower than that, and the places it is narrower are the interesting part.
A bank transaction has exactly two states, unposted and posted, and the status answers exactly one question: is this transaction currently represented in the ledger? Categorizing it posts an entry and marks it posted, with a pointer to the entry. Reversing that entry releases it back to unposted with the pointer cleared — regardless of why the entry was reversed, because after a reversal the answer to that one question is no for every possible reason.
That release is not tidiness. Without it the row is stranded: categorizing refuses anything already posted and tells you to reverse its entry first, which is the step that produced the stranded row, so following the advice again changes nothing. Reconciliation only considers unposted rows as candidates, so it would report a gap with no uncategorized transaction behind it and send you looking for something that does not exist.
- Import: preview, then accepted or flagged. A flagged import still inserts its rows — it tells you how far the arithmetic is off rather than refusing.
- Bank transaction: unposted or posted. Nothing else.
- Journal entry: no state at all. The journal is append-only, so an entry is never modified; a correction is a separate entry that points at the one it reverses.
- Period: locked or not, and a lock only moves forward.
Two states that deliberately do not exist
There is no reconciled state on anything. Reconciliation is a computation against a balance you supply at the moment you ask, not a fact stored on a row. Nothing is stamped as reconciled, because a reconciliation that was true in August tells you nothing about whether it is still true after a correction posted in September.
There is also no ignored state, and this one is a genuine gap rather than a design choice. A released transaction is one that needs a decision again, and a caller who wants a row left alone permanently has no way to say so — the schema admits only the two states. Adding one is not a matter of writing a third string: it needs its own reporting and its own reconciliation semantics, because a reconciler that silently skips ignored rows and one that counts them give different answers to the same question.
Why every bank account gets its own ledger account
A detail that turns out to matter for agents specifically. Each bank account is given its own child account under cash — 1010 becomes 1011, 1012 and so on — rather than every account sharing one cash line.
With a single shared cash account, a transfer between two of your own accounts nets to nothing and becomes invisible. With separate child accounts, the movement is visible on both sides, which is what makes it possible to reason about at all.
What this does not cover
- A second entry point into the dispatcher would produce zero audit rows, because the route handler is the audit log's only producer.
- Constraints validate structure. Nothing here can tell that a correctly formed entry describes the wrong economic event.
- A statement import whose arithmetic does not reconcile is flagged rather than refused, so an unnoticed flag can still become a gap.
- There is no way to mark a transaction as deliberately ignored. A released row looks identical to one never looked at.
Common questions
- What happens if the AI sends a malformed request?
- It is rejected by the schema before any handler runs, with an error naming the field. Strictness is enforced when the tool is defined, so a tool cannot accidentally accept arguments its schema does not describe.
- Can a bug in the application code corrupt the ledger?
- It can produce wrong entries — nothing stops code from posting a valid entry that describes the wrong thing. It cannot produce unbalanced entries, edit history, or post into a locked period, because those are refused by the database rather than by the code.
- Why does everything go through one endpoint?
- So the audit log is complete by construction. If tool calls could arrive by two paths, one of them would eventually stop logging and nothing would notice.
Checked against the implementation on 2026-08-09.