Trail content
PatternSafety and reliability

Audit Log

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.

Element
62/70
Level
Intermediate
Read
9 min
Updated
On this page 17
  1. 01Intent
  2. 02Problem
  3. 03Solution
  4. 04Structure
  5. 05Pseudocode
  6. 06Real-world scenarios
  7. 07Applicability
  8. 08When not to use
  9. 09How to implement
  10. 10Variations
  11. 11Cost, latency, and failure modes
  12. 12How to evaluate
  13. 13In production
  14. 14Security
  15. 15Pros and cons
  16. 16Code
  17. 17Check your understanding
  18. ··Quick answers

Intent

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.

In one sentence
An airplane’s black box: nobody reads it during the flight, and it is the first thing searched for when something goes wrong.

Problem

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?

Solution

Create an audit record separate from the traces, with four properties:

  • Complete for decisions that matter. Every side-effect action and every decision about people produces an event, with no sampling.
  • Self-contained. The event stores what the decision needed: who asked, which input (or a reference to it), prompt and model versions, what the model proposed, what was shown to the human, who approved, and what was executed.
  • Immutable. Events are only appended, never edited. Hash chaining or storage with locked retention makes tampering detectable.
  • Searchable, with defined retention. You can search by customer, by action, and by period, for as long as regulation requires, with personal data handled under the applicable privacy law (LGPD in Brazil, GDPR elsewhere).

Worked example

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

Structure

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"}
  1. The request arrives with the identity of whoever asked.
  2. The model proposes an action, with its justification.
  3. Approval records what was shown and the answer.
  4. Execution records the result and the external identifier (transaction id, email id).
  5. One event brings it all together, with prompt, model, and policy versions.
  6. Personal data is masked or split out, and the event gets the previous event’s hash.
  7. The event goes into storage that only accepts appends.
  8. Compliance, support, and engineering query it with different permissions.
Check +20 XP on first tryLuana's team has traces at 10% sampling and 14-day retention. Why does that not answer the Pix complaint at the central bank?

Pseudocode

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

Real-world scenarios

  • Personal finance assistant with approval. Each transfer has an event with the assistant’s suggestion, the screen shown, the approval time in the app, and the bank’s transaction id.
  • Resume screening with bias control. Each screening decision stores the criteria applied, the score, and the justification, to answer candidate review requests and bias audits.
  • Legal assistant. Each opinion stores the sources cited, the prompt version, and the lawyer who reviewed it.

Applicability

  • The system executes actions with financial or legal effects, or effects on third-party data.
  • Decisions affect people (credit, hiring, health) and can be contested.
  • Regulation requires keeping evidence for a period.
  • You need to answer “why did the system do that?” months later.

When not to use

  • Internal tools with no outside effect, such as a meeting summarizer. Regular traces are enough.
  • As a replacement for observability. Auditing records decisions; tracing records everything that helps debugging. Both live side by side. See Observability for AI.

How to implement

  1. List the decisions and actions that need evidence, with the legal or compliance team.
  2. Define the event schema and what each field proves.
  3. Store the full input in protected storage and put only a reference in the event when it holds sensitive data.
  4. Record the version of everything that influenced the decision: prompt, model, review policy, rules.
  5. Write in the same transaction as the execution, or through a queue that guarantees delivery.
  6. Use append-only storage and hash chaining.
  7. Define retention and role-based access.
  8. Test the query path: given a customer id, rebuild the full story of an action.

What each field proves

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.

Hash chain and concurrency

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.

Common mistakes

  • Sampling the audit. 10% is fine for finding slowness. It is useless for the Pix complaint that landed in the other 90%.
  • Trace retention. Fourteen days does not cover a central-bank complaint, or a labor claim on resume screening.
  • Logging “transfer scheduled”. Without a preview, a version, and an external id, the event cannot reconstruct the action.
  • Personal data in the event. CPF, a full Pix key, and the raw request go to separate storage, with a different key. The event carries a mask and a reference. Apply PII Redaction before writing.
  • Treating the model’s 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.

Variations

  • Audit derived from traces. Decision spans are copied to the audit record with no sampling. Less code, but watch for what the trace does not store (what was shown to the human).
  • External record. A logging service with retention locked by the provider, so not even the internal team can delete.
  • Explanation generated on the spot. A short justification field written by the model alongside the proposal. Useful for reading, but not proof: the proof is what the model saw and what it proposed.

Cost, latency, and failure modes

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 +20 XP on first tryThe team adds a rationale field where the model explains why it suggested the Pix. Luana wants to use that text as proof in the complaint. What do you say?

How to evaluate

  • Coverage: every side-effect execution has a matching event. Run the reconciliation daily.
  • Reconstruction: pick actions at random and rebuild the full story from the record alone. If something is missing, the schema is incomplete.
  • Integrity: verify the hash chain periodically.
{ "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.

In production

  • Daily reconciliation between the execution system (bank, email, ERP) and the audit record.
  • An alert for any execution without an event.
  • Access to the record only for defined roles, with that access itself audited.
  • A routine to answer data subject requests: what was stored about the person and why.

Security

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 and cons

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

Code

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 event

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

Check your understanding

Check +20 XP on first tryThe team has an internal meeting summarizer, with no outbound send and no decision about people. Someone asks for the full audit trail. What do you do?

Check +20 XP on first tryWhile reconstructing a Pix, the event has actor, proposal, decision=approved, and external_id. shown_to_human is missing. What is the gap?

Check +20 XP on first tryTwo Pix transfers for the same customer write at the same time and both events point at the same prev_hash. The nightly check reports a break. What is the fix?

Check +20 XP on first tryA data subject asks, under LGPD (Brazil's privacy law), what the assistant stored about them. The audit event has the full Pix key in proposal. What was the design error?

Quick answers

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.

Which smells does the Audit Log pattern help cure?

Unversioned Prompt and No Tracing.

Where does Audit Log show up in practice?

In the real-world scenarios Personal finance assistant with approval, Resume screening with bias control, and Legal assistant.

Go further

Relations

Smells it helps cure 2
Refactorings that lead here 1
Part of the tracks 1
Search