← All guidesGuide · 7 min read

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

ledger/schema.sqlUnedited
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.

correction-example.sqlUnedited
-- 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.

A ledger without idempotency keys and a ledger without a block-mutation trigger fail in opposite but equally quiet ways: one silently double-counts, the other silently loses history. Neither shows up until someone asks "why doesn't the balance match Stripe" — build both in from the first migration, not after the first drift incident.

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.

See the Autonomous Agent Ops Kit