> ## Documentation Index
> Fetch the complete documentation index at: https://docs.soundchecklive.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Audit actor model

> Who an audit row says acted. actor_type is what acted; actor_user_id is who authorized it. Agents make the two different, and collapsing them writes a false row.

## The one rule

`audit_log` records two different things about who acted, in two columns:

| column | question it answers |
| - | - |
| `actor_type` | **What** performed this action |
| `actor_user_id` | **Who** authorized it |

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.

> [!WARNING]
>
> 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

| `actor_type` | `actor_user_id` | Why it exists |
| - | - | - |
| `USER` | the person | The ordinary case. |
| `STAFF` | `NULL` | `admin_org_audit_log` stored a **staff email** in `actor_user_id`. It is not a user id and never was; `actor_email` holds it. |
| `WEBHOOK` | `NULL` | Clerk webhooks have no session. Attributing them to whoever the payload mentions would invent an actor. |
| `PORTAL_CONTACT` | `NULL` | The portal actor is a `CustomerContact`, not a `User`. Writing its id into `actor_user_id` would be a dangling reference. |
| `CHECKAI` | **the person** | An agent acting on someone's behalf. See below. |
| `INTEGRATION` | the person | A third-party agent: an OAuth client on `/mcp`, or an unverified `mcp` client on the REST API. |
| `SYSTEM` | `NULL` | Cron, Pub/Sub processors, migrations, and anything anonymous. |

## 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    = 'CHECKAI'        -- an agent performed this
actor_user_id = 'user_2abc...'   -- this person authorized the session
```

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.

> [!NOTE]
>
> 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](https://docs.soundchecklive.io/engineering/backend/creator-attribution.md).

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

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


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.