Skip to content
Blog

Audit Trails for Regulated Industries: Structuring Agent Events for Compliance

HIPAA, SOC 2, and financial regulators expect immutable records. How to structure agent events — who decided what, with which data — so audits pass.

Published on • October 15, 2026

AI Assistant

Ask an auditor what happened during a specific agent run and “the model decided” is not an answer. Auditors under HIPAA, SOC 2, PCI DSS, or financial-services rules want the same three things they’ve always wanted: who acted, what they did, and on what basis — with a record that can’t be quietly rewritten after the fact.

Agents compress “who” into a chain: a user triggered a run, an orchestrator dispatched a task, a model chose an action, a tool executed it, a human approved or skipped a gate. Each link in that chain is an event, and the audit trail is the structured, immutable record of those events.

This post is about the structure — the schema and the storage invariants — not the framework. We’ll use CrewAI-style concepts (agents, tasks, flows with start/listen/router steps, structured outputs, human-in-the-loop triggers) as the concrete illustration, since that’s how a lot of this is being built today.

The four questions every event must answer

If an event can’t answer all four, it isn’t audit-grade:

  1. Actor — which principal initiated this? A named user, a service account, a scheduled trigger.
  2. Action — what happened, as a typed verb (tool.call, model.generate, approval.granted, handoff), not free text.
  3. Basis — which data and which policy were in play? Not the whole prompt (that’s a privacy problem), but references: knowledge source ids, prompt version, retrieved document hashes, policy id.
  4. Outcome — success, failure, rejection, or escalation, with the resulting state.

That maps to a schema like this:

from pydantic import BaseModel, Field
from typing import Literal
import hashlib, json, datetime

class AgentEvent(BaseModel):
    event_id: str                       # ULID/UUIDv7, lexically sortable
    run_id: str                         # groups every event of one run
    prev_hash: str | None               # hash chain link (None for genesis)
    ts: datetime.datetime               # UTC, server-assigned
    actor: Literal["user", "service", "model", "orchestrator", "approver"]
    actor_id: str                       # user id / service account / agent name
    action: str                         # "task.assign", "tool.call", "approval.grant"
    target: str | None                  # what was acted upon
    basis: dict = Field(default_factory=dict)   # prompt_version, sources[], policy_id
    input_digest: str | None            # sha256 of the exact inputs
    outcome: Literal["ok", "error", "rejected", "escalated"]
    correlation: dict = {}              # trace_id / group_id back to the run

    def digest(self) -> str:
        payload = self.model_dump_json(exclude={"prev_hash"})
        return hashlib.sha256(payload.encode()).hexdigest()

Two design choices worth defending in review:

  • prev_hash creates a tamper-evident chain. Any retroactive edit breaks every subsequent hash, and an auditor can verify the chain by recomputing it. Immutability you can prove beats immutability you merely promise.
  • input_digest instead of raw input. You attest “these exact bytes were processed” without storing the PHI or card data in the trail itself. Verify by hashing the archived copy where the actual data lives under its own controls.

Structuring events across an orchestrated run

An onboarding or claims workflow isn’t one event — it’s a fan-out of tasks across systems. Structure matters most here, because this is where “who decided what” gets blurry.

Consider a CrewAI-style flow: @start receives a trigger payload, @listen handlers process it, a @router picks a branch, and state is carried in a Pydantic model between steps. Every one of those transitions emits an event:

from crewai import Flow, start, listen, router

class OnboardingFlow(Flow[OnboardingState]):
    @start()
    def intake(self):
        emit(AgentEvent(
            actor="user", actor_id=self.state.requester_id,
            action="flow.start", target="onboarding",
            basis={"trigger": "hris.webhook", "policy_id": "HR-ONB-004"},
            outcome="ok", correlation={"run_id": self.state.run_id},
        ))
        return self.state

    @router("intake")
    def classify(self):
        decision = classify_risk(self.state)   # model call
        emit(AgentEvent(
            actor="model", actor_id="classifier-v3",
            action="model.decision", target="risk_tier",
            basis={"prompt_version": "v3.2", "sources": decision.source_ids},
            input_digest=sha256_of(decision.inputs),
            outcome="ok", correlation={"run_id": self.state.run_id},
        ))
        return decision.tier

    @listen("$approve")
    def gate(self, decision):
        if not decision.requires_human:
            return                       # silent skip is an audit failure
        emit(AgentEvent(actor="approver", actor_id=self.approver,
                        action="approval.await", outcome="escalated", ...))

The critical line is the comment: never let a skipped gate be silent. If a risk tier routes around human approval, that bypass is itself an event with an actor (the policy) and a basis (the rule version). Auditors specifically look for controls that exist on paper but were routed around in practice.

Three structural rules that survive most audits:

  • Emit at the boundary, not inside business logic. Centralize emission in a wrapper around tool execution and model calls. Hand-rolled emit() calls inside every function drift the moment someone adds a feature.
  • One run, one run_id, plus the framework’s trace/group id. CrewAI flows persist execution state and can resume long-running workflows; OpenAI Agents SDK traces carry a group_id to link traces from the same conversation. Correlate on both so you can reconstruct a run that spans resumptions.
  • Human decisions are first-class events. An approval that lives only in Slack history isn’t in your trail. Record the approval decision, the approver identity, the artifact version approved, and the timestamp as an approval.grant event.

Immutability, retention, and access

Schema gets you the first half. Storage invariants get you the second.

Write-once storage. Append-only tables with no UPDATE/DELETE grants, object-lock (WORM) buckets, or an append-only log with restricted write credentials. SOC 2 reviewers ask to see the access controls on the log itself, not just the app code.

Clock discipline. Server-assigned UTC timestamps, because client clocks lie. If a regulator asks “was this approval before or after the transfer?”, the answer must not depend on a container’s NTP state.

Retention aligned to policy. HIPAA generally wants six years for required documentation; SOC 2 monitoring evidence is typically evaluated over at least six months of coverage. Pick retention per event class — decision events live long, high-volume generation payloads may live short — and make the deletion job itself emit an event.

Access with separation of duties. The team operating the agents shouldn’t be able to rewrite their history. Read access for auditors, write access for the pipeline only, and log reads of the log.

Redaction, not omission. Where events must carry sensitive context, encrypt the payload with a key the audit function controls. Stripping data entirely means the trail can’t answer “which data”; encrypting it means only the right people can.

Common pitfalls

  • Storing the whole prompt in every event. It feels thorough; it converts your audit log into a regulated data store subject to the strictest controls. Prefer digests plus a separately-governed prompt archive.
  • Free-text actions. "did something with the claim" cannot be queried. Typed verbs with a versioned enum can.
  • Logging only failures. Controls testing asks for evidence the control ran, which means successful approvals and normal-path decisions matter as much as errors.
  • Mutable metadata. Enrichment fields that get backfilled later destroy the “as-of” guarantee. If you must add context, append a new event; never edit the old one.
  • Framework trace as the only record. Traces are for debugging and can be disabled (OPENAI_AGENTS_DISABLE_TRACING=1) or unavailable under zero-data-retention policies. Audit events belong in your own governed store, independent of any vendor’s tracing dashboard.
  • Forgetting the trigger. The event that started the run — a webhook, a cron job, a human click — is the first domino. No trigger event means the run’s origin is unexplained.

Where to start

Pick your highest-risk workflow and give it full coverage: trigger, classification, every tool call, every approval (including bypasses), and final outcome — chained with hashes, stored append-only, with a run_id that lets an auditor pull the whole story in one query. Then prove it: have someone try to alter an old event and show the verification script failing.

That demonstration is usually the difference between “we have logging” and “we have an audit trail.”

Sources