arizuko

arizukoconcepts › audit trail

audit trail

Someone revoked a grant last Tuesday and nobody remembers who. An agent container was killed mid-turn and the user wants to know why. A secret changed and three people had the access to change it. The audit trail is the table that answers those questions, and this page is about what it does and does not cover.

one row per thing that changed

Every mutating call — an MCP tool the agent invoked, a REST endpoint an operator hit, a CLI command — writes one row to audit_log. Reads do not. Listing routes or fetching a chat produces a log line and nothing else, because the volume is enormous and the row would carry no decision worth recovering later.

The row is written in the same database transaction as the change itself. If the audit insert fails, the change rolls back. There is no window where a route moved and nothing recorded it, and no cleanup job reconciling two tables that drifted.

A row names who asked (actor, actor_sub), what they touched (action, resource, params_summary), how it went (outcome, error_msg, duration_ms) and where (folder, turn_id, instance). The full column list is in reference/schema.

the log is not the table

Every audited call also emits a structured log line, and the two are not the same thing. The log line reaches journald and, if you have wired an OTLP collector, your dashboards. It is lossy on purpose: journald rotates, levels filter, nothing waits for it.

So the table is the answer to “every ACL write last quarter” and the log is the answer to “what is happening right now”. When they disagree, the table is right.

what the agent does inside the container

The agent’s own tool use — every Bash, Edit, Read it runs while working — is captured by harness hooks and printed as one JSON line per call, which runed lifts into the log stream. None of it reaches the database.

That is deliberate. Agent tool use is operational detail at enormous volume, and the parts that actually change platform state already went through MCP, where they were audited like any other call. A hook that wrote to the database would turn every keystroke of the agent’s work into a distributed transaction.

Two streams, one shape. Both layers use the same field names, so journalctl | grep tool=Bash and journalctl | grep tool=set_routes return rows you can read side by side.

each daemon keeps its own

arizuko does not have one audit table. Each daemon owns its own database and its own audit_log inside it, and nothing writes across that line. Correlation is by turn_id, which travels with the work.

DaemonTable lives inRecords
routdroutd.dbroutes, groups, grants, secrets, tasks, tokens — the bulk of it
runedruned.dbwho claimed a folder’s run slot (run.hold) and who killed a run (run.kill)
authdauth.dbidentity and token events — logins, and who ended whose session (sessions:delete)

This is the same rule that keeps each daemon migrating only its own schema. The cost used to be that no single view spanned them; the dashboard now does the spanning for you, by asking each daemon rather than by merging their files.

reading it

The dashboard at /dash/audit/ is the everyday view: newest first, fifty rows a page, filterable by category, actor and folder, with an older link that carries your filters. It is operator-only.

It shows all three trails at once. Each daemon publishes its own table at GET /v1/audit, and the dashboard asks all of them and merges the answers by time, with a source column telling you which daemon a row came from. So “who killed that run” is a question the dashboard can now answer, and you no longer need a shell on the box.

The dashboard asks over the network rather than opening runed.db and auth.db itself. That is the same ownership rule as everywhere else: a daemon’s table is reached through the daemon, so there is one place that decides who may read what. If one daemon does not answer, you get a banner naming it and the other two still render — an empty section would tell you nothing happened there, which is the one thing an audit page must never say by accident.

Reading the log does not write to it. Only changes get a row, so refreshing the page all day adds nothing; a refused read does get recorded, which is the point.

Reading it yourself. The same data over HTTP, with an operator token:
curl -s -H "Authorization: Bearer $TOKEN" \
  "https://<host>/v1/audit?category=authz&limit=50" | jq .
Filters: folder (that folder and everything under it), category, actor (substring), limit (default 50, maximum 200), and before_id to page backwards. An agent can ask the same question about its own folder with the query_audit tool.

One daemon is still missing: onbod, which records admissions and invites, has the table but not yet the endpoint.

what is deliberately not audited

A table that already is the record does not get audited twice. The clearest case is a turn: spawns already holds its kind, state, outcome, exit code and every timestamp, and the dashboard renders it, so runed writes no extra row per turn. Chat messages are the same — the messages table is the record of what was said.

What such a table never holds is who asked. That is why runed audits the run slot and not the run: a hold stops every turn in a folder and a kill is an operator’s decision, and both can happen without leaving a spawns row at all.

One real gap, stated plainly: a denied call is recorded for every resource served through the registry — routes, grants, secrets, tasks, tokens, the audit read itself — but the older hand-written endpoints still refuse correctly without leaving a row. Making that uniform belongs at the authorization gate, and it is not finished.

what never reaches the table

Arguments are recorded so you can tell one call from another, but anything that looks like a credential is replaced with a marker before the row is written — passwords, tokens, secrets, cookies, API and private keys, database connection strings. The marker keeps the length (<redacted:64chars>) so you can still tell an empty value from a real key without ever storing one.

Redaction happens where the row is written, not where it is read, so there is one rule and every daemon obeys it. The matching is deliberately narrow at the edges: private_key is hidden, serving_keys — a count of how many keys a daemon is serving — is not, because over-hiding blinds the very questions this table exists to answer. A very long argument is shortened to its first 200 characters with a note saying how long it really was, and the rest of the row survives intact.

go deeper

The decision record, including the read/write split and why the row rides the mutation’s transaction: specs/5/I. Every column, index and writer: reference/schema. Who is allowed to do the thing being audited: grants.