An append-only ledger for AI agent spend
Why an autonomous agent's money trail needs to be tamper-evident by construction, the Postgres trigger pattern that enforces it, idempotency keys, and reconciliation.
Why an agent's money trail is a different problem
A human spending money leaves a paper trail almost by accident — bank statements, email receipts, a person who remembers roughly what they bought. An autonomous agent leaves nothing unless you build the trail on purpose, and it can generate that spend far faster than a human would notice a problem. Two failure modes matter more than any others once an agent is empowered to spend:
- Silent overwrite or deletion. If a spend record can be UPDATEd or DELETEd — by a bug in your own reconciliation code, or by the agent itself asked to "clean up the logs" — a real overspend can simply disappear from the record before anyone reviews it.
- Duplicate charges from retries. Agents retry failed tool calls as a matter of course. If a "charge $5" action is retried after a network timeout, a naive ledger records two $5 debits for one real charge, and your balance drifts from reality with no error anywhere.
Both are solvable with two small, boring pieces of database discipline: make the ledger genuinely append-only, and make every write self-checking against retries.
It's tempting to reach for a spreadsheet or a plain log file for this, and for a demo that's fine. It stops being fine the moment the agent runs unattended for more than a session — a log file has no constraint stopping a second process from writing a malformed line, no way to enforce that amounts are always positive integers, and nothing that turns a delete into an error instead of a silent gap. A real database with real constraints isn't heavier tooling for its own sake here; it's the minimum needed to make the guarantees below actually hold instead of merely being documented.
The append-only pattern
"Append-only" as a policy — a comment in the code saying "please don't edit these rows" — holds until the first person (or agent) under deadline pressure decides this one fix is an exception. Enforcing it as a policy is optional; the fix is to enforce it at the database level, so the guarantee doesn't depend on every future caller reading a comment. A Postgres trigger that fires before update and before delete and raises an exception makes tampering impossible for any caller — application code, an ORM migration, a well-meaning manual UPDATEin a SQL console — regardless of what role they're connecting as (short of a superuser dropping the trigger itself, which is a much louder, much more auditable action).
create table if not exists ledger_entries (
id bigint generated always as identity primary key,
-- 'debit' = money leaving the budget, 'credit' = money entering it.
direction text not null check (direction in ('debit', 'credit')),
-- Always positive; direction determines the sign.
amount_cents bigint not null check (amount_cents > 0),
category text not null, -- 'api_call', 'product_sale', ...
description text not null,
-- Unique per logical action. A retried write collides here instead
-- of double-counting — see "Idempotency keys" below.
idempotency_key text not null unique,
created_at timestamptz not null default now()
);
-- Reject any attempt to edit or remove a row after insert.
create or replace function block_ledger_mutation()
returns trigger language plpgsql as $$
begin
raise exception
'ledger_entries is append-only: % is not permitted. '
'Insert a compensating row instead.', tg_op;
end;
$$;
create trigger ledger_block_update
before update on ledger_entries
for each row execute function block_ledger_mutation();
create trigger ledger_block_delete
before delete on ledger_entries
for each row execute function block_ledger_mutation();Corrections are then handled the way accounting has always handled corrections: never edit history, insert a compensating row that nets out the mistake. This keeps the ledger a true chronological record of what actually happened — including the mistake and its fix — which is exactly what you want when reconstructing "how did we get to this balance" after an incident.
-- A debit recorded in error (agent double-billed itself for one call):
insert into ledger_entries
(direction, amount_cents, category, description, idempotency_key)
values
('debit', 499, 'api_call', 'LLM call: draft-summary',
'toolcall_2026-07-09T14:02:11Z_draft-summary');
-- The fix is a NEW row that nets it out, not an UPDATE of the old one:
insert into ledger_entries
(direction, amount_cents, category, description, idempotency_key)
values
('credit', 499, 'correction',
'Reversal of erroneous debit toolcall_2026-07-09T14:02:11Z_draft-summary',
'correction_2026-07-09T14:05:00Z_reverse-draft-summary');Idempotency keys
The idempotency_key column above is doing the second job: making every write self-checking. The rule is simple — every ledger write must supply a key derived from the action being recorded, not from the write attempt. A hash of tool_name + tool_args + a coarse timestamp bucket works for internal actions; a payment provider's own event or charge ID works even better when one exists, since the provider has already solved deduplication on their side.
With a unique constraint on that column, a retried action collides on insert instead of creating a second row. The critical detail is how your application code treats that collision: unique_violation on this specific column should be handled as success, not as an error to surface or retry differently — it means the action was already recorded, which is the correct outcome for a retry, not a failure.
One more habit worth adopting early: keep category to a small, fixed vocabulary (api_call, subscription, product_sale, refund, correction) enforced with a check constraint, rather than letting it drift into free-text. A ledger you can actually query for "how much did API calls cost this week versus refunds" is a ledger where that grouping was decided at schema time, not reconstructed later from inconsistent strings.
Reconciliation: the ledger is a claim, not a fact
Even a well-built ledger is your system's internal record of what it believes happened — it is not the same thing as what your payment provider actually processed. A webhook can fail to deliver, a network partition can drop a write after the external charge succeeded, a bug can compute the wrong amount. Treat the ledger as a claim that needs periodic verification, not as ground truth on its own.
A reconciliation job — run on a schedule, or triggered after any batch of financial activity — pulls the authoritative record from the external system (Stripe's balance transactions, a cloud provider's billing export) and compares it against sum(credits) - sum(debits) in your ledger for the same window. A match is the expected, boring outcome. A mismatch is a signal worth paging on immediately, not something to note and revisit later — the whole value of an append-only ledger is that it makes drift detectable at all; a detection job that doesn't actually run defeats the point.
This guide covers the concept; the Autonomous Agent Ops Kit packages the full implementation — the complete schema with balance and daily-spend views, a Python reconciliation script that diffs the ledger against a payment provider export, and the spend-guard hook that reads the ledger balance before every paid tool call.