Agent governance · Reference

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.

The requirement

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.

01

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.

02

What metric resolved?

Which definition, at which version. Without it you know a query ran and not whether it computed the right thing.

03

What SQL ran?

Verbatim, after policy injection. A paraphrase does not answer the question anyone actually asks.

04

What rows did it touch?

Tables, columns, row count, and for a write, the state before and after. This is the containment question.

05

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.

Field by field

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.
The schema

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

PostgresSnowflakeBigQueryDatabricks 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.

Worked entries

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.

an-allowed-metric-resolution.json
{
  "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.

a-refusal.json
{
  "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.

a-write,-with-its-approval.json
{
  "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.

Control mapping

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.

CC6.1

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

cc6-1.sql
-- 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;
CC7.2

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

cc7-2.sql
-- 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;
Against what you have

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.

In the console

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.

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.

Step 1 of 3
What are your goals?
Step 2 of 3
About your operation
Step 3 of 3
Your contact info