The audit log is the accountability record for actions a hive takes: who did what, when, and to which agent. Several docs reference it — the watchdog records observed restarts here, hooks record firing failures here, and the security self-assessment names it as the attribution substrate — but until now none of them defined its shape.
This page defines it, so a consumer can parse the file without reading Go.
A related but distinct record (which agent used a GitHub credential, and with
what gh command) lives in the
token-access audit log, not here.
The closed-PR settle scan still lists current claims and records claim/churn history synchronously. A listing failure remains non-authoritative; deferred accounting never means a claim is clear. Only historical merge actor lookup, terminal audit/timeline observations, and terminal review reads run in the client's single lifecycle-bound accounting worker.
The worker coalesces pending work by repository/PR and attempts at most eight
observations per batch, with a ten-second cancellable API budget and a
ten-second wake cadence. Failed work retains exponential retry backoff rather
than being silently discarded. These are accounting bounds, not a promise
that an uninterruptible filesystem sink finishes within ten seconds. A slow
sink occupies the accounting worker, not the claim evaluation call stack.
The historical PR enrichment batch log reports processed, failed, deferred,
duration, budget, and limit.
Pending accounting is in memory; after a restart or client credential rebuild the existing 72-hour terminal settle scan replays recent observations. This does not extend that historical recovery window or make the accounting queue an ownership authority. Timeline first sightings and corrected precious metadata still wait for atomic persistence. Identical replay does not increment counts or rewrite the file; dirty state after a failed save is retried. Atomic writers are serialized, but disk I/O no longer holds the journey reader mutex, and an older snapshot cannot acknowledge a concurrent unsaved update. New precious writers may still wait for an earlier disk writer, preserving their durability rather than reporting false completion.
One JSON object per line (JSONL), append-only. Written by
pkg/dashboard/audit.go.
{"ts":"2026-08-27T14:31:02Z","user":"clubanderson","action":"config_governor_save","detail":"level=4","agent":""}| JSON key | Go field | Always present | Meaning |
|---|---|---|---|
ts |
Timestamp |
yes | RFC 3339 UTC timestamp of the action. |
user |
User |
yes | Who performed it. May be a pseudo-user — see below. |
action |
Action |
yes | The action name, e.g. config_governor_save, pr_merged, hook_failed. |
detail |
Detail |
no (omitempty) |
Free-form k=v pairs joined by , . See the parsing note. |
agent |
Agent |
no (omitempty) |
The agent the action concerns, when it concerns one. |
repo |
Repo |
no (omitempty) |
Repository a hive-mediated GitHub write went to. Set on write entries since #9587; see GitHub write surface. |
target |
Target |
no (omitempty) |
Issue or PR number that write went to, when it has one. |
user_name |
UserName |
API responses only (omitempty) |
Display name for an opaque OIDC actor key. Stamped at serve time by GET /api/audit — never written to the file or the ring. See below. |
sensitive |
Sensitive |
API responses only (omitempty) |
true when the entry's action is in the dashboard sensitive-action vocabulary used by the collapsed Audit Log risk count. Stamped at serve time by GET /api/audit — never written to the file or the ring. |
Optional fields are omitted entirely rather than emitted empty, so a consumer
must treat a missing detail, agent, repo, target, user_name, or sensitive as
absent, not as an empty string.
When user is an opaque OIDC identity key, handleAuditLog attaches the
hub-delivered display name as user_name on the copies it serves. The on-disk
audit.jsonl and the in-memory ring keep only the raw key, so history survives
display-name changes. Consequences:
- A parser of the file will never see
user_name; a consumer of the API may. Do not treat its absence in the file as data loss. user_nameis cosmetic. Correlate entries byuser(the stable key), never byuser_name.
detail is a flat string of key=value pairs joined by , — not a nested
object. It is built by InvocationMeta.AuditDetail from a generic pair list, so
the keys present vary by action and there is no typed accessor.
repo=hivecommons/hive, number=4911, agent=guide, backend=copilot
Consequences worth knowing before you build on it:
- Prefer the typed
repofield. Entries for hive-mediated GitHub writes carryrepo(andtarget) as first-class fields since #9587, and keep therepo=pair indetailtoo. Entries written before that, and entries from audit sites that are not GitHub writes, have the pair only, so fall back to parsingdetailwhen the field is absent. - Do not assume a key exists. Match on the key you need and skip lines that lack it, rather than positionally.
- Values are not escaped. A value containing
,or=will parse ambiguously. In practice values are repo names, numbers, and enum-like strings, but a parser should be defensive.
user is not always a person. The values "", system, local, and unknown
are pseudo-users written by automated paths. They are recorded in the file like
any other entry — the distinction only affects the dashboard's "last action by
user" display, which skips them.
Do not treat a pseudo-user entry as less authoritative; a system entry is
still a real recorded action.
One system writer worth knowing by name: CLI model discovery (#7384). When a
backend's live model probe fails — or degrades to a catalog that is not the
one the CLI uses — the dashboard writes model_discovery_failed with
backend=…, platform=<os/arch>, fallback=…, degraded=…, served=…, error=<the probe's own message> — plus state=credential_missing, recovery=<operator action> when the failure is copilot's runtime Not authenticated refusal
(#12947), naming the dashboard Copilot Login that clears it — and
model_discovery_recovered (with previous= the failure it recovered from)
once a clean probe answers again. Entries are written on transitions only,
never per 30 s probe, and the ordinary not-configured fallbacks (no credential
found, no helper installed) are not failures and write nothing. The Not authenticated case is different: the helper ran and refused, so it does write
a model_discovery_failed entry (with state=/recovery=) even though no
fleet self-report is filed for it (see fleet-report.md). Before this
existed, a spoke served a wrong catalog for two days and the only record was a
server log line.
The hub records the contributor relay's lifecycle in
this same file (#13441); there is no separate file, store or
endpoint. The rows are written from frames the hub already receives, so an
unmodified relay needs no change. Unlike the rows above, the principal is the
contributor, not a dashboard user: user is the contributor's GitHub login
(within the pseudo-user contract above — a consumer that expects an OIDC actor
key must accept a login here), agent is the contributor session identity
(<contributor id> or <contributor id>#<session>), and task rows carry the
task's repo and issue/PR target.
| Action | Written when | detail keys |
|---|---|---|
relay_connected |
The socket authenticated (auth_ok sent) |
backend, model, tier, relay_version |
relay_rejected |
A recognised contributor was refused at auth | reason (revoked, model_not_accepted, effort_below_floor, role_claim_rejected) |
relay_disconnected |
The socket closed | reason (client, heartbeat_timeout, server_shutdown, revoked), uptime_s, tasks |
relay_task_assigned |
A task_assign frame was sent |
task, generation, kind, mcp |
relay_task_accepted |
The relay's first task_accepted for the task it holds |
task |
relay_task_declined |
The relay declined an offered task in its preflight | task, reason |
relay_token_minted |
An installation token was issued or re-minted for a task and sent to the relay (assignment, mid-task refresh, resume) | task, expires_at, installation, scopes |
relay_mcp_lease_issued / relay_mcp_lease_revoked |
Reserved for the task-MCP lease lifecycle; the hub does not issue a task-MCP lease yet, so nothing writes these today | task, expires_at |
relay_task_completed |
task_complete was accepted for the held task |
task, pr_url (only a PR the hub verified), verdict, duration_s, model as the relay reported it |
relay_task_failed |
task_failed was accepted for the held task |
task, class (the normalised failure kind: environment, task or unspecified), duration_s |
relay_task_revoked |
The hub took a task back: operator requeue or yank, or a resume refused by the admission gate | task, reason |
relay_task_abandoned |
The task ended with no terminal frame: lease expired, or the socket dropped and the task was not re-adopted within the disconnect grace | task, last_progress_at (lease expiry only) |
relay_task_attested |
The terminal frame carried the contributor's opt-in audit attestation (hivectl hives set <name> --share-audit; the relay-side flag lands with the v6 follow-up to #13458 — until then no relay sends audit_share and the hub is simply ready for it); never written without that consent |
task, level (shared level, after clamping), capture_level, local_ref (the contributor's local record key), rows (rows accepted), commands (command rows stored), stored (<login>/<task>.jsonl under /data/contrib-audit/, only at commands+), dropped (rows above the shared level or malformed), clamped (share exceeded capture) |
Things to know when you query them:
- Never a token.
relay_token_mintedrecords when the credential was sent, how long it lives and the installation it came from —installationis the join key to GitHub's installation-token audit — but never the token itself. Together with the task's terminal row it bounds the window in which a given contributor could have written to GitHub. - Sensitive.
relay_token_mintedandrelay_task_revokedare in the sensitive-action vocabulary, so they count towardsensitive_24hand carrysensitive: true.relay_connectedis not: it fires on every reconnect. - Not audited.
task_progress, heartbeat andping/pongframes are telemetry, not actions; they would evict operator actions from the 500-entry ring. A connection that never identifies a contributor (missing or invalid registration token) writes no row either. - No remote address.
relay_connectedomits the peer address; the reverse proxy's access log has it. - Volume. Expect roughly 6–10 rows per contributor task, plus one
relay_token_mintedper mid-task refresh on tasks that run past the token refresh period. - Shared command rows live outside this file. When a contributor shares
at
commandsorreasoning, the hub writes onerelay_task_attestedrow here. The command rows themselves go to/data/contrib-audit/<login>/<task>.jsonl(file 0600, directory 0700; login and task id lowercased, with anything outside[a-z0-9._-]replaced), so a chatty task cannot evict operator actions from the 500-entry ring.reason/reason_sourceare kept only atreasoning. Each file is bounded by the relay's 64 KiB frame limit, and a repeated attestation for the same task replaces it. Retention uses this log's max-age knob (90 days, see below): older per-task files are pruned when the next attestation is stored, at most once an hour.
Rotation is handled by lumberjack:
| Setting | Value |
|---|---|
| Max size before rotation | 5 MB |
| Backups kept | 3 |
| Max age | 90 days |
| Compression | yes (rotated backups are .gz) |
Rotation is size-triggered, so the effective lookback is a function of event rate, not calendar time. A busy hive rotates through its three backups in well under 90 days; a quiet one may hold the full window. Any analysis over the audit log should report the window it actually covered rather than assuming 90 days.
Reads that need history beyond the current file must walk the rotated and .gz
backups. OutputActionsSince does this, with a decompression cap that bounds a
malicious or corrupt .gz — see security.
The dashboard serves recent entries at GET /api/audit
(pkg/dashboard/audit.go, handleAuditLog):
- Auth: requires at least the read-write dashboard role; lesser roles get
403. - Shape: a JSON object
{"entries": [...]}— the entries are wrapped in an envelope, not served as a bare array. Entries include a serve-timesensitive: trueflag when their action uses the same sensitive predicate as the collapsed dashboard count. - Ordering: newest first.
- Cap: at most 200 entries (
auditMaxEntries), which is smaller than the 500-entry in-memory ring — the API never returns the whole ring, let alone the whole file. - Sensitive filter:
?sensitive=1returns sensitive entries from the same 24-hour window assensitive_24h, scanning the in-memory ring so an entry older than the default 200-row page can still be found. - Contributor filter:
?contributor=<login>returns only entries whoseuseris that GitHub login (case-insensitive) — the principal of the contributor relay rows above. It scans the in-memory ring like?sensitive=1, keeps the same 200-entry cap and the same role gate, and combines with?sensitive=1(both filters apply). The summary fields (histogram,sensitive_24h,today,last) still cover the whole ring. The contribute page's Operations Your assignments card reads the caller's ownrelay_*rows through it, and the Audit Log'srelay_chip narrows the loaded page torelay_*actions. - Enrichment:
user_nameis stamped here, on served copies only (see above).
The dashboard also keeps the most recent 500 entries in memory
(auditRingCap) for fast display. On restart the ring is rebuilt from the
current audit.jsonl — the last 500 entries of the current file only;
rotated backups are not walked (loadFromDiskPath). The file is the durable
record.
Consumers that need completeness must read the file (including rotated backups), not the API surface backed by the ring.
- Agent self-healing watchdog — writes
watchdog-restart-observedentries in observe mode - Hooks — writes
hook_failedentries when an action's sink is unwired - Security self-assessment — how the audit log functions as the attribution substrate