What a complete AI agent audit log looks like.
Most agent logs record that something happened. A log that survives a security review records who asked, which definition the number resolved through, the statement that ran, and the rows it touched, for the calls that were refused as well as the ones that were not. Here is the full schema, the DDL, three worked entries and the SOC 2 mapping.
Five questions. A log that cannot answer all five is a metrics feed.
These are not compliance questions. They are the questions that arrive in the hour after someone notices a number is wrong, and the log either answers them in a minute or the answer takes a week of reconstruction.
Who asked?
The agent identity, the person it acted for, and the human accountable for it. Three different people, and usually only one of them is in the room.
What metric resolved?
Which definition, at which version. Without it you know a query ran and not whether it computed the right thing.
What SQL ran?
Verbatim, after policy injection. A paraphrase does not answer the question anyone actually asks.
What rows did it touch?
Tables, columns, row count, and for a write, the state before and after. This is the containment question.
What was refused?
The evidence that an access control works is the attempt it stopped. A log of successes evidences nothing.
The second one carries more weight than it looks. Recording that a query ran tells you nothing about whether it computed the right thing, and the metrics where that distinction bites are the ones your own teams already disagree about. Grocery's five hardest metrics is what those definitions look like written down, and metric_key and metric_version below are where the log records which one was used.
The fourth and fifth are where most implementations stop. A log that records the prompt and the response is an LLM trace, and it is useful for debugging a model. It cannot tell you whether an agent read a table it should not have, because it never knew which tables there were.
Fifty-seven fields, in the order a reviewer reads them.
Grouped by the question each group answers. The third column is what the field is for, not a restatement of its name.
The record itself
Four fields before the interesting ones. They exist so that a log which has been exported, merged with another tenant's, and re-imported two years later is still unambiguous about what each row is.
| Field | Type | What it answers |
|---|---|---|
| event_id | VARCHAR(64) | The primary key. Quoted in an incident ticket and resolvable forever. |
| event_time | TIMESTAMPTZ | When the call was evaluated, with an offset. A log stored in local time cannot be merged with anything. |
| tenant_id | VARCHAR(64) | Present on every row even in a single-tenant deployment, so exports never need re-keying. |
| schema_version | SMALLINT | Which version of this schema wrote the row. A log that spans a schema change without saying so is a log you cannot query. |
Who asked
An agent is not a seat. The record has to carry the identity that ran, the person it ran for where there was one, and the human who owns the identity, because those are three different people and only one of them is in the room when something goes wrong.
| Field | Type | What it answers |
|---|---|---|
| actor_type | VARCHAR(16) | agent, human or service. The single most useful filter on the table. |
| actor_id | VARCHAR(128) | The identity that made the call. For an agent this is the agent identity, never the person who created it. |
| actor_display | VARCHAR(256) | Human-readable name at the time of the event, so a renamed agent does not rewrite its history. |
| actor_owner_id | VARCHAR(128) | The person accountable for this agent. Who you call, not who triggered it. |
| on_behalf_of_id | VARCHAR(128) | The person whose request started the chain, where one did. Their access narrows the call and can never widen it. |
| auth_method | VARCHAR(32) | oidc, saml or agent_credential. Distinguishes an interactive session from a scheduled one. |
| client | VARCHAR(32) | mcp, console, chat or scheduled. Which door the call came through. |
| session_id | VARCHAR(64) | Groups the calls of one conversation or one scheduled run. |
| source_ip | VARCHAR(45) | IPv4 or IPv6. Present for interactive clients, null for in-tenant schedules. |
What was asked
The question in the words it arrived in, and the tool it was routed to. Keep the natural language: six months later the SQL tells you what ran and only the question tells you why anyone wanted it.
| Field | Type | What it answers |
|---|---|---|
| request_id | VARCHAR(64) | One call. Returned to the client so a user can quote it in a ticket. |
| parent_request_id | VARCHAR(64) | The call that spawned this one. This is what makes an agent run reconstructable rather than a pile of unrelated queries. |
| tool_name | VARCHAR(64) | query_metric, run_sql, write_back and so on. Null for console activity. |
| question_text | TEXT | The prompt or question as received. |
| question_redacted | BOOLEAN | Whether a classifier stripped identifiers from question_text before it was stored. |
What metric resolved
The field that separates a governed call from a database query. A log that records the SQL but not the definition it came from cannot answer whether the agent computed the right thing, only that it computed something.
| Field | Type | What it answers |
|---|---|---|
| metric_key | VARCHAR(128) | The metric that was resolved, for example fill_rate. |
| metric_version | VARCHAR(32) | The version of that definition in force at the time. Without it, a number recomputed after a definition change cannot be reproduced. |
| semantic_version | VARCHAR(32) | The version of the semantic layer as a whole, so a whole quarter of answers can be pinned to one state. |
| grain | JSONB | The dimensions grouped by. A fill rate by store and a fill rate by supplier are different answers to the same question. |
| filters | JSONB | Caller-supplied filters, kept separate from policy filters below. The distinction matters: one is a choice and one is a constraint. |
| resolution_path | VARCHAR(32) | semantic_layer or raw_sql. Counting raw_sql over time tells you whether your semantic layer is actually load-bearing. |
What was decided
Every call, including the refused ones. A log that only records what succeeded cannot evidence an access control, because the evidence that a control works is the attempt it stopped.
| Field | Type | What it answers |
|---|---|---|
| decision | VARCHAR(16) | allow, deny or partial. Partial means rows came back with a policy filter or a mask applied. |
| decision_stage | VARCHAR(32) | warehouse_grant, agent_scope, row_policy or approval. Which of the layers refused it, which is the first thing you need when someone says access is broken. |
| policy_id | VARCHAR(64) | The policy that decided. Resolvable to the approved diff that created it. |
| policy_version | VARCHAR(32) | Pins the decision to the policy text in force at the time, not the text today. |
| denied_reason | TEXT | In the words the caller was given, so the log and the client agree about what happened. |
| scopes_applied | JSONB | The scopes the identity held when the call was evaluated. |
| row_filters_applied | JSONB | Predicates the policy plane injected. The proof that a restriction was enforced rather than assumed. |
| columns_masked | JSONB | Columns returned masked. The answer to "did that agent ever see a customer identifier". |
What ran, and what rows it touched
The statement verbatim, and the objects underneath it. Store the SQL text, not a summary of it. The one question that arrives under time pressure is what exactly did this thing run, and a paraphrase does not answer it.
| Field | Type | What it answers |
|---|---|---|
| sql_text | TEXT | The statement as executed, after policy injection. This is the field a reviewer asks for by name. |
| sql_hash | CHAR(64) | SHA-256 of the normalised statement, so identical queries group without scanning a text column. |
| source_system | VARCHAR(64) | Which connected source executed it. |
| tables_read | JSONB | Fully qualified names. Array, because a single metric resolution usually joins three or four. |
| columns_read | JSONB | Column-level reach. Required for any question about a specific sensitive field. |
| rows_returned | BIGINT | How much left the warehouse. A spike here on a read-only agent is the signal worth alerting on. |
| bytes_scanned | BIGINT | What it cost to answer, in the unit your warehouse bills in. |
| rows_written | BIGINT | Zero for every read-only identity, which makes a non-zero value trivially alertable. |
| write_target | VARCHAR(256) | The system and object written to. |
| write_before | JSONB | Prior state of the rows touched. Without it a write is not reversible and not reviewable. |
| write_after | JSONB | State after the write. |
| approval_required | BOOLEAN | Whether this action needed a human. Every write is true. |
| approver_role | VARCHAR(64) | The role the grant named. The control is the role, not the individual. |
| approver_id | VARCHAR(128) | The person who approved. Null on an approval_required row is an exception, not a gap. |
| approved_at | TIMESTAMPTZ | When. The gap to event_time tells you whether approval is a real gate or a reflex click. |
| model_provider | VARCHAR(64) | anthropic, openai, google or a private endpoint. Needed to answer where inference happened. |
| model_name | VARCHAR(128) | The exact model. A change in answer quality is usually a change here. |
| model_version | VARCHAR(64) | The snapshot or revision behind that name, because a model name is a moving target and a reproduction attempt needs the pin. |
| prompt_tokens | INTEGER | Input tokens. This and the next field are what the 5% compute fee is metered on. |
| completion_tokens | INTEGER | Output tokens. |
| outcome | VARCHAR(16) | success, error or denied. Distinct from decision: a call can be allowed and still fail. |
| error_code | VARCHAR(64) | Stable code, not a message. Messages change and break every saved query built on them. |
| duration_ms | INTEGER | End to end, including policy evaluation and inference. |
| record_hash | CHAR(64) | SHA-256 over the record and the previous hash. Makes a silent edit detectable. |
| prev_record_hash | CHAR(64) | The chain link. Null on the first record of a tenant. |
Copy this into your own warehouse.
Postgres dialect, with the substitutions for Snowflake, BigQuery and Databricks underneath. Take it whether or not you ever run Ward: a place to put agent events is worth having before you need one.
-- Agent audit event. One row per call, whether it was allowed, refused or failed.
-- Postgres dialect. See the type substitutions below for Snowflake, BigQuery
-- and Databricks.
CREATE TABLE agent_audit_event (
---- The record ------------------------------------------------------------
event_id VARCHAR(64) NOT NULL,
event_time TIMESTAMPTZ NOT NULL,
tenant_id VARCHAR(64) NOT NULL,
schema_version SMALLINT NOT NULL DEFAULT 1,
---- Who asked -------------------------------------------------------------
actor_type VARCHAR(16) NOT NULL,
actor_id VARCHAR(128) NOT NULL,
actor_display VARCHAR(256),
actor_owner_id VARCHAR(128),
on_behalf_of_id VARCHAR(128),
auth_method VARCHAR(32) NOT NULL,
client VARCHAR(32) NOT NULL,
session_id VARCHAR(64),
source_ip VARCHAR(45),
---- What was asked --------------------------------------------------------
request_id VARCHAR(64) NOT NULL,
parent_request_id VARCHAR(64),
tool_name VARCHAR(64),
question_text TEXT,
question_redacted BOOLEAN NOT NULL DEFAULT FALSE,
---- What metric resolved --------------------------------------------------
metric_key VARCHAR(128),
metric_version VARCHAR(32),
semantic_version VARCHAR(32),
grain JSONB,
filters JSONB,
resolution_path VARCHAR(32),
---- What was decided ------------------------------------------------------
decision VARCHAR(16) NOT NULL,
decision_stage VARCHAR(32),
policy_id VARCHAR(64),
policy_version VARCHAR(32),
denied_reason TEXT,
scopes_applied JSONB,
row_filters_applied JSONB,
columns_masked JSONB,
---- What ran --------------------------------------------------------------
sql_text TEXT,
sql_hash CHAR(64),
source_system VARCHAR(64),
---- What rows it touched --------------------------------------------------
tables_read JSONB,
columns_read JSONB,
rows_returned BIGINT,
bytes_scanned BIGINT,
rows_written BIGINT NOT NULL DEFAULT 0,
write_target VARCHAR(256),
write_before JSONB,
write_after JSONB,
---- Approval --------------------------------------------------------------
approval_required BOOLEAN NOT NULL DEFAULT FALSE,
approver_role VARCHAR(64),
approver_id VARCHAR(128),
approved_at TIMESTAMPTZ,
---- Model -----------------------------------------------------------------
model_provider VARCHAR(64),
model_name VARCHAR(128),
model_version VARCHAR(64),
prompt_tokens INTEGER,
completion_tokens INTEGER,
---- Outcome ---------------------------------------------------------------
outcome VARCHAR(16) NOT NULL,
error_code VARCHAR(64),
duration_ms INTEGER,
---- Tamper evidence -------------------------------------------------------
record_hash CHAR(64) NOT NULL,
prev_record_hash CHAR(64),
CONSTRAINT pk_agent_audit_event
PRIMARY KEY (event_id),
-- Enumerations are constrained rather than documented. A free-text status
-- column is the reason every log eventually contains both 'deny' and
-- 'denied', and every saved query built on it is quietly wrong.
CONSTRAINT ck_actor_type
CHECK (actor_type IN ('agent', 'human', 'service')),
CONSTRAINT ck_client
CHECK (client IN ('mcp', 'console', 'chat', 'scheduled', 'api')),
CONSTRAINT ck_decision
CHECK (decision IN ('allow', 'deny', 'partial')),
CONSTRAINT ck_decision_stage
CHECK (decision_stage IS NULL OR decision_stage IN
('warehouse_grant', 'agent_scope', 'row_policy', 'approval')),
CONSTRAINT ck_resolution_path
CHECK (resolution_path IS NULL OR resolution_path IN ('semantic_layer', 'raw_sql')),
CONSTRAINT ck_outcome
CHECK (outcome IN ('success', 'error', 'denied')),
-- A write that nobody approved must not be storable. This is the control
-- expressed as a constraint rather than as a policy somewhere else, so a bug
-- in the writer fails loudly instead of producing an unreviewable row.
CONSTRAINT ck_write_has_approver
CHECK (rows_written = 0 OR (approver_id IS NOT NULL AND approved_at IS NOT NULL)),
-- A refusal must say why. An empty denied_reason is the field a reviewer
-- asks about and the one nobody can reconstruct later.
CONSTRAINT ck_deny_has_reason
CHECK (decision <> 'deny' OR denied_reason IS NOT NULL)
);
-- The four access patterns this table is actually read by.
CREATE INDEX ix_aae_tenant_time ON agent_audit_event (tenant_id, event_time DESC);
CREATE INDEX ix_aae_actor_time ON agent_audit_event (actor_id, event_time DESC);
CREATE INDEX ix_aae_request_chain ON agent_audit_event (request_id, parent_request_id);
CREATE INDEX ix_aae_denied ON agent_audit_event (tenant_id, event_time DESC)
WHERE decision <> 'allow';
-- Containment questions ("did anything read this table") are asked against the
-- JSON array, so index it for containment rather than scanning.
CREATE INDEX ix_aae_tables_read ON agent_audit_event USING GIN (tables_read jsonb_path_ops);
COMMENT ON TABLE agent_audit_event IS
'One row per agent or human call against governed data. Append only: rows are never updated or deleted, and retention is enforced by dropping partitions.';
Two of the constraints are the controls rather than documentation of them. ck_write_has_approver makes a write with no approver unstorable, so a bug in the writer fails loudly instead of producing a row nobody can review. ck_deny_has_reason does the same for a refusal with no stated reason, which is the field a reviewer asks about and the one nobody can reconstruct six months later.
Type substitutions
| Postgres | Snowflake | BigQuery | Databricks SQL |
|---|---|---|---|
| TIMESTAMPTZ | TIMESTAMP_TZ | TIMESTAMP | TIMESTAMP |
| JSONB | VARIANT | JSON | STRING, or VARIANT on Runtime 15.3 and later |
| VARCHAR(n) | VARCHAR(n) | STRING | STRING |
| CHAR(64) | VARCHAR(64) | STRING | STRING |
| BIGINT | NUMBER(38,0) | INT64 | BIGINT |
| BOOLEAN | BOOLEAN | BOOL | BOOLEAN |
| GIN index | Not required | Not required | Not required |
Partition on event_time and enforce retention by dropping partitions rather than by deleting rows. An append-only table that something issues DELETEs against is not append-only, and the hash chain is the thing that makes the difference detectable.
Three events: one allowed, one refused, one written.
Consecutive records from one tenant. The hash chain links them, so the second entry carries the first entry's hash and a gap is visible.
An allowed metric resolution
A scheduled agent asks for fill rate by store. It is in scope, the row policy narrows the scan to two regions, and the SQL that produced the number is stored with it.
{
"event_id": "evt_01JX8S3H9P",
"event_time": "2026-09-19T06:15:04.881Z",
"tenant_id": "tn_northfield",
"schema_version": 1,
"actor_type": "agent",
"actor_id": "agent:replenishment-watch",
"actor_display": "Replenishment watch",
"actor_owner_id": "user:priya.raman",
"on_behalf_of_id": null,
"auth_method": "agent_credential",
"client": "mcp",
"session_id": "ses_01JX8S3H2A",
"source_ip": null,
"request_id": "req_01JX8S3H9N",
"parent_request_id": null,
"tool_name": "query_metric",
"question_text": "fill rate by store for the last 7 days",
"question_redacted": false,
"metric_key": "fill_rate",
"metric_version": "4",
"semantic_version": "2026.09.14",
"grain": ["store"],
"filters": {"date": {"gte": "2026-09-12", "lt": "2026-09-19"}},
"resolution_path": "semantic_layer",
"decision": "partial",
"decision_stage": "row_policy",
"policy_id": "pol_7f3a91",
"policy_version": "11",
"denied_reason": null,
"scopes_applied": ["metrics:read", "metrics:query", "catalog:read"],
"row_filters_applied": ["region IN ('NORTH','CENTRAL')"],
"columns_masked": ["retail.sales.fct_pos_line.loyalty_id"],
"sql_text": "SELECT s.store_key, SUM(l.units_delivered)::numeric / NULLIF(SUM(l.units_ordered), 0) AS fill_rate FROM retail.sales.fct_pos_line l JOIN retail.sales.dim_store s ON s.store_key = l.store_key WHERE l.delivery_date >= '2026-09-12' AND l.delivery_date < '2026-09-19' AND l.region IN ('NORTH','CENTRAL') GROUP BY s.store_key",
"sql_hash": "8f14e45fceea167a5a36dedd4bea2543b9b1f2ec4d0a55b1c4e8a7f0d3b26c91",
"source_system": "snowflake:prod",
"tables_read": ["retail.sales.fct_pos_line", "retail.sales.dim_store"],
"columns_read": ["units_delivered", "units_ordered", "delivery_date", "region", "store_key"],
"rows_returned": 148,
"bytes_scanned": 41930752,
"rows_written": 0,
"write_target": null,
"write_before": null,
"write_after": null,
"approval_required": false,
"approver_role": null,
"approver_id": null,
"approved_at": null,
"model_provider": "anthropic",
"model_name": "claude-opus-5",
"model_version": "2026-05-01",
"prompt_tokens": 1180,
"completion_tokens": 216,
"outcome": "success",
"error_code": null,
"duration_ms": 2140,
"record_hash": "b1946ac92492d2347c6235b4d2611184f1b3f4a0e8d2c6a7b5e93f81c07d2a55",
"prev_record_hash": "5d41402abc4b2a76b9719d911017c59284d1e2f3a6b7c8d9e0f1a2b3c4d5e6f7"
}
A refusal
The same agent asks for margin by customer. The metric is not in its scope, so nothing is planned and no SQL runs. The refusal is a full record with its own id, and it is the row that evidences the control.
{
"event_id": "evt_01JX8S4K2N",
"event_time": "2026-09-19T06:15:09.402Z",
"tenant_id": "tn_northfield",
"schema_version": 1,
"actor_type": "agent",
"actor_id": "agent:replenishment-watch",
"actor_display": "Replenishment watch",
"actor_owner_id": "user:priya.raman",
"on_behalf_of_id": null,
"auth_method": "agent_credential",
"client": "mcp",
"session_id": "ses_01JX8S3H2A",
"source_ip": null,
"request_id": "req_01JX8S4K2M",
"parent_request_id": "req_01JX8S3H9N",
"tool_name": "query_metric",
"question_text": "gross margin by customer for the same period",
"question_redacted": false,
"metric_key": "gross_margin",
"metric_version": null,
"semantic_version": "2026.09.14",
"grain": ["customer"],
"filters": {"date": {"gte": "2026-09-12", "lt": "2026-09-19"}},
"resolution_path": "semantic_layer",
"decision": "deny",
"decision_stage": "agent_scope",
"policy_id": "pol_7f3a91",
"policy_version": "11",
"denied_reason": "Metric 'gross_margin' is not in scope for agent:replenishment-watch. Scoped metrics: fill_rate, on_shelf_availability, waste_rate.",
"scopes_applied": ["metrics:read", "metrics:query", "catalog:read"],
"row_filters_applied": [],
"columns_masked": [],
"sql_text": null,
"sql_hash": null,
"source_system": null,
"tables_read": [],
"columns_read": [],
"rows_returned": 0,
"bytes_scanned": 0,
"rows_written": 0,
"write_target": null,
"write_before": null,
"write_after": null,
"approval_required": false,
"approver_role": null,
"approver_id": null,
"approved_at": null,
"model_provider": "anthropic",
"model_name": "claude-opus-5",
"model_version": "2026-05-01",
"prompt_tokens": 940,
"completion_tokens": 48,
"outcome": "denied",
"error_code": "scope.metric_not_granted",
"duration_ms": 61,
"record_hash": "c3ab8ff13720e8ad9047dd39466b3c8974e592c2fa383d4a3960714caef0c4f2",
"prev_record_hash": "b1946ac92492d2347c6235b4d2611184f1b3f4a0e8d2c6a7b5e93f81c07d2a55"
}
A write, with its approval
A different agent proposes a replenishment quantity change. The write is staged, a named approver releases it eleven minutes later, and the record carries both states of the rows it changed.
{
"event_id": "evt_01JX9C7T4R",
"event_time": "2026-09-19T14:02:57.130Z",
"tenant_id": "tn_northfield",
"schema_version": 1,
"actor_type": "agent",
"actor_id": "agent:order-adjust",
"actor_display": "Order adjustment",
"actor_owner_id": "user:priya.raman",
"on_behalf_of_id": "user:marcus.hale",
"auth_method": "agent_credential",
"client": "console",
"session_id": "ses_01JX9C7Q88",
"source_ip": "203.0.113.47",
"request_id": "req_01JX9C7T4Q",
"parent_request_id": null,
"tool_name": "write_back",
"question_text": "raise the order quantity on the three lines flagged short for store 118",
"question_redacted": false,
"metric_key": "fill_rate",
"metric_version": "4",
"semantic_version": "2026.09.14",
"grain": ["store", "item"],
"filters": {"store_key": 118},
"resolution_path": "semantic_layer",
"decision": "allow",
"decision_stage": "approval",
"policy_id": "pol_9c21b4",
"policy_version": "3",
"denied_reason": null,
"scopes_applied": ["metrics:query", "write:erp.purchase_order_line"],
"row_filters_applied": ["region IN ('NORTH','CENTRAL')"],
"columns_masked": [],
"sql_text": "UPDATE erp.purchasing.purchase_order_line SET units_ordered = :new_qty WHERE po_line_id IN (:ids)",
"sql_hash": "a7f5f35426b927411fc9231b56382173f5f0f3a21d4c3e6b8a90d7c2e1b4f6a3",
"source_system": "sap:erp-prod",
"tables_read": ["retail.sales.fct_pos_line"],
"columns_read": ["units_ordered", "units_delivered", "store_key", "item_key"],
"rows_returned": 3,
"bytes_scanned": 1048576,
"rows_written": 3,
"write_target": "sap:erp-prod/erp.purchasing.purchase_order_line",
"write_before": [
{"po_line_id": "PO-88421-3", "units_ordered": 24},
{"po_line_id": "PO-88421-7", "units_ordered": 12},
{"po_line_id": "PO-88440-1", "units_ordered": 36}
],
"write_after": [
{"po_line_id": "PO-88421-3", "units_ordered": 36},
{"po_line_id": "PO-88421-7", "units_ordered": 24},
{"po_line_id": "PO-88440-1", "units_ordered": 48}
],
"approval_required": true,
"approver_role": "supply_chain_manager",
"approver_id": "user:marcus.hale",
"approved_at": "2026-09-19T14:14:02.507Z",
"model_provider": "anthropic",
"model_name": "claude-opus-5",
"model_version": "2026-05-01",
"prompt_tokens": 2310,
"completion_tokens": 402,
"outcome": "success",
"error_code": null,
"duration_ms": 683,
"record_hash": "d2d2c5f1a9b8473e6c05f4a1b29e7d38c6a0f95b3e2d7148c9f0a6b5d4e3c2b1",
"prev_record_hash": "c3ab8ff13720e8ad9047dd39466b3c8974e592c2fa383d4a3960714caef0c4f2"
}
The refused entry is the one to read twice. No SQL was planned and no bytes were scanned, and it is still a full record with its own id, the policy that decided, and the reason in the same words the caller was given. The same refusal, as the calling agent receives it, is on the MCP server reference.
What this table evidences, under CC6.1 and CC7.2.
Two criteria, because these are the two the table is genuinely evidence for. Mapping a log to nine controls is how a mapping stops being useful.
This is Ward's reading of the criteria, not an auditor's. The criterion text below is paraphrased rather than quoted. Your auditor decides what satisfies a control in your environment, and the useful thing to take from this section is the field list, which is what they will ask you for.
Logical access controls restrict protected information to authorised users
In short, the criterion asks the entity to implement access controls over protected information assets, and to be able to show that access was restricted to those authorised to have it.
The awkward part with agents is that the population of users is no longer the population of people. An access review that enumerates employees does not enumerate the fourteen agent identities running against the warehouse on a schedule, and those are the identities with the broadest reach and no manager.
| What you have to be able to show | Fields that show it |
|---|---|
| Every identity that reached data is enumerable, including non-human ones. | actor_type, actor_id, actor_owner_id |
| Each identity had an accountable human owner at the time of access. | actor_owner_id |
| Authentication method is recorded per call, not per account. | auth_method, client, source_ip |
| Access was evaluated against a named policy, and the policy version is pinned. | policy_id, policy_version, decision, decision_stage |
| Restrictions were enforced rather than assumed. | row_filters_applied, columns_masked |
| Attempts outside the grant were refused, and the refusals are retained. | decision = deny, denied_reason, error_code |
| Privileged action required a second person in a named role. | approval_required, approver_role, approver_id, approved_at |
The access review query
-- Every identity that touched a named table in the review period, what it read,
-- and who is accountable for it. This is the population an access review has to
-- cover once agents are in scope.
SELECT
actor_type,
actor_id,
actor_owner_id,
COUNT(*) AS calls,
COUNT(*) FILTER (WHERE decision = 'deny') AS refused,
SUM(rows_returned) AS rows_out,
MAX(event_time) AS last_seen
FROM agent_audit_event
WHERE tenant_id = 'tn_northfield'
AND event_time >= DATE '2026-07-01'
AND event_time < DATE '2026-10-01'
AND tables_read @> '["retail.sales.fct_pos_line"]'::jsonb
GROUP BY actor_type, actor_id, actor_owner_id
ORDER BY rows_out DESC;
-- The control test. Both of these must return zero rows.
SELECT event_id, actor_id, write_target, rows_written
FROM agent_audit_event
WHERE rows_written > 0
AND (approver_id IS NULL OR approved_at IS NULL);
SELECT event_id, actor_id
FROM agent_audit_event
WHERE actor_type = 'agent'
AND actor_owner_id IS NULL;
System components are monitored for anomalies indicating a security event
In short, the criterion asks the entity to monitor its systems for anomalies that could indicate malicious acts or errors, and to analyse those anomalies to decide whether they are security events.
Agents make this criterion easier to satisfy than people do, and almost nobody exploits that. An agent has a declared job, a fixed schedule and a scope somebody approved, so its normal behaviour is narrow enough to describe. A read-only agent that writes a row, or returns forty times its usual volume, is an anomaly you can define in a WHERE clause rather than a model.
| What you have to be able to show | Fields that show it |
|---|---|
| Baseline behaviour per identity is derivable from the log itself. | actor_id, tool_name, rows_returned, bytes_scanned, event_time |
| Refusal spikes are visible, which is what a scope probe looks like. | decision, decision_stage, denied_reason |
| A read-only identity attempting a write is detectable as a single predicate. | rows_written, scopes_applied |
| Out-of-hours and off-schedule activity separates from routine runs. | event_time, client, auth_method |
| Use of the escape hatch is countable over time. | resolution_path = raw_sql |
| Silent modification of the log is detectable. | record_hash, prev_record_hash |
| Events reach the monitoring system that already exists. | The whole record, streamed as JSONL to your SIEM |
The anomaly queries worth alerting on
-- 1. A read-only identity that wrote anything. Should never fire.
SELECT event_id, event_time, actor_id, write_target, rows_written
FROM agent_audit_event
WHERE rows_written > 0
AND NOT (scopes_applied @> '["write"]'::jsonb)
AND event_time > NOW() - INTERVAL '1 hour';
-- 2. Scope probing: an identity refused repeatedly in a short window. One
-- refusal is a misconfiguration. Nine in five minutes is behaviour.
SELECT actor_id, COUNT(*) AS refusals, MIN(event_time), MAX(event_time)
FROM agent_audit_event
WHERE decision = 'deny'
AND event_time > NOW() - INTERVAL '5 minutes'
GROUP BY actor_id
HAVING COUNT(*) >= 5;
-- 3. Volume anomaly against the identity's own 30-day baseline, not against a
-- fleet average. An agent that normally returns 150 rows returning 60,000
-- is the signal; the fleet mean hides it.
WITH baseline AS (
SELECT actor_id,
AVG(rows_returned) AS mean_rows,
STDDEV(rows_returned) AS sd_rows
FROM agent_audit_event
WHERE event_time BETWEEN NOW() - INTERVAL '31 days' AND NOW() - INTERVAL '1 day'
AND outcome = 'success'
GROUP BY actor_id
HAVING COUNT(*) >= 30
)
SELECT e.event_id, e.actor_id, e.rows_returned, b.mean_rows
FROM agent_audit_event e
JOIN baseline b USING (actor_id)
WHERE e.event_time > NOW() - INTERVAL '1 hour'
AND b.sd_rows > 0
AND e.rows_returned > b.mean_rows + (6 * b.sd_rows);
-- 4. Chain integrity. Any row whose predecessor is missing means the log was
-- edited or a write was lost. Both are incidents.
SELECT e.event_id, e.event_time, e.prev_record_hash
FROM agent_audit_event e
LEFT JOIN agent_audit_event p ON p.record_hash = e.prev_record_hash
WHERE e.prev_record_hash IS NOT NULL
AND p.event_id IS NULL;
Most stacks can answer some of this already.
Worth being precise about which parts, because the gap is usually narrower and more specific than a vendor page would have you believe.
If you run the dbt Semantic Layer, your metric definitions are already in one place and under review, which is the hardest half. What the log there is scoped by is the credential a Semantic Layer query runs under, so the "who asked" question resolves to a service token and you correlate the rest from warehouse query history.
If you run Snowflake Cortex Analyst, row and column enforcement is genuinely handled, because it inherits Snowflake RBAC rather than restating it. The unit of identity is a Snowflake role, so several agents sharing one role are one row in the record, and the log covers what is inside Snowflake.
Neither of those is a reason to move. They are the two places to look first if you are trying to work out what you could evidence today, which is a cheaper question to answer than it sounds and worth answering before you go shopping.
One log, covering people and agents alike.
Ward writes this record for every call through the console, the chat panel and the MCP server, and streams it to your SIEM as JSONL. A separate agent log beside a human log is two populations nobody reconciles.
What the log is attached to.
The governed MCP server
The tools an agent can call, the three permission layers that gate them, and the warehouse grants that bound all of it.
Read the reference →Policy, scope and approval
Row and column policy evaluated at query time, scoped credentials, and write access granted per table against a named approver.
How governance works →The 48-hour test protocol
A vendor-neutral acceptance test. Connect a source, define ten metrics, run twenty agent queries, score the result.
Run the protocol →Free to deploy, 5% of model spend
No license and nothing per seat. Setup packages are one-time, from $0 self-guided to $50,000 and up for a program.
See the numbers →Send this to your reviewer before they ask.
The architecture note, the DPA and the SOC 2 report arrive the same day.
Read-only to start · your LLM keys · SOC 2 Type II underway · or book a call directly
Find out what your data has been hiding.
Tell us about your operation. We’ll show you the problems Ward catches, and the ones your current tools miss.