Skip to content

12 min read

An agent's audit trail must separate user intent from model choice

On this page

An audit record exists to answer a question after the fact. The question ordinary application logs were built for is "who did what to which object, when", and every field in this chapter's failing log line answers it correctly. The question an agent creates is a different one, and no amount of care about the existing fields will answer it: whether the human asked for the action, or the model decided on it several steps into a run while pursuing something the human did ask for. This chapter is about the fields that separate those two, where each one has to come from, and what the resulting record still cannot settle.

The support ticket said: "I never asked to cancel that subscription." The account had been on an annual plan, the cancellation had taken effect immediately, and reinstating it meant a new contract at this year's pricing, which was 14 percent higher. The customer success manager pulled the audit log and found one line.

text
2026-03-11T09:14:22Z user=u_8831 action=subscription.cancel object=sub_4419 result=success ip=10.4.19.8 ua=internal-agent/1.0

Every field in that line is accurate. It is also unable to answer the only question anyone is asking, because both explanations are consistent with it: the customer asked the assistant to cancel and now regrets it, or the customer asked the assistant to "clean up the old billing stuff" and the assistant decided that cancelling was part of cleaning up. In both, u_8831 is the principal whose authority was spent, subscription.cancel is the action, and sub_4419 is the object — so no field in the line varies between them, and a record whose fields are identical across two histories cannot distinguish them however carefully it is read. The investigation ran for nine days, involved three teams, and ended with a goodwill credit issued because nobody could establish what had happened.

Ordinary application audit logs were designed for a world where a human clicked a button, so the human's intent was implicit in the action's existence: the action could not have occurred unless somebody had asked for it, which made "did they ask" a question with no need for a field. An agent breaks that implication. The action still carries the human's authority — chapter three's sub claim means it genuinely was that user's authority being spent — and the decision to perform it may have been made several steps into a run by a model pursuing a goal it inferred. Distinguishing those two is the question agents create. Once authority and initiative can come apart, a record that captures only authority is missing half of what happened.

The fields, and what each one is for

A record that can answer it carries six groups of information beyond the timestamp. Several of the fields are correlation ids, meaning values generated once and carried unchanged through every component that touches the request, so that records written in different services can be joined back together afterwards.

GroupFieldsQuestion it answers
Principalsubject, auth_timeWhose authority was spent
Actoragent_id, agent_version, model, prompt_hashWhich software spent it, in which build
Operationtool, action, arguments_hash, arguments_redactedWhat was done
Resourceresource_type, resource_id, tenantTo which object
Authorisationdecision_id, policy_id, effect, grant_idUnder what rule it was allowed
Provenanceconversation_id, turn_id, run_id, tool_call_id, origin, input_sourcesWhere the instruction came from

Read the table against the failing log line rather than as a list of good ideas. That line already carried the principal group and the operation and resource groups, in a compressed form. The three it lacked are the actor, the authorisation and the provenance — and those are precisely the three that exist only because a piece of software with its own judgement stands between the human and the effect.

The actor group is the one that looks redundant next to the principal and is not. agent_id says which assistant acted; agent_version and prompt_hash say which build of it, and those two answer a question none of the other fields can, which is whether a run behaved the way it did because of a change somebody shipped. An investigation that finds a run of unexplained cancellations in one afternoon needs to know whether they all came from one prompt version, and with the field present that is a grouping over records rather than an argument between teams. model is there for the same reason one step further out, since the same prompt on a different model is a different system.

The last group is the one that does not exist in a normal audit log, and origin is its centre: a small enumeration recording whether this action was requested in the user's turn, chosen by the model while pursuing a request, or performed under a standing grant with no live human at all. It is derivable rather than guessed, and the derivation is mechanical because the conversation's structure already contains the answer. If the tool call appeared in the assistant message immediately following a user message that named the operation, the human named it and it is user_requested. If the run has taken three model turns since the last user message, then whatever produced the call was the model working from its own plan, and it is model_initiated. If there is no conversation at all and a grant_id is present, no human was in the loop at the time and it is scheduled_grant. Each of those reads a fact about message ordering rather than an opinion about intent.

json
{
  "ts": "2026-03-11T09:14:22.418Z",
  "subject": "u_8831",
  "auth_time": "2026-03-11T09:02:10Z",
  "agent_id": "agent:billing-assistant",
  "agent_version": "v2026-03-04",
  "model": "claude-opus-5",
  "prompt_hash": "sha256:1f0c…9ab2",
  "tool": "cancel_subscription",
  "action": "subscription.cancel",
  "arguments_hash": "sha256:77de…c410",
  "arguments_redacted": {"subscription_id": "sub_4419", "effective": "immediate"},
  "resource_type": "Subscription",
  "resource_id": "sub_4419",
  "tenant": "acct_88213",
  "decision_id": "dec_01HQ8ZM3TT",
  "policy_id": "billing.owner_required",
  "effect": "Allow",
  "grant_id": null,
  "conversation_id": "cnv_01HQ8Z9RRC",
  "turn_id": "trn_7",
  "run_id": "run_01HQ8ZK9PP",
  "tool_call_id": "toolu_01AbC",
  "origin": "model_initiated",
  "input_sources": ["user_turn:trn_3", "tool_result:mail.read#msg_5512"]
}

Two design choices in there are worth defending. Arguments are stored redacted with a hash of the full value, because tool arguments routinely contain email bodies, addresses and occasionally credentials, and an audit store that accumulates them becomes the most sensitive data store in the estate while remaining outside the retention rules that cover the real ones. The hash keeps the record useful for the question that actually gets asked in an investigation, which is whether two calls carried identical arguments — a hash answers that exactly, since identical inputs give identical digests, and it answers nothing else, which is the point.

The second is input_sources, which lists the messages a tool argument descended from. It is the same provenance tracking chapter five used as a policy input, recorded rather than only evaluated, and it is what turns "the model chose this" into "the model chose this after reading message 5512". Computing it exactly is hard; computing it as "every tool result the model saw between the last user turn and this call" is straightforward and answers most incidents, because an over-inclusive list still narrows an investigation from a whole conversation to a handful of messages.

Two writers, and which one is authoritative

The fields come from two places, which is the practical difficulty in building this. The agent framework knows the conversation, the turn, the run, the provenance and the model build, and it has no authority to say that the operation actually happened, since a tool call can be dispatched and then fail, be retried, or partially succeed. The resource server knows that the subscription was cancelled at 09:14:22.418 and knows the principal, actor, resource and decision from the token and the policy call, and it knows nothing about turns or provenance.

Resolving this by having the framework write the record is the common shortcut and it produces a log of what the agent intended, which diverges from what happened in exactly the situations an investigation cares about — a timeout after the effect landed, a retry that succeeded twice, a partial write. Every one of those is a case where the framework's view and the world's view differ, and every one of those is also a case somebody eventually opens a ticket about.

The arrangement that holds up is that the resource server writes the authoritative event, and the framework contributes the correlation and provenance fields by putting them on the wire where the resource server can copy them without interpreting them. A small header carrying conversation_id, turn_id, run_id, tool_call_id and origin does that, alongside the token that carries the fields the server must not take from a caller.

The split is the same one chapter three drew, and for the same reason. Anything that determines authority — subject, actor, grant — comes from the signed token, because the caller could otherwise write it. Anything that is descriptive and useful for correlation can come from a header, because a caller lying about its own conversation id gains nothing except a confusing investigation. The rule that keeps the split honest is a test rather than a list: if a header field ever starts influencing a decision, it has to move into the token or be verified independently, since the moment a value is consulted by a check it stops being descriptive.

Once the events land, the store should be append-only with the same discipline you would apply to a financial ledger: no updates, no deletes outside retention policy, and write access limited to the services that emit. The reason is specific to agents rather than general good practice. When the question is whether the model chose something, the people best placed to change the answer are the ones who own the agent, and a record they can edit is not evidence about their own system.

Prevention, classification, and what the store costs

The record does not prevent anything. Every field above is written after the operation, and treating a good audit trail as a control is a common way to end up with excellent documentation of an incident that the policy engine should have refused. The relationship runs the other way: decision_id in the record points at the decision that allowed it, and the value of the record is partly that it makes the policy engine's behaviour reviewable.

origin is a classification and not a proof. When a user says "handle my billing", every subsequent action is defensible as inside their request and the label model_initiated describes the mechanics rather than settling responsibility. What the record does is put the originating turn in front of a person who can read it and judge, which is a considerably better position than the single log line at the start of this chapter and less than a determination of intent.

A third limit is that the record describes actions your services performed, so an agent that acts through a third-party integration leaves a gap at exactly the boundary chapter four flagged. The vendor's own audit log shows the integration doing things, attributed to whoever installed it, with no subject and no run id. Where the integration matters, the fix is to route it through a broker of the kind the previous chapter described and write the record there, since a component you own is the only place a correlated event can be produced.

There is also a cost that shows up at scale, and the arithmetic decides the storage design. On the billing assistant these records averaged 1.4 KB and ran to roughly 40 million events a month, which multiplies out to about 56 GB before compression, and a naive setup that also stored full arguments came to nearly 400 GB — the full arguments being several times the size of the redacted record is what accounts for the difference. Splitting the store — structured records in the audit database with a two-year retention, full transcripts in a separate store with thirty days and tighter access control — keeps the queryable part small and the sensitive part short-lived, and it works because the two halves are asked different questions at different ages.

Instrumenting all of this at once is not the useful sequence, and the ordering follows from how much each field buys against what it costs to produce. The single highest-value join is decision_id, because it links what happened to the rule that allowed it, so emitting the decision id from the policy call of chapter five and carrying it into whatever audit line you already write turns an existing log into something an investigation can follow. Correlation ids come next, since conversation_id and run_id cost almost nothing and make the difference between one line and a story. Provenance is last and largest, and the coarse version earns most of its value.

What separates a usable agent audit trail from an ordinary one is that it records two things about every action rather than one: whose authority was spent, and who decided to spend it. The first was always there, because a system that could not say whose authority was spent could never have performed the action. The second is new, it is the only field that distinguishes the two histories the failing log line could not, and it is derivable from facts the framework already has.

You now have the whole set: a credential bounded by tool, resource, audience and clock; an exchange that keeps the user's session at the edge; a delegation claim carrying both principals; an originator check in every service that spends its own privilege; a policy decision at each action rather than at each session; a grant for the work that happens while the user sleeps; and a record that separates what the person asked for from what the model chose. What remains is applying it to a system that has none of it, in an order that produces a working improvement at each step rather than a six-month project — starting, in most codebases, with the eleven-line pull request from chapter two, because the cookie in the tool arguments is the thing an attacker finds first.

Agent identity and delegated authority — read the current version at https://daviddominguez.dev/learn/en/agent-identity-and-delegated-authority/an-agent-s-audit-trail-must-separate-user-intent-from-model-choice

Back to top