Skip to main content

AI governance

An Audit Trail for AI Agents That Holds Up: Append-Only Logs, Hash Chains and What Regulators Ask For

An AI agent audit trail records every proposal, policy decision, approval and action in an append-only, hash-chained log that shows any edit or deletion.

10 min read
A chain of six audit events on a dark grid: proposal, policy, approval, executed, proposal, policy, each with a short hash. The approval event is outlined in red with its hash struck through, the link after it is broken, and the chain ends at an anchor box holding the head hash.

Sooner or later someone will ask you a simple question: what did the AI do last Tuesday, and who said it could?

If your answer is "let me check the chat history", you have a problem. An AI agent audit trail is a record, written by your own code rather than by the model, of every proposal the agent made, the policy decision on it, who approved it, what actually ran and what came back. To hold up, it has to be append-only, so rows cannot be changed, and hash-chained, so that any edit or deletion that slips past the guard is detected and pinpointed.

This post is how we build that at Devs Core, including the hole we found in our own log last month, and a small Postgres version you can run in ten minutes. I ran it while writing this; the screenshots below are its real output.

Key takeaways

  • The agent's own explanation is not an audit trail. The record has to come from the code that decided, not the model that proposed.
  • Make the table refuse UPDATE, DELETE and TRUNCATE, and give the application role INSERT and SELECT only.
  • Chain each event to the previous one with SHA-256. A trigger prevents tampering; the chain detects it when someone with enough power switches the trigger off.
  • A hash chain cannot stop the database owner from rewriting everything. Publish the chain head somewhere they cannot reach.
  • Log references and hashes, not secrets or raw personal data.

Why isn't the agent's own account enough?

In April 2026, a Cursor agent working for PocketOS deleted the company's production database on Railway in a single API call. Founder Jer Crane posted his account on 25 April. The most quoted part was what the agent wrote afterwards when asked why: it admitted it had decided "on my own" to delete the volume, "when I should have asked you first" (Euronews). Railway later confirmed the token the agent found had been provisioned with account-scoped access even though a narrower scope existed, and wrote: "We've since recovered the database." The full story is in our breakdown of the incident.

Here is the part most coverage skipped. The clearest record of what happened was a confession produced by the same model that caused it, after the fact, in a chat window. It happened to be honest. It was also unsigned, unordered, and written by the party with the most reason to tell a tidy story.

The Monetary Authority of Singapore put this better than I can. Its SAFR paper (Safeguards for Agentic Finance at Runtime), published in July 2026, describes an audit log designed so that every action and every decision to allow, deny, escalate or observe it "can be fully reconstructed and reviewed without relying on the agent's own account of what occurred." That sentence is the design brief.

How did our own audit log fail a test?

Ware is our live demonstration: a simulated 3D warehouse where a real AI recovery agent proposes operations and a governed command service decides. The money is simulated. The governance is the same pattern we build for clients, described on our AI governance page.

In October 2026 we ran 12 independent tests against that governance. Three found real weaknesses. One of them was the audit log itself: records could be edited or deleted and nothing would notice. The log was a normal Postgres table. Anyone with write access to the database could change who approved something, or remove the row showing an action ran, and the trail would still look complete.

That is an awkward thing to find in a system whose sales pitch includes the words "audit trail". We fixed it in three moves:

  1. The audit table is append-only. A trigger refuses every UPDATE and DELETE.
  2. Every event is chained, per session, to the one before it with a SHA-256 hash, and a verifier walks the chain and reports any break. It catches an edit even if someone disables the trigger first.
  3. Execution events now name the approval they ran under, so you can go from "this happened" to "this person allowed it" without guessing from timestamps.

The third one sounds minor. It isn't. Before the fix, linking an execution to its approval meant matching times and arguments by hand. An auditor will not do that for you, and neither will a tired engineer at 2am.

What must an AI agent decision record contain?

A useful test: pick one row and ask whether a stranger could answer "who, what, under which rule, approved by whom, and what happened" from it alone. Here is the record we aim for, with values from the demo below.

Anatomy of one decision record as eight cards: who asked (agent:recovery acting for user:shift-lead), what was proposed (move_packer P-07 from bench-3 to bench-1 with its argument hash), policy decision (REQUIRES_APPROVAL under rule packer.move.other_customer_order, policy v2), approver highlighted in amber (user:shift-lead, approval apr-3f1c bound to the argument hash and state versions), what executed (command move_packer naming approval apr-3f1c), result (ok, new state versions), model and prompt versions, and integrity fields (sequence, previous hash, hash).

Who asked. The agent's identity and the human or service it acts for. "The AI did it" is not an identity. If the agent acts on a shift lead's delegated authority, both names go in.

What was proposed. The action and its exact arguments, plus a hash of those arguments. The hash is what lets you prove later that the approved action and the executed action were the same one.

The policy decision and the rule. Allow, deny or needs approval, the rule that produced it, and the policy version. "Denied" without a rule ID is an opinion.

The approver. A named person, the approval ID, and what the approval was bound to: the argument hash and the state it was granted against. More on that in our post on human-in-the-loop design.

What executed and the result. The command, the approval it ran under, and what came back, including failures and refusals. Refusals are evidence too.

Model and prompt version. Which model configuration and which prompt served the proposal. When behaviour changes after a model update, this is how you find out when.

Integrity fields. Sequence number, previous hash, hash.

What do regulators and frameworks actually ask for?

Less than vendors claim, and more than most teams log.

EU AI Act, Article 12. High-risk AI systems "shall technically allow for the automatic recording of events (logs) over the lifetime of the system" (Article 12). It lists purposes, not fields: identifying risk situations and substantial modifications, supporting post-market monitoring, and monitoring operation by the deployer. Providers keep the logs they control for at least six months (Article 19), and so do deployers (Article 26). After the Digital Omnibus, these apply to Annex III high-risk systems from 2 December 2027 (amended Article 113). Our Article 12 and 14 walkthrough goes field by field.

MAS SAFR. The most concrete of the lot. The SAFR paper calls the audit log "a tamper-evident, append-only record of every governance decision", and says each entry should capture the proposal as submitted, the mandate it was checked against, the outcome, "the specific rules applied, the basis for that outcome, and the time elapsed at each stage", subject to data minimisation and retention requirements. If you build to SAFR, you are close to building for everyone else.

ISO/IEC 42001. The AI management system standard has an Annex A control, A.6.2.8, on recording event logs: decide at which life-cycle phases logs are kept, at the minimum while the system is in use. It does not prescribe a format.

NIST AI RMF. Voluntary, and not a logging spec. But MANAGE 4.1 asks for post-deployment monitoring "including mechanisms for capturing and evaluating input from users and other relevant AI actors, appeal and override", and MANAGE 4.3 asks that incident tracking and response are "followed and documented" (AI RMF Core). You cannot document an override you never recorded.

Notice what none of them say: "keep the chat transcript". They all ask for decisions.

How do you build an append-only, hash-chained log in Postgres?

The pattern fits in about 60 lines of SQL and a little over 100 of Node. I built a cut-down version with PostgreSQL 16 for this post. Two triggers do the work. The first refuses changes, whoever asks:

CREATE FUNCTION audit_events_append_only() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
  RAISE EXCEPTION 'audit_events is append-only (% refused)', TG_OP;
END $$;

CREATE TRIGGER audit_events_append_only
  BEFORE UPDATE OR DELETE ON audit_events
  FOR EACH ROW EXECUTE FUNCTION audit_events_append_only();

CREATE TRIGGER audit_events_no_truncate
  BEFORE TRUNCATE ON audit_events
  FOR EACH STATEMENT EXECUTE FUNCTION audit_events_append_only();

The second makes every insert extend the chain: the new row must carry the next sequence number and the current head hash as its prev_hash, and the trigger moves the head (an audit_heads row per session) in the same transaction. The application role gets INSERT and SELECT on the log and only SELECT on the heads.

The hash itself is computed in the application over a canonical form of the event, with keys sorted so the same event always hashes the same:

const eventHash = (prev, e) =>
  sha256(
    prev +
      '\n' +
      canon({
        session_id: e.session_id,
        seq: e.seq,
        at: e.at,
        kind: e.kind,
        actor: e.actor,
        payload: e.payload,
      }),
  );

I wrote six events for one session: the agent proposes moving a packer, policy says it needs approval, the shift lead approves, the command executes naming that approval, then the agent proposes releasing a quality hold and policy denies it outright. Then I tried to break it.

Terminal output from psql. The app role's UPDATE fails with permission denied. The table owner's UPDATE, DELETE and TRUNCATE each fail with audit_events is append-only. An INSERT that skips to sequence 9 with a fake previous hash is rejected: expected seq 7 after 281c777be562.

The application role cannot even attempt an UPDATE. The table owner, who has the privilege, is stopped by the trigger. And a forged insert that tries to jump the sequence is rejected, because it does not extend the chain.

What happens when someone switches the guard off?

This is the case that matters, because a trigger is only a speed bump for anyone who owns the table. Postgres lets the owner disable a trigger with one statement (ALTER TABLE … DISABLE TRIGGER). So I did that, changed who approved event #3, and ran the verifier. Then I deleted event #4, the execution, and ran it again.

Each event stores the hash of the event before it. Three rows of chained cards: intact, where every previous hash matches; edited number 3, where the recomputed hash a994403b no longer matches the stored 987baf17; and deleted number 4, where the gap leaves number 5 pointing at a hash that no longer exists.

The verifier recomputes each event's hash from its contents and compares, checks that each prev_hash matches the hash before it, checks the sequence has no gaps, and checks the log ends where the head says it does.

Terminal output: the owner disables the append-only trigger, updates event 3's actor to user:ops-director, and the verifier reports BROKEN #3 approval.approved: content changed, stored 987baf17bc37, recomputed a994403b4683. After deleting event 4, the verifier reports three problems: #3 content changed, #4 missing, and #5 prev_hash does not match the event before it.

It names the event, not just "something is wrong". In a separate run I deleted only the last event, the denial, which is the classic way to hide something: there is no later row to complain. The head table caught it: head says #6 281c777be562, log ends at #5 51c1906509bc.

What can a hash chain not do?

Here is the honest limit. If someone owns the database, they can disable the triggers, edit a row, then recompute every hash after it and update the head. The chain is then internally consistent and the verifier passes. I wrote that attack too, about 20 lines:

Terminal output: anchor.json records head #6 281c777be562. rewrite.mjs edits event 3 and recomputes the chain to a new head 2a4d2817cf1d. A plain verify reports chain intact. verify with --anchor anchor.json reports BROKEN: external anchor says #6 281c777be562, log ends at #6 2a4d2817cf1d.

A hash chain detects tampering by anyone who cannot also rewrite the rest of the chain. Against the database owner it detects nothing on its own. The fix is to copy the head hash, on a schedule, somewhere that owner cannot change: a WORM bucket in a different account, a ticket in another system, an email to your auditor. Once the head is out of reach, rewriting history means rewriting that too.

Who should not be able to reach it? Ask your security lead which single person could currently alter both. If the answer is "the same DBA", you have found your next task.

Where should the audit trail live?

Not in one place.

Where the audit trail lives. The command service writes each record in the same transaction as the action. Four tiers: hot Postgres, append-only with a hash chain, kept for months to answer what happened; WORM object storage such as S3 Object Lock in compliance mode or Azure immutable blob with a locked policy, kept for years for the regulator; a SIEM stream for alerts on denials, overrides, verifier failures and schema changes; and the chain head anchored outside the database. A red dashed note: never log secrets, tokens, passwords or raw personal data.

Hot: Postgres. Append-only plus chain, as above, written in the same transaction as the action.

Retain: WORM storage. Export daily batches, with their head hashes, to write-once storage. In S3 Object Lock compliance mode, a locked object version "can't be overwritten or deleted by any user, including the root user" for the retention period (AWS docs). Governance mode is weaker: users with a bypass permission can remove it. Azure's equivalent is immutable blob storage, where a time-based retention policy must be locked to be in a compliant state (Microsoft docs). This bucket doubles as your external anchor.

Watch: a SIEM. Stream denials, overrides, approvals and verifier failures to wherever your security team already looks. An audit trail nobody reads until a regulator asks is half a control.

What not to log. The OWASP Logging Cheat Sheet lists what to keep out: access tokens, passwords, connection strings, encryption keys, sensitive personal data, payment data. Agents make this harder because tool arguments and model inputs are full of it. Redact before writing, and for documents store a reference and a hash, which proves what the agent saw without copying it into a log you must keep for years. Remember an append-only log means you cannot delete a mistake later. That is the point, and it is also the risk.

Where do you start?

If your agent writes to anything that matters, start with the decision record, not the storage tier. Get the eight fields written by your command service, in the same transaction as the action, on every path including refusals. Then make it append-only. Then chain it. Then anchor the head. Each step closes a gap you can name.

This is the audit layer of the access layer we build around client agents, and we test it the way we tested Ware: by trying to break it and keeping the test in the suite. If you want the same done to your system, our AI governance page explains how we work.

Frequently asked questions

What is an AI agent audit trail?

A record, written by the code around the agent rather than the model, of each proposal, policy decision, approval, executed action and result, with the identities and versions involved. It should be append-only and tamper-evident.

Is a hash chain the same as a blockchain?

No. It is the same basic idea, each record committing to the one before it, without consensus or distribution. One database and one verifier are enough, plus an external copy of the head hash.

Does a hash chain stop someone from editing the log?

No. The append-only trigger and database permissions prevent edits. The chain detects edits that get past them. Against the database owner, it only works if the head hash is anchored somewhere they cannot change.

How long should AI agent logs be kept?

Under the EU AI Act, at least six months for high-risk systems, for providers and deployers, unless other law says otherwise. Sector rules are often longer. Keep the hot copy short and the WORM copy for the longest period that applies.

Should we log the full prompt and model output?

Log enough to reconstruct the decision: the proposal, its arguments, the model and prompt versions. Keep full transcripts in a separate, access-controlled trace store with redaction, and reference them by ID and hash from the audit record.

One email when we publish something worth your time

No cadence, no drip sequence. We write when we have learned something running agents in production, which is not weekly.