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 223 keys across 38 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.
