Skip to main content

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.
Do not “simplify” actor_type away, and do not derive it from whether actor_user_id is set. Every value in that enum exists because something was being crammed into actor_user_id and producing a false row.

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:
An MCP agent authenticates with a human’s OAuth token. If 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_required and 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.
  • 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.