arizuko

arizuko › concepts › audit trail

audit trail

The audit trail is the table that records who changed what. It answers questions such as these: who revoked a grant last Tuesday, why did a guest stop during a turn, and who changed a secret?

One event can land in three places

The row is the record. The log line and the span are telemetry, and [obs/README.md] describes them.

┌───────────────────────────────────────────────────────────────────────────┐
│ one event in a daemon: a mutation, a denial, a tool call, a turn          │
└───────────┬───────────────────────────┬───────────────────────┬───────────┘
            ▼                           ▼                       ▼
┌───────────────────────┐ ┌─────────────────────────┐ ┌─────────────────────┐
│ audit_log row         │ │ slog record             │ │ span                │
│ the record            │ │ telemetry, lossy        │ │ opt-in              │
│ WriteFor: rides the   │ │ obs.Setup: JSON on      │ │ obs.StartSpan:      │
│ mutation's            │ │ stderr ► journald;      │ │ turn, mcp_tool,     │
│ GuardedBatch          │ │ an OTLP logs tee when   │ │ container_spawn,    │
│ EmitTo: beside an     │ │ OTEL_EXPORTER_OTLP_     │ │ cross_daemon;       │
│ effect on a platform  │ │ ENDPOINT is set         │ │ exported when       │
│ or a guest            │ │                         │ │ OTEL_EXPORTER_OTLP_ │
│                       │ │                         │ │ TRACES_ENDPOINT set │
└───────────┬───────────┘ └────────────┬────────────┘ └──────────┬──────────┘
            ▼                          └──────────────┬──────────┘
┌───────────────────────┐                             ▼
│ the owner's store:    │ ┌─────────────────────────────────────────────────┐
│ routd, runed, authd,  │ │ collector: TraceID = sha256(instance + "/" +    │
│ onbod; GET /v1/audit  │ │ turn_id)[:16], so one turn is one trace         │
│ through audit.Query   │ │ across daemons                                  │
└───────────┬───────────┘ └─────────────────────────────────────────────────┘
            ▼
┌───────────────────────────────────────────────┐
│ /dash/audit/: asks each owner and merges the  │
│ rows; a source that fails gets a banner       │
└───────────────────────────────────────────────┘
 audit_log is never exported over OTLP. /metrics is a fourth, separate
 door: Prometheus on seven daemons when METRICS_ENABLED is set.

Each change writes one row

Every call through the resource registry that changes state writes one row to audit_log. The call can be an MCP tool call of the agent or a REST call of an operator. A read that succeeds, such as a route list, writes no row and produces only a log line. A read that fails or that a gate denies writes a row, because the refusal is a decision.

The row is written in the same batch as the change, so both land or neither lands. No moment exists in which a route changed and nothing recorded it, and no cleanup job must reconcile two tables.

A row names who asked, in actor and actor_sub. It names what they touched, in action, resource and params_summary. It names the result, in outcome, error_msg and duration_ms, and the place, in folder, turn_id and instance. reference/schema has the full column list.

The log line is not the record

Every audited call also writes a structured log line, and the two are different. The log line goes to journald and, if you connect an OTLP collector, to your dashboards. The log can lose lines by design: journald rotates, levels filter, and nothing waits for a line to land.

So the table answers “every ACL write last quarter”, and the log answers “what happens now”. When the two disagree, the table is correct.

Tool use inside the guest goes to the log, not the table

Harness hooks capture the tool use of the agent while it works, such as every Bash, Edit and Read call. The hooks print one JSON line per call. runed moves these lines into the log stream. None of them reaches the database.

The calls that change platform state go through MCP, and the registry audits them. One kind of agent action gets a row, because it leaves no other trace. This is a like, dislike, edit, delete, pin or unpin on a message already on the platform. None of these appends a messages row, so the audit_log row is the only record. It has the category agent, the action message.<verb>, and the chat and target message as the resource, in routd/social_audit.go. An edit row names the message, never the new text. routd writes the row after the platform call, on success or error. A failed audit write goes to the log and is not returned, because the platform message already changed and a retry would change it twice.

The two streams do not match field for field. The hook line has tool, phase and args_summary. The line from the registry has caller, resource, action and surface. Both end with outcome and duration_ms. [audit/README.md] has the full row shape.

Each daemon keeps its own audit table

arizuko has no single audit table. Each daemon owns its own tables, including its own audit_log, and no daemon writes into the tables of another. A table name starts with the daemon that owns it, such as routd.audit_log, runed.audit_log, authd.audit_log and onbod.audit_log, so the name shows the owner. The turn_id travels with the work and connects the rows.

DaemonIts audit tableRecords
routdroutd.audit_logroutes, groups, grants, secrets, tasks and tokens, which is most of the trail
runedruned.audit_logwho claimed the run slot of a folder, run.hold, and who stopped a run, run.kill
authdauthd.audit_logidentity and token events: logins, and who ended whose session, sessions:delete
onbodonbod.audit_logadmissions, invites and onboarding gates

The dashboard merges the four trails

The dashboard page /dash/audit/ is the usual view, newest first, with fifty rows per page. It has filters for category, actor and folder, and an older link that keeps your filters. Only an operator can open it.

The page shows all four trails at once. Each daemon publishes its own table at GET /v1/audit. The dashboard asks all of them, merges the answers by time, and shows a source column with the daemon of each row. So you can find who stopped a run in the browser, with no shell on the host.

A read of the trail that succeeds writes no row, so a page refresh adds nothing. A refused read does get a row.

The same data is available over HTTP. Use an operator token:
curl -s -H "Authorization: Bearer $TOKEN" \
  "https://<host>/v1/audit?category=authz&limit=50" | jq .
The filters are folder, category, actor, limit and before_id. folder covers that folder and every folder under it, and actor matches a substring. limit has a default of 50 and a maximum of 200, and before_id gives older pages. An agent can ask the same question about its own folder with the query_audit tool.

onbod is a source only where it is deployed. Onboarding is an optional profile, and a daemon that never ran is not a failed source.

A table that is already the record gets no audit row

A table that already is the record is not audited a second time. A turn is the clearest case. spawns already holds its kind, state, outcome, exit code and every timestamp, and the dashboard shows it. So runed writes no extra row per turn. Chat messages are the same: the messages table is the record of what was said.

Such a table never records who asked. For this reason, runed audits the run slot and not the run. A hold stops every turn in a folder, a kill is the decision of an operator, and both can happen without a spawns row.

A denied call gets a row for every resource that the registry serves. A hand-written HTTP handler outside the registry can refuse and leave no row. The token mint of authd does this when it answers scope_exceeds_minter or scope_exceeds_parent in authd/http.go.

Credential-like argument names never reach the table

The row records the arguments, so you can tell one call from another. Before the write, a marker replaces the value of every argument whose name looks sensitive, as in <redacted:64chars>. The marker keeps the length, so you can tell an empty value from a real key without storing the key.

The check reads the name and never the value. A name is hidden when it contains pass, token, secret, credential, authorization, cookie, dsn or apikey, or when it is key or ends in _key or -key. A password in an argument named note is stored as written. serving_keys, a count, stays readable.

A string value over 200 characters is cut, with a note of its real length. The encoded params_summary has a cap of 512 bytes. A row over the cap gets _truncated: true, and a row still over the cap keeps only that flag. The rest of the row stays complete. The rule lives in audit/event.go, so every daemon follows it.

Three READMEs have the detail

[audit/README.md] describes the row, its closed enums and the redaction rule. [obs/README.md] describes the log and span half. reference/schema lists every column, index and writer. grants explains who can do the action that the trail records.