Why agents never touch a balance
The double-entry ledger under Payments, and the three invariants that let an agent issue refunds without finance losing sleep.
Daniel Achterberg
VP Engineering
- Published
- Reading time
- 4 min read
When we let agents issue refunds, the first question from every finance team was the same: how do we know the books still balance? It is the right question, and the answer is a design decision we made long before agents existed. Nothing in ConsciousLab ever updates a balance. Balances are computed from a ledger, and the ledger only accepts entries that balance.
Entries, not updates
A naive payments system stores a balance per wallet and changes it: subtract €182.40 from the merchant wallet, add it to the customer. If the process crashes between those two writes, money has vanished. If two processes run at once, one of them overwrites the other. Every payments team eventually rediscovers this, usually during an incident.
The Payments module stores postings instead. A posting is a set of ledger lines that must sum to zero in each currency:
{
"posting_id": "pst_01J9ZM2K8D",
"ref": "RFD-0193",
"lines": [
{ "account": "refunds.expense", "amount": "182.40", "currency": "EUR" },
{ "account": "cash.eur.operating", "amount": "-182.40", "currency": "EUR" }
],
"actor": "agent/support-triage",
"approved_by": "user/hlindqvist",
"idempotency_key": "run_7Hq2xV:refund.issue:1"
}
A balance is the sum of lines for an account. We cache those sums for speed, but the cache is derived and can be rebuilt from the lines at any time. Every night we rebuild a random 1% of accounts from scratch and compare. In 2026 that check has run 41 million times without a mismatch.
Three invariants
The ledger enforces three rules at write time, inside the same transaction that stores the posting.
Every posting balances. Lines must sum to zero per currency. A posting that moves EUR to USD has four lines: two in EUR against an FX clearing account, two in USD against the same account, and the clearing account itself nets to zero once both legs settle.
Postings are immutable. A mistake is corrected with a reversing posting that references the original, never by editing it. The audit trail is the ledger itself.
Every posting is idempotent. The idempotency key is unique per workspace. If an agent retries a refund after a timeout, the second call returns the first posting instead of creating another. For agent tool calls the key is derived from the run ID, the tool name and the call's ordinal in the run, so a replayed run cannot double-pay.
Where agents fit
An agent never writes a posting directly. It calls a tool such as refund.issue, which is a thin layer over the same API the console uses. The tool checks the approval policy, constructs the posting, and submits it. If the policy requires approval, the posting is created in a pending state with no effect on available balance until a person approves it. Pending postings do reduce available balance in the projections finance sees, so nobody spends money that is already committed.
This is why finance teams sign off. The agent has exactly the same constraints as a person in the console, and every action it takes is a posting with an actor, an approver, a reference and a reason.
Reconciliation
The ledger is only useful if it matches the bank. Payments reconciles every settlement file from our banking partners and card acquirers against the ledger daily. Each external transaction is matched to a posting by reference, amount and value date. Unmatched items land in a queue with a suggested match.
In practice 99.6% of items match automatically. Most of the remaining 0.4% are bank fees that arrive without a reference, and the matcher now proposes those with high accuracy because they recur.
Performance
People assume double-entry is slow. It is not, if you design for it. Postings are written to a single partitioned table with an append-only access pattern. Balance caches are updated in the same transaction with a row lock per account. At peak we write 3,400 postings per second per region with p99 commit latency of 11 ms.
The hot accounts problem, where one account such as a platform fee account is touched by every posting and becomes a lock bottleneck, is handled by splitting those accounts into 64 shards that are summed on read.
Exports
Finance teams do not want another system of record. The ledger exports to DATEV, NetSuite and CSV, with one line per ledger line and the posting reference preserved, so an auditor can trace any number in the general ledger back to the event that caused it, and from there to the agent run or person that initiated it.
The wallets and invoices documentation covers the API in detail. If you are evaluating ConsciousLab for a regulated use, our compliance team can walk your auditors through the ledger design.