This file documents the audit log subsystem. Claude Code auto-loads it when a file under devplacepy/services/audit/ is read or edited.

Audit log (services/audit/)

Admin-only, append-only record of every state-changing action. The authoritative event catalogue is events.md (its storage model, relation vocabulary, and per-event specs are authoritative); the catalogue currently spans 293 keys across 43 domains.

Package layout

  • store.py - the audit_log + audit_log_links tables, ensure_tables, insert_event/insert_links/get_event/get_links/sweep.
  • categories.py - category_for(event_key) (longest-prefix map).
  • record.py - the recorder + link builders.
  • query.py - list_events/filter_options/get_event_with_links for the admin UI (admin list/detail).
  • service.py - AuditService (retention sweep).

ensure_tables() + the 11 indexes are wired into database.py init_db() via a local import (audit modules import database/utils, so the import is deferred to avoid the cycle). AuditService is registered in main.py.

Two recorder entrypoints (the reuse keystone)

record(request, event_key, *, user=_UNSET, actor_kind=None, target_type/uid/label, old_value, new_value, summary, metadata, result="success", origin, via_agent, links, category) is for HTTP/WebSocket handlers - request may be a Starlette Request OR WebSocket (both expose .headers/.url/.client). It auto-derives the actor from get_current_user(request) (or an explicit user=), the request fields, and the X-Devii-Agent header (-> origin=devii, via_agent=1), and auto-appends the actor link.

record_system(event_key, *, actor_kind, actor_uid, actor_username, actor_role, origin, via_agent, ...) is for request-less contexts (rewards, notifications, the AI gateway ledger, the news/container services, Devii turns/tasks, job services, the CLI).

Both are best-effort: wrapped in try/except, they NEVER raise into the caller - the audited action is never blocked by a logging failure. summary is HTML-stripped and capped at 140 chars; metadata is JSON-serialised. Link builders (audit.target, audit.parent, audit.author, audit.recipient, audit.project, audit.instance, audit.setting, audit.job, audit.poll, audit.option, audit.task, ...) keep call sites terse.

Emission convention

Emission is explicit per mutation - audit.record(...) / record_system(...) calls placed after the mutation succeeds, or on the guard branch with result="denied"/"failure". DRY choke points:

  • Posts/projects/gists create/edit/delete record inside content.py (create_content_item/edit_content_item/delete_content_item, the latter two derive the key from target_type).
  • Project file ops record via the _audit_file/_edit/_fail helpers in project_files.py (read-only guard -> result="denied").
  • Services via _audit_service.
  • Containers via audit_instance (in routers/projects/containers/_shared.py) for the HTTP path, and in the Devii actions/dispatcher.py _audit_mechanic hook (after a successful _run) for the agent path - the two paths are disjoint (Devii calls api.py directly, never the routes), so there is no double counting. The dispatcher hook also emits the devii.* self-config mechanics (behavior/tools/tasks/lessons/customization) from one place.

The dispatcher's authorization guard (before _run) mirrors this with _audit_denied: a tool call refused for requires_auth/requires_admin/requires_primary_admin records a security.authz.denied event (origin="devii", via_agent=1, result="denied", metadata.tool/reason) so an agent-driven escalation attempt is visible in the admin Audit Log, not just the app log. This is what surfaces a non-admin's Devii probe of admin_*/db_* tools (the tool schemas are already withheld from non-admins by tool_schemas_for, so this fires only when the model invents a name it never received).

Failures and denials are events

auth.login.failure, auth.password.forgot_request (unknown email), security.rate_limit.block, security.maintenance.block, security.authz.denied (emitted inside require_user/require_admin on the HTTP side, and inside the Devii dispatcher's _audit_denied for a refused agent tool call), self-role-change and self-disable denials, admin-seniority denials (a junior admin managing a senior admin), read-only write attempts, and ai.quota.exceeded all record with result set to failure/denied. Auth failures, authz denials (in require_user/require_admin), rate-limit/maintenance blocks, self-role-change/self-disable denials, and quota blocks are recorded with result set.

Admin UI

GET /admin/audit-log (paginated, filterable list, admin_section="audit-log") and GET /admin/audit-log/{uid} (detail + links) in the routers/admin/ package, both require_admin, both negotiate via respond(..., model=AuditLogOut|AuditEventOut). Templates admin_audit_log.html / admin_audit_event.html reuse admin.css + a small audit.css; _pagination.html gained an optional backward-compatible pagination_query prefix (default empty) so filters survive paging. Sidebar link sits last, before Settings.

Devii access (read-only)

Admins query the same two routes conversationally via the admin-only Devii tools audit_log (GET /admin/audit-log) and audit_event (GET /admin/audit-log/{uid}) (services/devii/actions/catalog.py, handler="http", requires_admin=True) - no new endpoint, since the respond(...) routes already serve JSON to Devii's Accept: application/json client (like admin_list_users). audit_log forwards every filter as a query param (page, event_key, category, actor_role, actor_uid, origin, result, q, date_from, date_to) and returns AuditLogOut, whose options object lists the valid values for each filter so the agent can discover them in one call. audit_event returns AuditEventOut (the row + related links). A system-prompt steer in agent.py (the AGGREGATES block) routes audit/history/"who did X" questions to these tools.

Deferred persistence

record/record_system do all the cheap prep (actor resolve, sanitize, json.dumps, link assembly) on the request thread, then hand the two DB inserts to the background task queue (services/background.py, background.submit(_persist, row, links)) so the request returns without waiting on SQLite. The uid/created_at are generated eagerly at record time (so the return value and the audit timestamp reflect the action, not the flush). Under DEVPLACE_DISABLE_SERVICES=1 (tests) the queue runs inline, so audit rows are visible immediately. See CLAUDE.md -> "Background task queue".

Retention

AuditService (the audit service, daily) prunes rows older than audit_log_retention_days (default 90; 0 disables) via store.sweep. Configurable on the Services page like any other service.

Adding an event

Pick/extend a key in events.md, add the category_for prefix if it is a new domain, then call audit.record/record_system at the mutation point with the right links and result. Never gate the audited action on the recording.