The one rule
audit_log records two different things about who acted, in two columns:
For a person clicking in the web app these are the same fact, and the
temptation is to treat one as redundant. They are not the same fact, and the
cases where they differ are the cases the audit log exists for.
What each value is for
Agents: the case where both columns are populated and different
CHECKAI is the only value where actor_user_id is set and the actor is
not that person. That combination is the whole point:
actor_type were
dropped, or derived from “is there a user id”, every agent action would read as
though that person did it themselves. In a compliance record that is not a
missing detail — it is a false statement about a named individual.
The question support and auditors actually ask is “did a person do this, or
did something do it for them?” One column cannot answer it.
Where it is set
/mcp is declared auth: false, so Encore has no auth data and
auditContextMiddleware — which runs on the way in — can only honestly record
SYSTEM with no actor. The principal appears later, inside the handler, from
runtime.verify(token), and lives in the MCP package’s own AsyncLocalStorage.
enterMcpAuditContext (systems/mcp/mcpAuditContext.ts) joins the two stores,
nested inside runWithAuth. The nesting is the mechanism: auditPool
captures the context at BEGIN, so a transaction opened by a tool call carries
the narrowed actor into the Postgres GUC, and the capture-floor trigger writes
it on AUTO rows as well as semantic ones.
Before this existed, every agent action on the platform was recorded as an
unattributed
SYSTEM write — not misattributed to the human, attributed to
nobody.CHECKAI or INTEGRATION
An OAuth access token names the client that asked for it (client_id), and
our agent never uses one: CheckAI calls the REST API with the user’s Clerk
token. So on /mcp, a token with a client_id is a third-party agent and is
recorded as INTEGRATION with agent_id = oauth:<client_id>. A first-party
session token (no client_id, trusted origins only) stays CHECKAI.
On the REST API, Soundcheck-Client: mcp is not proof of anything: the stdio
@soundcheck/mcp SDK sends it too, and any client can. CheckAI is recognised
only by a valid Soundcheck-Agent-Assertion, signed with a key both services
derive from SLACK_CHANNEL_CHECKAI_SECRET and bound to the request’s bearer
token. Without one, an mcp call is INTEGRATION. Slack delegation tokens
remain CHECKAI as before. The creator kind every write records
(created_by_kind) is derived from these values; see
Creator attribution.
The anonymous tier is deliberately unattributed
/public/mcp takes no bearer token, and three of its tools write
(request_booking, request_sponsorship, send_feedback). That is a
storefront intake, not a hole — inbound booking and sponsorship enquiries and
product feedback are supposed to be anonymous.
There is no principal to recover, so those rows stay SYSTEM with no actor and
the request IP as the only identifying evidence. A row that implied a
principal would be worse than one that admits there is none.
What the semantic row adds
The capture floor already records what changed when an agent writes. It cannot see which tool the agent chose, that a call was refused, or that it failed — so an agent session reconstructed from AUTO rows alone reads as a series of anonymous edits with no account of intent.mcp_tool_invoked (category security) is that account. One catalog action,
with the tool name in target_name, rather than an entry per tool — an entry
per tool would make the catalog a second copy of the tool registry and drift
from it the first time one was added.
Three properties worth preserving:
- Mutations only. Derived from the catalog’s own
annotations.readOnlyHint, never a second list. Reads are omitted for volume; an agent issues many per task. An unannotated tool counts as a mutation — fail toward recording. - A confirmation prompt is not an action. A gated tool that returns the
prompt has not acted, so it records
confirmation_requiredand says so in the summary. Recording it as success would put an event in the log that never happened. - Failures are recorded. “The agent tried and it did not work” is a different fact from silence, and nobody can tell them apart afterwards if only successes are written.
Related
systems/audit/auditContext.ts— the context and its serialization into the Postgres GUC.systems/audit/recordAudit.ts—resolveActor, and why an explicit actor always beats the ambient one.middleware/auditContextMiddleware.ts— path-based derivation, and why it beats the client header.