--- title: "Audit log" description: "The workspace audit log: which human control-plane mutations are recorded, how actor and detail are bounded, how it is queried, and why it is a separate surface from per-invocation receipts." --- Obol keeps two accountability records, and they answer different questions. | Record | Question | Written by | Surface | |---|---|---|---| | **Audit log** | Who changed this workspace, and when? | Control, in the same transaction as the change | `GET /api/v1/workspaces/{id}/audit` | | **[Receipt](/receipts/overview)** | What did this tool call do? | Gateway, projected into control by a worker | `GET /api/v1/workspaces/{id}/receipts` | The audit log is human mutation history. It contains no invocations. A receipt is one tool call. Neither is a subset of the other, and the schema says so out loud: the audit action enum is documented as *"Human mutation history only; invocation receipts have their own surface."* ## What is recorded `apps/control/app/services/audit.py` and `record_audit` in `apps/control/app/services/operator.py` write to the `audit_log` table. Only actions in a closed allowlist can be written — `record_audit` coerces the action into an `AuditAction` enum and raises on anything else, so a caller cannot invent an action name. The recorded actions, grouped by what they touch: | Area | Actions | |---|---| | Workspace | `workspace.create` | | Virtual keys | `key.mint`, `key.revoke` | | Vendor connections | `connection.start`, `connection.credential.store`, `connection.federated.complete`, `connection.reconnect`, `connection.disable`, `connection.credential.purge`, `connection.discard` | | Model connections | `model_connection.start`, `model_connection.credential.store`, `model_connection.disable` | | Catalog | `catalog.provider.register` | | Webhooks | `webhook.configure`, `webhook.delete` | | Policy | `policy.draft.store`, `policy.publish` | | Routing | `route_qualifier.upsert`, `route_qualifier.delete` | | Approvals | `approval.approve`, `approval.deny`, `approval_policy.create`, `approval_policy.revise`, `approval_policy.disable` | | Pricing | `pricing.replace` | The through-line is the ADR-0033 consequence that accountability is never delegated to the identity provider. An attacker who could forge dashboard sessions could mint a virtual key; the record of who did so is Obol's own, keyed by workspace and actor. ### Written in the same transaction `record_audit` flushes; it does not commit. The row belongs to the same transaction as the act it describes, and callers commit both together. A minted key that exists always has a row saying who minted it — there is no window where the change landed and the accountability record did not. ## Row shape | Column | Notes | |---|---| | `id` | `aud_`-prefixed identifier | | `workspace_id` | Indexed; tenant isolation, as on every row | | `actor_user_id` | The Obol user, joined to an email at read time | | `actor_subject` | The verified identity-provider subject | | `action` | One of the allowlisted actions above | | `target` | What was acted on — a key id, a connection id, a policy id | | `detail` | Bounded JSON, described below | | `created_at` | Indexed; the ordering column | The actor must be a verified `Operator` object; passing anything else raises rather than writing an unattributed row. ## How `detail` is bounded `detail` never carries secrets, URLs, or raw arguments. It is filtered twice, on write and on read. **On write**, `_validated_audit_detail` rejects any key not in that specific action's allowlist, walks the value tree, and caps the canonical JSON serialization at 4096 UTF-8 bytes. The sole URL-shaped exception is `mcp_origin`, which must be a canonical `https://host` origin with an IDNA round-trippable hostname — a reviewed remote MCP origin, and nothing else. **On read**, `safe_detail` independently intersects the stored keys with a fixed allowlist and re-checks each value's type and size: ```text Readable detail keys agent_id bundle_hash connection_id env key_id last4 policy_id prefix provider_id publication_state qualifier_id reason revision_id snapshot_version source status tier ``` Values survive only if they are a string of at most 256 characters, a bool, an `int64`, or a list of at most 16 such strings; at most 16 keys are returned. A row written by an older, laxer version of the code still cannot project an unexpected field to a reader. ## Querying ```http Audit events GET /api/v1/workspaces/{workspace_id}/audit ?from=2026-09-01T00:00:00Z &to=2026-09-04T00:00:00Z &action=key.mint &actor_user_id=usr_... &target=key_... &limit=50 &cursor= ``` | Parameter | Notes | |---|---| | `from`, `to` | Required half-open UTC window: `from <= created_at < to` | | `action` | Optional. Must be one of the allowlisted actions, or the request is `422` | | `actor_user_id`, `target` | Optional exact-match filters | | `limit` | 1–200, default 50 | | `cursor` | Opaque time cursor from a prior `next_cursor` | Results are newest first, ordered by `created_at DESC, id DESC`, with the cursor encoding both so pagination is stable across rows sharing a timestamp. The query filters `AuditLog.action.in_(ACTIONS)` even without an `action` parameter, so a row carrying an action outside the current contract is never returned. Every response is validated against `packages/proto/operator_audit_page.schema.json` before it leaves. A database or validation failure returns `503` with `Observe read model unavailable` rather than a partial page. ### Access Reading requires workspace membership plus the `audit.read` action, which every role from **viewer** upward holds. Membership is resolved first, so a request for a workspace the operator does not belong to never reaches the query. The actor's email is joined from the `users` table and dropped if it exceeds 320 characters. ## Why receipts are separate Receipts are produced by the gateway, not by a dashboard action, and they travel a different path. The gateway writes an invocation audit envelope to `stream:invocation-audit:{workspace_id}`; control's arq worker runs `drain_invocation_audit` every ten seconds to project those envelopes into a queryable receipt read model ([ADR-0032](/receipts/overview)). Keeping that off the request path is what keeps Postgres off the invocation path. That surface has its own filters — `state`, `evidence_trust`, `verification_state`, `tool`, `decision`, `key_id`, `connection_id` — because the questions are different. In particular: A receipt names its evidence class. Federated catalog routes produce `untrusted` or `broker_attested` evidence and can never be `verified`. "The audit log proves what happened" is true of control-plane mutations; the equivalent claim about tool calls is tier-dependent. See [Evidence trust](/receipts/evidence). Per-invocation records, evidence classes, and verification state. Access logs, Prometheus metrics, and health endpoints.