The audit log is the artifact you reach for when an agent does something unexpected. A row got rewritten, a workspace got touched outside its scope, a teammate asks "was this me or my agent?" Every shop has a log. Very few have a runbook for it. The log sits untouched until the incident, at which point you're learning your own schema under pressure.
This is the runbook side of agent audit and compliance. That essay covers the dual-keyed model: every privileged action carries agent_principal_id and owner_user_id, written through one gate, no side doors. This is what you do with it on a Tuesday when something looks off. Three reading patterns, a drift cron that catches scope creep, a walkthrough from first query to runbook decision.
The schema we're querying, lifted straight from the audit gate:
{ ts, agent_principal_id, owner_user_id, target_id, action, scopes, consent_token, request_id }
Three reading patterns
Who did this?
You're staring at a row that changed and you don't know why. Start with the target. Every action on a resource is in the log, in order.
SELECT ts, agent_principal_id, owner_user_id, action
FROM audit_events
WHERE target_id = 'row_8e2c1f'
ORDER BY ts DESC
LIMIT 50;
The resource's full action history, dual key on every row:
2026-05-26T14:02:11Z agt_argus_qa usr_mike update_row
2026-05-26T11:47:03Z agt_scout_ops usr_mike update_row
2026-05-26T09:15:22Z NULL usr_mike create_row
Populated agent_principal_id means an agent did it. NULL means the human did it directly. The owner_user_id column tells you which human signs for it either way. That last fact does most of the work in a real investigation.
What did this agent touch?
The shape that catches scope creep. An agent meant for one job starts brushing up against work it shouldn't. Filter by principal over a window.
SELECT date_trunc('hour', ts) AS hour,
action,
COUNT(*) AS n,
COUNT(DISTINCT target_id) AS distinct_targets
FROM audit_events
WHERE agent_principal_id = 'agt_scout_ops'
AND ts > now() - interval '24 hours'
GROUP BY 1, 2
ORDER BY 1 DESC;
You're looking for surprise. New action verbs, new target classes, traffic at hours the agent shouldn't be awake. An agent whose job is "ingest CRM rows" should not be issuing update_doc on workspaces it never wrote to before.
Did the human or the AI take action X?
The "was this me or my agent" query. Same target filter, constrain the principal.
SELECT ts, owner_user_id, action
FROM audit_events
WHERE target_id = 'ws_launch_brief'
AND agent_principal_id IS NULL
ORDER BY ts DESC;
NULL principal is the proof: the action came from a human session, no agent in the chain. Drop IS NULL and you get the inverse, every agent-driven action on that workspace.
Drift detection: a cron, not a dashboard
Dashboards are for incidents in progress. Drift is the slower thing, an agent whose action mix changes over weeks. Treat it like an SLO breach: daily cron, query the audit table, page when the shape moves.
WITH weekly AS (
SELECT agent_principal_id,
action,
date_trunc('week', ts) AS wk,
COUNT(*) AS n
FROM audit_events
WHERE ts > now() - interval '14 days'
GROUP BY 1, 2, 3
),
shape AS (
SELECT agent_principal_id, wk, action,
n::float / SUM(n) OVER (PARTITION BY agent_principal_id, wk) AS pct
FROM weekly
)
SELECT this.agent_principal_id, this.action,
last.pct AS last_week, this.pct AS this_week,
(this.pct - last.pct) AS delta
FROM shape this
JOIN shape last
ON this.agent_principal_id = last.agent_principal_id
AND this.action = last.action
AND this.wk = date_trunc('week', now())
AND last.wk = date_trunc('week', now() - interval '7 days')
WHERE ABS(this.pct - last.pct) > 0.20;
Anything with a >20% shift lands in a workspace called dock/agent-drift. Most weeks it's empty. When it isn't, an owner reads the row and decides: expected (new task, shape should shift) or not (open an investigation).
Incident walkthrough: an agent off-leash
Tuesday, 14:15. A teammate flags recent edits in a workspace nobody scheduled work in. Three queries decide what we do.
Query one, target history. Pattern A on target_id = 'ws_partner_review'. Five rows in the last hour, all update_row, all from agt_ingest_crm, owner usr_govind. The agent isn't supposed to write here. It has discovery via signed-agent inheritance because the owner is a member, but its task scope is the CRM workspace.
Query two, agent behavior. Pattern B on agt_ingest_crm, last 24 hours. Normal CRM traffic plus a tail of writes to ws_partner_review starting at 13:48. New target class. Drift would have caught this on tomorrow's run; we caught it today because a human noticed.
Query three, scope vs. action. Pull scopes and consent_token for the off-target writes. Scopes valid for the surface, no consent token (not dangerous ops, see dangerous-ops-contract). Nothing strictly disallowed. The agent is acting inside its permissions but outside its job. That distinction is the whole reason this runbook exists.
Runbook says: suspend, investigate, decide. Following the agent identity lifecycle, we suspend agt_ingest_crm (key revoked, writes 403, log keeps flowing). The owner reads the conversation that produced the off-target writes, finds a prompt that ambiguously pointed the agent at "the review workspace," rewrites the task. Reactivate, narrow scope, document the near-miss.
Flag to suspension: about eight minutes. The audit log did the heavy lifting, the runbook did the rest.
Where this fits
The audit log is the after-the-fact tool. Prevention lives upstream in consent gates for dangerous operations and the scope issued at provisioning. Identity lifecycle (suspend / rotate / revoke) is what you reach for during. The substrate, agents as principals with owning humans, is the parent essay. Architectural model under /docs/mcp/overview.
Google's SRE book has been saying for years that the runbook turns an incident into a procedure (Managing Incidents). NIST says the same in the compliance register (PR.PT-1, audit-log review). The log exists so you can answer questions under pressure. Write the queries before you need them.
FAQ
Can an agent forge a NULL agent_principal_id to make its writes look like a human's?
No, because the principal columns are stamped by the audit gate from the authenticated session, not supplied by the caller. An agent presents its key, the gate resolves agent_principal_id and owner_user_id, and that is what gets written. A NULL principal means the gate saw a human session with no agent in the chain, which is why pattern C treats it as proof rather than a hint.
The incident agent did nothing strictly disallowed, so why suspend it instead of just narrowing scope quietly?
Because the runbook optimizes for stopping the bleed first and reasoning second: suspend, investigate, decide. The agent was acting inside its permissions but outside its job, and you do not yet know whether the off-target writes are one ambiguous prompt or a wider pattern. Suspension revokes the key so writes 403 while the log keeps flowing, which buys you a clean window to read the conversation before you reactivate and re-scope.
Why a >20% shift as the drift threshold, and won't legitimate new tasks trip it constantly?
The threshold is a starting line, not a verdict: a >20% week-over-week move in an action's share of a principal's mix is large enough to be worth a human glance and small enough to surface real scope creep early. Legitimate new tasks do trip it, which is the point, because the dock/agent-drift row asks an owner to classify the shift as expected or not. Most weeks the workspace is empty, so the cost of the occasional expected-shift row is a few seconds of reading.
Drift runs daily, but this incident was caught by a human first, so what is the cron actually buying me?
It buys you the cases no human happens to notice. The walkthrough agent was caught early only because a teammate flagged a workspace they watched; drift would have surfaced the same ws_partner_review tail on the next run regardless of whether anyone was looking. The cron is the floor on detection latency, the human is the occasional faster path, and you want both rather than betting that someone is always watching the right workspace.
