Files
devplacepy/devplacepy/services/audit/CLAUDE.md
T
blindxfishandClaude Fable 5 80956ce0f4 Add Opinion Wars: week-long two-faction battles attached to posts
A new post attachment type beside polls: the composer gains a Start
Opinion War builder (same disabled-inputs opt-in as the poll builder)
that names exactly two factions; the battle runs for exactly 7 days
from post creation. Members join a side, may defect at any time
(damage already dealt stays with the faction it was dealt to), and
fight once per 24 hours per battle. A fight spends 25 Code Farm coins
and deals deterministic level-weighted damage: 100 + 10 * min(level,
20) HP, so a newcomer deals 110 and a veteran caps at 300 - no
randomness anywhere.

The battle renders on the post card as a CSS pixel-art battlefield
(box-shadow sprites: castles, faction flags, marching soldiers, a
flickering campfire; steps() animation, disabled under reduced motion)
with live HP bars, a countdown, the viewer's faction strip, top
contributors and an event ticker. Live frames ride pub/sub on
public.battle.{uid} via a relay on the service-lock owner, with the
durable opinion_war_events trail (per-war atomic seq) as the source of
truth and a 15s incremental poller as fallback. /battles lists battles
with active/ended/mine filters, search and pagination.

Every mutation is a conditional UPDATE via conditional_update_row: the
fight sequence claims the cooldown first, then spends coins, then lands
the damage, compensating earlier steps on any later refusal so a crash
costs a turn, never coins. Resolution is lazy on read (no cron):
an exactly-once CAS computes the winner in the statement, awards XP
(participation, winner bonus, top damage dealer bonus; draws pay
participation only), emits the result event and notifies fighters. The
OpinionWarService backstop resolves unviewed wars and sends
fight-ready notifications, exactly-once via a marker CAS.

Fan-out: battle notification type, four badges, audit keys
(battle.create/join/switch/fight/resolve), Devii actions (join/fight
confirm-gated), API docs group, docs prose page, sitemap and topnav
entries, REPORTABLE_TARGETS registration, post-delete cascades,
README and nested CLAUDE.md documentation.

Verified with the four-layer procedure: property checks over the full
damage domain, 1200-step stateful fuzz (hp-sum invariant, coins never
negative, resolved totals frozen), and real 8-process races proving
exactly-once semantics for concurrent fights, double-spends across two
wars, resolution XP and double-joins. Persisted tests in
tests/unit/services/opinionwar, tests/api/battles, tests/e2e/battles
and tests/api/posts/create.py.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 23:30:02 +02:00

7.1 KiB

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.