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