How to export the audit trail
Use this guide when a reviewer needs to replay what agents proposed and what people decided over a period.
You start with a program (or the organisation) and a date range; you end with a CSV or JSON file whose rows
carry the audit envelope and a database-set sequence and hash, optionally a thread export with the provenance
footer, and a verifier run that recomputes the hashes. Exporting a program needs program.audit.read; the
organisation needs org.audit.read; a thread export needs program.threads.manage (with a reason) or
ownership of the thread.
The export describes controls, not a certification: entry_seq and row_hash are set by a database trigger
the application role cannot bypass, audit rows cannot be updated or deleted by the application, and every
foreign key on them refuses deletion of the referenced user or program. The per-organisation hash chain that
links entries is designed for R2; exports gain a prev_hash column then.
Choose the scope and range
Setup › Audit (/p/<slug>/setup/audit) shows the program’s rows with filters for action, actor kind and
date. Choose Export, pick csv or json and a range. The web app calls
GET /programs/{program_id}/audit/export?from=...&to=...&format=csv (AuditExportQuery). For every program
at once use Organization › Audit › Export, which calls GET /orgs/current/audit/export with
org.audit.read.
Read the envelope
Each row is an AuditEventOut:
| Column | Meaning |
|---|---|
action, target_type, target_id, program_id | what changed (finding.create, protocol.approve, thread.share, document.ingest, …) |
actor_kind, actor_user_id, actor_agent_id | human with the user, agent with agent:<agent_key>@<org_id>, or system |
agent_run_id, interrupt_id, approved_by_user_id | the run, the gate and the person who approved an agent write |
proposal_hash, approved_hash | the proposal as the agent made it and as the person approved it (they differ when the proposal was edited before approval) |
signature_id | the signature of a signing mutation |
reason | required for updates and deletions of records in review or approved states |
occurred_at, created_at | when it happened; created_at is set by the database |
entry_seq, row_hash | global sequence (gaps allowed, order preserved) and sha256(JCS(row without entry_seq and row_hash)), both set by the BEFORE INSERT trigger |
diff, request_id | before and after values, and the request that caused the row |
Verify the hashes
The Python twin of the SQL function recomputes every hash:
cd api
uv run python - <<'PY'
import csv, sys
from app.services.audit import verify_row_hash
rows = list(csv.DictReader(open("audit-export.csv")))
bad = [r["entry_seq"] for r in rows if not verify_row_hash(r)]
print(f"{len(rows)} rows, {len(bad)} hash mismatches", bad[:5])
PYverify_row_hash canonicalises the row with RFC 8785 JSON exactly as the database’s trovensa_jcs does
(tests compare the two byte for byte), so a mismatch means the row differs from what was inserted.
entry_seq must be strictly increasing inside one organisation.
Export a thread
On a thread choose Export (owner) or Export with reason (program.threads.manage). GET .../threads/{thread_id}/export returns messages, artifacts (every version), tool calls, interrupts and the
run snapshots, followed by the provenance footer {agent_id, agent_version_id, model_config_label, run_id, generated_at, citations[], statement: "agent-generated, unsigned"} and the attribution blocks of every cited
knowledge source. customer_internal documents leave as locator and hash only; cc_by_nd passages are listed
separately as quotations. The export is audited (thread.export).
Replay a run
Setup › Agents filters runs by agent_version_id. Open a run’s snapshot: chunk ids and hashes, record
ids with updated_at, sop_version_ids, prompt version, tool allowlist, policy version, permission snapshot
and the model check outcome. Together with the audit export a reviewer can answer which SOP version applied,
which sources were read, which tool calls were proposed, approved, edited or denied, and who signed what with
which meaning.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Export is empty | the range is outside the program’s rows, or the program was archived and filtered out | widen the range; archived programs keep their rows |
A row has actor_kind = system and no user | worker-side writes such as document.ingest, document.purge, thread.redact | expected |
approved_hash differs from proposal_hash | the person edited the proposal before approving | the record page shows “edited before approval” with the diff |
A thread export contains “Content removed under the retention policy on <date>” | the thread passed thread_retention_months | message bodies are tombstones; tool calls, interrupts, snapshots, signatures and audit rows remain |
Related
- About records and signatures.
- Role split runbook for the database controls behind the export.