Skip to content

Audit & accountability

Core anchor

The audit log and invocation log are Coffer's accountability records — they answer "who did what" and "who called what, when, with what outcome." They are distinct from operational observability. Both are stored locally, both are prunable, and neither ever stores secret material or user data.

For operational observability (structured logs, trace correlation, error envelope), see Observability.

The problem this solves

A developer registers a fleet of MCP servers, imports skills, builds knowledge bases, accumulates memory, runs chat conversations, pairs notification channels, and syncs it all across machines — driven from Claude Code, Codex, the UI, and custom scripts, often simultaneously. Without accountability records, answering even basic governance questions becomes opaque: "Who disabled the filesystem__write_file tool — me or the UI?" "Which server's tool did Claude Code call at 2pm, and what was the outcome?" "When was this server's config last changed?" "When was a credential last rotated, or the master key relocated?" The audit log and invocation log answer these questions without requiring the user to run a separate monitoring stack.

Coffer's approach is deliberately lean: a pair of local database tables, not a time-series database or a log-management SaaS. All records stay on-device and within the ~/.coffer/ backup footprint.

Audit log: lifecycle changes

Every change to any resource or capability is written to the audit_log table before the response is returned to the caller. The audit entry captures:

FieldWhat it tells you
event_typeWhich lifecycle operation occurred (e.g. resource_created, capability_disabled, token_rotated)
resource_kindThe kind of the affected resource (e.g. mcp_server), or null for daemon-level events
resource_nameThe specific resource name, or null for daemon-level events
actorWho triggered the change: cli, api, ui, or system
timestampUTC time of the event
detailsA structured JSON payload describing the change (e.g. the pre-delete snapshot, the new config diff)

The actor field deserves particular attention. Every surface sets it explicitly: the Typer CLI passes X-Coffer-Actor: cli in its HTTP calls to the daemon; REST API clients can set X-Coffer-Actor: api or X-Coffer-Actor: ui; if the header is absent, the daemon defaults to "api". The daemon itself emits system events for automated operations like retention cleanup. This means the audit log provides an accurate picture of whether a change was initiated interactively, programmatically, or automatically.

The full set of audited event types (defined as AuditEventType in domain/audit.py), grouped by domain:

Resource & capability:

EventTrigger
resource_createdAfter ResourceService.register
resource_updatedAfter config or description change
resource_enabled / resource_disabledAfter set_enabled when state actually flipped
resource_deletedAfter delete; includes a pre-delete config snapshot in details
capability_first_seenWhen discovery sees a capability for the first time
capability_enabled / capability_disabledWhen user toggles a capability

Daemon:

EventTrigger
daemon_started / daemon_stoppedAt daemon startup / graceful shutdown
token_rotatedAfter POST /api/v1/daemon/rotate-token
retention_updatedWhen a retention policy is changed
backup_createdAfter POST /api/v1/vault/backup

Credentials & master key:

EventTrigger
credential_set / credential_read / credential_deletedAfter a write / read / delete in the encrypted credential store
credential_migratedPer ref, when a legacy keychain secret is migrated into the store
master_key_relocatedAfter the master key moves between file and keychain storage
master_key_exported / master_key_importedOut-of-band master-key transfer to / from another machine
keychain_set / keychain_read / keychain_deletedLegacy (pre-encrypted-store) events, kept renderable for old rows

Embedding:

EventTrigger
embedding_config_updatedWhen the embedding provider/model is changed

Agent workspace:

EventTrigger
agent_config_file_written / agent_config_file_deletedWhen an agent config file is written / deleted
agent_mcp_installed / agent_mcp_uninstalledWhen an MCP entry is installed / uninstalled into an agent
agent_mcp_entry_removed / agent_mcp_entry_adoptedWhen an MCP entry is removed from / adopted by an agent
agent_plugin_toggled / agent_plugin_uninstalledWhen an agent plugin is toggled / uninstalled

Skill:

EventTrigger
skill_imported / skill_fetchedWhen a skill is imported locally / fetched from a source
skill_updated / skill_update_noopWhen a skill update applies / is a no-op
skill_renamedWhen a skill is renamed
skill_bound / skill_unboundWhen a skill is bound to / unbound from an agent
skill_autobind_skippedWhen autobind is skipped
skill_relinkedWhen a skill link is repaired
skill_drift_detectedWhen on-disk drift from the managed skill is detected
skill_adopted / skill_unmanaged_deletedWhen an unmanaged skill is adopted / a stray is deleted

Knowledge base:

EventTrigger
kb_document_ingested / kb_document_updated / kb_document_deletedWhen a KB document is ingested / updated / deleted
kb_reindexedAfter coffer kb reindex rebuilds the index

Memory:

EventTrigger
memory_added / memory_updated / memory_deletedWhen a memory fact is added / updated / deleted
memory_clearedWhen a memory store is cleared
memory_projectedWhen memory is projected into an agent/project

Chat, conversation & model:

EventTrigger
conversation_created / conversation_deletedWhen a conversation is created / deleted
conversation_archived / conversation_unarchivedWhen a conversation is archived / unarchived
chat_turn_completedAfter a chat turn completes
model_created / model_updated / model_deletedWhen a chat model definition is created / updated / deleted

Channel:

EventTrigger
channel_pairing_issued / channel_pairedWhen a channel pairing code is issued / a peer pairs
channel_notify_sentWhen a notification is sent to a paired channel

Sync:

EventTrigger
sync_config_updatedWhen the sync configuration is changed
sync_completedAfter a sync run finishes
sync_conflicted / sync_resolvedWhen a sync run conflicts / a conflict is resolved

Note that credential_set and credential_deleted are audited — the fact that a secret was stored or removed is recorded. The secret value itself is never in the details payload. (The legacy keychain_set / keychain_deleted event types remain renderable for historical rows.)

Invocation log: what went through the gateway

Every tool call, resource read, and prompt fetch that the daemon routes through the gateway produces one row in mcp_invocations. The row captures:

FieldWhat it tells you
timestampWhen the call started
resource_nameWhich registered MCP server handled the call
capability_typetool, resource, or prompt
capability_keyThe original (unprefixed) capability name
duration_msWall-clock milliseconds from receipt to upstream reply
statusok, error, timeout, or denied
error_messagePopulated when status != "ok"
session_idPer-MCP-client session correlation ID

Invariant: arguments and results are never persisted

The mcp_invocations schema has no column for call arguments or return contents. This is a deliberate, permanent design decision — not an omission to fill later. Arguments and results may contain sensitive information (file contents, API responses, user data). Storing them would make the invocation log a potential data exfiltration channel, would inflate storage significantly, and would create a retention problem with no clear solution. The invocation log answers "who called what, when, and with what outcome" — nothing more.

The status field distinguishes four outcomes that matter for accountability:

  • ok — the upstream replied successfully within the timeout.
  • error — the upstream replied with a JSON-RPC error (the error message is stored without the full response payload).
  • timeout — the upstream did not reply within request_timeout_seconds. The upstream subprocess or HTTP connection is torn down.
  • denied — the call was rejected by Coffer before reaching the upstream, because the capability is disabled or the resource is in a non-ready state. This lets the user distinguish "the upstream failed" from "I disabled this tool yesterday".

The session_id field correlates all invocations from a single MCP client session. A user who asks "why did Claude Code's tool call fail" can filter mcp_invocations by session ID to see the full sequence of calls that session made — without needing to look at Claude Code's own logs.

Retention: bounded log growth

Log-style tables grow without bound unless pruned. The retention_policies table and the RetentionService background worker together keep growth under control.

How a table opts in

Any table that should be prunable implements the PrunableTable protocol and is registered at the composition root:

python
PrunableTable(
    name="mcp_invocations",
    timestamp_column="timestamp",
    default_retention_days=30,
    display_name="Tool invocations",
    description="Records of every capability call through the gateway",
)

name must appear in the SQL allowlist set. timestamp_column must appear in the column allowlist. These allowlists are hardcoded in infrastructure/persistence/retention.py and cannot be extended at runtime. This means the prune worker can only delete from tables the developer explicitly whitelisted — arbitrary SQL execution is not possible.

The retention_policies table

One row per registered prunable policy. Default values seeded at first daemon startup:

PolicyActionDefault
audit_logDelete rows older than the window365 days
mcp_invocationsDelete rows older than the window30 days
conversations_archiveAuto-archive chats idle for this many days7 days
conversationsDelete archived chats this many days after archival30 days

The user can change any via PATCH /api/v1/retention/{table_name}. Setting retention_days to null means "keep forever". Zero is forbidden. The change is audited as retention_updated.

The background worker

An asyncio task running inside the daemon polls the retention_policies table on a configurable interval and issues DELETE FROM <table> WHERE <timestamp_column> < ? for each table whose retention_days is not null. The worker:

  • Runs the delete inside a transaction so partial deletes cannot produce a half-pruned table.
  • Updates last_pruned_at and last_pruned_rows in retention_policies after each successful prune.
  • Does not block the event loop between tables — it yields between each table's delete.
  • Does not cascade-delete across tables: audit entries for a deleted server are retained (the retention policy is per-table, not per-resource).

See also