Overview
The audit log records what agents do. By default nothing stops someone with database write access from editing or deleting a row afterwards. Turn on the hash chain and every new row stores the hash of the row before it, so an edit, a deletion or a reorder shows up the next time you verify. It is off by default and additive. Rows written before you enable it are left alone and are reported as unchained.What gets recorded
Withagents.auditAll on (the default), every decision about a known agent writes one row, allowed or denied. That includes a request from an agent that is revoked or expired. Those rows have result: "denied" and a stable reason code, so you can filter for them:
The
reason string on the returned result is unchanged (for example Agent "deploy-bot" is revoked). The row stores the short code. The row never stores the token. It stores the action, resource, arguments, IP and user agent, like any other row, and it goes through the same writer, so with tamperEvident on it joins the agent’s chain like any other row.
Some denials write no row:
- Unknown agent id (
authorize("no-such-agent")). There is no agent to attach a row or a chain to, and the audit table requires a real agent. Writing rows keyed by arbitrary ids would let a caller fill the table with junk. - Unknown token (
authorizeByTokenwith a token that matches no agent). Same reason. Nothing identifies an agent. - Blocked by a
beforeAuthorizehook. The hook runs before any database work by design. UseonViolationto log these. agents.auditAll: false. No rows are written for any decision, including these.
auditId on the result is an empty string.
A failed audit write on one of these denials does not change the outcome. The call still returns allowed: false and does not throw, and auditId is empty. This differs from an allow or a permission denial, where a failed audit insert propagates as an error.
A caller holding a revoked token can produce one row per attempt. Use audit.cleanup({ retentionDays }) and your gateway’s rate limiting if that matters for you.
How the chain works
There is one chain per agent. Each row getschain_seq (1, 2, 3…), prev_hash and hash. The hash is SHA-256 over the canonical JSON (sorted keys) of the row fields, the sequence number and prev_hash. With hmacKey it is HMAC-SHA256 instead.
Why per agent and not per tenant or global: writers for different agents never contend, a replay is exactly one chain, and a single unique index on (agent_id, chain_seq) is enough to stop a fork.
Concurrent writers do not need a transaction or a lock. A writer reads the chain head, computes the next hash and inserts. If another writer took that sequence number first, the unique index rejects the insert and the writer re-reads the head and retries. This behaves the same on SQLite, Postgres, MySQL and D1. Writers inside one process also queue per agent, so retries only happen across processes.
Timestamps are hashed at one-second precision, because that is what the SQLite column stores.
Migration
createTables adds the three nullable columns and the unique index to an existing theauth_audit_logs table. The statements are idempotent (Postgres uses ADD COLUMN IF NOT EXISTS, SQLite and MySQL tolerate “already exists”). If you run with skipMigrations: true, apply this yourself:
Verify
hash_mismatch (a row was edited), seq_gap (a row was deleted), prev_hash_mismatch (rows were reordered or relinked), missing_hash, and truncated.
Deleting the newest rows of a chain leaves a shorter chain that still verifies. To catch that, keep a chain head somewhere the database owner cannot edit (an export manifest works) and pass it back:
Replay an agent
action when allowed, decision when denied or rate limited), delegations the agent gave or received, approval requests and answers, and token cost events, ordered by time. Only audit rows are covered by the chain, and each event says whether it is chained. The userId filter applies to audit rows and approvals.
Export for compliance
audit.hmacKey (or signingKey if you pass one); export fails with NO_SIGNING_KEY otherwise. Check an export later without a database:
CLI
--db <url> overrides DATABASE_URL. The commands never write.
Limits
- Tail truncation is only detectable against a saved head (see above).
- Anyone holding
hmacKeyand database write access can forge a chain. Keep the key outside the database host. - Retention cleanup and GDPR erasure remove or rewrite rows. Removing the oldest rows is not flagged; rewriting a row in the middle is.
- Audit rows written by the policy engine are not chained yet. They show up as unchained in verify output.