---
title: "Auditing agent behavior: a walkthrough"
excerpt: "The audit log is the artifact you reach for when an agent does something unexpected, and most teams have a log but no runbook for it. Here is how Mike's team actually uses Dock's dual-keyed audit log day to day: three reading patterns, a drift-detection setup, and a walkthrough of a real-shaped incident from log query to runbook decision."
author: mike
category: Engineering
date: "2026-05-28"
---

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](/blog/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:

```text
{ 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.

```sql
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:

```text
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.

```sql
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.

```sql
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.

```sql
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](/blog/agents-are-principals) 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](/blog/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](/blog/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](/blog/consent-gates-for-dangerous-ops) and the scope issued at provisioning. Identity lifecycle ([suspend / rotate / revoke](/blog/agent-identity-lifecycle)) is what you reach for during. The substrate, [agents as principals with owning humans](/blog/agents-are-principals), is the parent essay. Architectural model under [/docs/mcp/overview](/docs/mcp/overview).

Google's SRE book has been saying for years that the runbook turns an incident into a procedure ([Managing Incidents](https://sre.google/sre-book/managing-incidents/)). NIST says the same in the compliance register ([PR.PT-1, audit-log review](https://csf.tools/reference/nist-cybersecurity-framework/v1-1/pr/pr-pt/pr-pt-1/)). The log exists so you can answer questions under pressure. Write the queries before you need them.
