Wheelhouse docs

The audit log#

The audit log is the agent's own record of every mutation it performed and every one it refused: an actor, a role, an address, an operation, whether it worked, and what the error was. It is append-only, one JSON object per line, in its own file beside the agent's state. It is not the router's log and it is not the commit history. Those are three different records that answer three different questions, and this documentation never blurs them.

Three records, three questions#

RecordWhereAnswers
The journaljournalctl on the router, surfaced by GET /api/log and the Logs pageWhat the system and its daemons said.
The commit historyThe router's revision archive, surfaced by GET /api/history and /api/history/diffWhich commits happened, when, and what each one changed to the configuration.
The audit logaudit.jsonl in the agent's data directory, surfaced by GET /api/audit and the System → Audit tabWho asked the agent to do something, from where, and what came of it.

They are complementary rather than redundant. The commit history knows the content of a change; the audit log knows the person. Answering "who changed my NAT rule and when" today means reading both — the audit entry for the timestamp and the actor, the revision diff for the lines.

What an entry holds#

json
{"timestamp":"2026-09-02T14:07:11Z","actor":"kate","role":"operator",
 "remote_ip":"192.0.2.40","operation":"commit","success":true}

The fields are fixed (agent/store.go, AuditEntry):

FieldMeaning
timestampRFC 3339, UTC, stamped when the entry is recorded.
actorThe principal's name: an account name, token:<label> for an API token, admin-token for the break-glass credential, reconcile-loop for the loop, system for the first-run bootstrap.
roleThe role that principal held.
remote_ipThe client address as the agent saw it — see the --trust-proxy note in Accounts, sessions and tokens.
operationcommit, commit-confirm, rollback, configure, save, login, logout, change-password, create-user, delete-token, license-set, denied POST /api/commit, and so on.
pathPresent where an operation names an object: the archive file for a rollback, the account name and role for a user change, the image for an app pull, the router id for a fleet write.
commandsThe rendered set / delete lines — see the limit below.
success, errorWhether it worked, and the router's or the agent's own words if it did not.

Refusals are recorded too#

A request whose role is insufficient is logged as denied <method> <path> with the actor, the role held, the address and the role required (agent/authhttp.go, requireRole). So are failed logins, with the username that was tried and the reason — no such user, bad password, bad totp — even though the response the caller gets says only invalid credentials, because distinguishing them to the caller would be a username oracle.

Failed writes are recorded as failures, with the router's message, before the caller is answered. That includes a write to another router in a fleet: POST /api/fleet/{id}/configure records an entry naming the actor, the router id and the outcome, on both the success and the failure path (agent/main.go, handleFleetConfigure).

How it is stored#

Each entry is one line of JSON appended to audit.jsonl in the agent's data directory — /config/wheelhouse/audit.jsonl on an appliance — mode 0600, and each append is flushed to the disk before the call returns (agent/store.go, auditLog.append). An audit log whose last entries were lost to the power cut that followed the action they describe is not one you can testify from, and the cost is a few hundred bytes.

It used to live inside state.json. That meant one audited action rewrote every account, token and session as well — half a megabyte of flash for a single refused request at the default retention, on the storage the router boots from, with a full rewrite in flight on every 403. A state file written by an older agent still carries its entries, and they are moved into the new log once, on open.

BoundValueEffect
Rotation8 MiBThe file rotates to audit.jsonl.1; exactly one previous generation is kept, so the log costs at most twice that on disk.
audit_retention2000 by defaultHow many entries the agent keeps in memory to serve GET /api/audit.
Hard ceiling10 000Whatever audit_retention says, the in-memory tail stops there, and the agent logs a warning saying older entries stay in the file.

Rotation is why the tail is loaded from the rotated generation first at start-up: a restart shortly after a rotation would otherwise serve the handful of lines written since. A line torn by a power cut is skipped and the entries either side of it are still good.

Reading it#

bash
R=https://<router>:8443
T=wh_...                                  # an API token with the viewer role

curl -sk -H "Authorization: Bearer $T" "$R/api/audit?limit=300"

Newest first; limit defaults to 300. The viewer role is enough to read it — a deliberate choice, and the same reason the whole read plane stays open without a licence. Losing sight of what happened to your router is not a sensible consequence of anything.

For long-term retention, ship the file itself. It is line-delimited JSON in a fixed directory; any log shipper can tail it.

What it does not do#

See also#

Checked against#

agent/store.go · agent/main.go · agent/authhttp.go · agent/admin.go · agent/desired.go · agent/apps.go · docs/deploy.md · docs/security.md · PRICING.md · README.md

Updated 2026-09-02 concepts audit accountability