What is Audit Log?
Audit Log is an AI design pattern for LLM apps and agents, in the Safety and reliability group. Record who asked, what the model saw, what it decided, and what it ran.
Trilha de Auditoria
Audit Log is an AI design pattern for LLM apps and agents, in the Safety and reliability group. Record who asked, what the model saw, what it decided, and what it ran.
Record, immutably and searchably, who asked, what the model saw, what it decided, and what the system executed. It is for systems that decide about money, data, or people and must answer for each decision later.
An airplane’s black box: nobody reads it during the flight, and it is the first thing searched for when something goes wrong.
Luana is the compliance manager at a fintech. The app’s finance assistant schedules payments with customer approval. A customer filed a complaint with Brazil’s central bank: they say they never approved a Pix of R$2,300. Pix is Brazil’s instant-payment rail, closer to a real-time bank transfer than to a card charge.
The team had observability traces, but they were sampled at 10%, kept for 14 days, and did not record what the customer saw on the approval screen. The application log said “transfer scheduled” and a user id. There was no way to prove whether the customer approved, what they saw when they approved, or why the assistant suggested that payment.
Observability serves the engineering team in understanding the system. Auditing serves to answer a customer, an auditor, or a judge. The questions are different, and so is the record.
The question this pattern answers: how do you keep enough evidence to explain and defend every decision the system made?
Create an audit record separate from the traces, with four properties:
The complaint arrives in April. The Pix ran in January. With the right record, Luana searches action=schedule_pix and the customer id and rebuilds the story:
event_id: a9f3...
ts: 2026-01-12T14:32:11Z
actor: { type: "user", id: "cli_8821" }
action: schedule_pix
prompt_version: finance-assistant@7
model: claude-sonnet-5-5
proposal: { amount_brl: "2300.00", dest_name: "Supplier X", dest_key_type: "cnpj" }
shown_to_human: { title: "Schedule Pix", amount: "R$ 2,300.00", dest: "Supplier X", key: "**.***.***/****-**" }
decision: approved
reviewer: cli_8821 (in-app biometrics)
execution: { status: "ok", external_id: "E0000000020260112..." }
input_ref: obj_91c2 (original request, in separate storage)
That answers three questions from the central bank. Who asked: the account holder, authenticated. What they saw: amount, name, and a masked key. What the bank executed: the transaction id matches the Pix. Without shown_to_human, you are left with a “transfer scheduled” that proves nothing. Without prompt_version, you cannot tell whether that week’s assistant was suggesting the wrong payee.
The observability trace from that day had already been sampled away and deleted at the 14-day retention. Audit cannot inherit those two properties.
Compare the two records:
| Question | Trace | Audit event |
|---|---|---|
| For whom | Engineering, debug | Customer, auditor, judge |
| Sampling | 10% is common | None on side-effect actions |
| Retention | Days or weeks | The period regulation asks for |
| What the human saw | Usually not stored | A required field |
| Editable | Often yes | Append-only, hash-chained |
flowchart LR
U(["1. User request"]):::io --> A(["2. Model proposes"]):::llm
A --> H((("3. Approval"))):::human
H --> X["4. Execution"]:::tool
U --> E["5. Build event"]:::tool
A --> E
H --> E
X --> E
E --> R["6. Mask and sign"]:::tool
R --> L[("7. Append-only record")]:::store
L --> Q{"8. Query and report"}
schema AuditEvent {
event_id: string, ts: datetime, actor: { type: "user" | "agent" | "reviewer", id: string },
action: string, input_ref: string, prompt_version: string, model: string,
proposal: any, rationale: string, shown_to_human: any?, decision: "approved" | "rejected" | "auto",
execution: { status: string, external_id: string? }, prev_hash: string, hash: string
}
function record(ctx, proposal, review, execution):
event = AuditEvent(
actor = ctx.user, action = proposal.tool, input_ref = store_input(ctx.input), # 1
prompt_version = ctx.prompt_version, model = ctx.model,
proposal = proposal.args, rationale = proposal.reason, # 2
shown_to_human = review.preview, decision = review.decision, # 3
execution = execution, # 4
)
event = redact_pii(event) # 6
event.prev_hash = audit_log.last_hash()
event.hash = sha256(canonical_json(event))
audit_log.append(event) # 7
Write the schema as a list of questions, not as a copy of the span:
| Field | Question it answers |
|---|---|
actor |
Who asked? |
proposal |
What did the model want to run? |
shown_to_human |
What did the person see at approval time? |
decision and reviewer |
Who authorized it, and how? |
execution.external_id |
What actually left in the bank, the email, the ERP? |
prompt_version and model |
Which system was live? |
input_ref |
Which original request, without copying personal data into the event? |
The justification the model writes (rationale) helps whoever reads this in six months. It is not proof. Proof is what came in, what was shown, and what went out.
Each event stores the previous event’s hash and its own hash of the canonical payload, without the hash field. If someone edits an event in the middle, the nightly check breaks the chain. That does not stop an administrator with disk access; it stops a silent edit in the application.
Parallel writes need a single writer per chain (a queue, a lock, or one chain per partition, for example per actor.id). Without that, two Pix transfers in the same millisecond point at the same prev_hash and the check reports a conflict even with no fraud.
rationale as proof. The model describes what it thinks it did. The record describes what the system did.The trail starts at the Add Tracing refactoring. Trace first, so the team can see the system. Audit on top, for what has to outlive debug.
| Aspect | What happens | How it shows up |
|---|---|---|
| Latency | A synchronous write adds milliseconds; an async queue adds none | write duration in the action’s span |
| Cost | Storage for years, larger when full inputs are kept | record volume per month |
| Failure: missing event | Action executed with no record | daily reconciliation between executions and events shows a gap |
| Failure: exposed personal data | Event with unmasked sensitive data | finding in an internal audit |
| Failure: incomplete event | The preview shown or the prompt version is missing | a query that cannot explain the action |
Write the event in the same transaction as the execution, or on a queue with guaranteed delivery and reconciliation. Async without reconciliation is the polite way to lose the Pix that will be contested.
The storage numbers below are illustrative. Suppose 5,000 Pix transfers a day, 2 KB per masked event, and the full 20 KB request only in separate storage.
| What you keep | Rough yearly volume |
|---|---|
| Masked event only | a few gigabytes |
| Event plus the full request | tens of gigabytes |
Gigabytes a year are cheap next to being unable to answer the central bank. The cost that hurts is too much personal data in the event, not the disk.
{ "check": "reconstruct", "action_id": "pix-88123", "must_include": ["actor", "proposal", "shown_to_human", "decision", "external_id", "prompt_version"] }
The reconstruction test is this pattern’s eval. Someone from the legal team, with no access to the code, tries to tell the Pix story from the event alone. If they ask “but what did the customer see?”, a field is missing. If they ask “did this Pix actually go out?”, external_id is missing.
The record concentrates sensitive information. Split personal data into its own storage, with a different access key, and keep only references in the event when possible. Apply PII Redaction before writing, and set retention according to the legal basis.
| Pros | Cons |
|---|---|
| Answers customers, auditors, and regulators | Storage cost for years |
| Shows exactly what the person approved | One more schema to maintain along with the system |
| A basis for investigating incidents and bias | Concentrates sensitive data |
| Tampering becomes detectable | Takes discipline to record every new action |
Both tabs write a self-contained, hash-chained event to append-only storage.
Python. Use this when actions run in Python and the record goes to a database table with insert-only permission.
import hashlib
import json
import uuid
from datetime import datetime, timezone
def canonical(obj: dict) -> str:
return json.dumps(obj, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
def record(db, ctx, proposal: dict, review: dict, execution: dict) -> dict:
prev = db.fetch_one("SELECT hash FROM audit_log ORDER BY seq DESC LIMIT 1")
event = {
"event_id": str(uuid.uuid4()),
"ts": datetime.now(timezone.utc).isoformat(),
"actor": {"type": "user", "id": ctx.user_id}, # 1
"action": proposal["tool"],
"input_ref": ctx.input_ref, # input stored separately
"prompt_version": ctx.prompt_version,
"model": ctx.model,
"proposal": proposal["args"], # 2
"rationale": proposal.get("reason", ""),
"shown_to_human": review.get("preview"), # 3
"decision": review["decision"],
"reviewer": review.get("reviewer"),
"execution": execution, # 4
"prev_hash": prev["hash"] if prev else "genesis",
}
payload = canonical(event)
event["hash"] = hashlib.sha256(payload.encode()).hexdigest() # 6
# 7. this service only has INSERT permission on the table
db.execute("INSERT INTO audit_log (event_id, body, hash) VALUES (%s, %s, %s)",
(event["event_id"], payload, event["hash"]))
return eventWhat you gained: each event tells the action’s whole story, and the hash chain exposes edits. What you lost: concurrent writes need serialization (a queue or a lock) to keep the chain correct.
TypeScript. Use this when actions run on Node and you write to a queue before final storage.
import { createHash, randomUUID } from "node:crypto";
type AuditEvent = {
event_id: string; ts: string;
actor: { type: "user" | "agent" | "reviewer"; id: string };
action: string; input_ref: string; prompt_version: string; model: string;
proposal: unknown; rationale: string; shown_to_human: unknown;
decision: "approved" | "rejected" | "auto"; reviewer?: string;
execution: { status: string; external_id?: string };
prev_hash: string; hash?: string;
};
const canonical = (o: unknown): string =>
JSON.stringify(o, (_k, v) =>
v && typeof v === "object" && !Array.isArray(v)
? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a.localeCompare(b)))
: v,
);
export async function record(
log: { lastHash(): Promise<string | null>; append(e: AuditEvent): Promise<void> },
e: Omit<AuditEvent, "event_id" | "ts" | "prev_hash" | "hash">,
): Promise<AuditEvent> {
const event: AuditEvent = {
...e,
event_id: randomUUID(),
ts: new Date().toISOString(),
prev_hash: (await log.lastHash()) ?? "genesis",
};
event.hash = createHash("sha256").update(canonical(event)).digest("hex"); // 6
await log.append(event); // 7. append-only storage
return event;
}What you gained: the AuditEvent type forces you to fill in preview, decision, and execution. What you lost: the same care with concurrency; serialize writes through a single writer.
Audit Log is an AI design pattern for LLM apps and agents, in the Safety and reliability group. Record who asked, what the model saw, what it decided, and what it ran.
In the real-world scenarios Personal finance assistant with approval, Resume screening with bias control, and Legal assistant.