Building an LLM Audit Trail That Passes SOC 2
An append-only Ecto schema for LLM calls, the fields an auditor actually samples, and why provider request IDs are what makes an entry defensible.
TL;DR: Every SOC 2 guide that mentions LLMs stops at policy language — “log access to AI systems” — and never shows you the table. Here’s the table. An append-only
llm_audit_eventsschema in Ecto, the exact fields that make an entry defensible in an auditor’s sample versus decorative, and the field most teams skip that ends up saving them in the room: the provider’s own request ID. Also covered honestly: whether you have to log the raw prompt (usually yes, with a redaction step, not a blanket no), and how long to keep any of it before “audit trail” becomes “unmanaged PII warehouse.”
The instrumentation point is the same call, different consumer
Metering LLM usage per
customer in Phoenix covers the append-only ledger you build to bill
a tenant for token usage — integer micro-dollars, idempotent writes
keyed on a provider request ID, never :float. This post
instruments the same outbound call, but the record it produces
answers a different question and lives under different rules. A billing
ledger exists to defend an invoice line for as long as you keep
customers; it can be thin — tenant, tokens, cost, done. An audit trail
exists to defend a claim about control, to a third party you
didn’t choose, on a schedule you don’t control, and it has to survive
scrutiny of who called what, with what input, under whose authority, for
as long as your retention policy says it does. Same
:telemetry hook. Different table, different columns,
different retention clock.
There’s a third consumer of that same call worth separating out. The agent evidence pack for a security review specifies the tool-call record as a reviewer-facing artifact — the field set you hand someone evaluating your product, and how to generate it so it can’t drift from the code. This post is the storage side of that record: the schema it lives in, the retention clocks it runs on, and what an auditor pulls out of it. Read that one for what to show; read this one for what to build.
If you haven’t wired the underlying telemetry yet, the ReqLLM integration
walkthrough covers the event shapes this post assumes — specifically
[:req_llm, :request, :stop], whose metadata already carries
request_id, provider, model, and
finish_reason before you’ve written a line of your own
code.
What SOC 2 actually asks you to log
The reframe I give founders heading into their first SOC 2 is that there is no “AI” criterion to satisfy. There are criteria that predate LLMs by a decade and apply to them without modification, and knowing which ones is what separates a defensible answer from a guess in the audit room. SOC 2 as a revenue tool rather than a security project is why you’re in the room at all; this is the artifact that has to survive once you’re there.
CC7.2 (System Operations) requires the entity to monitor system components for anomalies and analyze them to determine whether they’re security events — this is the criterion an LLM call log most directly satisfies, because a prompt-injection attempt or a call from a deprovisioned service account is the anomaly it’s asking you to be able to detect. The criterion’s own words are “monitors system components and the operation of those components for anomalies that are indicative of malicious acts, natural disasters, and errors” (AICPA, TSP Section 100, 2017 Trust Services Criteria). CC6.1 and CC6.3 (Logical and Physical Access Controls) govern who’s authorized to trigger the call in the first place, which is why “calling user or tenant” is a required column, not an optional one — an audit log with no actor attached can’t prove access was restricted to authorized parties. CC7.3 (System Operations) picks up where CC7.2 stops: it covers evaluating the security events CC7.2 surfaced to determine whether they rise to security incidents — a failure to meet your objectives — and taking action if they do. That evaluation only works if the log entry carries enough context (input, output, timestamp, actor) to judge against.
None of that is AI-specific. What’s new is the volume and the surface area: a REST API you called twelve times a day now gets called on every user keystroke, through an SDK whose retry behavior you don’t fully control, against a vendor whose model version changes under you. The criteria didn’t change. The number of events that need to satisfy them did. Controlling what you send in the first place is the other half of that: throttling against the provider’s real TPM and RPM ceilings keeps the 429 from happening at all.
An append-only Ecto schema for LLM audit events
Same discipline as the billing ledger — no update, ever,
corrections are new rows — but a wider column set, because the auditor
isn’t sampling for a dollar amount, they’re sampling for a story: who
did what, to which model, with what result.
create table(:llm_audit_events,
primary_key: false) do
add :id, :binary_id, primary_key: true
add :occurred_at, :utc_datetime_usec,
null: false
add :actor_id, :binary_id, null: false
add :actor_type, :string, null: false
add :tenant_id, :binary_id, null: false
add :provider, :string, null: false
add :model, :string, null: false
add :provider_request_id, :string,
null: false
add :input_tokens, :integer, null: false
add :output_tokens, :integer, null: false
add :latency_ms, :integer, null: false
add :tool_calls, {:array, :map},
default: []
add :finish_reason, :string
add :prompt_digest, :string, null: false
add :prompt_redacted, :text
add :outcome, :string, null: false
timestamps(updated_at: false)
end
create unique_index(
:llm_audit_events,
[:provider, :provider_request_id],
name: :llm_audit_events_provider_request_idx
)
create index(
:llm_audit_events,
[:tenant_id, :occurred_at]
)
create index(
:llm_audit_events, [:actor_id]
)prompt_digest (a SHA-256 of the raw prompt) always gets
written, even when prompt_redacted doesn’t — it lets you
prove which prompt produced a given output, and lets you
re-associate a customer complaint with an exact call, without keeping
the raw text around by default. More on that split below.
The Ecto schema, with the invariant a future reader needs written where they’ll see it:
defmodule MyApp.Audit.LLMEvent do
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id,
autogenerate: true}
schema "llm_audit_events" do
field :occurred_at, :utc_datetime_usec
field :actor_id, Ecto.UUID
field :actor_type, :string
field :tenant_id, Ecto.UUID
field :provider, :string
field :model, :string
field :provider_request_id, :string
field :input_tokens, :integer
field :output_tokens, :integer
field :latency_ms, :integer
field :tool_calls, {:array, :map},
default: []
field :finish_reason, :string
field :prompt_digest, :string
field :prompt_redacted, :string
field :outcome, :string
timestamps(updated_at: false)
end
# append-only: no update_changeset/2 exists
# on this module. Corrections are new rows
# with :outcome set to "superseded".
@fields ~w(occurred_at actor_id actor_type
tenant_id provider model
provider_request_id input_tokens
output_tokens latency_ms
tool_calls finish_reason
prompt_digest prompt_redacted
outcome)a
def changeset(event, attrs) do
event
|> cast(attrs, @fields)
|> validate_required(@fields -- [
:prompt_redacted, :finish_reason
])
|> unique_constraint(
:provider_request_id,
name: :llm_audit_events_provider_request_idx
)
end
endWriting this from inside the same call wrapper the billing ledger
uses is deliberate, not laziness — one project-owned
MyApp.LLM.call/3 that requires actor_id and
tenant_id as arguments, emits one billing row and one audit
row from the same :telemetry handler, off the same event.
Two consumers, one instrumentation point, no code path that reaches the
provider without both attached.
Why the provider request ID is the field that saves you
The provider request ID is the join key that lets you prove your log
matches the vendor’s log, which is the difference between an entry an
auditor believes and one they don’t. Every major provider stamps a
request ID on its response — x-request-id from OpenAI,
request-id from Anthropic, x-amzn-requestid
from Bedrock — and ReqLLM already surfaces it in the
[:req_llm, :request, :stop] metadata as
request_id, so capturing it costs you nothing beyond a
column. One gotcha carried over from that post: model
arrives as an LLMDB.Model struct rather than a string, so
take .id before it reaches the changeset.
Here’s the failure mode it prevents. An auditor doing Type 2 testing
doesn’t take your word for a control’s operating effectiveness across
the review period — they pull a sample of events and check each one has
proof. If their sample includes an LLM call and your row has no way to
independently verify it happened as described, you’re asking them to
trust your application’s own account of itself, which is close to the
definition of an unauditable control. A provider_request_id
gives a reviewer something to check that you didn’t author. How strong
that check is depends on the provider. On Bedrock it’s the best case: model
invocation logging writes each call’s requestId,
calling principal ARN, and input/output token counts to CloudWatch Logs
or S3 — though it is disabled by default, so turn it on before you need
it rather than after. With OpenAI and Anthropic the path is narrower:
the ID is what their support resolves a disputed call against, and your
token counts still reconcile against the usage they invoice you for.
Either way your row stops being a claim and becomes a claim with a
witness.
A row with a provider request ID is a claim with a witness. A row without one is just your application’s word for what happened.
It’s also the field that makes incident response tractable under CC7.2/CC7.3: “this model call misbehaved” becomes “here’s the exact provider-side request, escalate it to their support with this ID” instead of “here’s an application log we hope corresponds to something.”
Do you have to log the prompt?
Usually yes, but not the raw text by default — you need enough to
reconstruct what happened without warehousing customer PII you have no
retention plan for. The prompt_digest /
prompt_redacted split is the practical answer: hash every
prompt unconditionally (cheap, reversible only with the original text,
and enough to prove which input produced which output), and only store
redacted or truncated prompt text when your data-handling policy
explicitly allows it for that tenant and that data class.
Redaction in practice means running a PII scrubber (structured field
masking for known formats — emails, SSNs, card numbers — plus a
conservative regex pass) on the prompt before it’s written to
prompt_redacted, and treating anything you can’t
confidently classify as “don’t store it, keep the digest.” This is a
judgment call your data classification policy should make explicit, not
one an engineer should default on ad hoc per feature — which is the same
governance gap running
security at an AI-native company argues costs teams the most when
it’s left implicit.
The trap to avoid is the binary version of this decision: “we log everything” turns your audit trail into the biggest unencrypted PII surface in the company, and “we log nothing” turns it into an audit trail an auditor can’t actually use. The digest-plus-conditional-redaction split is what lets both things be true — you can prove exactly which prompt produced a disputed output when you’re authorized to look, and you’re not carrying raw customer text you never needed for anything but that one dispute.
Retention: the tension nobody writes down
Retain audit records only as long as your documented policy says, and
no policy should say “forever” — SOC 2 doesn’t mandate a duration. If
your report includes the Confidentiality or Privacy categories, C1.1 and
P4.2 put you on the hook to define a retention period and dispose on it,
and an auditor will check your documented period against what’s actually
in the table. If you scoped Security only — which most startups do —
nothing forces the question, which is exactly why teams never answer it
and end up with a five-year-old prompt table nobody owns. A common,
defensible split: prompt_digest, token counts, actor, and
provider_request_id (the audit-value columns) live for the
length of your audit window plus a buffer — 13 months covers a Type 2
period with room to spare — while prompt_redacted gets a
shorter clock, often 30–90 days, because raw text is the
highest-liability column in the table and the one with the least ongoing
audit value once the immediate incident-response window has closed.
Build the shorter clock as an actual job, not a policy document nobody automates:
defmodule MyApp.Audit.PurgeRedactedPrompts do
use Oban.Worker, queue: :maintenance
import Ecto.Query
@retention_days 60
@impl Oban.Worker
def perform(_job) do
cutoff = DateTime.utc_now()
|> DateTime.add(-@retention_days, :day)
from(e in MyApp.Audit.LLMEvent,
where: e.occurred_at < ^cutoff,
where: not is_nil(e.prompt_redacted)
)
|> MyApp.Repo.update_all(
set: [prompt_redacted: nil]
)
:ok
end
endNotice this nulls prompt_redacted and leaves everything
else in the row — the digest, the actor, the request ID, the token
counts all survive, because those are the columns that carry audit value
across the full retention window. The record isn’t deleted; the
liability is.
This is the one sanctioned mutation of an otherwise append-only
table, and it’s worth writing down as an invariant rather than
rediscovering it during an audit: the purge job may null
prompt_redacted and nothing else. No column an auditor
samples is ever rewritten. If you want the database holding that line
instead of code review, a column-level grant or a Postgres rule will do
it.
What does an auditor actually ask for?
A sample of events across the review period, and for each one: proof the actor was authorized, proof the call happened as logged, and proof an anomaly (if any occurred) was evaluated per your documented process. What makes a sampled row pass or fail comes down to whether each field is independently checkable or just asserted.
| Field | Why it’s captured | What it proves to an auditor |
|---|---|---|
actor_id / actor_type |
Ties every call to an authorized identity, satisfying CC6.1/CC6.3 | Access was restricted to authorized parties, not anonymous or shared credentials |
provider_request_id |
Vendor-issued, independently verifiable | The call happened as described — not just your application’s word |
occurred_at (usec precision) |
Correlates with vendor logs, rate-limit windows, and incident timelines | The sequence of events is reconstructable, not approximate |
tenant_id |
Scopes the call to a customer/data boundary | No cross-tenant data exposure in a multi-tenant system |
prompt_digest |
Hash of the exact input, always written | Which input produced which output, without storing raw text by default |
finish_reason / outcome |
Flags truncation, refusal, or error states | Anomalies were captured, not silently dropped (CC7.2) |
input_tokens / output_tokens |
Sizes the interaction | Corroborates cost and volume against provider billing, an independent cross-check |
A row missing provider_request_id or
actor_id isn’t wrong, exactly — it’s unfalsifiable, and an
auditor’s job is to reject unfalsifiable evidence.
What makes an entry defensible instead of decorative
The short version: every field a stranger could independently check,
versus every field that only your own application vouches for. A log
statement that says "LLM call succeeded" in your Sentry
breadcrumbs is decorative — it’s true only because your code says it’s
true. A row with a provider request ID, an authenticated actor, a token
count that reconciles against the vendor’s own invoice, and a timestamp
precise enough to sequence against a rate-limit event is defensible,
because three of those four fields can be checked against a source you
don’t control.
This is the same distinction security controls that assume a team you don’t have makes about controls generally — a policy document is decorative until something makes it operational, and an audit trail is the specific place that gap shows up first, because it’s the artifact the auditor actually opens. If you’re heading into a SOC 2 process for the first time and want a second set of eyes on which controls in your stack are load-bearing versus theater before the auditor tells you, that’s exactly the kind of gap review I do as a fractional CTO / vCISO for AI startups — cheaper to find in a working session than in a finding.
Read this next
If SOC 2 readiness for an AI product is new territory generally, surviving enterprise security reviews as an AI startup covers the broader review a prospect’s security team runs before signing, of which an audit trail like this one is a single, load-bearing piece.