Audit Log
Yuzu records every operator action as a structured audit event. The audit log provides an access-controlled, retention-managed trail suitable for compliance reporting, incident investigation, and forwarding to external SIEM platforms such as Splunk or Elastic.
Fixed (#2579), and it affected upgrades only. A retention pass that began with NO stored clock reading declined nothing on its own account, so a host whose clock was ALREADY skewed forward when it first ran the guard could delete expired rows unremarked. Such a pass now declines once, warns, and anchors the reading; the next pass proceeds normally. A correct clock was never at risk, and a fresh install with nothing expired does not decline. If you ran an affected version with a wrong clock, rows deleted then are not recoverable without a backup taken before it. Detail under Limits.
Known issue in v0.9.0 (advisory YZA-2026-001): audit rows for requests authenticated via
Authorization: BearerorX-Yuzu-Token(every MCP tool call and every REST automation using an API token) had an emptyprincipalfield. Cookie-authenticated dashboard activity was unaffected. Fixed forward in v0.10.0; pre-fix rows are not backfilled. Operators auditing a window that spans the v0.9.0 → v0.10.0 boundary should expect a bimodalprincipaldistribution. SeeCHANGELOG.mdfor full remediation context.
Storage
Audit events are persisted in the server’s PostgreSQL substrate under the
dedicated schema audit_store (ADR-0006/0040) — the same database that backs the
rest of the server’s stores, not a separate file. There is no SQLite
fallback: if the substrate is unreachable at boot the server fails closed and
refuses to start.
Because the audit log is the SOC 2 evidence chain, its write and read paths have deliberately asymmetric failure postures:
- Writes fail hard. A persistence failure is never silently dropped — the
write returns an error to the caller. Behavioural-PII REST routes turn that
into
503+Sec-Audit-Failed: trueand serve no data, so evidence-less PII is never recorded as audited. - Reads degrade to DENY. A store or connection-pool failure on a query
returns
503, never a false-empty200. A reviewer or SIEM can never mistake an infrastructure blip for “no audit activity.”
| Setting | Default | Description |
|---|---|---|
| Retention period | 365 days | Events older than this are deleted by background cleanup |
| Cleanup interval | 1 hour | How often the sweeper prunes expired events (fleet-wide, single-sweeper — see The retention clock guard) |
Event structure
Every audit event contains the following fields:
| Field | Type | Description |
|---|---|---|
timestamp | integer (Unix epoch) | When the event occurred |
principal | string | Username of the operator who performed the action (agent:<agent_id> on a row the server writes from an agent’s own report, such as registry.write). Empty (as are principal_role and session_id) on a row built from the request after a step-up-gated request changed principal mid-request, because the later resolution yields no session; principal_class still reflects the presented credential, and rows that name their principal explicitly keep it |
principal_role | string | Role of the principal at the time of the action (admin, user; agent on registry.write rows) |
action | string | Dot-notated action identifier (see table below) |
target_type | string | Category of the affected object (Tag, Agent, Session, etc.) |
target_id | string | Identifier of the affected object |
detail | string | Human-readable description or the new value |
source_ip | string | IP address of the client that initiated the request |
user_agent | string | HTTP User-Agent header value |
session_id | string | For dashboard requests, the SHA-256 hex digest (64 characters) of the yuzu_session cookie the request presented — the value the session store keys on (token_hash). Empty when the request carried no cookie (API-token, Bearer and X-Yuzu-Token calls normally have none); when the cookie did not resolve a session on the session-resolving path (an expired or unknown cookie; the auth.logout row, written after the session is invalidated); and on a few hand-built rows that do not stamp it (api_token.create/api_token.revoke, management_group.create/management_group.delete). Pre-session sign-in rows (login, MFA, OIDC/SAML callback) carry the digest of any cookie presented. If a token call also presents a cookie, the digest is of that cookie, which may not be the credential that authenticated the call; principal is authoritative. Agent-session rows (session.* actions written by the gRPC service) carry the agent session identifier when the request presented one, otherwise empty. Rows written by an earlier release, or by a server still on one during a rolling upgrade, hold the value recorded at the time; the two forms look alike and are separated by timestamp against each server’s upgrade date. The audit readers do not return this column; read it from the database. |
result | string | Outcome: success, denied, or failure (or attempted, the audit-first row an operation writes before it changes anything, or no_change, the outcome row of an idempotent request that found nothing to change). See Result vocabulary below the actions table for the precise semantics of each value. On the behavioural-PII access-audit rows (dex.device.view, dex.signal.view, guardian.device.view, and siblings — #4858), result=success does NOT uniformly mean “data was disclosed”; see that same section for the pre-read-vs-post-read split. |
Logged actions
The following actions are recorded automatically. No operator configuration is required.
MCP
mcp.query_responsesemits a pair of rows on a scope-filtered call. When an agentic worker collects responses it is only partly entitled to (some agents outside its management groups), the tool writes aresult=deniedrow (the access-boundary event —detailcarries the distinct dropped-agent count) and aresult=successrow (the served, possibly-empty set), both under the same principal + correlation key. A SIEM rule must treat these as a pair for one call: thedeniedrow is informational CC6.1 access-boundary evidence (an operator reached toward agents outside its scope and was filtered), not a failed call — do not alert onmcp.query_responsesdenials as errors on a scoped multi-operator deployment.
The seven
/cryptoMCP tools write a genericmcp.<tool>row on two more paths. A rejected argument (an unknown key, a wrongly typed value, a bad cursor) writesresult=denied,detailinvalid arguments: <reason>; the<reason>is fixed text and never echoes the caller’s input. A burst of these from one principal is a misbehaving client, not a gap in access control. A tool whose provider, caller identity or audit sink is not wired answers a non-retryable internal error (the response says onlyservice unavailable, or a... unavailabledata message) and, when an audit sink exists, writesresult=failure: adetailendingprovider unwiredis a deployment fault that cannot occur on a correctly started server, whilecaller identity unresolvedis usually a credential revoked or expired between authentication and the tool call, which can occur on a healthy server. With no audit sink there is no row to write, and the four tools that write acrypto.*.viewrow andset_crypto_dashboard_layoutrefuse the call instead of succeeding without one. A row that fails to persist on a working sink is different: the three library reads andget_crypto_ca_statsstill answer, withaudit_persisted:false, and so doesset_crypto_dashboard_layoutwhen only its closing row fails; if its openingattemptedrow cannot be written, it refuses and does not change the layout. Anexport_crypto_cbomrandom-number failure answers a retryable internal error and writes no row.
Per-principal quota-cap rejections (PR 4.4, ADR-1005 class engine principals) are deliberately NOT audited. When an engine principal (
principal_kind=="engine", usernameengine:<slug>) exceeds its per-principal concurrency or rate cap, the server’s pre-routing chokepoint returns429(REST A4 envelope / MCP JSON-RPC-32010, both transports — seedocs/user-manual/rest-api.mdanddocs/user-manual/mcp.md) and incrementsyuzu_server_principal_quota_exhausted_total{side,limit}(docs/user-manual/metrics.md) — it writes no audit row. This is a deliberate fail-visibility choice, not an oversight: a quota rejection is a high-frequency, pre-handler operational event — an engine principal running hot against its own cap, expected under normal steady-state load — not a forensically interesting action against a resource. Auditing every rejection would flood the audit store under a busy or misconfigured engine principal with no compliance benefit; the counter (SIEM-routable,absent()-safe pre-seeded series) is the intended observability surface. Compare withyuzu_onbehalf_rejected_totalandyuzu_auth_lockout_blocked_ total(seedocs/observability-conventions.md), which follow the same metric-is-the-signal / no-per-rejection-audit-row pattern for the same anti-flood reason.
Agent OTA admission rejections (#913) are deliberately NOT audited, but OTA identity rejections ARE. The two halves of the OTA gate are treated differently on purpose. When a peer exceeds its per-peer concurrency or rate bound,
DownloadUpdatereturns gRPCRESOURCE_EXHAUSTEDand incrementsyuzu_ota_download_admission_total{decision}(docs/user-manual/metrics.md) with no audit row — the same fail-visibility reasoning as the per-principal quota block above: a peer running hot against its own bound is expected steady-state behaviour during a fleet-wide update, and auditing every rejection would flood the store during exactly the event it is least useful for. By contrast, a rejection from the identity half — no usable client certificate, a certificate from a foreign CA, or anagent_idthat does not match the certificate — writes asession.ota_identity_rejectedrow, because that is an authentication failure against a specific enrolled identity and is forensically interesting exactly once. Metric is the signal; the audit row is the evidence — and the row is rate-limited per peer while the metric is not, so under a flood the counter is the complete record and the rows are a sample of it. See the row’s entry in the table below.
| Action | Target type | When |
|---|---|---|
auth.login | Session | Operator logs in via the dashboard or API |
role.elevation.granted | User | A pre-authorized operator activated a JIT admin elevation via POST /api/v1/elevate (SOC 2 CC6.3/CC6.6). principal=the operator, principal_role=admin (the effective role for the window), result=ok, detail=duration_secs=<N> mfa=<oidc_amr|local_totp> expires_at=<RFC3339 UTC> justification=<sanitised, ≤1KiB> — the machine-parsed mfa= and expires_at= tokens are placed before the free-text justification= field so a crafted justification can never forge either token a first-match grep reads. duration_secs is the TRUE post-clamp window, and expires_at reflects any clamp to the session’s own absolute lifetime (follow-up B). The grant was MFA-step-up-gated; mfa=oidc_amr records that the second factor was an IdP-MFA assertion (OIDC amr claim) rather than local TOTP enrollment — an OIDC session’s factor is NEVER a local namesake account’s TOTP enrollment (the two identity sources are checked on disjoint code paths). |
role.elevation.denied | User | POST /api/v1/elevate was refused. result=denied, detail names the reason, with distinct reasons per identity source and cause (never a single generic message): “not eligible” / “eligibility read failed” (fail-closed; also the outcome for an OIDC identity with no users row at all); identity-source mismatch (the session’s auth_source does not match the target row’s identity_source — closes a cross-protocol collision where a crafted SAML NameID, or a legacy local row, shares a principal string with a real OIDC identity; #1852 governance round); “identity-source lookup failed” (fail-closed — the eligible row vanished between the eligibility read and the source-match lookup, e.g. a concurrent deprovisioning); for a local session, “no MFA enrolled” (no TOTP enrollment); for an OIDC session, “no MFA in SSO login (the IdP did not assert amr MFA)” (the current login carries no seeded amr proof) or “OIDC-amr elevation is disabled (—no-jit-oidc-amr-elevation)” (the toggle is off — OIDC sessions cannot elevate at all when disabled, they cannot fall back to a local TOTP step-up); “eligibility revoked during step-up” (won a TOCTOU race); “mfa_step_up_refused” (enrolled/proof present but stale). |
mfa.step_up.required | Endpoint | One of the 37 step-up-gated REST and dashboard routes refused a request before the route ran (the central MFA step-up check, SOC 2 CC6.6). target_id is the route’s action label. result=error for every refusal, a policy denial included: it is never result=denied, unlike a per-route handler denial. For a stale or absent proof the detail is user=<name> age_secs=<N> window_secs=<N> cid=<id> stage=pre_authz (age_secs=-1 when the session has no proof at all); every other refusal is user=<name> reason=<token> cid=<id> stage=pre_authz. In every mfa.step_up.required row, whether written by the central check or from inside a handler (including the approver gate’s own challenge), the user= value is neutralised before it is embedded: a space, =, ,, DEL or control byte in the account name is written as _. An SSO account name can contain those characters, so a SIEM rule that reads user= must match the neutralised form, and a name cannot add a reason= token of its own. cid equals the response’s X-Correlation-Id header and the body’s correlation_id, so a reported refusal joins to its row, and stage=pre_authz marks a refusal made before the route’s own authorization. For a cookie session the server-wide same-site gate runs first: a cross-site or header-less write is refused with a csrf.denied (central gate) row and writes no mfa.step_up.required row. A row from a JIT-elevation route’s own in-handler check, or from the unwired-store refusal made inside a handler, has the same fields but carries neither cid= nor stage=. reason tokens: engine_principal_denied (an engine principal, 403), saml_session_no_mfa_this_release (a SAML session, 403), mfa_status_unavailable (the store could not be read, 503 or 401), auth_store_unwired (no authentication store, 503; a defensive state), chokepoint_exception (an unexpected failure inside the check, or a session that could not be fingerprinted, 503, fails closed); step_up_class_unsupported is reserved and not written today. Written best effort: a failed audit write never turns a refusal into a pass. A pass never writes a row. While --mfa-step-up-window-secs is 0 or less no stale proof is refused (so none is recorded) and the SAML refusal and the store read are skipped too, but engine denials and unwired-store refusals still are recorded. The one refusal that writes no row is a request with no session at all (counted on the metric only; a defensive branch, since pre-routing answers anonymous requests first). On these 37 routes an engine denial is this row; the per-route handler row (result=denied, detail engine_session_denied) is no longer written there, so rules keyed on it must be updated. Approve and reject (POST /api/approvals/{id}/approve and /reject) are among the 37, so with the check active a stale proof, a SAML session or an engine principal on them is refused here and leaves no ticket-specific approval.approve / approval.reject row. Their own approver gate (#6079) is stricter (an enrolled factor and a proof no older than the approver window, min(--mfa-step-up-window-secs, 300) seconds, and 300 when that setting is 0 or negative) and runs inside the handler: when it refuses a stale proof, including a proof made before the account’s current enrolment ( reason=proof_predates_enrolment is appended), it writes this same row from the handler, with neither cid= nor stage=, next to the ticket-specific denied row with detail=approver_assurance:mfa_proof_stale. Counted on yuzu_auth_mfa_step_up_gate_total (see docs/observability-conventions.md). |
mfa.step_up.passed / mfa.step_up.failed | User | POST /login/mfa/stepup refreshed the session’s MFA proof (passed, result=ok, detail=method=totp or method=recovery) or refused (failed, result=error: a missing or rejected code, an unreadable store, a bearer, OIDC or SAML session that cannot step up locally, or a session that vanished during the step-up). These record the proof; the refusals that make a client call it are the mfa.step_up.required rows. A proof is also granted by a Settings enrolment confirm, which writes no mfa.step_up.passed row (and no event or yuzu_auth_mfa_step_up_total increment): its evidence is the step_up_proof=stamped detail of mfa.enroll.verified. |
role.elevation.revoked | User | An operator stepped their elevation down early via POST /api/v1/elevate/revoke. result=ok, detail=was_elevated=<bool>. If the durable clear fails the call answers 503 and the row is written with result=error and a durable_clear_failed=true detail. |
role.elevation.expired | User | A JIT admin elevation window LAPSED passively (no manual revoke). Emitted lazily — there is no background reaper — by AuthManager::reap_expired_elevation at the AuthRoutes::resolve_session cookie chokepoint, on the operator’s first authenticated request after the window elapses. result=expired, detail="JIT admin elevation window lapsed". Written once per window on a single server replica; with several replicas a rare duplicate is possible (effectively-once, ADR-2002). A manual role.elevation.revoked clears the same internal sentinel first, so a revoke never ALSO produces this row. Boundary: an operator who abandons the session (closes the browser, lets it idle) without another authenticated request never triggers this event — the window’s end is still reconstructible from the granted row’s duration_secs/expires_at. |
user.elevation_eligibility.set | User | An admin set/cleared a user’s JIT-elevation eligibility via POST /api/v1/users/<name>/elevation-eligibility (admin + MFA-step-up gated). result=ok, detail=eligible=<bool> (plus elevations_cleared=<N> when revoking dropped active windows); result=denied/detail=self_grant_blocked on a self-grant attempt, or detail=elevated_session_eligibility_grant_blocked when the caller is administrator only through a temporary elevation (HTTP 403); result=error on a store failure. |
user.password_change | User | A local account changed its own password via POST /api/v1/users/me/password or Settings → User Management → Change password (#5342). target_id = the caller, and the principal is the caller on every row. result=ok with detail=self_service sessions_revoked=<N> provisional_mfa_cleared=<true|false> on success (no separate session.revoke_all row). The success row is written in the same database transaction as the password change and the sign-out of the account’s sessions, so it exists if and only if the change happened. Refusals are result=denied (error for the store and audit-failure tokens) with one detail token: token_session (API/MCP/engine/service-scoped token), not_local (OIDC/SAML session or SSO/SCIM account), csrf (cross-origin, or both Origin and Referer absent), wrong_current, account_locked (the wire answer is identical to wrong_current; only this row tells them apart), sso_only_local_disabled (--auth-mode=sso-only and not an armed break-glass account; no password was checked), verify_transient (the current password could not be verified — store read failure or a concurrent change; not counted toward lockout), not_found, conflict (the stored password changed concurrently), store_unavailable, audit_unavailable (the success row could not be written, so the change was not made — a best-effort record of the attempt). Every refusal leaves the password, sessions and lockout as they were, except that wrong_current counts a lockout strike (account_locked is refused before the counter is touched — auth_routes.cpp:1685-1702 — and counts none) and a verified current password resets the counter before any later refusal. A malformed body, a new password outside 12–1024 bytes or a wrong Content-Type is not audited (nothing security-relevant was attempted). Never carries the password, its hash or its length. |
user.password_reset | User | An administrator reset another local account’s password via POST /api/v1/users/{name}/password or Settings → User Management → Reset password (#5342). target_id = the account reset; principal = the caller. result=ok with detail=admin_reset target_role=<admin|user> sessions_revoked=<N> provisional_mfa_cleared=<true|false> on success (no separate session.revoke_all row) and — only when the account had a lockout (locked_until set — even if the lock has since expired — or a non-zero failure count) — an auth.lockout.cleared row with result=ok, detail=password_reset. Both rows are written in the same database transaction as the reset itself, so they exist if and only if the reset happened; an account with no lockout gets no auth.lockout.cleared row. Refusal detail tokens: token_session, csrf, durable_admin_required (the caller is not a durable Administrator — a JIT elevation, IdP-group admin role, group-held grant or custom role does not qualify), admin_gate_unavailable (the stores could not confirm the caller’s authority), self_target (use the self route), break_glass_target (the configured break-glass account cannot be reset), not_found, not_local, store_unavailable, audit_unavailable. A malformed username or body, a new password outside 12–1024 bytes or a wrong Content-Type is not audited. The target’s API tokens are not revoked. |
user.delete | User | A local account was deleted via DELETE /api/settings/users/{username}. The full list of result/detail values is in docs/user-manual/rest-api.md (audit action table). Added with the last-Administrator guard (#4966): result=denied, detail=last_administrator_blocked is an early refusal (nothing revoked); a late refusal is result=partial, detail=last_administrator_blocked credentials_revoked_before_refusal=true account_remains_active=true <revoke summary> (the linked credentials were revoked, the account remains active). result=failure, detail=store_failure ... is a store fault or an unwired guard (HTTP 503; the delete may not have completed); result=partial, detail=account_deactivated session_wipe_failed=true ... means the account is deactivated but a session may survive. Audit-write-failure posture: audit-first. An attempted row is written before anything changes; if that write fails the server answers 503 with Sec-Audit-Failed: true and changes nothing (no credential is revoked). If only the success outcome row is lost the 2xx stands, Sec-Audit-Failed: true is set and the attempted row remains (“started, outcome unknown”). Failures are logged at warn and counted on the generic yuzu_server_audit_emit_failed_total (no per-action counter). |
user.role_change | User | A local account’s role was changed via POST /api/settings/users/{username}/role. Added with the last-Administrator guard (#4966): result=denied, detail=last_administrator_blocked (demotion of the last active local admin, HTTP 409, nothing changed) or detail=inactive_promotion_refused (HTTP 409; a store-level rule, reached at the route only when the account is deactivated between its lookup and the write, because the route answers user_not_found 404 for an inactive account); result=failure, detail=store_failure (HTTP 503; replaces the former denied / db_failure); result=partial, detail=role_changed session_wipe_failed=true old_role=<role> new_role=<role> (the role is changed, a session at the old role may survive). result=denied, detail=elevated_session_role_change_blocked (HTTP 403; the caller is administrator only through a temporary elevation; principal_role on the row shows the effective role admin, so detail is the discriminator). The remaining values are in docs/user-manual/rest-api.md. Audit-write-failure posture: audit-first. An attempted row is written before anything changes; if that write fails the server answers 503 with Sec-Audit-Failed: true and changes nothing. If only the success outcome row is lost the 2xx stands, Sec-Audit-Failed: true is set and the attempted row remains (“started, outcome unknown”). Failures are logged at warn and counted on the generic yuzu_server_audit_emit_failed_total (no per-action counter). |
scim.user.last_administrator_refused | User | The last-Administrator guard refused a SCIM deactivation, delete, POST with active:false, or group-derived demotion. result=denied for an early refusal and for a refused demotion, result=partial for a LATE refusal (the linked credentials were revoked before the guard refused); target_id is the SCIM resource id. detail carries attempted_action=<scim action>; on the Users routes also early_refusal=true|false, credentials_revoked_before_refusal=true|false (false: nothing was revoked; true: the linked credentials were revoked first, ADR-2001), account_remains_active=true and live_api_tokens_slug=<n> (the username’s own live API tokens, unknown if the store could not answer) and linked_principals_not_walked=true (the linked OIDC/SAML principals’ tokens are not counted, so a 0 does not mean nothing is live), followed on an early refusal by sessions_not_revoked=true and on a late refusal by identity_links_unchanged=true and the revoke summary; for a refused demotion reason=group, old_role, intended_new_role, via_group and role_unchanged=true. Written for every refusal, including the repeats an identity provider sends after a 409 (only the SCIM server log line is rate-limited). Counted on yuzu_server_rbac_last_admin_guard_refused_total{transport="scim"}. Every refusal, including a refused group-derived demotion (an IdP-demoted person keeps admin), also raises a critical analytics event. The revoked_principals= list inside the revoke summary is lossy: ,, space, =, control bytes and bytes >= 0x80 become _, so two distinct principals can collapse into one token (the exact identity stays in identity_links). |
scim.group.recompute_incomplete | Group | A Group request’s role recompute did not apply every member as intended: a demotion was refused or failed, a member was undetermined (a read failed), or promotions were skipped after a failed guarded write. result=partial, target_id is the Group’s SCIM id, detail=members=<n> followed, for each non-empty class, by refused=<n> refused_users=<...>, failed=<n> failed_users=<...>, undetermined=<n> undetermined_users=<...> or skipped_promotions=<n> skipped_promotions_users=<...> (up to 20 usernames per class, then ,+N more, so the first 20 are named and the count is exact; an undetermined member whose username could not be read is scim_id:<id>), then trigger="<group> op=<op>". One row per Group request. Counted on yuzu_scim_group_recompute_skipped_total{cause="promotion"|"undetermined"}. The same request’s scim.group.created/updated/deleted success row says recompute=incomplete (or recompute=complete when nothing was left undone). The Group request keeps its status. Nothing repairs the members automatically; see the SCIM provisioning guide, “Known limits of the Group role recompute”. A refused demotion is also its own scim.user.last_administrator_refused row, and a failed one a scim.user.role_changed failure row plus a critical analytics event. |
scim.user.deactivated / scim.user.deleted (store failure) | User | When the guarded deactivation hits a store failure (a lock wait that expired, an unavailable store, a lost commit acknowledgement) the request answers 503 and the row is result=failure with detail=account_state=unknown credentials_revoked=true followed by the revoke summary: the linked credentials were revoked, the account may or may not be deactivated. A failed durable session wipe after the deactivation is result=failure with account deactivated; durable session wipe failed (HTTP 500). |
scim.user.reactivated (failure) | User | PATCH/PUT active:true could not proceed: tombstone role unreadable before reactivation (500), administrator grant check unavailable (503), or an account/mirror write failure (500). An elevated tombstone or a name holding an Administrator grant is instead scim.user.provenance_denied (404). |
scim.user.compensation_failed | User | A SCIM create or revive that failed part-way could not be undone: the guarded undo of the just-created account was refused (outcome=guard_refused_last_administrator) or failed (store_failure, deactivated_but_session_wipe_failed). result=failure, target_id is the username, detail=reason=<step> outcome=<outcome>. The request answered 500 and a critical analytics event is emitted; an operator has to check the account. It is left active only for outcome=guard_refused_last_administrator; after deactivated_but_session_wipe_failed it is deactivated but a session may survive, and after store_failure its state is indeterminate. Counted on yuzu_scim_compensation_failed_total{outcome}. |
audit.auth_sample.exported | AuditLog | A caller pulled a pseudo-random authentication-surface evidence sample via GET /api/v1/audit/auth-sample or the Draw sample button on Access > Audit and reviews (SOC 2 CC7.2). target_id=auth-sample; detail=from=<epoch-or-0> to=<epoch-or-0> limit=<N> returned=<N> so the window + sample size are on the chain. result=success. Permission gate: AuditLog:Read. Store-unavailable is a 503 (not an audited failure). If the audit-store write itself fails, the response carries Sec-Audit-Failed: true (the export still returns). Coverage note: the sample is filtered to action_prefixes = {"auth.", "mfa.", "session.", "engine_principal."} — engine_principal.* rows (below) are included in this export, since an engine principal is itself a distinct authentication-surface principal class (ADR-1005 item 2b), not general application activity; they remain independently queryable as ordinary audit-log rows via GET /api/v1/audit/query_audit_log as well. |
mfa.disabled | User | An administrator cleared their own second factor from Settings → Security and sign-in → Two-factor sign-in (POST /api/settings/mfa/disable). principal and target_id are the account; principal_role and the session are the acting session’s, even though that session is deleted by the same request. A disable leaves two rows, ordered by the audit id (timestamp is whole seconds). result=attempted with detail=MFA clear requested via Settings is the intent row: it is written BEFORE the factor is touched and fails closed, so if the audit store does not accept it the route answers 503 (with Sec-Audit-Failed: true) and neither the factor nor any session is changed. The outcome row follows, written after the session delete, with the results below. result=ok with detail=sessions_ended=<n>: a verified factor was cleared and every session of the account was ended, the caller’s included (API tokens are not ended). <n> is the number of durable session rows deleted for the account; it includes sessions that had already expired or idled out but whose row the reaper (absolute lifetime, runs about every 15 minutes) had not yet removed, so it can exceed the number of sessions the user could still have used and is not a count of live sessions. result=ok with detail=no verified factor to clear: an unfinished enrolment was cancelled (Cancel setup); no session was ended. result=partial with detail=sessions_ended=failed: the factor was cleared but the durable session delete failed, so the account’s sessions may still be live; there is no retry control, and an administrator must end them (DELETE /api/v1/sessions?username=<name> or yuzu-server --mfa-reset). result=error: detail=blocked: mfa_enforcement=<mode> when the self-target guard refused the disable because --mfa-enforcement protects the caller (the only row for that request: nothing was attempted, so there is no attempted row); detail=disable failed (sessions_ended=<n>) or disable failed (sessions_ended=failed) when the clear itself failed (its sessions are still ended, because the clear may have committed with its acknowledgement lost; with sessions_ended=failed the page tells the user that the account’s state is unknown and asks them to have an administrator check it and end the sessions). The outcome row is written after the changes and cannot undo them: if it cannot be written, the request still completes and the server logs an error (mfa.disabled: MFA cleared for '<user>' but the outcome audit row failed to persist). The factor clear and the session delete are separate database commits, so a process kill between them leaves the attempted row with no outcome row. An attempted row with no outcome row marks a clear that was requested and whose outcome is unknown: a process that dies after the clear leaves the factor cleared, and one that dies before the clear leaves it intact, so check the account’s MFA state and end its sessions if the factor is cleared. yuzu-server --mfa-reset follows the same two-phase rule. The runbook has two read-only discovery queries for these rows, “Finding a clear whose outcome is unknown” and “Finding a clear that ended error or partial”, in Emergency MFA disable. |
mfa.enroll.required | User | A sign-in under --mfa-enforcement that protects the account reached the enrolment step: the server issued the enrolment challenge during sign-in (result=ok, detail=enforcement=<mode>), or could not start one (result=error; the sign-in does not complete. detail is auth_db unavailable (fail-closed) or mfa_init_enrollment: secret/store unavailable (fail-closed) for a 503, and mfa_init_enrollment failed for a 500). principal and target_id are the account. It is the row to watch while an enrolment sign-in window is open, next to mfa.enroll.verified. See Enrolling a non-admin approver. |
mfa.enroll.failed | User | A TOTP enrolment did not complete: Settings → Security and sign-in → Two-factor sign-in (POST /api/settings/mfa/init / verify) or the enforced login bootstrap (POST /login, POST /login/mfa/enroll). target_id = the enrolling account (empty when a /login/mfa/enroll pending token was unknown). result=error, with detail=code rejected (a wrong first code), a store-unavailable detail (fail-closed), or detail=credential_changed (#5342) — the account’s password was changed or reset after the enrolment’s credential was established, or while its write waited on the change; nothing was revealed, written or enrolled. credential_changed is the same token at every site; at POST /login it replaces auth.login_failed for that attempt and counts no lockout strike. Expect it next to a user.password_change/user.password_reset row for the same account. |
mfa.enroll.initiated | User | Settings → Security and sign-in → Two-factor sign-in (POST /api/settings/mfa/init) asked for a TOTP secret. target_id = the enrolling account. result=ok with an empty detail when the secret was issued; result=error with the operator-facing reason as the detail (the already-enabled message (disable it first to re-enroll), the store-unavailable message, Enrollment failed, or auth_db unavailable) when it was not. A credential change that races the mint is audited as mfa.enroll.failed with detail=credential_changed instead. |
mfa.enroll.verified | User | The first TOTP code was accepted and the enrolment is live: Settings → Security and sign-in → Two-factor sign-in (POST /api/settings/mfa/verify) or the enforced login bootstrap (POST /login/mfa/enroll, detail=enforcement bootstrap). principal and target_id are the account that enrolled, result=ok, and the row carries the request’s source_ip and user_agent. A Settings confirm also stamps a step-up proof on the confirming session, and writes no mfa.step_up.passed row for it, so this detail is the only evidence of that proof: detail=step_up_proof=stamped, or detail=step_up_proof=not_stamped when the session could not be stamped (a bearer principal, an absent or mismatched cookie, or a store failure). The enrolment itself stands either way. For an approver this is the row to match to a person: it shows which account enrolled, when, and from where, not who held the device. See Enrolling a non-admin approver. |
mfa.recovery_codes.generated | User | Ten recovery codes were issued: at an enrolment confirm (detail=10 codes issued, Settings, or the login bootstrap with no detail) or by Regenerate recovery codes (POST /api/settings/mfa/recovery-codes, detail=10 codes issued (rotation)). target_id = the account, result=ok. A failed regeneration is result=error, detail=regeneration failed. The code values are never in the row. |
mfa.enroll.race | User | An enrolment confirm found the account already enrolled because a concurrent verify won the race (Settings or POST /login/mfa/enroll). target_id = the account, result=ok, detail=already enrolled by a concurrent verify; no duplicate enrollment. Audited apart from mfa.enroll.failed so it stays out of the bad-code population (#3777, CC7.2). The losing session is not stamped with a step-up proof. |
mfa.login.verified | User | A local account’s TOTP code was accepted at the login challenge (POST /login/mfa). principal and target_id are the account. result=ok. A recovery code instead writes mfa.recovery_code.used with detail=method=recovery. For an approval it is one of the adjacent rows that evidence the second factor behind a sign-in. |
mfa.reset.breakglass | User | yuzu-server --mfa-reset <username> cleared an account’s second factor and ended its sessions. principal is the OS account that ran the command (not an authenticated session), principal_role is break-glass, target_id is the cleared account. A reset leaves two rows, ordered by the audit id (timestamp is whole seconds). result=attempted, detail=MFA clear requested via --mfa-reset CLI (os_identity=<account>), is the intent row: it is written BEFORE the factor is touched and fails closed, so if the audit store does not accept it the command prints error: audit store did not accept the intent row; nothing was changed and exits 1. The outcome row follows: result=success, detail=MFA enrollment cleared via --mfa-reset CLI (os_identity=<account>, sessions_ended=<n>), where <n> is the number of sessions deleted; result=partial with the same detail ending sessions_ended=failed when the factor was cleared but the session delete did not persist (the command exits non-zero and the sessions must be ended by hand with DELETE /api/v1/sessions?username=<name>); and result=error, detail=MFA clear failed; state unknown, re-run after checking status (os_identity=<account>), when the clear itself reported a failure. In that last case the commit acknowledgement may have been lost, so the clear MAY have landed; no session is ended, the command exits 1 with error: failed to clear MFA; the state is unknown, check the account's status and re-run, and the operator checks the account before re-running. If the outcome row cannot be written after the clear, the command exits 1 with error: MFA cleared but audit row failed to persist; record this reset manually. An attempted row with no outcome row marks a reset that was requested but whose outcome is unknown (the process was killed or the host lost after the intent row: the factor may have been cleared or may be intact, so check auth.users.mfa_enrolled_at). The runbook has two read-only discovery queries for these rows, “Finding a clear whose outcome is unknown” and “Finding a clear that ended error or partial”, in Emergency MFA disable. |
audit.auth_sample.exported | AuditLog | A caller pulled a pseudo-random authentication-surface evidence sample via GET /api/v1/audit/auth-sample (SOC 2 CC7.2). target_id=auth-sample; detail=from=<epoch-or-0> to=<epoch-or-0> limit=<N> returned=<N> so the window + sample size are on the chain. result=success. Permission gate: AuditLog:Read. Store-unavailable is a 503 (not an audited failure). If the audit-store write itself fails, the response carries Sec-Audit-Failed: true (the export still returns). Coverage note: the sample is filtered to action_prefixes = {"auth.", "mfa.", "session.", "engine_principal."} — engine_principal.* rows (below) are included in this export, since an engine principal is itself a distinct authentication-surface principal class (ADR-1005 item 2b), not general application activity; they remain independently queryable as ordinary audit-log rows via GET /api/v1/audit/query_audit_log as well. |
auth.logout | Session | Operator logs out |
auth.login_failed | Session | Failed login attempt |
auth.oidc_login | User | An OIDC/Entra login completed via GET /auth/callback. Explicit-principal row (the request lands with no session cookie yet) — principal/target_id is the stable oidc:<iss>#<sub> principal (oidc_principal_id), never the mutable display name, principal_role is the RESOLVED session role (group-mapping may have promoted the login to admin), result=ok. detail=auth_source=oidc;amr_mfa_asserted=<true|false>;display=<sanitized display name>;email=<sanitized e-mail> — the CC6.6 evidence of whether the IdP’s amr claim attested MFA, and the human-readable name — with ;admin_group=<value> appended when the login resolved to admin, naming the granting --oidc-admin-group value. |
auth.oidc_login_failed | User | An OIDC login attempt at /auth/callback failed. result=error on early rejections (IdP returned an error param, missing code/state) with detail naming the reason; result=failure on the handle_callback failure branch (token exchange / claim validation) and on a refused browser-binding check. The generic failure branch carries no detail, and it is where a callback that could not get the discovery document or the signing keys lands (a discovery or JWKS outage is not told apart from a token-exchange or claim failure in the row); a failure to START the flow (GET /auth/oidc/start, for example when discovery fails) writes no row and increments no counter (#5704); a binding refusal carries detail=reason=browser_binding_missing (no binding cookie), reason=browser_binding_mismatch (a cookie that does not match the flow) or reason=browser_binding_unavailable (the server could not compute the digest; the flow stays pending). A sign-in that passed every check but whose session could not be stored (the durable session write failed) is also result=failure with detail=reason=session_create_failed: no session cookie is set and no auth.oidc_login row is written. A signature or key refusal (a token no usable key verifies, or one signed by a key skipped at parse for being an RSA key under 2048 bits or with a degenerate exponent) writes this same generic failure row with no detail; the refusal text is in the analytics event’s error attribute, and the server’s failed to parse JWK warning names the skipped key. A sign-in whose token exchange finished after a Settings save replaced the OIDC provider is refused before any group, role or session work: result=failure, detail=reason=provider_replaced; if the save landed after the session was minted, the session is deleted again, no cookie is set and the detail is reason=provider_replaced;post_mint=true (with ;db_persisted=false if the durable delete failed). Both are counted in yuzu_auth_oidc_login_total{result="error"}. An IdP response of an unexpected shape (a wrong-typed field in the token response, the ID token header or its claims) is an ordinary failure on the generic branch, never an unhandled error: the provider catches an unexpected exception while handling the IdP’s response, logs OIDC handle_callback: unexpected <type> while processing the IdP response, and the route then writes the usual no-detail failure row and counts it (the analytics event’s error attribute is the fixed OIDC callback processing failed). |
auth.saml_login | User | A SAML login completed via POST /saml/acs. Explicit-principal row (same rationale as auth.oidc_login above) — since ADR-2001 PR4a, principal/target_id is the stable saml:<entity_id>#<NameID> principal (saml_principal_id), not the raw NameID; principal_role is the RESOLVED session role, result=ok. detail=auth_source=saml;name_id=<sanitized NameID> (the raw NameID, run through sanitize_detail_value since it is untrusted IdP-supplied input carried in a detail field — same rationale as OIDC’s display/email handling), with ;admin_group=<value> appended when the login resolved to admin, naming the granting --saml-admin-group value. |
auth.saml_login_failed | User | A SAML login attempt at /saml/acs failed — missing browser-binding cookie (forced-login/CSRF guard), oversized POST body, missing SAMLResponse field, signature/assertion validation failure, or an internal exception during validation/session creation. result=error, detail names the specific rejection reason. |
auth.sso_group_provision | User | The OIDC /auth/callback handler reconciled the IdP-asserted claims.groups into the RBAC store (RbacStore::reconcile_idp_memberships, source="entra") — #1832, hardened per the follow-up round below. result=ok: reconciliation committed AND actually changed something (added+removed>0); detail=source=entra;added=<N>;removed=<M>. A no-op login (the asserted set exactly matches what’s already on record — the common steady-state case) writes no row for this action at all. result=skipped: the IdP omitted the groups claim, or replaced it with an Entra group-overage indirection pointer (_claim_names/_claim_sources, asserted once a user is in more groups than fit in the token) — reconciliation is not run, existing memberships are left untouched, and the login still proceeds (fail-open on membership, never on authentication); `detail=reason=groups_overage |
auth.breakglass.login | User | The configured break-glass account (--break-glass-user) passed the password gate under --auth-mode=sso-only while armed. result=ok means the password was accepted — detail is explicit that no session is minted yet (the mandatory TOTP challenge still runs). Emitted at Severity::kCritical; paired with the metric yuzu_auth_break_glass_login_total. SOC 2 CC6.6. (Note: the sso-only denial of a non-break-glass local login is not audited per-attempt — it is the metric yuzu_auth_local_disabled_total only, to avoid audit-flood under credential spray; the boot-posture log is the activation evidence.) |
auth.breakglass.denied | User | A break-glass login was hard-denied because the account has no MFA enrolled (cleared out-of-band after boot). result=denied, Severity::kCritical. Enrollment is deliberately not offered (it would defeat the second factor) — re-enroll it with the sequence in docs/ops-runbooks/auth-db-recovery.md (break-glass section). SOC 2 CC6.6. |
auth.breakglass.armed | User | yuzu-server --break-glass-arm armed the break-glass account for the configured window. principal = the kernel OS identity that ran the CLI (not the forgeable USER env var), principal_role=break-glass, result=success, detail carries the armed_until timestamp + window. The audit store is verified reachable and migrated before the arm mutates, so the exemption is never granted without a record. SOC 2 CC6.6. |
rbac.bootstrap.first_admin | User | A fresh-install boot (auth.users was genuinely empty) provisioned the config-file admin’s account AND granted it a durable fleet-wide Administrator role, atomically, via RbacStore::provision_first_admin. principal/principal_role=system (no human/session actor exists this early in boot); target_id=the provisioned username; result=success; detail=reason=fresh_install_bootstrap ... plus the OS identity that ran the boot. This is a one-time, non-retriable event — auth.users’s WHERE NOT EXISTS gate means it can never fire again on that database. The audit store is verified writable before the mutation runs (same pre-flight posture as --break-glass-arm above); if the write itself then fails after the mutation already committed, the boot continues (the grant is durable regardless) and an ERROR-level log line is the operator’s only signal — see docs/adr/1008-rbac-management-groups-target-architecture.md “Delivery note (fresh-install bootstrap)” for the narrow ack-loss residual this doesn’t close. SOC 2 CC6.1/CC6.2/CC7.2. |
session.revoke_all | User | An admin force-logged out every active cookie session for a named user via DELETE /api/v1/sessions?username=<name>. target_id=<username>; detail=count=<N> (in-memory cookies wiped). result=partial with detail=count=<N> db_error=true if the durable SessionStore delete failed (since HA WS-1/1a; operator should retry — the delete is idempotent — NOT restart: durable sessions survive a restart, so a failed revoke stays valid until its absolute expiry). API tokens belonging to the user are NOT revoked by this action. |
session.revoke_all.self | User | A user wiped all their own sessions via DELETE /api/v1/sessions/me, OR an admin supplied their own username to the admin path. The /me flow also revokes API tokens; detail=count=<N> api_tokens_revoked=<M>. result=denied if a non-interactive credential (MCP token, service-scoped token) attempted self-revoke. |
session.peer_mismatch | Session | A gRPC Subscribe whose agent peer IP did not match the IP recorded at Register time. Two outcomes share this action. Rejected (result=denied, #1059 — a stolen-session signal): detail carries agent_id=<id> register_ip=<ip> subscribe_ip=<ip> gateway_mode=<bool> (plus raw_peer=<peer> when the presented peer was malformed and subscribe_ip is empty); the SIEM signal is yuzu_grpc_subscribe_peer_mismatch_total{event="security"}. Tolerated (result=ok, #1128 — a NAT-aware relaxation downgraded it): detail carries agent_id=<id> outcome=advisory reason=<mtls_identity_match|trusted_nat_cidr> register_ip=<ip> subscribe_ip=<ip>; the SIEM signal is yuzu_grpc_subscribe_peer_advisory_total{event="security",reason=…}. principal=agent:<agent_id>; target_id is the session_id. SOC 2 CC7.2: the denied form is a high-signal security event; the advisory form is expected operational telemetry in NAT-traversal deployments (a spike there alone is benign; correlated with a denied spike it warrants investigation — see docs/observability-conventions.md). Security events route to the SIEM via Prometheus, not by tailing the audit store; this row is the paired forensic evidence. If the audit-store write fails, the gRPC response carries trailing metadata x-yuzu-audit-failed: true (see gRPC audit-failure signal below). |
session.identity_mismatch | Session | A gRPC Subscribe was rejected because the mTLS client identity (cert CN/SAN) did not overlap the identity bound at Register time (#1118 — a stolen-session signal, the mTLS sibling of session.peer_mismatch). principal=agent:<agent_id>; target_id is the session_id; detail carries agent_id=<id> reason=mtls_identity_mismatch presented=[<cert CN/SANs at Subscribe>] bound=[<cert CN/SANs at Register>] so an auditor can identify the cert used in the attempt. result=denied. SOC 2 CC7.2: high-signal security event. The SIEM signal is the Prometheus counter yuzu_grpc_subscribe_identity_mismatch_total{event="security"}; this audit row is the paired forensic evidence. On audit-store write failure the gRPC response carries x-yuzu-audit-failed: true (see gRPC audit-failure signal below). |
session.ota_identity_rejected | AgentCertificate | An agent-initiated OTA RPC (CheckForUpdate or DownloadUpdate) was rejected because the peer presented no usable client-certificate identity, or the agent_id in the request body did not match the certificate’s CN/SAN (#416). principal=agent:<cert identity, or the claimed agent_id when no cert was presented>; target_id is the cert identity; detail carries reason=<foreign_ca|agent_id_missing|agent_id_mismatch> rpc=<check_for_update|download_update>. result=denied. Distinct from session.identity_mismatch above, which covers the Subscribe binding check and carries a different detail shape. Emitted only where the agent listener actually requires a client certificate — on a default-certificate deployment the OTA identity gate is inert so unenrolled agents can still bootstrap. Not every identity rejection writes this row. reason=no_client_identity — a peer that presented no certificate at all — is deliberately metric-only: it has no resolvable principal to attribute, it is the cheapest case to forge in volume, and this audit write is synchronous and sits ahead of the per-peer admission bound, so an unbounded stream of them would pin server threads on the audit path. That is the same split reject_revoked_peer already makes for heartbeat (“a flood must not hammer the WAL”). The remaining three reasons all name a certificate we recognise — but they are not bounded by the issued-certificate population, as an earlier version of this page claimed; they are bounded by the number of REQUESTS, since one enrolled agent holding a valid certificate can loop CheckForUpdate with a mismatched agent_id indefinitely. So this row is RATE-LIMITED, at roughly two per second per (peer, RPC, reason) — a peer exercising both OTA RPCs therefore has a bucket on each (keyed on the peer’s certificate identity, or its source IP where it presented none — never on the claimed agent_id, which the caller controls and could vary to mint a fresh allowance per request). During a sustained flood you will therefore see far fewer rows than rejections. The metric below is NOT rate-limited and counts every one, so the gap between the counter and the row count is itself the signal that suppression is happening; treat the counter, never the row count, as the measure of volume. agent_id_missing returns INVALID_ARGUMENT rather than UNAUTHENTICATED — the request is malformed, not the certificate — and still writes this row. SOC 2 CC7.2: the SIEM signal is yuzu_grpc_ota_identity_rejected_total{event="security",rpc,reason}, which counts ALL four reasons including the unaudited one; this row is the paired forensic evidence for the three that have a principal — sampled, per the rate limit noted above, while the counter is complete. On audit-store write failure the gRPC response carries x-yuzu-audit-failed: true. |
session.gateway_peer_denied | GatewayRpc | A call to the gateway-upstream service (--gateway-upstream) was refused because the caller was not an authorized gateway peer (SPKI public-key pin; see server-admin.md). result=denied. Written only when the peer’s key hash is known, that is, for a TLS-authenticated peer whose certificate parsed: principal is gateway-peer:<spki8> (the first 8 hex characters of the SHA-256 of the peer certificate’s public key, computed by the server) with principal_role=unverified_peer (the caller failed the pin, so the row does not claim it is a gateway; an agent-leaf probe produces the same label); target_id is the RPC name (proxy_register, batch_heartbeat, proxy_inventory, notify_stream_status or forward_guardian_message); detail is reason=<reason> rpc=<rpc> spki=<16 hex> (hex only: never a certificate, CN or serial), with reason from the closed set no_server_auth_eku, not_pinned, outside_validity or internal_error for these peers; source_ip is the transport peer. Refusals with no key to attribute write no row: null_context, not_authenticated, no_cert, bad_cert, and an internal_error raised before the certificate was read are counted and logged only. That is the same split the OTA no_client_identity rejection makes (session.ota_identity_rejected above): there is no resolvable principal, the audit write is synchronous on the request path, and for three of the four there is also an unbounded-volume concern: null_context, not_authenticated and no_cert are reached by unauthenticated or certificate-less callers, while bad_cert is reached only for a transport-authenticated peer whose certificate does not parse (there is no key to attribute a row to). The full reason set is null_context, not_authenticated, no_cert, bad_cert, no_server_auth_eku, not_pinned, outside_validity, internal_error. The action is in the session.* family, so it is inside the CC7.2 auth-sample export. Rows are written under a bounded per-source budget (keyed by reason and key prefix; 10 rows per key per 10-second window); beyond 64 distinct active keys, new keys are charged to one overflow bucket per audit-eligible reason (same 10-row limit; the worst case is 680 rows per 10-second window per replica); rows beyond the budget are not written and are counted in yuzu_server_gateway_peer_denial_audit_suppressed_total. The row is bounded evidence, not a count: the SIEM signal and the complete count is the unsampled Prometheus counter yuzu_server_gateway_peer_denied_total{event="security"} (see metrics.md). Audit-write-failure posture: set-and-proceed. The refusal is decided before the row is written, so a failed or budget-suppressed write never changes the refusal. Only a FAILED write logs gateway peer guard: audit write failed for a denied call (rpc=<rpc>) at warning level; a budget-suppressed row increments the suppression counter and appears only in the per-window summary warning (gateway peer: N denial audit rows suppressed in the last window, logged when the next denial arrives): the caller always receives the same gRPC UNAUTHENTICATED. The response does not carry the x-yuzu-audit-failed trailer (see “gRPC audit-failure signal” below): the denial counter is complete whether or not a row lands, so the evidence chain for the count is intact. Budget suppression is counted by yuzu_server_gateway_peer_denial_audit_suppressed_total; a failed write has no counter of its own, only the warning line, so there is no metric to alert on. The write is synchronous on the gRPC handler thread and shares the audit store’s pool; the per-source budget above bounds that cost. Not written when --insecure-gateway-peer is set, since no call is refused. |
server.gateway_peer_authz_disabled | GatewayUpstream | The server started with --gateway-upstream and --insecure-gateway-peer, so peer authorization on the gateway-upstream service is disabled on every port the service is reachable on. principal is system, target_id is peer_authorization, result=success, detail is mode=<insecure_ack|insecure_ack_tls> tls=<true|false>; one row per start (insecure_ack_tls always has tls=true). insecure_ack_tls means TLS and a client CA were present, a production-shaped configuration. Not written in enforce mode. If the row cannot be written (the audit store is not open, or the write fails) the server logs a warning, gateway peer audit row 'server.gateway_peer_authz_disabled' was not written: <cause>, and starts anyway. This row is not in the CC7.2 auth-sample export (its prefixes are auth., mfa., session. and engine_principal.); query it by action. |
gateway.cluster_affinity_violation | Agent | A gateway-side CONNECTED notification claimed a cluster_id differing from the agent’s durably-bound home_cluster_id affinity (#4669, multi-cluster gateway mode) — refused outright, nothing published to either the durable routing directory or the in-memory dispatch path. principal=gateway_cluster:<claimed_cluster_id> (gateway-asserted, NOT cryptographically bound to the presenting peer’s actual identity — see gateway_mgmt_stub_pool.hpp’s TRUST BOUNDARY note); principal_role=gateway; target_id is the agent_id; detail=session=<id> claimed_cluster_id=<id> detected_at=<pre_check|announce_connected_write> (the latter distinguishes which of the two detection sites caught it — the pre-check is the common case; announce_connected_write is the narrow race window where the pre-check itself degraded or missed it, and by that point the in-memory placement had already published — see ADR-2002 §7d). result=failure always. Any non-zero rate is worth investigating — unlike most gateway desync outcomes, this is not a benign background rate: it means either a cluster_id rename/misconfiguration, or a genuine rogue/compromised-gateway claim attempt. Paired Prometheus signal: yuzu_server_gateway_route_desync_total{op="announce_connected",outcome="cluster_affinity_violation"} (also op="cluster_affinity_check" on the pre-check’s own read-degrade, a distinct, non-security desync). SOC 2 CC6.1/CC6.6. If the audit-store write itself fails, the gRPC response carries x-yuzu-audit-failed: true (see gRPC audit-failure signal below) — the refusal itself is never contingent on the audit write succeeding. |
gateway.session_held_by_other_agent | Agent | A gateway-side CONNECTED notification presented a session_id that a DIFFERENT agent’s directory row already holds, and the presenting agent has no directory row of its own, so the fallback insert was refused by the unique session index (#5683, HA WS-5) - refused outright: the in-memory placement is unpublished and the notification is not acknowledged. principal=gateway_cluster:<cluster_id> (gateway-asserted, NOT cryptographically bound to the presenting peer’s actual identity, same as gateway.cluster_affinity_violation); principal_role=gateway; target_id is the presenting agent_id; detail=detected_at=announce_connected_write; result=failure always. The session id is deliberately NOT recorded: it may be another agent’s live session. The ProxyRegister replay-adopt refusal of the same condition writes NO audit row (metric and WARN only). An agent that still has a directory row, including a tombstone, is not covered (its CONNECTED is a session_mismatch; ADR-2002 7f). The audit write is best-effort: if the write fails, the gRPC response carries x-yuzu-audit-failed: true (see gRPC audit-failure signal below); an unwired or closed audit store writes no row and sends no trailer, and the refusal itself never depends on the audit write succeeding. Any non-zero rate is worth investigating (after ruling out the one-time v5 migration dedup by the two-part rule in gateway.md: at most N proxy_register refusals, none from announce_connected, AND all within that start’s window of roughly 2 minutes when every gateway includes #5695, or roughly 6 minutes plus 20 ms per agent if any gateway is older; refusals inside the bound and window are still not proof of benignness): it means a forged or colliding presented id from a pinned gateway peer. Paired Prometheus signal: yuzu_server_gateway_route_desync_total{op="announce_connected",outcome="session_held_by_other_agent"}, alert YuzuGatewaySessionHeldByOtherAgent. SOC 2 CC6.1/CC6.6. |
ca.cert.revoked | AgentCertificate|CodeSigning | An internal-CA-issued certificate was revoked via POST /api/v1/ca/revoke, the revoke_certificate MCP tool, or the dashboard revoke wrapper (POST /api/settings/ca/revoke). target_type is derived from the certificate’s own recorded purpose (derive_cert_audit_target_type, shared by REST/MCP so both classify identically); target_id is the serial hex. result=success on an actual revoke, with detail=<reason>; result=denied when the serial is unknown or already revoked (reject-without-state-change, matching every other idempotent-retry-safe destructive verb in this table); result=failure on a genuine store/lease error (distinct from denied — never conflate a database outage with a rejected revoke attempt). A successful revoke always attempts a CRL republish, recorded separately below as its own ca.crl.published row. |
ca.crl.published | Security | Every internal-CA CRL (re)publish now writes this row (HA WS-6, #4829) — the single production publisher CaStore::publish_next_crl is reached from five distinct triggers, each with its own reason= token in detail: an operator revoke (reason=revoke, from the ca.cert.revoked row above), a subordinate-CA import on any of its three surfaces — REST POST /api/v1/ca/import-chain, MCP import_ca_chain, or the dashboard wrapper (reason=import_chain, via=dashboard appended on the dashboard surface) — the boot-time pre-publish (reason=startup), the leader-gated freshness re-publish when the served CRL nears its nextUpdate (reason=freshness), and the count-compare self-heal re-publish when the served CRL does not cover every current revocation (reason=self_heal). The two operator-triggered reasons (revoke, import_chain) are audited by the calling REST/MCP/dashboard handler under the caller’s own request context, exactly like every other operator-initiated row in this table; the three system-initiated reasons (startup, freshness, self_heal) are audited by the publisher itself (CrlPublisher::audit_publish) under principal=system principal_role=system, since there is no live request to attribute them to. target_id differs by which handler wrote the row: the two operator-triggered rows use the SAME target_id as their sibling row above (ca.cert.revoked’s serial hex for reason=revoke; the literal root for reason=import_chain, matching ca.subordinate.imported); the three system-initiated rows (CrlPublisher::audit_publish) use the new CRL’s version number on success, or the literal crl when no version was minted (a failed publish, or a known-degraded-store early-out). result=success/failure mirror whether the publish was durably recorded (publish_crl()/CaStore::publish_next_crl never reports success unless the transaction committed — #1240 B-1), so result=failure here always pairs with crl_republished:false on any REST/MCP response that surfaces it. A self_heal row with result=success is the resolution of an earlier revoke-triggered result=failure row — read the pair together, not the failure row in isolation, when reconstructing whether a given revocation ever reached the public CRL. See docs/pki-architecture.md “CRL numbering across replicas” and docs/user-manual/metrics.md’s yuzu_server_ca_crl_publish_failure_reason_total{reason} for the paired real-time signal. |
ca.subordinate.imported | CaRoot | An enterprise-signed intermediate was imported to switch the internal CA from built-in to subordinate mode, via POST /api/v1/ca/import-chain, the import_ca_chain MCP tool, or the dashboard wrapper (POST /api/settings/ca/import-chain, CSRF-gated same-site before Security:Write). target_id is always the literal string root. result=success (detail=mode=subordinate, via=dashboard appended on that surface) on a validated import (own-key + CA:TRUE + chains-to-parent, all checked before any state change); result=denied for each business rejection (detail=reason=no_root|bad_intermediate|not_ca|key_mismatch|chain_invalid, via=dashboard appended where applicable) — reject-without-state-change, matching this table’s other maker-checker-adjacent verbs; result=failure (detail=reason=store_error) on a genuine persistence fault, and also on an internal exception during validation (detail=reason=exception[...]) so a thrown C++ exception from the crypto/chain-validation path never silently skips the audit trail on this privileged trust-root switch. A successful import always attempts a CRL republish under the new issuing identity, recorded separately as its own ca.crl.published row with reason=import_chain. |
tag.set | Tag | A tag is created or updated on an agent |
tag.delete | Tag | A tag is removed from an agent |
instruction.execute | Instruction | An instruction is dispatched to one or more agents, or POST /api/instructions/{id}/execute refuses the call (target_id is the definition id). A refusal for supplied params has result=denied and detail reason=param_schema path=<path> (HTTP 400; <path> is /<name>, /<name>/<index>, /* for an undeclared name or (root), never a submitted value); a stored schema that cannot be used has result=failure and detail reason=param_schema_invalid (HTTP 500, fail closed; it is a server-side condition, not an operator denial). Both happen before any approval ticket, execution row or dispatch |
command.dispatch | command | A plugin.action pair is dispatched through the shared classification/authorization chokepoint — written by POST /api/command, the dashboard exec console (POST /api/dashboard/execute) and the legacy forwarder. result=success records the dispatch (a dashboard send that targets a scope_expression first writes result=attempted and dispatches nothing, answering 503 with Sec-Audit-Failed, when that row is not stored; a send that reaches no agent then records result=failure); result=denied records a refusal, with detail carrying reason=<r> <plugin>:<action> (both caller-supplied fields sanitised and capped at 128 bytes). <r> is the union of three families: the DispatchDenialReason set from the shared chokepoint (unclassified, ambiguous, anonymous_operator, forbidden, approval_required, kill_switched), the targeting-shape and route-reject reasons (see yuzu_server_dispatch_target_rejected_total in metrics.md), and three emitted by the dashboard’s Destructive gate: destructive_untargeted (a Destructive-classified pair dispatched without explicit, non-empty agent_ids — broadcast and scope fan-out are refused), scope_violation (explicit targets supplied, but confinement against the operator’s visible agents left none reachable), and classifier_unavailable (the capability classifier is unwired, so every dispatch on that surface is refused, ReadOnly included — a misconfiguration, not a client error). POST /api/command’s own REST-side counterpart to scope_violation is destructive_no_visible_target (#2557): explicit, non-empty agent_ids WAS supplied, but confining it to the caller’s visible agents left none reachable (the route answers 404 in this case — see rest-api.md). Before #2557 this REST arm emitted no metric increment, no server log line, and no audit row at all — a bare 404 with nothing to correlate it to; only the dashboard’s analogous scope_violation case had any observability. Caveat: the refusal rows record who dispatched what, but not which agents were requested — target_id is empty and the requested target is not in the detail string. It is recorded in the server log instead, on both dashboard refusal arms: the untargeted arm logs the supplied scope_expr, and the confinement arm logs the requested agent ids it could not reach. So a SIEM rule keyed on this row alone cannot reconstruct the intended blast radius of a refused Destructive dispatch — that requires correlating the audit row with the server log by principal and timestamp. The same limitation applies to the REST twin. |
command.dispatch_withheld | Command | A dispatch was withheld before send because the plugin is absent from every named target’s reported inventory (#3511), or because the per-OS kill switch for a target’s OS is off (#5294) — added in the PR #3939 review round, correcting an earlier claim that this had “nothing for an auditor to review”: a command that was not delivered is exactly what docs/observability-conventions.md’s “Denied operations MUST emit an audit event” clause requires durable evidence of. result=denied always, target_id="*" — ONE row per dispatch (not per withheld id), mirroring quarantine.dispatch_denied’s fail-closed shape: this is a single withholding decision applied to a batch. detail carries COMMAND_DISPATCH_WITHHELD command=<id> plugin=<name> reason=plugin_not_found agents=<N>, or reason=kill_switched_os agents=<N> for the per-OS case (a separate row, counting only the ids withheld for that cause). A third variant, COMMAND_DISPATCH_WITHHELD command=<id> plugin=<name> reason=os_gate_unreadable (#5294, no agents= count and no paired rejected-counter), records a dispatch refused before targeting because agent presence could not be read while a per-OS kill switch was set; the scheduled path additionally counts it on yuzu_server_command_outbox_deliver_retry_cause_total{cause="os_gate_unreadable"}. The paired yuzu_server_dispatch_target_rejected_total{reason="unknown_plugin"} (or reason="kill_switched_os") counter moves by the same count — use the counter to detect the rate, this row for identity (who dispatched, which plugin). Emitted on the same three routes as the quarantine emitters (dispatch_closure, command, legacy); MCP execute_instruction and the dashboard exec console reach the shared dispatch_closure chokepoint and are covered by that row. |
registry.write | command | A mutating action of the Windows registry plugin (set_value, delete_value, delete_key, set_user_value, delete_user_value, apply) ran, was refused or failed on an endpoint, with the old and new values as the plugin renders them (hex of the raw bytes for every value type, at most 256 bytes each, then ~<total>); a delete_key row carries counts and no values, a per-user row names hive=HKU and the profile’s sid=<SID> (a result=success per-user row with reason=hive_unload_failed is a durable write whose offline hive may still be mounted: a reg unload is owed, see the plugin README’s token table), and an apply writes one row per plan item it attempts with op=apply.<op>, as each item concludes (items skipped after a stop have none; the reply’s summary row gives the totals) (a dry run, of apply or of delete_key, writes result=success rows with dry_run=1: the row records that a vet ran, not a change, so a reader filtering result=success must also read dry_run) (the field grammar is in the plugin README, “Audit line grammar and producer contract”). Written by the SERVER from the agent’s output, not by an operator route: the agent streams audit|... lines in its output frames and the server records one row per line. Binding: only a command whose command_id is exactly what the server mints for the registry plugin (registry- plus 16 or 32 lowercase hex digits; the plugin name keeps the operator’s spelling and is matched ASCII-case-insensitively) whose response the server’s own dispatch record labels registry, and whose agent-stamped action is one of the six mutating actions produces rows: a read action never does, and an agent that predates the action stamp stamps none and produces none (the write still happens; there is no row, so during a mixed-fleet rollout only upgraded agents are audited). The plugin is not taken from anything the endpoint sends: the gate takes it from the server’s dispatch record for the command_id (never from the id’s text), and the server also checks the shape of the command_id and the stamped action. What the endpoint still supplies is the content of the lines, and an endpoint can replay a registry id the server issued to it (the record proves the server issued the id, not who sent the frame). A response with no usable dispatch record (an id the server never issued, a failed record write, a record reaped after its 24 hour window, or a lookup that failed twice) is stored ~unverified, mints no row and moves no reason of yuzu_server_registry_audit_dropped_total; the counters that move are yuzu_server_response_provenance_total{outcome} (unrecorded or lookup_degraded) and, for a failed record write, yuzu_exec_correlation_write_degrade_total. Fields: principal=agent:<agent_id> (the agent id the session is bound to: certificate-bound when the agent registered with a client certificate under mTLS enforcement, otherwise bound by session and peer address), principal_role=agent, target_type=command, target_id=<command_id>. detail is agent=<agent_id>, a space, cut=0 or cut=1 (1 when the line was cut at the byte bound; see Bounds) and a space, then the line: the server’s tokens come first, so a reader that takes the first agent= or the first cut= match never finds one from inside the endpoint’s text. result=success only when the whole line’s first |result= token is ok (read from the whole line, before it is cut); any other value, or none, is result=failure — never derived from dry_run. Registry values can hold secrets (an autologon password, a product key): treat these rows as sensitive. Owner risk acceptance: old and new values are recorded as raw hex and can contain secrets; the product owner accepted this risk on 2026-10-20 and masking is a planned later change. A row is evidence of a change (except a dry_run=1 row, which records that a vet ran), not a restore point. The same holds for the apply and per-user rows. The write parameters are stored unredacted in executions.parameter_values, which no server retention pass prunes: value and data_hex of set_value and set_user_value, and the whole apply plan. The responses carry the same old values, and an apply reply’s undo manifest holds the previous values (up to 4,096 bytes each, 512 KiB per reply) for Response:Read holders for 90 days by default. A recursive delete_key row looks like a leaf delete apart from its counts: recursive is only in executions.parameter_values, joined on the command_id. The rows are readable by every role holding AuditLog:Read (Viewer, Operator, PlatformEngineer, ITServiceOwner and Administrator; 365-day audit retention by default), by MCP query_audit_log, by the stored response through Response:Read (the same five roles; 90-day response store by default), and, through the legacy /events stream, by any authenticated session that is not service-scoped, regardless of RBAC. The command_id joins to the dispatching operator’s row where one exists: the command.dispatch / instruction.execute row for /api/command and the dashboard (same target_id); an MCP execute_instruction row names the tool as its target with the command_id in its detail; POST /api/instructions/{id}/execute uses the definition id as target; a bundle step joins by correlation_id; a scheduled run is paired by its command.outbox_delivered row (see docs/observability-conventions.md), whose target_id is the command_id. A workflow step (workflow.execute), a result-set producer (result_set.create) and a compliance remediation (policy.remediate, always operator-initiated) record only an execution_id, so this row’s target_id is the only handle on the command_id. Bounds: at most 300 lines of 4,096 bytes per output frame are taken; lines past the 300th are not recorded; a longer line is cut on a UTF-8 character boundary (other bytes are cut at the bound) and its row says cut=1; ASCII control bytes in a line are replaced with _. Budget: each agent id may write 600 rows per fixed 60-second window (a burst straddling a window boundary can admit up to twice that) on each server process (an HA replica keeps its own, and a restart resets it), because a bound per frame is not a bound on frames. Rows over it are refused, not queued. An apply of 256 items is up to 256 rows (inside the 300-line bound even when one frame carries them all), so three such plans inside one window on one agent exceed the budget and the later rows are suppressed. The first 262,144 agents to write in a window each have an allowance; later agents share 65,536 allowances by a hash of the id, so a fleet-wide change answered at about one row per agent still loses nothing (at 1.2 million agents an agent that writes twenty rows or more in one window can start to lose some), while an agent that loops rows there exhausts the allowance it shares. Refused rows are counted, and one log line per window reports their number (count only, naming no agent), logged when the next window opens. Signals: the rows of a frame are written before its execution advances, and the writes are synchronous (one commit per row): a failing audit write never stops an execution, but the frame waits for it. A failed store write ends that frame’s rows, is logged, moves yuzu_server_audit_emit_failed_total (alert YuzuAuditPersistFailures) and, on the direct session, adds the gRPC x-yuzu-audit-failed trailer once per stream, delivered when the stream closes (nothing in the agent reads it; the gateway path has no trailer, so the one log line the hook writes for the failed frame is its signal). At shutdown ServerImpl::stop() closes the hook and waits up to 15 seconds for a write in progress before it releases the audit store and the database pool (a write still stuck then ends the process, as the neighbouring shutdown waits do): the rows of a frame that are not yet attempted when the hook closes are counted as persist_failed and logged once at warning level (the counter is this process’s own and its /metrics listener is already stopped by then, so that log line is the evidence; on the direct session the call also gets the audit-failed trailer), and a frame that arrives afterwards records and counts nothing. yuzu_server_registry_audit_dropped_total{reason} counts the evidence the server sees go unrecorded while its audit store is up: suppressed (rows refused by the budget), persist_failed (rows lost to a failed write, or not attempted because shutdown closed the hook mid-frame), unmintable_id (registry mutating frames whose command_id names the plugin but is not the minted shape: no rows, logged once per frame; an empty id names no plugin) and truncated (frames that hit a bound, logged once per frame). To find the agent behind a climbing suppressed, count the persisted registry.write rows per principal. Rows can be lost, uncounted, when a stream drops after a mutation (the frames not yet delivered), when a command finishes more than 300 seconds after dispatch for a gateway-connected agent (the gateway’s command window: its later frames are dropped), for frames that arrive once shutdown has closed the hook (just before the server releases its audit store), when a registry command has no dispatch record (the frame is labelled ~unverified; see Binding), when an allocation failure stops the server working out a frame’s rows (only an error log line says so), when the agent process dies between a write and its reply (the stored response and the command.dispatch row keep the attempt attributable; the registry may hold the write), and when the agent cannot even write its exception line. An exception inside a mutating action on the agent is not a lost row: the plugin writes a fixed result=failed reason=exception line before it answers `error |
instruction.approve | Instruction | An instruction pending approval is approved |
instruction.deny | Instruction | An instruction pending approval is denied |
instruction_set.create / instruction_set.delete | InstructionSet | Written twice per call: result=attempted BEFORE the change (if it cannot be stored the request is refused with 503 and Sec-Audit-Failed: true, and nothing is created or deleted), then the outcome (success, denied, error). See the REST API reference for the detail values. |
csrf.denied (central gate) | Endpoint | Any cookie-session request other than GET/HEAD/OPTIONS that carried no Origin/Referer, or one naming another site (and not a --csrf-trusted-origin entry), and any guarded cookie-session request (one that dispatches work, persists state or forces a refresh of server state, whatever its method) whose Sec-Fetch-Site header is present and is not exactly one same-origin or none value (a present header decides alone; only when it is absent is the Origin/Referer test applied), refused 403 by the server-wide same-site check, after authentication and before step-up evaluation, challenge emission and route-handler effects, and before the body is read. target_id is the sanitised request method and path, with the request path truncated at 200 bytes (for example POST /path, or GET /path for a guarded read); result=denied; detail is the fixed text cross-site or header-less browser write refused; reason=<token>, where <token> is one of the closed set no_origin_no_referer, origin_mismatch, referer_mismatch (no header bytes are ever recorded; a guarded read uses the same fixed text, so it still reads “browser write refused”, with origin_mismatch for any refused Sec-Fetch-Site value; a refusal caused by a repeated Sec-Fetch-Site header is likewise recorded with reason origin_mismatch, since the reason set is closed); the principal is the signed-in operator. One row per refused request. A request that presents a valid session cookie is checked as a cookie-session request even if it also carries a token; requests authenticated only by a token are not gated. If the row cannot be written the response still refuses and carries Sec-Audit-Failed: true. If the check itself fails (counter reason gate_error) the request is refused and counted but this path writes no row of its own, which is why the shipped YuzuSameSiteGateInternalError alert exists. A deployment with no audit store configured (audit off) writes no row at all and the response carries no Sec-Audit-Failed header, since nothing failed to persist: the counter yuzu_server_csrf_denied_total is then the only record. For a cookie-session caller this row replaces the per-route csrf.denied rows below, so target_id values keyed on those no longer appear for cookie-session callers; since this release they appear only for a token caller of a route whose own copy is not token-exempt. Rows written by those copies may use result=error with sanitised header bytes, while this row always uses result=denied with fixed text. |
csrf.denied (user management) | Endpoint | A browser (session-cookie) POST /api/settings/users, DELETE /api/settings/users/{username} or POST /api/settings/users/{username}/role carried no Origin/Referer, or one naming another site, and was refused with 403 before anything was read or changed. target_id is the route (/api/settings/users, /api/settings/users/{username}, /api/settings/users/{username}/role). API-token callers are exempt. For a cookie-session caller the csrf.denied (central gate) row is written instead. |
csrf.denied (instruction family) | Endpoint | A browser (session-cookie) write to an instruction, instruction-set, response-template or /api/instructions/{id}/execute route that carried no Origin/Referer, or one naming another site, was refused with 403. Token-authenticated callers are not gated. For a cookie-session caller the csrf.denied (central gate) row is written instead. |
csrf.denied (dashboard dispatch) | Endpoint | A browser (session-cookie) POST /api/dashboard/execute or POST /api/dashboard/echo-execute that carried no Origin/Referer, or one naming another site, was refused with 403 before anything was parsed, audited as attempted or dispatched. target_id is dashboard. Token-authenticated callers are not gated. For a cookie-session caller the csrf.denied (central gate) row is written instead. |
csrf.denied (access reviews) | Endpoint | A browser (session-cookie) POST /api/v1/access-reviews, POST /api/v1/access-reviews/{id}/attestations or POST /api/v1/access-reviews/{id}/close carried no Origin/Referer, or one naming another site, and was refused with 403 before anything was read or changed. target_id is the route as written. API-token callers are exempt. A body that is not application/json on the first two is refused with 415 and an access_review.campaign_opened or access_review.attested row, result=denied, detail=content_type_not_json. For a cookie-session caller the csrf.denied (central gate) row is written instead. |
csrf.denied (privileged RBAC and Guardian writes) | Endpoint | A browser (session-cookie) call to one of the privileged RBAC or Guardian write routes (each is marked “Cross-site writes” in the REST API reference) carried no Origin/Referer, or one naming another site (and not a --csrf-trusted-origin entry), and was refused with 403. The central check (first csrf.denied row of this group) decides this first; each of these routes keeps its own copy after its authorization gates and any MFA step-up and before the body was read, as defence in depth. target_id is a short per-route label. The refusal is recorded under csrf.denied, not under the route’s own verb. API, MCP and engine token callers are not gated. |
instruction.import | InstructionDefinition | An InstructionDefinition JSON envelope was submitted via POST /api/instructions/import. On result=success: target_id is the new definition’s id; detail is empty. On result=denied: target_id is empty (no id assigned on rejection); detail carries the store-returned error string, which begins with a stable SIEM-keyable token - known tokens are duplicate_id (409 conflict), signature verification failed for instruction — content may have been tampered with (#1073 / W7.4), instruction-import is unsigned and signature enforcement is enabled (#1073), instruction-import has incomplete signing metadata (#1073), instruction-import has signing field of wrong JSON type (#1073 R1), signature length invalid / publicKey length invalid (#1073 R1 DoS amplification guard), instruction-import has signature + publicKey but no yaml_source (#1073), definition id may not use the reserved 'mcp.' prefix (#2442 - the mcp. definition-id namespace belongs to MCP approval tickets; note this one is reached AFTER signature verification, so a signed pack is verified first and then refused on the id), definition id may not use the reserved 'guardian.' prefix (#6062 - the guardian. namespace belongs to Guardian rule-deploy approval tickets; same ordering as the mcp. token), and the write-time parameter-schema denials parameter_schema is larger than the 262144-byte limit, parameter_schema is not a valid parameter schema: <up to 3 problems>[; and N more] and parameter_schema could not be checked (the problems name only a property and a fixed reason, never a schema value). Permission gate: InstructionDefinition:Write. SOC 2 CC6.7: every import attempt is logged regardless of outcome; if the audit-store write itself fails, the response carries the Sec-Audit-Failed: true header AND an audit_emitted=false field in the JSON body (PR #883 evidence-chain pattern). |
enrollment.approve | Agent | A pending agent enrollment is approved from the dashboard (POST /api/settings/pending-agents/{id}/approve). target_id is the agent id; detail carries by=<operator> (and agent_not_found with result=denied when no such row exists; refused_wrong_status with result=denied and HTTP 409 when the row is no longer pending, because it was already approved or denied, so an approve never reverses a denial; invalid_agent_id with result=denied and HTTP 400 when the id is . or .., which no browser link can address). A store outage audits result=failure. Set-and-proceed, like every dashboard settings mutation. |
enrollment.deny | Agent | A pending agent enrollment is denied (POST /api/settings/pending-agents/{id}/deny). Same shape as enrollment.approve, including refused_wrong_status (a deny never flips an enrolled agent). A denied agent is refused at Register before any token is consumed (#1067), and a token presented by a denied agent has its use rolled back (#1135). |
enrollment.bulk_approve / enrollment.bulk_deny | Agent | POST /api/settings/pending-agents/bulk-approve / bulk-deny. One atomic statement moves only the rows that are pending at that instant, so a concurrent single deny/approve is never overwritten. target_id empty; detail is by=<operator> count=<N> ids=<first 20 agent ids> where count is the number of rows THIS call moved. |
enrollment.remove | Agent | A pending-agent row is hard-deleted (DELETE /api/settings/pending-agents/{id}) — only when the row is still pending (WS-6 6.2 hardening; removal never reverses an admin decision). result=success, detail=by=<operator> when a pending row is removed; result=denied, detail=invalid_agent_id by=<operator> (HTTP 400) for an id of . or ... result=denied, detail=refused_wrong_status by=<operator> (409) when the row is approved or denied — the row is left untouched and the earlier decision stands. result=denied, detail=agent_not_found by=<operator> when no such row exists. result=failure, detail=store_error by=<operator> on a store outage. |
enrollment.token_create | EnrollmentToken | An enrollment token is generated (POST /api/settings/enrollment-tokens). target_id is the 8-hex token id — never the raw token; detail carries by=, max_uses=, ttl_s= and the label (space, =, , and control bytes replaced by _). A malformed max_uses or ttl_hours, or a negative ttl_hours, emits result=denied with an empty target_id and detail=invalid_numeric_parameter alongside the 400. |
enrollment.bulk_token_create | EnrollmentToken | POST /api/settings/enrollment-tokens/batch, and the server CLI --generate-tokens (principal cli:<OS account>, role cli). target_id empty; detail carries by=, max_uses=, ttl_s=, the label prefix (space, =, , and control bytes replaced by _) and count=<N> ids=<first 20 token ids>. On the REST route, a mid-batch store failure audits result=failure with minted_before_failure=<N>; a malformed count, max_uses or ttl_hours, or a negative ttl_hours, emits result=denied with detail=invalid_numeric_parameter; a count outside 1 to 10000 emits result=denied with detail=count_out_of_range, each alongside a 400; and a body that is not one JSON object emits result=denied with detail=invalid_body. The CLI writes one result=success row after the whole batch is minted and no row otherwise: a mid-batch store failure or a refused option exits non-zero and logs how many tokens were minted before the failure, and those tokens remain valid. |
enrollment.token_revoke | EnrollmentToken | A token is revoked (DELETE /api/settings/enrollment-tokens/{id}). result=denied with token_not_found for an unknown id. |
enrollment.import | Enrollment | The one-time boot import of the legacy enrollment-tokens.cfg / pending-agents.cfg into PostgreSQL (WS-6 6.2). Principal system. target_id is the file kind (tokens / pending); detail carries source=<path> sha256=<fingerprint> imported= skipped_existing= skipped_garbled= recovered_colon= id_disambiguated= renamed=. result=success for a first import; result=warning when a file that differs from the one already imported (a restored old backup) was refused; result=failure for an import error. The steady-state outcomes (no file / already imported) write no row. |
enrollment.token_consumed | EnrollmentToken | An agent auto-enrolled by presenting an enrollment token (gRPC Register — direct or gateway-proxied). result=success when the token is accepted; result=failure when rejected (invalid/expired/already-consumed). target_id is the token id (success) or the token-hash prefix (failure); detail carries variant=<not_found|revoked|expired|already_consumed> on a failure row (invalid_input and internal_error are not produced on this path) and variant=success use_count=<n>/<max or unlimited> on success, and, on the gateway path, source=gateway_proxy gateway_ip=<ip> [origin_observed=false]. Gateway-path source_ip (#1064): the server records the agent’s origin IP as source_ip when the gateway populates RegisterRequest.gateway_observed_peer. The gateway overwrites that field on every registration (whatever the agent sent is discarded) with the address it decided for the call, a bare IP, or with an empty value meaning “not observed”, in which case source_ip is the gateway node’s own IP and detail carries origin_observed=false. By listener configuration: a listener in transport mode (the default) reports the TCP peer; a listener configured as proxied (origin mode proxy_protocol or forwarded_for) reports the address the trusted proxy vouched for, reports empty when that proxy gave no usable address (origin_observed=false; the gateway counts `yuzu_gw_grpc_origin_ignored_total{reason=“no_claim" |
settings.update | Setting | A server setting is changed |
rbac.role.assigned | User | A fleet-wide RBAC role was assigned to a human user via POST /api/v1/rbac/roles/{name}/assignments or the MCP assign_rbac_role twin (A2 — global human role assignment, see “Fleet-Wide Role Assignment” in docs/user-manual/rbac.md). target_id is the username, neutralized before it is embedded (a control-byte/CRLF/ANSI-escape payload cannot forge an adjacent audit field or inject terminal escapes into the trail meant to hold its author accountable — same neutralization the principal_type copy inside detail gets on a rejection) — except on the structural pre-gate denials below (service-scoped, engine-session, mcp-tier), which use an EMPTY target_id, since those deny before the route ever reaches the caller-supplied principal_id. result=success with detail=<role_name>; target_provisioned=<true|false|unknown> (whether the target already had an auth.users row at assignment time — unknown means the AuthDB read itself degraded and could not confirm either way, never conflated with a genuine false; pre-provisioning a role ahead of first login is allowed). result=denied when the caller is not a durable Administrator (re-read fresh from the store — a dedicated is_rbac_administrator gate, NOT an ordinary permission check), the gate itself could not confirm authority because the RBAC/AuthDB store is degraded (kUnavailable, 503/kInternalError, detail="RBAC admin gate could not confirm administrator authority — store degraded" — also logged via spdlog::warn, so this outcome is never silently invisible to an operator), does not hold authority under the regime that is durably true right now (A1: a second, fresh re-check closing a cross-replica cache-staleness gap in that same gate — see “Enabling RBAC” in docs/user-manual/rbac.md; ITS OWN degraded-store outcome is a SEPARATE detail="RBAC regime-authority check could not confirm authority — store degraded", also warned+audited so the two checks are individually diagnosable), is a service-scoped/engine session — REST/MCP asymmetry: on MCP specifically there is no separate engine-session pre-gate call (that helper is REST-only); an engine session instead falls through to is_rbac_administrator(kMcp)’s own internal check, auditing target_id=<username> (not empty) with the SAME detail="caller is not a durable RBAC administrator" an ordinary non-admin denial gets — indistinguishable from an ordinary denial in the audit log on that transport, (REST only) carries a non-empty mcp_tier — detail="MCP token blocked from admin route (mcp_tier='<tier>')" via the shared deny_mcp_token_session helper, denied BEFORE is_rbac_administrator even runs, since REST has no tier/approval concept to legitimize a tiered bearer token the way MCP’s own dispatch guard does — principal_type is not "user", principal_id is malformed, the role is not one of the 7 fleet-wide-assignable roles — principal_id missing or wrong-typed is the one exception: it 400s before target_id is computed and is deliberately left unaudited on this route (matching the pre-existing unaudited malformed-JSON-body branch), not a CWE-117 necessity, just this route’s established convention (ITServiceOwner and an unknown/custom role share the SAME uniform client-facing message, no role-catalog oracle — the specific reason is in detail server-side only), or a genuine store/query fault occurred while writing the grant (503/kInternalError, distinguished from a client-validation rejection via an allow-list of known validation-error shapes — rbac_store.hpp’s rbac_assign_error_is_client_fault). |
rbac.role.unassigned | User | A fleet-wide RBAC role was removed from a human user via DELETE /api/v1/rbac/roles/{name}/assignments/{principal_id} or the MCP unassign_rbac_role twin. target_id is the username (REST: URL-path-captured, already charset-safe by construction; MCP: neutralized before embedding, same as the assign twin) — except on the structural pre-gate denials below (service-scoped, engine-session, mcp-tier), which use an EMPTY target_id. result=success with detail=<role_name>; removed=<true|false> — the unassign is idempotent (matching the engine-principal sibling below): removing a role the principal never held still returns success/removed=false, so an auditor can tell an actual revoke apart from a no-op from the log alone. On MCP, role_name inside detail is neutralized too (this tool’s role arg is deliberately NOT charset-restricted, so it can clean up an out-of-band grant no other surface could have created). result=denied when the caller is not a durable Administrator, the gate itself could not confirm authority because the RBAC/AuthDB store is degraded (kUnavailable, 503/kInternalError, detail="RBAC admin gate could not confirm administrator authority — store degraded" — also logged via spdlog::warn), does not hold authority under the regime that is durably true right now (A1: same fresh re-check as the assign twin above, including ITS OWN separate detail="RBAC regime-authority check could not confirm authority — store degraded" on a degraded read), is removing their own Administrator assignment (self-lockout guard, detail=self_admin_unassign_blocked), is a service-scoped/engine session — same REST/MCP asymmetry as the assign twin above (MCP has no separate engine-session pre-gate; it falls through to is_rbac_administrator(kMcp)’s own internal check, auditing target_id=<username> with the ordinary detail="caller is not a durable RBAC administrator"), (REST only) carries a non-empty mcp_tier — detail="MCP token blocked from admin route (mcp_tier='<tier>')" via the shared deny_mcp_token_session helper, denied BEFORE is_rbac_administrator even runs — or the store-level last-Administrator guard refuses to leave the fleet with zero authenticatable administrators (REST 409; the guard joins against auth.users and requires is_active, so a grant naming a nonexistent, deactivated, soft-deleted, or group-held (not creatable via this surface) principal is never counted as a survivor — matching the is_rbac_administrator gate on the principal_type axis only (with RBAC off the gate reads auth.users.role instead of grants), closed against a concurrent reactivation of an existing account, with two narrower cases still outside that guard — see “Fleet-Wide Role Assignment” in docs/user-manual/rbac.md), or a genuine store/query fault occurred while removing the grant (503/kInternalError). |
rbac.enforcement_changed | Setting | RBAC enforcement was enabled or disabled fleet-wide via PUT /api/v1/rbac/enforcement or the MCP set_rbac_enforcement twin (A1 — enforcement enable/disable toggle, see “Enabling RBAC” in docs/user-manual/rbac.md). target_id is the fixed literal rbac_enabled (the durable key) on every gate this route reaches on its own — but NOT on every denial path: the shared deny_fleet_wide_service_scoped, deny_engine_session (REST only — see the asymmetry note below), and deny_mcp_token_session structural pre-gate denials (below) use an EMPTY target_id on REST, since those chokepoints deny before this route’s own handler ever runs. result=success with detail=enabled=<true|false>; previous=<true|false>; changed=<true|false>; post_transition_administrators=<N> — changed=false marks the idempotent no-op path (requesting the already-current state; no write, no guard evaluation). result=denied with one of these detail shapes: caller is not a durable RBAC administrator (the same dedicated is_rbac_administrator gate as the two rows above, NOT an ordinary permission check); RBAC admin gate could not confirm administrator authority — store degraded (that SAME gate’s kUnavailable outcome, also logged via spdlog::warn, 503/kInternalError — never silently invisible to an operator); service-scoped token blocked from RBAC enforcement toggle — via the shared deny_fleet_wide_service_scoped helper, which appends ; cid=<uuid> in practice, so this is never byte-exact as its own literal alone; the deny_engine_session structural denial (REST only — REST/MCP asymmetry, same shape as the mcp-tier one below: MCP has no separate engine-session pre-gate call; an engine session there instead falls through to is_rbac_administrator(kMcp)’s own internal check, auditing target_id="rbac_enabled" — not empty — with the ordinary detail="caller is not a durable RBAC administrator" above, not a distinct “engine session” literal); MCP token blocked from admin route (mcp_tier='<tier>') (REST only — the shared deny_mcp_token_session helper, same as the two rows above, denied BEFORE is_rbac_administrator even runs; REST has no tier/approval concept to legitimize a tiered bearer token the way MCP’s own dispatch guard does); empty mcp_tier, denied outright (auth_source=<...>) (MCP only, #4309 parity with assign_rbac_role/unassign_rbac_role — a DIFFERENT check on a DIFFERENT transport from the REST-only one immediately above, despite the similar name); guard: <refusal message> — the store-level guard itself (RbacStore::set_rbac_enforcement) refusing on either of TWO checks, both prefixed identically so the audit row alone does not distinguish which one fired (the fixed, server-authored <refusal message> text does — never caller-supplied): a DESTINATION-regime survival conflict (the caller would not remain a durable administrator after the switch — enable: no fleet-wide Administrator grant; disable: local account role is not admin), or a SOURCE-regime authority failure (the caller does not hold authority under the regime that is durably true right now — enable: not a local admin; disable: no fleet-wide Administrator grant — this closes a cross-replica cache-staleness gap in the outer admin gate itself); or, a seventh case: kStoreFailure’s detail is typically the RAW underlying store-error text (a pool/txn/query failure), with one fixed fallback literal when that text is empty — either way it can carry implementation detail a SIEM rule matching on the other fixed literals above will not catch. Fails closed on a dropped success row on both transports (REST 503; MCP audit_persisted:false), same #2466/#2406 posture as the two rows above. Transport asymmetry: on MCP specifically, the service-scoped-token denial above fires under a COMPLETELY DIFFERENT action/target — mcp.set_rbac_enforcement/mcp_tool/set_rbac_enforcement, via the generic C8 chokepoint (#2298) — never rbac.enforcement_changed at all for this one case; a SIEM query filtering on action="rbac.enforcement_changed" alone will silently miss an MCP-side service-scoped denial. |
rbac.assignments.list | AccessReview | A caller pulled the complete fleet-wide RBAC grant table via GET /api/v1/rbac/roles/assignments, the dashboard’s Role assignments panel (GET /fragments/access/roles/assignments, same gate and same row; a denied engine session writes result=denied, detail=engine_session_denied) or the MCP list_rbac_role_assignments twin — every (principal_type, principal_id, role_name) row on record, across all three principal types, in one bulk read (reuses RbacStore::list_all_principal_roles_checked(), the same method the SOC 2 CC6.2 access-review export uses). target_id is empty (a fleet-wide read, not a single-target one); result=success with detail=count=<N>. Gated on AccessReview:Read, the same dedicated securable the access-review family below uses — not audited on a genuine store-query failure on REST (fire-and-forget, matching the access_review.* family’s own list-route template; the MCP twin sets audit_persisted:false in its response body instead of a separate audited-failure row — both asymmetries are pre-existing, family-wide, tracked in #5063, not specific to this action). Note: the access_review.* action family this route reuses the read path from does write audit rows of its own, from the REST routes and their MCP twins (access_review.exported, access_review.list, access_review.campaign_opened, access_review.get, access_review.attested, access_review.flagged for a flagged_revoke decision, and access_review.closed); they are not yet catalogued as separate rows in this table (a pre-existing gap, not introduced here). |
management_group.create | ManagementGroup | A management group is created |
management_group.delete | ManagementGroup | A management group is deleted |
management_group.add_member | ManagementGroup | An agent is added to a management group |
management_group.remove_member | ManagementGroup | An agent is removed from a management group |
group.create_from_results | ManagementGroup | A new management group is materialised from a filtered results view via POST /api/dashboard/group-from-results (#1712). target_id is the new group’s id once created; empty on the pre-creation denied CSRF path below, since no group exists yet. result=success: the group and all requested members were created; detail empty. result=denied: the CSRF same-site gate rejected the request (detail=csrf_cross_origin) before any read or write — no group is created, so target_id is empty. result=failure: the group was created but one or more add_member calls failed partway through (detail=partial_materialisation added=<X> failed=<Y>) — the group is left existing with only the members that actually landed; there is no rollback, so a SIEM rule on result=failure here should treat the named group as needing manual review, not as a failed no-op. Permission gate: ManagementGroup:Write; the id-list materialised is additionally intersected against the caller’s Response:Read-visible agent set before creation (see the response.read/denied row above for the corresponding drop-audit). |
management_group.assign_role | ManagementGroup | A role is assigned to a principal on a management group |
management_group.unassign_role | ManagementGroup | A role is unassigned from a principal on a management group |
api_token.create | ApiToken | An API token is generated |
api_token.revoke | ApiToken | An API token is revoked |
api_token.rotate | ApiToken | A human token’s self-service overlap-pair rotation was initiated (POST /api/v1/tokens/{id}/rotate or the rotate_api_token MCP twin, P2 #11, SOC 2 CC6.3) — the human twin of engine_principal.credential.rotate, self-service rather than admin-gated (ApiToken:Rotate, owner-vs-nonexistent 404 belt: the caller must own {id}). target_id is the predecessor’s token id — the token being rotated, not the successor it mints. result=denied when {id} exists but is owned by a different principal (detail=owner=<principal_id>, same body as a genuine 404 so the route is not an ownership-enumeration oracle); result=failure when the store rejects the rotation (e.g. below the 24h overlap floor, or a different operator touching an in-flight rotation; detail is the store error). result=partial is a THIRD, distinct outcome (UP-11): rotate_token already committed and minted a live successor row, but reading its secret back to hand to the caller failed — the mint happened, only the reveal did not, so this is never folded into failure (which a CC6.3 reviewer would read as “no credential exists” when one plainly does). result=success is not emitted by this route — a successful rotation’s evidence is the api_token.reveal row below, fired once the successor is confirmed findable. |
api_token.reveal | ApiToken | The raw successor secret was returned to the caller by a human self-service rotate call — the human twin of engine_principal.credential.reveal, emitted on every successful reveal, including a same-caller grace-window re-serve of the same bytes. target_id is the predecessor’s token id — the token that was rotated, NOT the successor whose secret this row records revealing; api_token.confirm stamps the successor’s id instead, so the two ends of one rotation share no correlation key (#2970 C); detail is the literal string rotate. This row, not a api_token.rotate success row, is the audit evidence that a rotation actually delivered a secret. |
api_token.confirm | ApiToken | A human self-service maker-checker confirmation that a rotation’s successor secret was received/installed (POST /api/v1/tokens/{id}/confirm or the confirm_api_token_rotation MCP twin) — the human twin of engine_principal.credential.confirm; {id} is the successor token id the rotate response returned. target_id is the token id. result=denied when {id} exists but is owned by a different principal (detail=owner=<principal_id>, same 404-shaped body as rotate’s denied case); result=failure when the store rejects the confirm (e.g. a replay after the rotation already resolved — detail carries the store’s terminal message); result=success on confirm (detail=confirmed), which revokes the predecessor immediately and promotes the successor to the token’s sole active credential. |
engine_principal.role.assigned | EnginePrincipal | A fleet-wide RBAC role was assigned to an engine principal via POST /api/v1/engine-principals/{id}/roles or the MCP assign_engine_role twin (PR 4.2, design §4.1). target_id is the full engine:<slug> principal id. result=success with detail=<role_name>; result=denied (target is not a live engine principal, the role is unknown, or validate_assignment or the built-in-role check rejects an admin or built-in role — design §4.2 “no admin, ever”; a custom role that grants every permission is not refused here, the no-admin audit reports it) with detail=<role_name>: <reason>. Permission gate: Security:Write, plus admin + MFA step-up. |
engine_principal.role.unassigned | EnginePrincipal | A fleet-wide RBAC role was removed from an engine principal via DELETE /api/v1/engine-principals/{id}/roles/{role} or the MCP unassign_engine_role twin. target_id is the full engine:<slug> principal id; detail=<role_name>. result=success only — the unassign is idempotent (matching the management-group sibling): removing a role the principal never held still returns 200 and still writes a success row, so a success row does not by itself prove a grant existed. A 503 (store unavailable) writes no row. Permission gate: Security:Write, plus admin + MFA step-up. |
engine_principal.role.listed | EnginePrincipal | The fleet-wide RBAC roles assigned to an engine principal were enumerated via GET /api/v1/engine-principals/{id}/roles or the MCP list_engine_roles twin. target_id is the full engine:<slug> principal id; result=success. Audited because privilege enumeration is a security-sensitive read (SOC 2 CC7.2), even though it is a read. Permission gate: EnginePrincipal:Read. The Tokens and principals page fragments (/fragments/access/principals/list and /detail, gated on the same EnginePrincipal:Read) also write this verb, once per fragment and only when the roles were actually read (the list fragment for its Roles column, with an empty target_id and detail=count=<rows>; the detail fragment for the selected principal, target_id the principal id). A refused engine or MCP-tier session is recorded under engine_principal.list / engine_principal.get below, not here. |
engine_principal.create | EnginePrincipal | A new engine-principal identity is minted via POST /api/v1/engine-principals or the create_engine_principal MCP tool. detail carries owner=<username>; classification=<internal|external> on both REST and MCP. |
engine_principal.list | EnginePrincipal | The engine-principal list surface (GET /api/v1/engine-principals) was enumerated. Emitted on every successful call, including by a read-only auditor role — an admit-then-audit read, not a mutation. result=failure is written by the REST route when the engine-principal list or the token store’s credential counts could not be read (the route answers 503 and serves no list); detail is engine principal roster could not be read for the former and the token store’s reason (pool lease timed out, query failed or token store is not open) for the latter. The row goes through the same connection pool that just failed, so it is best-effort: it can be lost, which REST signals with Sec-Audit-Failed: true. The Tokens and principals fragments write result=success when only a credential or role column renders Unavailable, and write no row when the roster itself cannot be read; the Settings Engine Principals panel writes no row at all. REST-only — the list_engine_principals MCP tool does not emit this verb; its call is recorded only under the generic mcp.list_engine_principals tool-call audit action (target_type=mcp_tool), not this domain-specific verb. The Tokens and principals page list fragment (/fragments/access/principals/list, gated on the same EnginePrincipal:Read) also writes this verb; an engine or MCP-tier session that passes the permission gate is then refused and writes result=denied, detail=engine_session_denied or mcp_session_denied (one that fails the permission gate gets that gate’s ordinary denial). |
engine_principal.get | EnginePrincipal | A single engine principal’s identity row was read (GET /api/v1/engine-principals/{id}). result=failure is written by the REST route when the token store’s credential read failed (the route answers 503 and serves no active_credentials); detail is the store’s reason (pool lease timed out, query failed or token store is not open). The MCP tool’s call is recorded as the generic mcp.get_engine_principal row with result=failure, detail=principal_id=<id> <reason> (best-effort, as above). The Tokens and principals detail fragment writes result=success when only its credentials render Unavailable. REST-only, same caveat as engine_principal.list above — the get_engine_principal MCP tool records only the generic mcp.get_engine_principal tool-call audit action. The Tokens and principals page detail fragment (/fragments/access/principals/detail, gated on the same EnginePrincipal:Read) also writes this verb; an engine or MCP-tier session that passes the permission gate is then refused and writes result=denied, detail=engine_session_denied or mcp_session_denied (one that fails the permission gate gets that gate’s ordinary denial). |
engine_principal.revoke | EnginePrincipal | An engine principal was terminally revoked (DELETE /api/v1/engine-principals/{id} or revoke_engine_principal). detail carries credentials_revoked=<N> and, if given, superseded_by=<successor id> (REST) or the caller-supplied reason (MCP). The revoke call itself is idempotent (a repeat call on an already-revoked principal still returns success, never an error) but the audit row is not — a repeat call re-emits a fresh result=success row each time (with credentials_revoked=0 on the repeat), so the audit trail shows every repeat operator action, not just the first revoke. |
engine_principal.credential.mint | EnginePrincipal | The first credential was minted for an engine principal (POST /api/v1/engine-principals/{id}/credentials or mint_engine_credential). REST: target_id is the engine principal id; detail carries ttl_days=<N>. MCP: target_id is instead the newly-minted credential’s token_id (a success row always has it: a read-back that finds no row is a partial row, below); detail carries principal=<engine principal id>, not ttl_days. The raw secret itself is never written to the audit log on either surface. result=failure is also written, on REST and MCP, when the check for an existing active credential could not read the token store (the call answers 503 / a retryable error and mints nothing; detail is the store error). result=partial means the credential was minted but the read that locates it for the response failed, or answered and found no active credential (revoked out of band): the secret was withheld and no one holds it, so list the principal’s credentials and rotate if one is listed rather than retrying the mint (target_id is the principal id in that case; detail says which of the two it was). On MCP a partial or failure outcome also writes an mcp.mint_engine_credential row. |
engine_principal.credential.rotate | EnginePrincipal | An overlap-pair credential rotation was initiated or continued (POST /api/v1/engine-principals/{id}/credentials/rotate or rotate_engine_credential). result=failure on a rejected call (e.g. below the 24h overlap floor, or a different operator touching an in-flight rotation); on success the reveal itself is recorded separately as engine_principal.credential.reveal below, not folded into this row. target_id is the engine principal id on both REST and MCP. result=partial (REST and MCP) means the rotation committed and a successor exists (newly minted or re-served), but reading it back to build the response failed, or no active credential linked back to a predecessor: the call answered 503 / a retryable error, the secret was withheld, and no reveal row follows, so it is never recorded as failure. The no-linked-successor case carries no retry hint (a retry would perform a new rotation), unlike the read-failure case. On MCP a partial outcome also writes an mcp.rotate_engine_credential row. A partial row is the operator signal that a successor exists whose secret was never delivered; recover per Engine Principals. Otherwise this verb is emitted only on the failure path. |
engine_principal.credential.reveal | EnginePrincipal | The raw successor secret was returned to the caller by a rotate call, emitted on every successful reveal, including a same-caller grace-window re-serve of the same bytes, so every time a secret left the server is independently on the audit chain. REST: target_id is the engine principal id; detail is the literal string rotate. MCP: target_id is instead the successor credential’s token_id (a rotation whose successor cannot be read back returns earlier as a partial and writes no reveal row); detail is principal=<engine principal id> action=rotate. |
engine_principal.credential.confirm | EnginePrincipal | A rotation’s successor secret was explicitly confirmed received/installed by its consumer (POST /api/v1/engine-principals/{id}/credentials/confirm or confirm_engine_rotation), closing the overlap window early and revoking the predecessor. A separate attestation from the reveal — never inferred from a successful rotate call. target_id is the engine principal id; on success detail carries token_id=<confirmed successor id> on both REST and MCP (#2384 — server-verified against the pending pair, never a raw secret), binding the attestation to the exact credential confirmed. A replay after the rotation already resolved writes a failure row here whose detail is the terminal store message (rotation already confirmed ... / no rotation in flight ... / the rotation was resolved ..., #2404) — the forensic evidence that a client kept confirming a resolved rotation, paired with the yuzu_engine_principal_confirm_total{result="conflict"} signal. (Audit-persist failure on a mutating engine-principal REST route — this row included — now FAILS THE REQUEST CLOSED: 503 + Sec-Audit-Failed: true, since #2466/#2406, so the privileged mutation never reports success on an unrecorded row; since #3937 the MCP mutation twins fail closed too — a JSON-RPC 503 error with audit_persisted:false in error.data (not on a success result). The engine-principal reads instead set Sec-Audit-Failed: true and proceed — they commit no state and disclose only authorization metadata, not per-person PII. See docs/user-manual/rest-api.md “Engine Principals”.) |
engine_principal.transfer_owner | EnginePrincipal | An engine principal’s named responsible owner was reassigned (POST /api/v1/engine-principals/{id}/transfer-owner or transfer_engine_principal_owner). Admin-forced — does not depend on the outgoing owner’s cooperation. REST: detail carries old_owner=<username>; new_owner=<username>. MCP: detail carries only new_owner=<username> — the outgoing owner is not named in the MCP audit row. |
engine_principal.audit.no_admin | AuditLog | The “no admin” auditor ran (GET /api/v1/engine-principals/audit/no-admin or audit_engine_no_admin). target_id is the literal string no-admin; on result=success detail carries violations=<N> scope=all (scope=active when the MCP tool was narrowed with include_revoked=false) and, when N is above zero, sample= followed by up to ten comma-separated principal_id:reason pairs. result=failure (not denied) with detail=rbac_resolution_failed when the run could not verify (a grants, roles or role-permissions read failed, or the RBAC reference data was unresolvable); no success row is written for that run, and that outcome must never be read as “clean”. The rows are the evidence the control operated: the auditor is pull-only, so a period with no rows shows at most that no recorded run exists, and is not evidence that the check ran or that it passed (a dropped success-row write is signalled only by Sec-Audit-Failed on REST; the MCP tool gives no signal). The Tokens page also runs it incidentally, in the browser, when a viewer who holds AuditLog:Read loads the principals panel; that is not a schedule and not a control. |
engine_principal.rotation.auto_revoke | ApiToken | The 60-second background overlap-rotation sweep auto-revoked a predecessor credential whose overlap window elapsed without an explicit confirm. principal="system"; detail carries principal_id=<engine principal> reason=overlap_window_elapsed. Governance UP-5: the sweep withholds this auto-revoke (writes no row) when the successor was never presented at all (last_used_at == 0) — a dropped/lost successor secret must never leave the principal at zero usable credentials; the pair stays fully active and .successor_unused below is raised instead (see that row for its cadence — the audit row fires once per pair per state, not per tick). Also capped per tick (kMaxAutoRevokesPerTick) — a clock jump drains over several ticks rather than auto-revoking the whole eligible set in one; a capped tick logs and increments yuzu_rotation_sweep_capped_total (deliberately kind-neutral — shared across engine and human rotation pairs) but writes no distinct audit row of its own. |
engine_principal.rotation.successor_unused | ApiToken | The same background sweep’s operational health signal: a rotation’s successor credential is nearing its predecessor’s overlap-expiry with no recorded use. Operational, not security — result=warning, deliberately not folded into event="security". Fires once per rotation attempt pre-elapse, then once more on crossing into the elapsed state (the pair the .auto_revoke carve-out above is now deliberately leaving alone) — the audit row and its metric are de-duplicated once per pair per state, process-local (a restart re-emits once). The underlying log line, by contrast, repeats every tick once the window has elapsed, so a stuck-open rotation is never silent even though the audit row and metric fire only once per state. detail carries principal_id=<...> predecessor_token_id=<...> overlap_expires_at=<epoch>, plus status=elapsed_unrevoked_successor_unused once past the window. |
api_token.rotation.auto_revoke | ApiToken | The human-owned twin of engine_principal.rotation.auto_revoke above (P2 #11, SOC 2 CC6.3) — the SAME 60-second background overlap-rotation sweep auto-revoked a HUMAN predecessor token whose overlap window elapsed without an explicit confirm. Routed here instead of the engine action by the swept row’s own principal_kind (the single chokepoint rotation_sweep_names_for_kind, rotation_sweep_naming.hpp), never by which endpoint minted the pair. principal="system"; detail carries principal_id=<username> reason=overlap_window_elapsed. Same UP-5 never-used-successor carve-out and per-tick cap as the engine row above. |
api_token.rotation.successor_unused | ApiToken | The human-owned twin of engine_principal.rotation.successor_unused above (P2 #11) — same background sweep’s operational health signal for a HUMAN rotation pair: the successor token is nearing its predecessor’s overlap-expiry with no recorded use. Operational, not security — result=warning. Fires once per rotation attempt pre-elapse, then once more on crossing into the elapsed state (same UP-5 behavior as the engine row above) — the audit row and its metric are de-duplicated once per pair per state, process-local (a restart re-emits once); the underlying log line repeats every tick once elapsed. detail carries principal_id=<...> predecessor_token_id=<...> overlap_expires_at=<epoch>, plus status=elapsed_unrevoked_successor_unused once past the window. |
engine_principal.owner_deprovisioned | EnginePrincipal | A SCIM deprovision (deactivate/delete) removed a user who still owned active engine principals. The deprovision itself always succeeds (a CC6.8 termination is never blocked) — this is a detective, not preventive, control: it also increments yuzu_engine_principal_owner_deprovisioned_total so an admin can reassign ownership out of band. target_id is the departing username; detail carries orphaned_active_engine_principals=<N> or engine_ownership_unverifiable. Never fires on a SCIM undo/rollback. The interactive dashboard delete uses the preventive form instead (blocked with 409). |
quarantine.enable | Security | A device is placed in quarantine. A request the REST route refuses at the dispatch approval check (#6065) writes result=denied with detail reason=<label> securable=<name> (target_id is the agent id) and NO attempt row, because nothing was attempted (the denied row is best-effort: a failed write is logged but does not change the 403, since no state changed); a refusal by the scoped permission gate before that check is audited by that gate, not here. An unwired check writes result=failure. Every row the REST route handler writes after its first look-up is stamped from the session the route resolved, not from a second look-up of the request, so the actor on the attempt and outcome rows is the account recorded as quarantined_by. Not covered: rows the scoped permission gate writes itself, and the result=failure row with detail audit identity writer not configured that an unwired writer produces (the route also answers 500), which come from a look-up of the request. A request whose two look-ups name different principals ends at 401 with a result=denied row naming the first principal, detail authenticated principal changed during the request now=<second account> (the name has control bytes, spaces, commas, = and ; replaced by _ and is cut at 128 bytes), and writes no record; like the approval-check denied row it is best-effort. The REST route writes an attempt row (result=attempt) after authorisation and before the change, then success or failure; if the attempt row cannot be written it answers 503 with Sec-Audit-Failed: true and changes nothing, and a lost outcome row is flagged with the same header. success therefore always means the change was applied. Caveat for evidence queries: the MCP twin quarantine_device — the only surface that both records and dispatches isolation — audits under action="mcp.quarantine_device" with target_type="mcp_tool" and the agent id in free-text detail, following the mcp_audit convention shared by every MCP tool. So ?target_type=Security&target_id=<agent> returns the REST rows and the quarantine.dispatch_denied rows but not the MCP ones; add ?action=mcp.quarantine_device and match the agent id in detail to complete the picture for a device. |
quarantine.disable | Security | A device is released from quarantine. Written like quarantine.enable: an attempt row before the change, then success or failure; if the attempt row cannot be written the REST route answers 503 with Sec-Audit-Failed: true and changes nothing. A request the REST route refuses at the dispatch approval check (#6065) writes result=denied with detail reason=<label> securable=<name> and NO attempt row; the record stays active. Rows are stamped from the session the route resolved, as for quarantine.enable, and the changed-principal 401 is written here as result=denied with the detail given there. |
quarantine.dispatch_denied | Security | An instruction dispatch was refused because the containment gate would not let it through (#881). result=denied always. Three shapes, distinguished by detail — and target_id="*" means two different things, so read detail before interpreting it. (1) Per-device: QUARANTINE_DISPATCH_DENIED command=<id> agent=<id> reason=active_quarantine, target_id = the contained device, one row each. (2) Fail-closed: reason=degraded_fail_closed agents=<N>, target_id=* — ONE row for the whole dispatch, because a fail-closed denial is a single policy decision about every connected agent and N synchronous audit INSERTs would storm the very pool whose degradation caused it. (3) Capped fan-out: reason=active_quarantine agents_elided=<N> agents_total=<M>, target_id=* — emitted after the first 25 per-device rows when one dispatch is denied against more than 25 contained devices. Shape-1 rows for that dispatch still exist; this row declares how many did not get one. Which 25 get rows is unspecified (the head of the target list in registry order — an arbitrary, unstable sample), and for a broadcast the elided identities are not reconstructable, since no target list is stored. The cap exists because the alternative is unbounded: a 1,000-host containment incident plus a per-minute scheduled broadcast is ~1.44M rows/day of blocking INSERTs on the dispatch thread; capped it is ~37k. In every shape the paired yuzu_server_dispatch_target_rejected_total{reason="quarantined"} counter moves by the TRUE denial count — count denials there, and use these rows for identity. The quarantine plugin’s own control actions (quarantine/unquarantine/status/whitelist) are exempt from the gate and never produce this row — release must not be blocked by the containment it lifts. |
quarantine.reapply | Security | A system-initiated re-application of endpoint containment for a device that reconnected while an active quarantine record existed (#3425), distinct from the operator-initiated quarantine.enable above. principal="system". target_id is the device. Four shapes, distinguished by result/detail: (1) result=success, detail=record_id=<id> command_id=<id> trigger=tick|heartbeat — the reconciler re-dispatched the stored whitelist and the plugin registry accepted it (not yet proof of endpoint containment). (2) result=success, detail=CONFIRMED record_id=<id> command_id=<id> — a follow-up quarantine.status read reported state|active, the only signal this repo treats as proof the endpoint firewall is actually applied. This is the target agent’s own self-report of its own containment state (cert-bound to the correct agent identity, so a DIFFERENT agent cannot forge it — but not independently corroborated by any network-side signal), the same trust boundary #3127’s MCP already_active retry path already relies on for its own confirmation check; see docs/user-manual/security-hardening.md’s “Reconnect re-application” section. (3) result=failure, detail=<what> record_id=<id> command_id=<id> but the durable stamp failed: <store error> — the dispatch WAS accepted (or the status read DID confirm state|active), a real containment-affecting action already happened, but the durable last_applied_at/last_confirmed_at stamp write failed (a store outage, or a concurrent release/requarantine racing the write) — recorded so a transient store failure never produces a silent gap in exactly the evidence this verb exists to provide. (4) result=failure, detail=status read confirmed state|active but the session churned between dispatch and confirm (dispatch_session=<id> confirm_session=<id>) record_id=<id> command_id=<id> — the status read DID say state|active, but the session it describes is not the session live now (a reboot landed between the reconciler sending the verifying dispatch and processing the response), so the read is refused as confirmation evidence: no durable stamp write is even attempted (unlike shape (3), where the write was attempted and failed). This is the negative-case record for the reboot window the dispatch-time session check closes — refused one step before the write is ever attempted, distinct from shape (3)‘s two write-failure call sites (one of which is unrelated to session churn entirely — a dispatch-accepted-but-store-write-failed outcome can happen with no reboot involved). record_id is the internal QuarantineRecord::id (not exposed on REST/MCP) the reapply cycle was built from — added so a release-then-requarantine race for the same agent_id leaves an unambiguous audit trail across the two quarantine episodes, matching the record-identity scoping the store write itself already applies. A re-application that never reaches a dispatch attempt at all (device offline, stored whitelist invalid, degraded read BEFORE any dispatch) is still counted via yuzu_server_quarantine_reapply_total{result} rather than audited, the same “counted, not audited” posture yuzu_server_system_reserved_push_total documents for its own bypass channels, chosen for the identical reason: an unreachable device produces this outcome on every tick for as long as it stays offline, and an audit row per tick per offline device is an unbounded write rate the counter already answers without it. Shapes (3) and (4) are the exceptions — a genuine agent-facing action, or a genuine agent self-report, already happened, so neither is merely counted. |
policy.evaluate | Policy | A forced compliance check was triggered via POST /api/policies/{id}/evaluate. target_id is the policy id; on result=success detail is execution_id=<id>. The 409 branch (no check instruction / no matching agents) rejects without an audit row. Permission gate: Policy:Execute. |
policy.remediate | Policy | A manual remediation was triggered via POST /api/policies/{id}/remediate. result is success, denied, or error. On success: detail is execution_id=<id> agents=<N>. On denied: detail is the rejection reason (no fix instruction / no in-scope agents / no non_compliant agents / a remediation for this policy is already in flight). On error: detail is a genuine store/evaluator degrade — distinct from denied, which is always a business rejection. Remediation is always operator-initiated, never automatic. Permission gate: Policy:Execute. |
preflight.run | Scope | An operator ran a /auto pre-flight check via POST /fragments/auto/run. target_id is the management-group id scoped to the run, or all-visible when no group filter was applied; detail carries run=<id> checks=<N> devices=<N>. result=success, or no_devices when the chosen scope resolved to no visible devices (a no-op, not a denial). A service-scoped API token is denied outright (result=denied, target_id= empty) before its device cohort is even resolved — the SEC-2/SEC-3 confinement-gap class: devices_fn is username-keyed and does not confine a service-scoped token, and unlike the read-only fixes elsewhere in this branch this route MUTATES (creates a run and dispatches the configured checks), so the deny is unconditional rather than data-dependent. Two more emitters share this same result=denied shape for the identical confinement-gap reason, applied to session->username-scoped reads rather than a fleet-wide list: GET /fragments/auto (the saved-runs rail) and GET /fragments/auto/result (the run result poll) — a service-scoped token shares its creating principal’s username (ApiToken::principal_id), so without this deny it could enumerate or read back a fleet-wide run its own principal created interactively. Neither emits result=success (no dedicated success-path audit on the rail/poll — a lower-sensitivity read than the mutating run-creation path). Permission gates: Infrastructure:Read + Execution:Execute (run creation only; the rail/poll gate on Infrastructure:Read alone). |
preflight.run.delete | Scope | An operator deleted a pre-flight run from the saved-runs rail via POST /fragments/auto/delete?run=<id>. target_id is the run id; detail carries run=<id> scope=<label>. result=success when an owned row was removed, noop when the run id was unknown or not owner-visible (a no-op rather than a permission rejection — prevents cross-owner id enumeration). A service-scoped API token is denied outright (result=denied, target_id= empty) before delete_run is ever called — same confinement-gap reason as the preflight.run rail/poll emitters above: a token sharing its creating principal’s username could otherwise delete evidence for a fleet-wide run outside its own service. Permission gate: Execution:Execute; owner-scoped at the store seam (created_by must match). |
dex.app_perf.compare | GuaranteedState | An operator ran a /auto Verify before/after app-performance aggregate comparison — REST GET /api/v1/dex/perf/compare or the dashboard VERIFY /run fragment. target_id is the cohort (management-group) id; detail carries app=<name> base=<v> cand=<v> cohort=<N> paired=<N> view=aggregate cid=<cid> (paired= so a singleton aggregate — effectively one machine’s data — is distinguishable). Operational, set-and-proceed: the read is recorded (the accountability that stands in for the absent cohort floor — Verify deliberately has no minimum cohort size), but a dropped audit row sets the Sec-Audit-Failed response header rather than blocking the read. The aggregate carries no per-machine identity. Permission gate: GuaranteedState:Read; a service-scoped API token is denied outright (result=denied, target_id= empty) under this same verb, on both REST and the MCP tool compare_app_perf_versions — found by this branch’s own governance review (PR #3156, while re-verifying the external review’s separate findings on the same file): the route’s own prior comment reasoned about RBAC management-group-scoped grants, a different axis from a service-scoped token, which holds the global GuaranteedState:Read grant via ITServiceOwner regardless and could otherwise supply any group. (Over MCP, a SUCCESSFUL compare_app_perf_versions call is recorded under the generic mcp.compare_app_perf_versions tool-call audit, not this verb — but its detail carries the same subject (group=… app=… base=… cand=… cohort=… paired=…), and a dropped row surfaces as audit_persisted:false in the result body since MCP has no Sec-Audit-Failed header. MCP exposes only the identity-free aggregate, no per-machine drill. The service-scoped DENY is the one case where MCP records under dex.app_perf.compare rather than the generic verb — matching the deny-path convention every other REST/MCP twin pair in this table uses.) |
dex.perf.group.view | GuaranteedState | A service-scoped API token was denied outright (result=denied, target_id= empty) on GET /api/v1/dex/perf/group (REST), via that route’s own explicit deny_fleet_wide_service_scoped call, which fires BEFORE perm_fn — found by this branch’s own governance review (PR #3156), the same gap and reasoning as dex.app_perf.compare above. MCP does not record this verb on the equivalent deny: the MCP tool get_dex_group_app_perf used to have its own interim deny call here, but it was retired as provably dead code (guardian-confinement-2298 PR 3 “the flip”, #3290 Phase 2 bucket 1a) — the SHARED perm_fn (every MCP tool-call branch runs behind it first) already denies any service-scoped token outright for (GuaranteedState, Read) before this tool-specific branch is ever reached, since the service-scope global-safe allow-list is seeded empty (docs/adr/1006-service-scope-default-deny.md). That denial is recorded under the generic auth.permission_required verb (AuthRoutes::require_permission’s service-scope branch, auth_routes.cpp), not under dex.perf.group.view or a mcp.* tool-call verb. This verb currently records the REST DENY only — a successful group app-perf-trend read is not audited on either transport (matches this route’s existing aggregate/cohort posture: a management-group trend is treated the same as the unaudited fleet/app-level trends it sits beside). |
dex.perf.tag.view | GuaranteedState | The device-model tag-value cohort twin of dex.perf.group.view just above — same gap, same reasoning, same MCP/REST asymmetry, applied to a device TAG value instead of a management-group id: a service-scoped API token is denied outright (result=denied, target_id= empty) on GET /api/v1/dex/perf/tag (REST), via that route’s own explicit deny_fleet_wide_service_scoped call. MCP does not record this verb on the equivalent deny: get_dex_tag_app_perf’s own interim deny call was likewise retired as dead code (#3290 Phase 2 bucket 1a) — the shared perm_fn already denies every service-scoped token for (GuaranteedState, Read) before this tool-specific branch runs, and that denial is recorded under the generic auth.permission_required verb, not dex.perf.tag.view and not a mcp.* tool-call verb. A tag-value cohort is equally a “named set of specific devices” for confinement purposes. A successful tag-cohort trend read is not audited on either transport, matching dex.perf.group.view’s existing posture. |
dex.app_perf.compare.drill | GuaranteedState | An operator opened the per-machine pairs of a /auto Verify comparison (the dashboard /fragments/auto/verify/drill, reached by a deliberate “Show per-machine pairs” click). This is the per-device behavioural-PII view, so it carries its own verb (distinct from the identity-free aggregate above) to keep per-machine access independently countable. target_id is the cohort id; detail carries app=<name> base=<v> cand=<v> cid=<cid>. Operational set-and-proceed (the HTML surface renders even on a lost audit row, setting Sec-Audit-Failed). There is no REST or MCP twin of the drill — the per-machine pairs are dashboard-only. Permission gate: GuaranteedState:Read. |
dex.app_perf.devices.view | GuaranteedState | An operator (or integration) opened the version-row “which devices” drill on the application-performance-over-time trend — devices reporting one exact (app, version) among their retained top-N daily summaries, each row an agent_id + last-seen day + that day’s CPU/working set. target_id= empty (fleet-wide, filtered to the caller’s own visibility rather than a single entity); detail carries app=<name> version=<token> devices=<n> cid=<cid> — the count is recorded AFTER the read so it is honest, but the row is written BEFORE the response is composed. Permission gate: GuaranteedState:Read via require_fleet_read (ADR-0017) — unlike dex.perf.device.view below, a management-group/service-scoped caller is admitted with the visible-agent intersection pushed into the store query, not denied outright, because the underlying store query can genuinely narrow per-caller (routed-concerns.md’s DEX row has the full reasoning). Two emitters, different postures: the REST endpoint (GET /api/v1/dex/perf/app/devices) fails closed (503 + Sec-Audit-Failed: true) if the row cannot persist; the MCP tool list_dex_app_perf_devices is set-and-proceed (audit_persisted:false in the result body on a dropped row). No dashboard-fragment success audit failure blanks the page either (same set-and-proceed posture, via try_persist_audit). |
deployment.config.view | Scope | A service-scoped API token was denied outright (result=denied, target_id= empty) on the deploy config form, GET /fragments/auto/deploy?run=<id>, before its source pre-flight run is even resolved — the SEC-2/SEC-3 confinement-gap class: PreflightRunStore::get_run is owner-scoped by session->username alone, and a service-scoped token shares its creating principal’s username (ApiToken::principal_id), so without this deny it could read back the go/warn counts for a fleet-wide run outside its own service. No success-path emission (a lower-sensitivity config-recap read than deployment creation itself). Permission gate: SoftwareDeployment:Read. |
deployment.create | SoftwareDeployment | An operator deployed an installer to a pre-flight run’s go-cohort via POST /fragments/auto/deploy/run. target_id is the new deployment_id; detail carries run=<source_run_id> file=<filename> sha256=<hash> url=<url> devices=<N> — the SHA-256 is the content identity of the binary executed on the fleet. result=success (deployment created + first advance), resumed (an in-flight deployment for this run already existed — re-attached, not re-created, so the installer is not run twice; target_id is the existing deployment), or no_devices (the go-cohort was empty after re-intersecting with the operator’s current visible set — a no-op). A service-scoped API token is denied outright (result=denied, target_id= empty) before the run’s go-cohort is even resolved — this is the FIRST fleet-wide dispatch step for a deployment, the same confinement-gap class as deployment.advance/.delete below (found by external review, PR #3156, after those two siblings were fixed). Permission gates: Infrastructure:Read + SoftwareDeployment:Execute. |
deployment.advance | SoftwareDeployment | The deployment engine was ticked via the result-poll GET /fragments/auto/deploy/result. Emitted on every poll tick while the page is open (up to ~600 ticks before the page stops self-polling); result=success. target_id is the deployment_id. SIEM: a burst of these rows with one deployment_id is a single open-page session, not repeated operator intent (auditing only ticks that actually dispatch is a tracked follow-up). A service-scoped API token is denied outright (result=denied, target_id= empty) before the tick ever runs — same confinement-gap reason as deployment.config.view above: the token shares its creating principal’s username, and this route re-invokes the engine’s mutating advance() (claims + dispatches to newly-authorized devices). Permission gates: SoftwareDeployment:Read + SoftwareDeployment:Execute. |
deployment.delete | SoftwareDeployment | An operator deleted a deployment via POST /fragments/auto/deploy/delete?dep=<id>. target_id is the deployment_id. result=success when an owned row was removed, noop when the id was unknown or not owner-visible (prevents cross-owner enumeration — same posture as preflight.run.delete). A service-scoped API token is denied outright (result=denied, target_id= empty) before delete_deployment is ever called — same confinement-gap reason as the two rows above: a token sharing its creating principal’s username could otherwise delete evidence for a fleet-wide deployment outside its own service. Permission gate: SoftwareDeployment:Execute; owner-scoped at the store seam. |
deployment.list.view | SoftwareDeployment | A service-scoped API token was denied outright (result=denied, target_id= empty) on the read-only deployments list, GET /api/v1/deployments or GET /fragments/auto/deployments. The reads are owner-scoped by username, which a service-scoped token shares with its creating human, so they cannot be confined to the token’s service. A successful list is not audited. |
deployment.detail.view | SoftwareDeployment | The same denial for the single-deployment read, GET /api/v1/deployments/{id} or GET /fragments/auto/deployments/detail. Unlike deployment.advance this read never ticks the engine and writes no row per poll. |
directory.users.view | Directory | An operator (or integration) read the AD/Entra-synced directory user list — email/UPN/group-membership PII — via GET /api/directory/users (legacy), the Integrations page fragment GET /fragments/settings/directory-sync/users (audited before the read; the list is withheld if the row cannot be persisted, and the read is capped at 201 rows) or its REST v1 twin GET /api/v1/directory/users / MCP tool list_directory_users (#4031). target_id is the group_id query-param filter when supplied, empty otherwise. Behavioural-PII tier, but the surfaces do NOT share one posture (the Integrations fragment fails closed like REST v1, answering a 200 note that withholds the list rather than a 503): the REST v1 route fails closed (503 + Sec-Audit-Failed: true, no data served) if the audit row cannot persist; the MCP tool is genuinely set-and-proceed, surfacing a dropped row as audit_persisted:false in its response payload (MCP has no response-header channel); the legacy route cannot detect or signal a dropped row at all — it reads through DiscoveryRoutes::AuditFn, which now returns bool for the Integrations writes but whose read route still discards the result, so the read always proceeds regardless. This is a known architectural inconsistency the legacy route inherits from DiscoveryRoutes’s older audit contract, not a per-route decision — tracked as #950. Closes a real pre-existing gap — before #4031 this route returned the same PII with no audit call at all. Permission gate: Directory:Read. |
directory.sync | Directory | An operator ran an Entra directory sync via POST /api/directory/sync (the Integrations page uses this route). target_id is the provider (entra; empty for invalid_json). Once the request is valid, a result=attempted row (target_id=entra, empty detail) is written BEFORE the sync starts; if it cannot be written the sync is refused (503 + Sec-Audit-Failed: true) and no upstream call is made. A cross-site cookie write is refused 403 (csrf.denied). result=success on a completed sync; result=denied with detail the in-progress refusal (409) when another sync is running; result=failure with a fixed detail of missing_credentials (400, a tenant, client or secret field was empty), invalid_json (400, unparseable body) or sync_failed (500). The detail never carries the tenant id, client id, secret or the upstream error text; the upstream text is in the status row’s last error. Permission gate: Directory:Write. |
directory.group_mapping.update | Directory | An operator saved group-to-role mappings via PUT /api/directory/group-mappings. target_id is group-mappings. The whole request is validated first (every entry an object, group_id and role_name strings when present): a malformed request is refused 400 with nothing stored and no row. A valid one writes a result=attempted row BEFORE any pair is stored, whose detail is <N> mappings requested (<N> is the total asked for; (first listed) follows when the list does not fit one row) and the pairs that fit its first 1000 bytes; if that row cannot be written the save is refused (503 + Sec-Audit-Failed: true) and nothing is stored. The rows below follow the read-back. detail is <N> mappings updated: <group>=<role>, ... (a removal reads (removed); control bytes, , and = are replaced (a group id cannot add pairs) and each value is clipped at 256 bytes plus ...). Every pair is recorded: a long list is split across several rows of at most 1000 bytes each, <N> counting the pairs in that row, so a large request writes several rows. The route reads the stored set back after writing (a group named twice in one request counts as its last entry, which is what the store ends with): the pairs that are stored get the result=success row, and the pairs the store did not take get a separate result=failure row whose detail is <N> mappings not confirmed by read-back: ..., so a mixed request writes both rows. If the read-back itself fails nothing is confirmed, and every requested pair, removals included, is in the failure row. Mappings are recorded for reference only; nothing reads them at sign-in. Permission gate: Directory:Write. |
nvd.sync.request | NvdSync | An operator asked for an NVD CVE-feed sync via POST /api/nvd/sync. target_id is nvd. result=success is written before the request is made, and if it cannot be persisted the request is refused with 503 (an A4 error with retry_after_ms: 5000) and Sec-Audit-Failed: true, and no sync starts; result=disabled when sync is off on this server (the call answers 503). |
enrollment.auto_approve.rule_add | AutoApproveRule | An auto-approve rule is added (POST /api/settings/auto-approve). Every successful write leaves two rows: result=attempt, written before the rule is saved (if it cannot be stored the write is refused with 503, Sec-Audit-Failed: true, and nothing changes), then result=success with the same detail. detail carries type=, value=, label=, by=<operator>; caller-supplied text is tokenised (spaces, =, , and control bytes become _) so it cannot forge an adjacent field. A rule with an unknown type, an empty value, a value over 253 or label over 128 characters, a :/CR/LF/control character in value or label, a malformed ip_subnet (IPv4 address, optional /0-32), a trusted_ca that is not 64 hex characters, or a match-everything rule (hostname glob of only *, ip_subnet /0) is refused with 400, nothing is persisted, no attempt row is written, and the row is result=denied with rejected=<reason>. If the policy file cannot be written the row is result=error, detail=persist_failed by=... (500, nothing changed). A request whose Origin/Referer names another site is refused with 403 and a csrf.denied row. |
enrollment.auto_approve.rule_remove | AutoApproveRule | A rule is deleted (DELETE /api/settings/auto-approve/{index}). Same attempt then outcome pair, 503/500/403 behaviour as rule_add. target_id is the list index; detail carries the removed rule’s type=, value=, label= (the rule actually removed, read under one lock), by=. result=denied, detail=rule_not_found by=<operator> when the index does not exist. If the request states the rule it was drawn for (type, value query parameters, which the dashboard always sends), nothing is removed when that rule is no longer at that index: the answer is 409, with detail=stale_rule_view by=<operator> when the index now holds a different rule (or a stated type is not a rule type) and detail=rule_not_found by=<operator> when the index no longer exists. A request that states neither keeps the by-index behaviour: an unknown index is a 200 no-op with the same rule_not_found row. |
enrollment.auto_approve.rule_toggle | AutoApproveRule | A rule is enabled or disabled (POST /api/settings/auto-approve/{index}/toggle). Same attempt then outcome pair and failure behaviour as rule_add. detail carries type= and value= (the rule actually toggled, read under one lock), `enabled=0 |
enrollment.auto_approve.mode | AutoApproveRule | The match mode is changed (POST /api/settings/auto-approve/mode). Same attempt then outcome pair and failure behaviour as rule_add. detail carries `mode=any |
enrollment.auto_approve.view | Enrollment | An operator read the configured auto-approve enrollment rules (agent-registration bypass criteria) via REST v1 GET /api/v1/enrollment/auto-approve-rules (#4031) — no MCP twin, REST-only per #520. target_id= empty. Operational, set-and-proceed (a dropped row is logged but never blocks the read — the rule set is fleet config, not per-person data). Permission gate: Enrollment:Read (Administrator only). The dashboard fragment this route twins (GET /fragments/settings/auto-approve) remains unaudited, matching its pre-existing posture. |
enrollment.pending_agents.view | Enrollment | An operator read the pending/denied agent-enrollment queue (device-identity fingerprints: agent_id, hostname, OS/arch, agent_version) via REST v1 GET /api/v1/enrollment/pending-agents (#4031) — no MCP twin, REST-only per #520; do not conflate with the unrelated Approval securable’s list_pending_approvals MCP tool. target_id= empty. Operational, set-and-proceed — a lighter version of the device_ci GDPR-personal-data-adjacent class, so recorded but not fail-closed. Permission gate: Enrollment:Read (Administrator only). The dashboard fragment this route twins (GET /fragments/settings/pending) remains unaudited. |
oidc.configure | OidcConfig | An operator saved the OIDC sign-in configuration via POST /api/settings/oidc. target_id is the issuer; result=success, or result=failure with detail=persist failed: <keys> when the running provider was swapped but the store refused part of the write. result=denied with detail=elevated_session_oidc_change_blocked (HTTP 403, target_id empty) when the caller is administrator only through a temporary elevation (principal_role on the row shows the effective role admin, so detail is the discriminator); nothing was changed. |
settings.oidc.view | OidcConfig | An operator read the OIDC SSO configuration (issuer, client ID, redirect URI, admin group, skip-TLS-verify — never the client secret, which is masked to a boolean) via REST v1 GET /api/v1/settings/oidc (#4031) — no MCP twin, REST-only per #520. target_id= empty. Operational, set-and-proceed — IdP/admin-group configuration carries reconnaissance value but is not per-person data. Permission gate: OidcConfig:Read (Administrator only) — deliberately not Directory:Read, despite this route twinning the legacy /fragments/settings/directory path: that path is pure OIDC config, unrelated to the AD/Entra directory-sync feature Directory gates. The dashboard fragment this route twins remains unaudited. |
discovery.scan | Discovery | An agent reported network-discovery scan results via POST /api/discovery/scan (discovery_store, ADR-0044). target_id is the scanned subnet; detail=<stored>/<attempted> devices stored (an entry skipped for an empty ip_address is not counted as attempted). result=success when every attempted device persisted, partial when some but not all did (the same “some succeeded, some failed” outcome session.revoke_all/api_token.rotate use partial for — never folded into failure, which would misreport devices that DID persist), failure when every attempted device failed to persist (the request also returns 503). |
patch.deploy | Patch | An operator created a patch deployment via POST /api/patches/deploy (PatchManager, ADR-0062). result=success: target_id is the new deployment_id; detail="Deployed <kb_id> to <N> agents". result=denied: emitted on any PatchManager::deploy_patch validation rejection — invalid KB ID format, the agent_ids batch exceeding kMaxDeployTargets (5000, post-de-duplication), or a failed transaction; target_id is empty, detail is the validation error string verbatim (e.g. "too many target agents (max 5000)"). Known gaps, tracked in #3705: the DB-transaction-failure and patch-manager-unavailable causes are server-fault, not operator denial, and should audit failure rather than sharing denied with the client-caused rejections; detail should carry a stable reason=<token> rather than the raw error string; the route’s five pre-deploy_patch validation branches (malformed body, missing kb_id/agent_ids, past reboot_at, 503 unavailable) remain unaudited entirely. Permission gate: Patch:Write. |
approval.approve / approval.reject | approval | An operator approved or rejected a pending approval request via POST /api/approvals/{id}/approve or /reject, or via the dashboard Approvals page, which posts to the same routes. target_id is the approval id; a path id that is empty, longer than 128 characters or holds anything other than letters, digits, _ and - is recorded as the fixed placeholder invalid-id so that caller-supplied bytes never reach an audit row; such a request is answered 400 approval not found with a single result=denied row (detail invalid_approval_id) and no attempted row, because the store is never asked. From #6079 the attempted and success rows carry detail approver_assurance:enrolled_fresh_mfa; rows without it were written before the control existed. A decision leaves two rows (a replica that predates #6061 leaves only the outcome row, with an empty detail). result=attempted is written BEFORE the decision is applied and fails closed (#5823): if it cannot be written the route returns 503 with Sec-Audit-Failed: true and the request stays pending. The outcome row follows: result=success, or result=denied with the store’s reason in detail when the store refuses the decision (400), for example the self-approval (reviewer = submitter) block. Only the attempted row is fail-closed. The outcome rows are best effort: if one cannot be written, the response is unchanged and carries no Sec-Audit-Failed header, and yuzu_server_audit_emit_failed_total is the alert signal for the loss (on a connection-pool timeout or an INSERT error the server log also carries an AuditStore::log error line naming the action; a closed audit store logs nothing there). For a lost success row the decision has been applied and the response is still 200; for a lost denied row the store refused the decision, so this request applied nothing and the response is still 400; the ticket keeps its prior state, which is usually still pending but may already be decided (the store also refuses an already-reviewed or unknown ticket, and an update failure cannot prove the update did not commit), so check its status. An attempted row with no outcome row means “started, outcome unknown”: the decision may or may not have been applied, so check the approval’s status. A ticket can hold several attempted rows (a refused attempt writes attempted then denied, and the real decision writes its own pair), so a surviving denied row can hide a lost success row: reconcile by COUNT, not by presence. For each target_id, count the attempted rows and the outcome rows (success, plus denied rows whose detail does not start with approver_assurance:, which are written with no attempted row), and treat attempted greater than outcome as suspect (an example query follows the table, under “Reconciling approval decisions”). Cutover marker (#6079): the attempted row and the success row both carry detail=approver_assurance:enrolled_fresh_mfa, which records that the decision passed the enrolled-factor and fresh-proof check. An attempted or success row without it was written before that control, or by a replica that does not have it (see the mixed-version warning in Upgrading). The token states that the check passed; it does not record the factor type or the proof’s age. It is a pass marker on attempted and success rows: the NOT LIKE 'approver_assurance:%' filter in the reconciliation query below applies to denied rows only and must not be widened to exclude these. For those, correlate by principal and time with the adjacent mfa.login.verified, mfa.step_up.passed and mfa.enroll.verified rows (local accounts) or the auth.oidc_login row’s amr_mfa_asserted detail (OIDC), and do the same for rows that predate the token. Class refusal: in the handler, after the central step-up check, an API-token, MCP-token or other non-interactive session is refused 403 approver_assurance_required:<reason> and leaves ONE row, result=denied with detail=approver_assurance:<reason> (non_interactive_session or non_human_principal; engine_principal is not written on these rows, because over REST an engine principal is refused earlier, by the central check described below); no attempted row is written, because nothing was attempted. The refusal does not depend on that row: if it cannot be written the 403 stands and the response carries Sec-Audit-Failed: true. MFA refusals (#6079) come from the approver gate and follow the same shape, one result=denied row with no attempted row and detail=approver_assurance:<reason>: mfa_not_enrolled (403), mfa_proof_missing (403), saml_mfa_unsupported (403), account_unavailable (403; a local session whose account row is deleted, deactivated or has an invalid username; this typed row is written only with --mfa-step-up-window-secs at 0 or when the account is removed in the middle of the request, because with step-up on the central check normally answers such an account first with a 401), and mfa_proof_stale (the step-up 401, which also leaves the gate’s own mfa.step_up.required row; when a local proof predates the account’s current enrolment that row’s detail carries reason=proof_predates_enrolment). An unreadable MFA store (503) leaves one row with result=error, detail=approver_assurance:mfa_store_unavailable and no attempted row. An OIDC approver whose stored MFA proof is dated after the database clock (503, the clock-skew refusal) leaves one row with result=error, detail=approver_assurance:mfa_proof_clock_skew and no attempted row; it is a clock fault (the replica that took the sign-in ran ahead of the database clock; the identity provider’s token time is clamped to that replica’s own clock), not a store fault, and yuzu_auth_secret_unavailable_total{route="approval"} does not count it (see “OIDC approvers refused with a clock-skew 503” in docs/ops-runbooks/auth-db-recovery.md). The same rule holds for all of them: the refusal stands whether or not its row persists, and a row that cannot be written adds Sec-Audit-Failed: true to the 403, 401 or 503. The one exception is the gate’s own mfa.step_up.required row on the 401: it is best effort, so a failed write is logged on the server and never surfaced in the response, while the ticket-specific mfa_proof_stale row above it does set the header. None of these counts as an outcome row in the reconciliation below. The invalid_approval_id row is different: its detail does not start with approver_assurance:, so the query counts it as an outcome row under target_id invalid-id, which has no attempted rows and is therefore never listed. Central refusals leave a different row, and no approval.* row. Both approval routes are among the 37 routes the central step-up check covers, and it runs before the handler. Over REST it refuses an engine principal with 403 whatever the window: that attempt is recorded as mfa.step_up.required with reason=engine_principal_denied in the detail and the route label (POST /api/approvals/{id}/approve or /reject) as target_id. With --mfa-step-up-window-secs above 0 it also refuses a SAML session (reason=saml_session_no_mfa_this_release, 403) and a proof older than the configured window (age_secs=<n> window_secs=<n>, 401), with stage=pre_authz in the detail and no ticket-specific row. The typed approver_assurance_required:<reason> refusals above are therefore what an account with no enrolled factor, a bearer-class credential, a proof older than 300 seconds but inside a longer configured window, a proof made before the current enrolment, or (with the window at 0) a SAML session sees. SIEM guidance: count approvals with result=success; check every attempted row that has no outcome row, by comparing counts per target_id as above (its outcome row was lost, or the server stopped before the decision; read the ticket’s status); alert on result=denied rows whose detail is approver_assurance:non_interactive_session or approver_assurance:non_human_principal as evidence that a bearer credential reached the decision surface (the MFA reasons mfa_not_enrolled, mfa_proof_missing, saml_mfa_unsupported, account_unavailable and mfa_proof_stale are interactive approvers being turned away, not a credential misuse signal), and on mfa.step_up.required rows with reason=engine_principal_denied and an approval route label as evidence that an engine credential tried to (an engine attempt over MCP is instead recorded on mcp.approve_request / mcp.reject_request rows whose detail contains approver_assurance:engine_principal, never on approval.* rows, so a SIEM rule for engine attempts should cover those rows too); do not alert on every approval.approve row, since the attempted row doubles the count. --mfa-step-up-window-secs=0 no longer weakens approvals (the approver window is then 300 seconds and an enrolled factor is still required), but a success row written before the #6079 upgrade carries only the weaker assurance (and, with the setting at 0, only an interactive session), so review those, and check every attempted row that has no outcome row (compare counts per target_id). |
csrf.denied (approvals) | Endpoint | This route’s own copy of the same-site check, on POST /api/approvals/{id}/approve or /reject with no Origin/Referer, or one naming another site: refused 403 before the class check, step-up or the attempted row. It is written only where the server-wide check does not run (a handler called directly); a cookie-session caller in production gets the csrf.denied (central gate) row instead. target_id is approval; detail=cross-site or header-less browser write refused. Token-authenticated callers are not gated by this check (they are refused by the class check above). An Origin or Referer matching a --csrf-trusted-origin entry is accepted. |
approval.precondition_denied | approval | A redemption of an approved ticket was refused by its pre-consume precondition (#6062): the state the approved effect depends on had changed since the ticket was minted, or the ticket’s tool and arguments no longer matched the recall. Written by the approval store for every redeemer that supplies a precondition; today that is the MCP recall only (every approval-gated tool supplies a request match, confirm_engine_rotation adds a rotation-state check), so every row in this release carries origin=mcp (the other origins appear once their producers exist: Guardian deploy approvals are not mintable yet, #6063). result=denied, principal is who tried to redeem it, target_id is the approval id, detail is origin=<surface> reason=<the precondition's own server-authored message, cut at 256 bytes> (ticket not consumed). The ticket is NOT consumed and stays redeemable. An MCP recall that is refused this way also writes its own mcp.* denied row, so one MCP event leaves two rows; count approval.precondition_denied for redemptions across all surfaces. Best effort: if the row cannot be written the refusal is unchanged and yuzu_server_audit_emit_failed_total is the alert signal. The matching counter is yuzu_approval_precondition_denied_total{origin}. |
mcp.approve_request / mcp.reject_request | mcp_tool | The MCP tool approve_request or reject_request was called (#6061). Both are always refused after the Approval:Approve permission check: result=denied, target_id is the tool name, detail=approval_id=<id> approver_assurance:<reason> with <reason> as above (non_interactive_session unless the session is an engine or non-human principal). No decision is recorded and no attempted row is written. These rows have a different shape from the REST refusal, which is an approval.approve / approval.reject row. A replica that predates #6061 does not refuse the call: its MCP decisions leave result=success rows for these same actions, which is why the mixed-version query in Upgrading lists them. |
schedule.create | schedule | An operator created a recurring instruction schedule via POST /api/schedules. target_id is the new schedule id; detail is the schedule name. Permission gates: Schedule:Write + Execution:Execute (H-01, #1806 — a created schedule fires unattended through ScheduleRunner with no per-fire permission check, so create-time is the only gate). A service-scoped API token is denied outright even holding both permissions — an ITServiceOwner-shaped grant passes both gates, and a created schedule dispatches fleet-wide with NO per-fire confinement (exec_visible=std::nullopt), which is worse than a one-shot mutating read. This denial has been produced by require_permission’s own service-scope default-deny since guardian-confinement-2298 PR 3 (“the flip”) — audited as auth.permission_required/result=denied (empty target), not a schedule.create/result=denied row. The route’s own interim deny that used to emit the latter was already unreachable dead code by then, and was formally retired as such in #3290 Phase 2 bucket 1a (2026-08-21) — this row’s correction reflects that, not a new behavior change on that date. Proper fix (persist the minting token’s scope on the row, derive ScheduleRunner’s exec_visible from it) is tracked follow-up work. |
schedule.enable / schedule.disable | schedule | An operator (re)enabled or disabled a schedule via POST /api/schedules/{id}/enable. target_id is the schedule id. Owner-scoped at the store seam (set_enabled(id, enabled, user) — a Schedule:Write grant only affects schedules the caller created). Re-enabling additionally gates Execution:Execute (H-01 — arms unattended fleet dispatch, same concern as create); disabling gates Schedule:Write alone, deliberately, so an operator can always kill a runaway schedule. A service-scoped API token is denied outright, both enable and disable — Schedule:Write is checked unconditionally before enabled is even parsed, so a service-scoped token never reaches either branch (this was true before #3290 Phase 2 bucket 1a too; the route’s own interim enable=true-only deny was already provably dead — a service-scoped token was always caught by the Schedule:Write gate first — and was retired as such). The kill-switch guarantee this route’s design intends (disable stays reachable for a service-scoped token) does not currently hold; tracked as #3378. This has been audited as auth.permission_required/result=denied (empty target) via require_permission’s own service-scope default-deny since the flip — not a schedule.enable/result=denied row; bucket 1a (2026-08-21) only formally retired the already-dead code that used to emit the latter. |
schedule.delete | schedule | An operator deleted a schedule via DELETE /api/schedules/{id}. target_id is the schedule id on success. result=success reported via the response body’s deleted boolean (no distinct noop result value — unlike the preflight/deployment delete rows above). Owner-scoped at the store seam (delete_schedule(id, user) — a Schedule:Delete grant only affects schedules the caller created). A service-scoped API token is denied outright before delete_schedule is ever called — a token sharing its creating principal’s username could otherwise delete a fleet-wide schedule outside its own service. This has been audited as auth.permission_required/result=denied (empty target) via require_permission’s own service-scope default-deny since guardian-confinement-2298 PR 3 (“the flip”) — not a schedule.delete/result=denied row; the route’s own interim deny that used to emit the latter was already unreachable dead code by then, formally retired as such in #3290 Phase 2 bucket 1a (2026-08-21). |
schedule.list | schedule | The MCP list_schedules tool denies a service-scoped API token outright via its own STILL-LIVE pre-gate deny (result=denied, target_id= empty), audited as schedule.list — a separate, worse-than-username-scoping gap found in a guardian-confinement-2298 hardening sweep: ITServiceOwner grants full CRUD on Schedule, and ScheduleEngine::query_schedules has no owner/service filter of any kind, so a bare Schedule:Read gate alone let a service-scoped token enumerate every schedule from every other service. The REST twin, GET /api/schedules, no longer emits this verb on denial — its own interim deny was retired as provably dead code in #3290 Phase 2 bucket 1a (a service-scoped token was already caught by the route’s Schedule:Read gate first); its denial now audits as auth.permission_required instead. This is no longer “one shared verb across REST and MCP” — only MCP’s list_schedules still emits it. No success-path emission on either transport (neither previously audited the list read at all). |
schedule.list.access_denied | schedule | The dashboard-fragment twin of schedule.list above: a service-scoped API token was denied on GET /fragments/schedules, the Instructions page’s Schedules tab. Distinct verb from schedule.list (mirrors guaranteed_state.fragment.access_denied’s relationship to its own REST/MCP siblings) since a dashboard fragment is a separate surface. target_id= empty; result=denied. This fragment ALSO previously had no RBAC gate of any kind (any authenticated session could read it) — now gated Schedule:Read in addition to this deny; the ordinary Schedule:Read denial has no dedicated audit row (matches every other bare perm_fn rejection in this codebase — RBAC denials are not separately audited unless the route says otherwise). |
auth.fleet_read_required | (empty) | The shared fleet-read gate (require_fleet_read) refused a request on a fleet-wide read route. result=denied for a refusal, and result=failure when a read inside the check failed (detail fleet read blocked: authorization read failed for <permission>, engine principal: authorization read failed for <permission>, or engine principal: RBAC enforcement state unconfirmed for <permission> (no grant read was attempted: the enforcement state could not be confirmed); the caller is answered a retryable 503 with retry_after_ms: 5000, never a 403, and the body names no permission); target_type and target_id are empty; the reason is in detail. Every denial path of the gate is audited (except on the Dashboard panel fragments, which use the non-auditing variant: a refused interactive sign-in leaves no auth.fleet_read_required or auth.permission_required row there, nor does a store outage met by an interactive sign-in on those panels (the 503 and the degrade counter remain), while a token or engine caller still leaves its row; the header bell and the approvals badge keep the auditing gate and write one auth.permission_required row per page view for a refused account, because each stops polling after the first 403), and the refusal does not depend on the audit write succeeding. Two variants apply only to a service-scoped API token and check that token against the ITServiceOwner role, which is the authority ceiling for service tokens regardless of what the minting user holds: detail=fleet read blocked: service-scoped token lacks ITServiceOwner permission <Securable:Operation> (HTTP 403; the role does not hold the pair, whether the grant was never seeded or an operator revoked it) and detail=fleet read blocked: RBAC read degraded resolving the ITServiceOwner ceiling for <Securable:Operation> (HTTP 503 with retry_after_ms; the role’s grants could not be read, so the request is refused rather than admitted, and yuzu_server_rbac_read_degrade_total is incremented; require_scoped_permission answers the same failed read with the same 503 and counter and records it under auth.scoped_permission_required with detail=service-scoped token blocked: RBAC read degraded resolving the ITServiceOwner ceiling; require_permission does not answer 503 (its empty service-scope allow-list refuses every pair anyway), it records its default-deny row under auth.permission_required with detail=service-scoped token blocked: default-deny (<Securable:Operation> not on the service-scope global-safe allow-list), and a failed read of the role’s grants appends ; ceiling read degraded to that detail (the HTTP response and metrics are the same as for a healthy default-deny); the definitive deny of both keeps detail=service-scoped token blocked: lacks ITServiceOwner permission and 403). The gate’s other denials use the same verb with their own detail text: an engine principal or MCP token tier refused, RBAC store, management-group store or tag store unavailable, a service-scoped token while RBAC enforcement is off, the topology floor, no management-group grant. |
upload_grant.list.access_denied | UploadGrant | A service-scoped API token was denied outright on GET /api/v1/upload-grants, before the grant list is read. result=denied; target_id= empty; detail is service-scoped tokens may not list upload grants (path=/api/v1/upload-grants). The list route resolves the caller’s UploadGrant:Read view from the minting user’s grants, so without this deny a service token would have seen whatever its minter could (the whole fleet for a minter with a global grant). The MCP twin refuses the same caller through the shared permission gate. Same shape as schedule.list.access_denied above. |
upload_grant.revoke | UploadGrant | An operator revoked an upload grant via DELETE /api/v1/upload-grants/{grant_id} or the MCP tool revoke_upload_grant. Both write result=attempted before revoking, then success, or failure with the store error or not found or not revocable in detail (a grant already redeemed, revoked or unknown). If the attempted row cannot be stored, nothing is revoked: REST answers 503 and MCP a retryable -32603 (#6169). target_id is the grant id. An attempted row with no matching outcome row for the same target_id means the outcome is unknown (a crash, or a lost outcome write), not an unaudited revoke: check the grant’s state with GET /api/v1/upload-grants or list_upload_grants. |
cert.reload | TlsCertificate | Certificate hot-reload attempted; detail contains outcome (success or failure reason) |
guaranteed_state.rule.create | GuaranteedState | A Guaranteed State rule is created. On 400 (missing required fields), 409 (rule_id/name conflict), or RBAC rejection, emits result=denied with the store error in detail. A rule_id that fails the [A-Za-z0-9._-]+/256-byte check also emits result=denied (both REST and MCP, #4665) — target_id is the rejected rule_id (REST replaces control bytes, space, = and , with _ in it, on this row and on the denied rows below that are written before the store call; MCP records it unmodified) and detail is the fixed string "invalid rule_id, missing name, or missing yaml_source", not a store error. A service-scoped API token is denied outright (result=denied, target_id= empty) on both POST /api/v1/guaranteed-state/rules (REST — found by external review, PR #3156) and the dashboard fragment twin POST /fragments/guardian/guards — same confinement-gap class as the read fragments in this file’s guaranteed_state.fragment.access_denied row, but on the create path: perm_fn’s service-token branch checks only the ITServiceOwner role, never the token’s own service-tag scope. A version outside 1 to 2147483647 on POST /api/v1/guaranteed-state/rules also emits result=denied (target_id is the rule_id from the body, neutralised as above, detail="version out of range") alongside the 400; the MCP create_guardian_rule twin answers -32602 for the same value without a guaranteed_state.rule.* row. |
guaranteed_state.rule.update | GuaranteedState | A Guaranteed State rule is updated (version is incremented on every successful update). An update of a rule whose stored version is already 2147483647 or above emits result=denied (target_id=<rule_id>, detail="rule version counter exhausted") with a 409 on PUT /api/v1/guaranteed-state/rules/{id}; the MCP update_guardian_rule twin answers -32602 for it without a guaranteed_state.rule.* row. On 404 (rule not found), 400 (invalid JSON body — detail is the literal string "invalid JSON body"), 409 (name conflict), or RBAC rejection, emits result=denied. A service-scoped API token is denied outright (result=denied, target_id=<rule_id>) on both PUT /api/v1/guaranteed-state/rules/{id} (REST — PR #3156) and the dashboard fragment twins POST /fragments/guardian/guard/{id}/enabled and POST /fragments/guardian/guard/{id}/update — same confinement-gap class as .rule.create above. The dashboard twins write a success row before the change and refuse it if that row cannot be written (503 with Sec-Audit-Failed: true for edit and delete; the Enable/Disable toggle answers 200 with a banner and the same header); a store rejection after the row (409, 400, 404 on edit and delete) adds a result=failure row, so one request can produce a success and a failure row. A cross-origin dashboard post is result=denied, detail=csrf_denied. |
guaranteed_state.rule.delete | GuaranteedState | A Guaranteed State rule is deleted. On 404 or RBAC rejection, emits result=denied. A service-scoped API token is denied outright (result=denied, target_id=<rule_id>) on DELETE /api/v1/guaranteed-state/rules/{id} — found by external review, PR #3156; this REST route had no confinement check of any kind before this fix. The dashboard twin POST /fragments/guardian/guard/{id}/delete has the same denial, writes its success row before the delete (503, Sec-Audit-Failed: true if it cannot be written), adds result=failure if the store then rejects it, and records result=denied, detail=csrf_denied for a cross-origin post. |
guaranteed_state.rule.read | GuaranteedState | A service-scoped API token was denied outright (result=denied, target_id= empty for the list, or the rule id for a single-rule read) on GET /api/v1/guaranteed-state/rules or GET /api/v1/guaranteed-state/rules/{id} — found by external review, PR #3156, the same confinement-gap class as .rule.create/.update/.delete above, but neither REST route previously had any audit trail at all. This verb currently records the service-scoped DENY only — an ordinary successful read is not audited (matches the read-tier posture of .rule.create’s sibling reads elsewhere in this table; the mutating verbs above audit both outcomes). |
guaranteed_state.push | GuaranteedState | A push of the active rule set to scoped agents is accepted. target_id is empty (pushes are fleet-level, not per-entity); detail carries rules=<N> full_sync=<bool> scope="<sanitised-expr>" agents=<N>, where agents is the number of in-scope agents the push was queued for. The scope value in detail is sanitised before embedding: " and \ are backslash-escaped, C0 control bytes (0x00–0x1F, DEL) are dropped. SIEM parsers reconstructing the original scope expression must account for this normalisation — a raw operator input of env="prod" appears in the audit as scope="env=\"prod\"". Delivery is asynchronous: a success row means the push was queued, not that every agent applied it. If a store read the push depends on is degraded (the policy generation, the rule store, the Baseline deployed set, the tag or custom-property store, or a group: scope’s membership), nothing is sent and the row is result=denied with detail store degraded scope="<sanitised-expr>" for every cause (REST answers 503, MCP a JSON-RPC internal error with a retry hint); an unparseable scope is result=denied with detail invalid scope="<sanitised-expr>". Non-object JSON bodies are rejected with result=denied and detail="invalid JSON body". A service-scoped API token is denied outright (result=denied, target_id= empty) on POST /api/v1/guaranteed-state/push — found by external review, PR #3156, and the single most severe instance of the guardian-confinement-2298 class found on this branch: unlike every other fix in this table, this route’s fleet-wide full_sync push directly mutates what every OTHER service’s agents enforce, and (before this fix) had no confinement check of any kind — not even the dashboard-analogue check guaranteed_state.baseline.deploy below already had. |
guaranteed_state.reconcile | GuaranteedState | A system-initiated re-push of the deployed Guard set to ONE agent, fired from the heartbeat path when the agent reports a policy generation behind the server’s (an agent that was offline when a push fired, has just reconnected, or is holding its generation, see Guaranteed State). principal=system, target_id is the agent id, and at most one row is written per agent per 25 s: an agent is admitted at most once per 25 s, and a claim younger than 25 s is kept even when the agent reports the current generation in between, so alternating reports cannot buy extra pushes (the limit is kept in memory by each server process, so it applies per server replica, not across a cluster, and a restart empties it). A reconcile that this replica sheds (#5513: its admission budget is exhausted, its per-second ceiling is reached, or its in-flight cap of 256 is reached; see --guardian-reconcile-budget-ms-per-s) writes no row and does not consume the 25 s slot: the agent re-reports on its next heartbeat, and only yuzu_server_guardian_reconciles_total{result="shed"} records it, so a row is always one admitted push (the converse does not hold: an admitted push that ends without sending and without aborting writes no row and keeps its claim, so it is still limited to one admission per 25 s; that covers result="no_session", a fresh generation that is not ahead of the agent, and a failed queue write. no_session fires only when the agent’s session was removed (reaped, revoked or torn down) while a pending-map or gateway-held session id still resolved, on a deregistration race, or when the agent re-registered while the push was being prepared (the push was built for the earlier session, so it is withheld). An agent that has Registered but not Subscribed already has a session: it takes the full push path and a failed send shows only as yuzu_server_system_reserved_push_total{result="undelivered"}). Two shapes, distinguished by result. (1) result=success, detail=heartbeat reconcile re-push (generation <agent> -> <server>): the push was dispatched to the agent’s session. success means the push was sent, NOT that the agent applied it or converged: an agent whose generation stays behind keeps producing a fresh row about once per heartbeat at the default 30 s heartbeat (the interval is the smallest whole number of heartbeats that is at least 25 s), so repeated rows for one agent_id mean that agent is not converging (the agent can also be deliberately holding its generation while an arm is still unresolved), not that the server is healthy. A push that could not be sent writes no row. A row reading generation 0 -> <N> is either an agent that has never had a push acknowledged or one that reports 0 because a start-up re-arm failure is unresolved (#5513); today the agent log (N cached rule(s) failed to re-arm at boot) is the operator-visible discriminator, and the agent’s yuzu.guardian_boot_rearm_unresolved heartbeat tag is a wire diagnostic with no reader (#5558): the health store overwrites tags each heartbeat and the audit row does not record it. Repeated generation 0 -> <N> rows for one agent_id mean that agent keeps reporting 0, for example because a boot re-arm failure persists; a persistent failure is re-pushed with an agent-side back-off, one row per push: when the agent’s persisted generation equals the server’s current generation, at the default 30 s heartbeat about 60 s, 90 s, 150 s and 270 s apart, then about every 330 s (5.5 minutes) in steady state, because after each failed catch-up that reaches the end of apply_rules the agent reports its real generation for the next 1, 2, 4, 8 and then 10 heartbeats before reporting 0 again (the server sees no generation gap on those heartbeats and does not push). An agent whose persisted generation is behind the server’s (or is 0), or whose push returns before the end of apply_rules (a persistent KV write failure, a begin_application throw, the invalid-rule-id reject), earns no holdoff and keeps producing a row about once per heartbeat; a generic server-side per-agent back-off for that held-generation retry is a follow-up. (2) result=degraded, detail begins heartbeat reconcile ABORTED, then names the stage that failed and ends agent rules did not converge (generation <agent>). The stage is one of rule store degraded (<error>), baseline store degraded (<error>), rule bundle load failed (<error>), policy generation unreadable (<error>) (the fresh read taken at admission), since #5513 rule bundle invalidated during load (a rule or policy change landed while the re-push was being prepared; retried on a later heartbeat) (a rule create, update or delete, a Baseline update, delete or deployed create, a policy-generation raise that increased the stored value, or a Guardian push fanned out by this replica landed while the reconcile was being prepared, so the rule set it held may be older than that change; nothing was sent, and the claim is kept, so the agent is reconciled from a fresh load at its first heartbeat after the 25 s slot; this is a benign, edit-driven abort rather than a failing store: it is counted by yuzu_server_guardian_reconcile_invalidated_total and NOT by yuzu_server_guardian_reconciles_total{result="degraded"}, and the row itself carries result=degraded, exactly like the store-failure abort (they share one audit helper), so a SIEM filter on result=degraded mixes both; tell them apart by the stage text rule bundle invalidated during load) or, since #5513, scope store degraded (db_degraded), where the parenthesis is db_degraded, optionally followed by : <detail> (any other untrusted-read kind the scope evaluator reports, such as owner_check_failed or presence_degraded, is written as the kind name alone, never with its detail): nothing was pushed, deliberately, because an unreadable store is never fanned out as an empty rule set. The scope literal is written when the tag or property store cannot be read while evaluating a rule’s scope for the agent; its detail does not say whether tags or properties failed (read yuzu_server_tag_store_read_degrade_total and yuzu_server_custom_properties_read_degrade_total for that): previously that case was treated as “agent not in scope” and pushed a narrowed rule set; now nothing is pushed, and the agent retries on its first heartbeat after its 25 s slot (an abort keeps the claim, so a retry inside the slot is rate limited). Count the rows per agent_id to find agents being re-pushed repeatedly; the fleet counters yuzu_server_guardian_reconciles_total and yuzu_server_guardian_pushes_dispatched_total{reason="reconcile"} track the same events without the per-agent axis. |
guaranteed_state.policy_generation.raise | GuaranteedState | The Guardian policy generation was raised via POST /api/v1/guaranteed-state/policy-generation/raise or the MCP twin raise_guardian_policy_generation (administrator-only, see “Restoring the policy generation” in Guaranteed State). target_id is the fixed literal policy_generation on every row of this verb, including the denials of a service-scoped token and an MCP-tier bearer token on REST. It is a fail-closed write, so it produces TWO rows: result=attempted is written FIRST, before the counter is touched (if it cannot be written the change is not made), then one outcome row. A store fault after the attempted row leaves attempted followed by failure; an attempted row with no outcome row means “started, outcome unknown”. A request refused before the attempted row leaves a single denied row (or none: see the list of cases below), and no attempted row. A store fault after the attempted row records failure with a detail ending the outcome is unconfirmed: the value may or may not have moved, and repeating the request is safe because it is idempotent (a repeat of a raise that took effect returns raised=false). result=success carries detail=target=<N>; previous=<P>; resulting=<R>; raised=<true|false>; rules=<X>; deployed_baselines=<Y>: raised=false marks the idempotent no-op (a target at or below the stored value; nothing changed), and rules and deployed_baselines are the counts read just before the raise. result=denied covers a caller who is not a durable Administrator (caller is not a durable RBAC administrator), a caller whose authority the re-check under the current RBAC regime refuses (caller does not hold authority under the regime that is durably true right now; a re-check that cannot be answered is also denied, with a 503), a service-scoped token, an MCP-tier bearer token on REST, an empty MCP tier (empty mcp_tier, denied outright (auth_source=<...>)), an unreadable rule or Baseline count (target=<N>; store degraded before the change, nothing was written on MCP, store degraded: rule or deployed Baseline count unreadable; target=<N> on REST), a target the store itself rejects as out of range, and, on both transports, an empty rule set (no rules, or no deployed Baseline) raised without acknowledge_empty_rule_set: true (detail is empty rule set not acknowledged; rules=<X>; deployed_baselines=<Y>; target=<N>; REST answers 400, MCP -32602; nothing is changed and no attempted row is written). On REST a request body that is not valid JSON or fails validation (bad target, missing acknowledgement) also leaves one denied row, with detail invalid request: <fixed reason>; the MCP tool writes no row of this verb for an argument its input schema rejects and answers -32602 (the empty-rule-set refusal above is the exception on MCP and does write its denied row). A store fault leaves result=failure. Cases that write no row of this verb: a missing session (401); a step-up refusal on REST, which is recorded under mfa.step_up.required instead (a SAML session’s 403 and an engine session’s 403 at the same central check are recorded there too, the latter with reason=engine_principal_denied and the route label as target_id); a cross-site or header-less cookie-session write on REST, which is refused with 403 and recorded as csrf.denied (target_type Endpoint, target_id policy_generation) rather than under this verb; a REST request that finds the RBAC store missing (503) or the Guardian state store or Baseline store missing or not open (503, counted in yuzu_server_guardian_policy_generation_raise_total only, after the administrator gate); on MCP, an argument rejected by the input schema (-32602), an RBAC store that is not available, a call whose Guardian stores were never wired (an internal error, counted only), and a tier or approval refusal. If the success row cannot be persisted after the request reached the store, REST answers 503 with Sec-Audit-Failed: true stating that the audit record was not persisted, and MCP returns an error with audit_persisted:false stating the change took effect; the attempted row is then the only record. |
guaranteed_state.policy_generation.read | GuaranteedState | A service-scoped API token was refused on GET /api/v1/guaranteed-state/policy-generation: the generation is a fleet-wide scalar, so such a token is denied outright (403). This verb is REST only. result=denied is the only result it carries: a permitted REST read writes no audit row. target_id is the fixed literal policy_generation, and detail names the denial and ends with a correlation id (cid=). A caller who merely lacks GuaranteedState:Read is refused by the permission gate and writes no row of this verb. The MCP twin get_guardian_policy_generation has no service-scope class, so a service-scoped token is refused earlier, by the generic MCP service-scope default-deny gate, and the refusal is recorded as mcp.get_guardian_policy_generation with result=denied, target_type=mcp_tool, target_id=get_guardian_policy_generation and detail=service-scoped token blocked: default-deny (C8, #2298), with yuzu_auth_service_scope_default_denied_total incremented; no row of this verb is written for it. A permitted MCP call is recorded by the same mcp.get_guardian_policy_generation row with result=success. |
guaranteed_state.baseline.create | GuaranteedState | A Guardian Baseline (the deployable collection of Guards) is created from the dashboard. detail carries <name> (members=<N>, draft). RBAC rejection (GuaranteedState:Write) is audited centrally under auth.permission_required. A service-scoped API token is denied outright (target_id= empty) before the form is parsed — same confinement-gap class as .rule.create above. |
guaranteed_state.baseline.update | GuaranteedState | A Baseline is renamed or its member set edited (GuaranteedState:Write). detail carries <name> (members=<N>). For a deployed Baseline the change is staged — it reaches agents only on re-deploy. A service-scoped API token is denied outright (target_id=<baseline_id>) before any edit is applied. |
guaranteed_state.baseline.deploy | GuaranteedState | A Baseline is deployed or re-deployed (GuaranteedState:Push): its member set is snapshotted and the fleet converges to the union of deployed Baselines’ enabled members. detail carries <name> deployed fleet-wide (agents=<N>, members=<M>). If the fan-out was abandoned because a store read it depends on degraded (the rule store, the Baseline deployed set, the tag store, the custom-property store or a management-group membership), the row is result=degraded and the detail reads (a store read degraded - fan-out deferred to reconcile, members=<M>): the Baseline is saved and a generation bump was attempted, the dashboard shows a warning toast, and the heartbeat reconcile re-sends the rule set once the store recovers, but only if the bump succeeded (it is best effort). (push not wired/failed, members=<M>) is recorded, with result=success, if no push function is wired, which a production server never does; the dashboard then shows a warning toast (“Baseline saved; fleet fan-out not sent - push not wired or failed”) instead of the “Baseline deployed” toast. Store-unavailable / no-such-baseline precondition failures emit result=denied. A service-scoped API token is now ALSO denied outright (target_id=<baseline_id>) before the deploy even reads the Baseline: this route’s fleet-wide full_sync push meant a service-scoped token could change what every OTHER service’s agents enforce, not merely read it — a severe instance of the guardian-confinement-2298 class, though guaranteed_state.push above (found later in the same branch) is the more severe of the two: this route is at least gated on GuaranteedState:Push, where the REST push endpoint had no confinement check of any kind. |
guaranteed_state.baseline.delete | GuaranteedState | A Baseline is deleted (GuaranteedState:Delete); if it was deployed the fleet re-converges to remove its Guards where no other deployed Baseline still delivers them. detail carries one of three outcomes: <name> (was deployed; reconciled agents=<N>); <name> (was deployed; a store read degraded - fan-out deferred to reconcile) with result=degraded when the fan-out was abandoned because a store read it depends on degraded (the rule store, the Baseline deployed set, the tag store, the custom-property store or a management-group membership; the Baseline is deleted and a generation bump was attempted, and the heartbeat reconcile converges the fleet once the store heals, but only if the bump succeeded); or <name> (draft) for a Baseline that was never deployed. If no push function is wired, which a production server never does, the row reads <name> (was deployed; push not wired or failed; fleet not re-converged) with result=success and a warning toast, because the delete itself succeeded. Store-unavailable / no-such-baseline emit result=denied. A service-scoped API token is now ALSO denied outright (target_id=<baseline_id>) before the delete is applied. |
dex.device.view | Agent | An operator opened a single device’s DEX signal history — the behavioral view that reveals which applications a person runs. Emitted by the dashboard per-device drill-down (GET /fragments/dex/device?id=<agent_id>), by the shared device page’s DEX lens (GET /fragments/device/dex?id=<agent_id>), and by the agent-scoped REST query (GET /api/v1/guaranteed-state/events?agent_id=<agent_id>, which returns that device’s detail_json). target_id is the agent_id; permission gate is per-device-scoped GuaranteedState:Read (management-group aware; a service-scoped API token is confined to its own service’s agents). A bulk events query with no agent_id filter (GET /api/v1/guaranteed-state/events, no agent_id param) is ALSO audited under this same verb — every reporting agent’s row is identity-linked, so this is not a de-identified aggregate. That fleet-wide shape has no single agent: target_type=GuaranteedState, target_id= (empty), and it requires global GuaranteedState:Read and denies a service-scoped token outright (result=denied) — the bare role-only service-token check does not confine a fleet-wide read. Both shapes emit result=success for the access attempt (access-transparency of the open) and fail closed (503 + Sec-Audit-Failed: true, no data served) if the audit row cannot persist. |
app_usage.agent.view | Agent | An operator opened one device’s per-executable app-usage projection (Wave 7 PR7.2) via GET /api/v1/forensics/agents/{agent_id}/app-usage — exe_key, first_seen/last_seen (within Echo’s retained usage window, 31 days default — never a full run-history value), run_count_30d, total_seconds_30d. target_id is the agent_id; permission gate is per-device-scoped Forensics:Read (management-group aware; Forensics:Read is held by default only by Administrator and the read-only ForensicsAnalyst role, and is absent from the seeded Viewer read-list). Behavioral data revealing which applications a person ran, so this uses the same fail-closed audit tier as dex.device.view/sle.agent.view: result=success for the access attempt, and the request is refused (503 + Sec-Audit-Failed: true, no data served) if the audit row cannot persist. A store degrade is separately audited result=failure. |
dex.observation.view | Agent | An operator clicked a single signal-history row on a DEX device drill-down, loading the per-event observation detail panel (every captured projection field for that one event: subject / reason / symbolic / component / metric / platform / exact timestamp / event ID). Emitted by GET /fragments/dex/observation?agent_id=<agent_id>&event_id=<event_id>; the event_id is bound to the scoped agent_id, so a guessed or foreign event ID returns an opaque 200 placeholder (byte-identical to “no such event” — the route returns 200 not 404 so the dashboard’s htmx swap:false-on-4xx still renders it) and reveals nothing. target_id is the agent_id; permission gate GuaranteedState:Read. The detail records the opened event’s obs_type and bound event_id, so usage-class opens (e.g. process.crashed / process.hung — what a person ran) stay separately countable from machine-class opens for works-council access-audit purposes (same rationale as dex.device.procperf.query). Behavioral PII — reveals which specific application event a person’s device generated at a given moment. As an HTML dashboard fragment it is set-and-proceed (not fail-closed like the REST per-device endpoints): if the audit row cannot persist the response carries Sec-Audit-Failed: true and the panel still renders (#1647 — the emit routes through the shared emit_behavioral_audit chokepoint, which also catches a throwing audit sink). |
dex.signal.view | ObsType | An operator (or integration) opened a DEX signal drill-down for one obs_type — top subjects, per-OS split, per-day trend, and a most-affected devices list naming agent_ids exhibiting the signal (behavioral, individual-identifying). target_id is the obs_type; permission gate GuaranteedState:Read, and a service-scoped API token is denied outright (result=denied, target_id= empty — the route has no per-device shape to confine a per-target check against; the empty target_id on denial is deliberate, not a bug: the deny fires before obs_type charset validation, so the raw route match is not yet safe to embed in an audit detail string). Three emitters: the REST endpoint (GET /api/v1/dex/signals/{obs_type}) fails closed (503 + Sec-Audit-Failed: true, no data served) if the audit row cannot persist; the dashboard fragment (GET /fragments/dex/catalogue/signal?type=<obs_type>) and the MCP tool get_dex_signal_detail are both set-and-proceed (a dropped row flags Sec-Audit-Failed/audit_persisted:false but still renders/serves). |
guardian.device.view | Agent | An operator opened the Guardian lens on the shared device page (GET /fragments/device/guardian?id=<agent_id>) — per-guard compliance state for that device. target_id is the agent_id; permission gate GuaranteedState:Read. |
guaranteed_state.rule.view | GuaranteedState | An operator opened the per-Guard fleet-wide drilldown on the Guardian dashboard (GET /fragments/guardian/guard/{id}/page) — every reporting agent’s identity, hostname, and compliance state for ONE rule, fleet-wide. target_id is the guard/rule id. Deliberately a DIFFERENT verb from guardian.device.view above: that verb’s contract is per-DEVICE (target_type=Agent); this drilldown is per-RULE, fleet-wide agents, so reusing guardian.device.view would silently corrupt a SIEM/works-council query filtering that verb by target_type=Agent. Permission gate GuaranteedState:Read; a service-scoped API token is blanket-denied before reaching this fragment (see guaranteed_state.fragment.access_denied below). Set-and-proceed (HTML dashboard fragment, not fail-closed) — a transient audit hiccup must not blank the operator’s lens. |
guaranteed_state.fragment.access_denied | GuaranteedState | A service-scoped API token was denied on a fleet-wide/identity-bearing Guardian dashboard fragment — the data-bearing set (/fragments/guardian/status, /guards, /events, /guard/{id}/page, /baselines, /baseline/{id}/page) plus the create/edit form fragments (/guard-form, /baseline-form, /baseline/{id}/edit, PR #3156) — one shared verb across every fragment this gate covers, since the gate itself is a blanket deny (these fragments have no single agent to scope a per-target check against). target_id= empty; result=denied. A rejected probe still leaves a trace — mirrors the REST fleet-wide deny’s own denial audit (dex.device.view/dex.perf.device.view/network.device.view, result=denied). |
notification.mark_read | notification | The header bell marked one notification read (POST /api/notifications/{id}/read). Written FIRST with result attempted, then success or failure (detail says no such notification or store error); if the attempted row cannot be persisted the change is refused with 503 and Sec-Audit-Failed: true. target_id is the notification id. target_id is the canonical decimal id (0007 is recorded as 7). A digit string that does not fit a 64-bit id answers 404 and writes no row at all; the success/failure outcome rows are best-effort (only the attempted row gates the change). |
notification.dismiss | notification | The header bell dismissed one notification (POST /api/notifications/{id}/dismiss). Same attempted, then success or failure order and the same fail-closed 503 as notification.mark_read. |
csrf.denied (notification bell) | Endpoint | A browser (session-cookie) POST /api/notifications/{id}/read or /dismiss that carried no Origin/Referer, or one naming another site, was refused with 403 before anything was written. Token-authenticated callers are not subject to it. target_id is notification. An Origin or Referer matching a --csrf-trusted-origin entry is accepted. For a cookie-session caller the csrf.denied (central gate) row is written instead. |
dashboard.fragment.access_denied | dashboard | A service-scoped API token was denied on a Dashboard panel fragment (/fragments/dashboard/summary, /policies, /activity, /health). The panels answer 403 (summary) or a 200 note (the others) and the page loads no data for the caller. |
dex.perf.device.view | GuaranteedState | An operator (or integration) read the fleet-wide DEX performance device list — the worst-by-metric, not-reporting, or cohort-member device list behind every Performance drill, each row an agent_id + its perf metrics. target_id= empty (fleet-wide, no single entity); permission gate GuaranteedState:Read, and a service-scoped API token is denied outright (result=denied) for the same reason as dex.signal.view above — no per-device shape to confine against. Three emitters: the REST endpoint (GET /api/v1/dex/perf/devices) fails closed (503 + Sec-Audit-Failed: true) if the audit row cannot persist; the dashboard fragment (GET /fragments/dex/perf/devices) and the MCP tool list_dex_perf_devices are both set-and-proceed. |
network.device.view | GuaranteedState | An operator (or integration) read the fleet-wide network device list — the worst-by-metric, not-reporting, co-occurrence-band, or cohort-member device list behind every /network drill, each row an agent_id + its network perf/correlation facts. target_id= empty (fleet-wide); permission gate GuaranteedState:Read, service-scoped token denied outright (result=denied), same shape and rationale as dex.perf.device.view above. Three emitters: the REST endpoint (GET /api/v1/network/devices) fails closed (503 + Sec-Audit-Failed: true) if the audit row cannot persist; the dashboard fragment (GET /fragments/network/devices) and the MCP tool list_network_devices are both set-and-proceed. |
network.discovered.view | Infrastructure | An operator (or integration) read the fleet-wide discovered-hosts list — hosts found by network discovery scans, each row an address, MAC and agent-reported hostname. target_id= empty (fleet-wide); permission gate Infrastructure:Read, service-scoped token denied outright (result=denied on the REST endpoint and the fragment; an MCP denial is recorded at the MCP gate as mcp.list_discovered_devices, result=denied). Three emitters: the REST endpoint (GET /api/v1/network/discovered-devices) fails closed (503 + Sec-Audit-Failed: true) if the audit row cannot persist; the dashboard fragment (GET /fragments/network/discovered) and the MCP tool list_discovered_devices are both set-and-proceed. |
dex.overview.view | GuaranteedState | On the dashboard fragment (GET /fragments/dex/overview) a service-scoped API token was denied outright — the default /dex landing view’s top-devices list, agent_ids fleet-wide; target_id= empty, result=denied only on this surface (the overview fragment is the default fragment every /dex page load fetches, so no per-open success audit was added there). On the REST endpoint (GET /api/v1/dex/overview) and the MCP tool get_dex_overview (#4035): every call additionally emits this SAME verb with result=success before serving (target_id= empty) — deliberately MORE rigor than the fragment’s own denial-only posture. A service-scoped API token is denied outright on both surfaces too (result=denied, target_id= empty), via each surface’s own deny_fleet_wide_service_scoped call. Fail-closed on the REST endpoint: if the result=success audit row cannot persist, returns 503 + Sec-Audit-Failed: true and serves no data; the MCP tool is set-and-proceed (audit_persisted:false in the result on a dropped row). Permission gate GuaranteedState:Read. |
dex.app.view | GuaranteedState | On the dashboard fragment (GET /fragments/dex/app?name=<app>) a service-scoped API token was denied outright — the app-centric affected-devices list, agent_ids fleet-wide; target_id= empty, result=denied only on this surface (same deferral as dex.overview.view above). On the REST endpoint (GET /api/v1/dex/app) and the MCP tool get_dex_app (#4035): every call additionally emits this SAME verb with result=success before serving — deliberately MORE rigor than the fragment’s own denial-only posture, same shape and fail-closed/set-and-proceed split as dex.overview.view above. Permission gate GuaranteedState:Read. |
device.list.view | Infrastructure | A service-scoped API token was denied on the fleet device roster (GET /fragments/devices/list) — hostname/agent_id/OS/online-state for every visible device. target_id= empty; result=denied only (this route’s own success path has no dedicated audit — a pre-existing gap, not introduced here). Permission gate Infrastructure:Read. Same confinement-gap class as dex.overview.view: the route’s DevicesFn narrowing is username-keyed and does not confine a service-scoped token. |
echo.device_picker.view | Infrastructure | A service-scoped API token was denied on an Echo frame’s device picker (GET /fragments/echo/process-tree or GET /fragments/echo/capture-sources) — one shared verb across both, disambiguated by path=<url> in detail. target_id= empty; result=denied only (no success-path emission — same deferral as dex.overview.view). Permission gate Infrastructure:Read. Same confinement-gap class as device.list.view above. |
device.live.uptime | Agent | An operator clicked Get live info on a device page and the uptime panel dispatched a live os_info/uptime instruction to the device. Execute-gated (Execution:Execute, beyond GuaranteedState:Read); target_id is the agent_id, detail records the agent count reached and the command_id. Machine-health telemetry. Two emitters, by design: the dashboard panel audits post-dispatch (result=dispatched/no_agents); the REST endpoint (POST /api/v1/dex/devices/{id}/live?kind=uptime) audits pre-dispatch (result=requested, detail carries cid=<correlation_id>) and fails closed (503 + Sec-Audit-Failed, no dispatch) if the row cannot persist. The dashboard live + lens sites also set Sec-Audit-Failed on a persist failure (#1585). Aligning the dashboard emitter to pre-dispatch is a tracked follow-up. Known gap (Stream B): the device.live.* REST incarnation (POST /api/v1/dex/devices/{id}/live in rest_api_v1.cpp) is the last behavioural-PII audit site not yet migrated to the shared emit_behavioral_audit chokepoint — it fails closed before dispatch on a false return (no PII without audit), but an audit sink that throws escapes to an httplib 500 instead of the documented 503 + Sec-Audit-Failed. Also note the throw path emits no yuzu_server_audit_emit_failed_total metric (catch-arm spdlog::warn only) — for set-and-proceed routes alert on the Sec-Audit-Failed header/log, not the counter (#1703). |
device.live.processes | Agent | An operator clicked Get live info and the running-process panel dispatched a live processes/list_hashed instruction (PID, name, and the SHA-256 of each on-disk executable image). Usage-class behavioral telemetry — reveals which programs a person is running. Deliberately a separate verb from device.live.uptime so usage-class reads stay separately countable for works-council access-audit purposes (same rationale as dex.device.procperf.query). Execute-gated; target_id is the agent_id, detail records the agent count and command_id. No hash/process data leaves the device until an operator explicitly dispatches this panel. Persistence: the dispatched result (process names, paths, SHA-256) is stored in the response store (PostgreSQL schema response_store) for response_retention_days like any command result, so it is also a DSAR/data-inventory source and is retrievable via the standard response-store API under Execution:Read — the works-council “separately countable” guarantee covers the dispatch (this verb), not a later read-back of the persisted row. Note: the dashboard’s Get-live-info Processes card now dispatches process_tree (parent PID + SHA-256 + connection join) instead; device.live.processes (flat processes/list_hashed) remains valid for REST/scripted callers dispatching that action directly. SIEM / works-council migration: a query previously keyed on device.live.processes to count dashboard process reads should add device.live.process_tree, or dashboard process reads will be silently undercounted from this release onward. Two emitters (same as device.live.uptime): the dashboard panel audits post-dispatch; the REST endpoint audits pre-dispatch (result=requested) and fails closed (503 + Sec-Audit-Failed) on a persist failure. |
device.live.process_tree | Agent | Get live info Processes card — dispatched processes/list_tree (PID, parent PID, name, SHA-256) plus network_diag/connections (joined to the tree by PID), reconstructed into a parent→child tree with per-process network endpoints. Usage-class behavioral telemetry (reveals what a person runs + where it connects); a separate verb from device.live.uptime. Execute-gated; target_id is the agent_id. The tree + connection results persist in the response store (PostgreSQL schema response_store) like any command result (same DSAR/read-back note as device.live.processes). Currently one emitter — the dashboard /run route, post-dispatch (result=dispatched/no_agents); a process_tree card is dashboard-only until the REST/JSON A1 backfill (#1649), at which point a second pre-dispatch (result=requested) emitter will be added to match device.live.uptime. |
device.live.services | Agent | Get live info Services card — dispatched services/list (service run state). Machine-health telemetry. Execute-gated; target_id is the agent_id. |
device.live.netconfig | Agent | Get live info Adapters & IP card — dispatched network_config/ip_addresses. Machine-health telemetry. Execute-gated. |
device.live.arp | Agent | Get live info ARP card — dispatched network_config/arp (host ARP / neighbour table). Available on all three platforms; IPv6 neighbours and incomplete entries are reported on Windows only, Linux covers IPv4 ARP, and macOS omits the interface name and entry type. Machine-health telemetry. Execute-gated. |
device.live.dns_cache | Agent | Get live info DNS-cache card — dispatched network_config/dns_cache (resolver cache; Windows and Linux, not available on macOS). DNS-cache is device-level usage-class telemetry (names a person resolved). Execute-gated. |
device.live.listening | Agent | Get live info Listening-ports card — dispatched network_diag/listening. Machine-health telemetry. Execute-gated. |
device.live.connections | Agent | Get live info Active-connections card — dispatched network_diag/connections (established TCP). Usage-class behavioral telemetry. Execute-gated. |
device.live.users | Agent | Get live info Logged-in-users card — dispatched users/logged_on. Usage-class behavioral telemetry (who is signed in). Execute-gated. |
device.live.capture_sources | Agent | Get live info Capture-sources card — dispatched echo/status to show which Echo warehouse sources are capturing locally (read-only; configuration stays on the Echo page). Machine-health/configuration telemetry. Execute-gated. |
device.live.disk | Agent | Get live info Disk-space card — dispatched disk_space/free to report free/total bytes for the root volume. Machine-health telemetry (not behavioral — no per-user content). Execute-gated (Execution:Execute); target_id is the agent_id. Emitted post-dispatch by the dashboard /run route (result=dispatched/no_agents). |
device.live.hw_disks | Agent | Get live info Disks card (also reachable from the Hardware CI record’s Live lens) — dispatched hardware/disks to report per-disk model, capacity, media type, and interface. Machine-health telemetry. Execute-gated; target_id is the agent_id. |
device.live.hw_memory | Agent | Get live info Memory card — dispatched hardware/memory to report installed memory modules (slot, capacity, type, speed). Machine-health telemetry. Execute-gated. |
device.live.hw_processors | Agent | Get live info Processors card — dispatched hardware/processors to report installed CPU(s) (model, core/thread counts, clock speed). Machine-health telemetry. Execute-gated. |
device.live.hw_drivers | Agent | Get live info Drivers card — dispatched hardware/drivers to report installed device drivers (name, version, date, provider, device class). Windows and Linux only — the agent enumerates no equivalent driver registry on macOS. Machine-health telemetry. Execute-gated. |
device.live.battery | Agent | Get live info Battery card — dispatched power_health/battery to report presence, charge state, percent, time-to-empty, cycle count, and health percent. Machine-health telemetry. Execute-gated. |
device.live.thermal | Agent | Get live info Thermal card — dispatched power_health/thermal to report thermal-zone status and temperature. On a device reporting no thermal zones the result carries status/detail only, with no temperature reading. Machine-health telemetry. Execute-gated. |
device.live.smart | Agent | Get live info Disk health (SMART) card — dispatched disk_actions/smart to report per-disk model, bus and media type, plus SMART health, percent used and spare percent for NVMe drives on Windows only (macOS and non-NVMe or virtual disks report health unknown and - for percent used and spare percent). Windows and macOS only — the disk_actions plugin’s Linux leg does not back this action. Machine-health telemetry. Execute-gated. |
device.live.volumes | Agent | Get live info Volumes card — dispatched disk_actions/volumes to report mounted volumes (mount points, backing device, filesystem type, size). Windows and macOS only — same Linux gap as device.live.smart. Machine-health telemetry. Execute-gated. |
device.live.adapters | Agent | Get live info Network adapters card — dispatched network_config/adapters to report adapter name, MAC address, link speed, and status. Distinct from device.live.netconfig (network_config/ip_addresses, IP-layer configuration). Machine-health telemetry. Execute-gated. |
device.live.wifi | Agent | Get live info Wi-Fi card — dispatched wifi/connected to report the device’s current Wi-Fi association (SSID, signal, security, BSSID/channel). Machine-health telemetry. Execute-gated. |
dex.device.perf.query | Agent | An operator clicked Load performance on a DEX device drill-down, dispatching a live read-only $Perf_Hourly Echo query to that device. Execute-gated (beyond GuaranteedState:Read); target_id is the agent_id, detail records the agent count reached and the command_id. Machine-health telemetry — distinct from the behavioral dex.device.view. |
dex.device.procperf.query | Agent | An operator clicked Load applications on a DEX device drill-down, dispatching a live $ProcPerf_Hourly query (per-application image names — usage-class telemetry, observes what people run). Deliberately a separate verb from dex.device.perf.query so usage-class reads stay separately countable for works-council access-audit purposes. Execute-gated; target_id is the agent_id, detail records the agent count and command_id. The subsequent result polls require this dispatch’s command_id, so no usage-class read occurs without a counted dispatch row. |
inventory.software.query | Inventory | A which-devices-run-X fleet software query — emitted by the REST endpoint (GET /api/v1/inventory/software) and the /software page’s devices tables (/fragments/inventory/software/devices); the MCP query_installed_software tool writes under the verb mcp.query_installed_software with target_type mcp_tool and target_id the tool name, and the REST-style target below travels in its detail (the MCP emitter writes the target only, with no rows=). Machine-scope installed-software data (no per-user/PII — lower sensitivity than the behavioral device lenses), so set-and-proceed. For the REST and MCP emitters the target is name=<title> / agent=<id> / fleet (name=<title>;agent=<id> when both are set), followed by the filters that were set in a fixed order — ;version=..;q=..;kind=..;ecosystem=..;source=.., each value passed through log_token; the page’s devices tables write the same shared target_id. result=success carries rows=<n> for the visible rows (REST route; an export writes the inventory.software.export verb instead, below) and is written on every call that serves a page, continuation pages included (tagged page=continuation in detail): the keyset cursor is opaque but not authenticated, so per-page audit is what keeps a hand-made cursor from walking the fleet unrecorded. On the REST route and the MCP tool the caller’s scope is applied in SQL for up to 10 000 visible devices, so nothing is dropped and no denied row is written there for the post-filter; only a scope wider than that bound is post-filtered in process, and then a result=denied row (recording the distinct dropped-device count, for cross-operator isolation evidence) is written and the result=success row in the same request. A result=denied row is also written, on both emitters and under either scope, when the request names an agent_id outside the caller’s scope (detail scope: agent_id outside the caller's scope; the response is an empty page with devices_omitted unchanged; when the post-filter also dropped devices the one row carries the filtered-count text instead). The /software devices tables use the same scope push-down and bounded refill as the REST route, so the rules above apply to them unchanged — a SIEM rule keying on denied must expect a paired success on every surface; a store degrade emits result=failure (REST returns 503, the dashboard renders the “unavailable” banner — never a silent empty, ADR-0016 §7). Gate Inventory:Read. As of #3290 Phase 2, the REST and MCP surfaces give a service-scoped API token a real confined result — rows/devices_omitted reflect the intersection of the caller’s management-group scope and its service-tag scope (target_id itself is unaffected by scope, still just the query params echoed back), the same authz::meet composition the drop-row above already uses, rather than a blanket denial. The /software page’s devices tables gate solely on the fleet-read gate (ADR-0017): a management-group-confined caller is admitted and sees only in-scope devices, and a service-scoped token is denied outright (result=denied, target_id=fleet). A gate-level denial (no grant at all, RBAC-off+service-token, RBAC/management-group/tag-store unavailable, an unrecognized engine/mcp_tier caller class) on REST or MCP is now audited under a DIFFERENT action, auth.fleet_read_required (empty target_type/target_id, the same shared-primitive convention require_permission/require_list_read use for their own gate-level denials) — inventory.software.query’s own result=denied row fires ONLY for the scope-filtered partial-drop case and the explicit out-of-scope agent_id case above, never for an outright gate denial. A SIEM rule counting this route’s total denial population must sum BOTH actions; keying on inventory.software.query’s denied result alone under-counts it (consistency-auditor + sre, governance run 2026-08-20). |
inventory.software.catalog | Inventory | A software title catalogue read — emitted by the REST endpoint (GET /api/v1/inventory/software/catalogue) and the /software page (the dashboard emitter); the MCP query_software_catalogue tool writes under the verb mcp.query_software_catalogue with target_type mcp_tool and target_id the tool name; the target and the titles=<n> totals=<titles>/<installs> figures travel in its detail. Machine-scope, set-and-proceed (audit_persisted: false in the REST body, or the page rendering regardless, when the row cannot persist). The REST route writes result=success on every call that serves a page, first page and continuation pages (an out-of-scope per-device read writes denied only) — the cursor is opaque but not authenticated, so per-page audit is what keeps a hand-made cursor from walking the catalogue unrecorded; a continuation page carries page=continuation in detail. REST target_id is fleet (fleet view) or agent=<id> (per-device view), followed by the filters that were set in a fixed order — ;kind=..;ecosystem=..;source=..;q=..;versions_min=N, each value passed through log_token (so a hostile value cannot forge a field); the page writes the same shared target. result=success carries titles=<n> totals=<titles>/<installs> on REST (the /software page writes the same figures). result=denied is written (a) when a management-group-confined caller asks for the fleet view — the rollup is fleet-wide and cannot be scoped, so the REST route answers 403 (permission: Inventory:Read) — and (b) when the per-device view names a device outside the caller’s scope (the response is an empty page, no oracle on whether the device exists). A store degrade (rows, totals or refresh-metadata read, incl. the statement timeout) emits result=failure (REST 503 + retry_after_ms, never an empty 200). Service-scoped API tokens are denied (result=denied, 403, no permission field) before the query string is parsed, so the row carries target_id=fleet and the route’s own verb (inventory.software.catalog, also for a ?format=csv request). A gate-level denial (no grant of any kind (global or management-group), RBAC unavailable, or the management-group/tag store unavailable) is audited under auth.fleet_read_required, not this verb — a SIEM rule counting this route’s denial population must sum both. The /software page gates solely on the fleet-read gate (ADR-0017): the fleet aggregate cannot be narrowed to a management group, so a confined caller is refused it (result=denied) while the host view is admitted for an in-scope device. Gate Inventory:Read. |
inventory.software.versions | Inventory | An installs-per-version drill for one title — emitted by the REST endpoint (GET /api/v1/inventory/software/catalogue/versions) and the /software page (title click); the MCP get_software_versions tool writes under the verb mcp.get_software_versions with target_type mcp_tool and target_id the tool name (the target travels in its detail), and answers the unfiltered read before the first catalogue refresh with the retryable error shape and a failure row (detail says building). Same access, set-and-proceed and denied posture as inventory.software.catalog (including the pre-parse service-token deny with target_id=fleet and the auth.fleet_read_required gate-level denials), and success on every call that serves a page (an out-of-scope per-device read writes denied only). REST target_id is name=<title> followed by ;agent=..;kind=..;ecosystem=..;source=.. when set, with every value passed through log_token (the /software page writes the same target); result=success carries versions=<n>, a degrade emits result=failure, and so does the unfiltered read before the first catalogue refresh (detail says building; REST 503). Gate Inventory:Read. |
crypto.libraries.view | Inventory | A cryptographic-library family summary read — emitted by the REST endpoint (GET /api/v1/crypto/libraries) and the /crypto page; the MCP get_crypto_libraries tool writes under the verb mcp.get_crypto_libraries with target_type mcp_tool and target_id the tool name (the target travels in its detail). Machine-scope technical data (installed package names and versions), set-and-proceed (when the row cannot persist: audit_persisted: false in the REST body plus a Sec-Audit-Failed: true header; on the /crypto page the fragment carries the same header and renders an “Audit not recorded.” notice above the data or the error banner, as a table row on a Load more page). target_id is fleet followed by ;family=..;ecosystem=.. when set, each value passed through log_token; result=success carries `cid= |
crypto.libraries.devices | Inventory | A device drill for one cryptographic-library family — emitted by the REST endpoint (GET /api/v1/crypto/libraries/devices) and the /crypto page (family or version click, and Load more); the MCP list_crypto_library_devices tool writes under the verb mcp.list_crypto_library_devices with target_type mcp_tool and target_id the tool name. Same access, set-and-proceed and failure posture as crypto.libraries.view. Every page served is audited, continuation pages included (tagged page=continuation in detail) — the cursor is opaque but not authenticated, so per-page audit is what keeps a hand-made cursor from walking the device list unrecorded. target_id is family=<id> followed by ;version=..;ecosystem=.. when set; result=success carries `cid=.. tv=<table_version> family=.. rows= |
crypto.cbom.export | Inventory | A CycloneDX 1.7 CBOM export of the cryptographic-library inventory — emitted by the REST endpoint (GET /api/v1/crypto/cbom); the MCP export_crypto_cbom tool writes under the verb mcp.export_crypto_cbom with target_type mcp_tool and target_id the tool name (the target travels in its detail). Same target and detail key set as crypto.libraries.view (rows = families) plus, on the success row, serial=<urn:uuid:...> [ecosystem=<filter>] components=<n> (REST and MCP alike; the ecosystem key only when that filter was given) so the row names the exact document served; same set-and-proceed posture. A failure minting the BOM serial number happens before any inventory read and writes no row. See crypto.md. |
crypto.ca.stats.view | Inventory | The Yuzu-issued certificate counts were read — emitted by GET /api/v1/crypto/ca/stats, by the /crypto dashboard (one row per draw for all its Yuzu-issued cards, widgets=<n> appended), by a single Yuzu-issued card reloaded on its own (/fragments/crypto/widget/{id}, one row per request) and by the MCP tool get_crypto_ca_stats (same verb, one row per call). Yuzu’s own internal-CA inventory only (ADR-0070 D6). target_id yuzu_ca; result=success carries [cid=<correlation id> ]ws=crypto_widget_windows_v1 as_of=<epoch>[ widgets=<n>], as_of being the database time the counts were measured against (read at request time on every call, never cached). A caller whose view is confined is refused (ADR-0070 R2) and written as result=denied with [cid=.. ]ws=crypto_widget_windows_v1 withheld=confined. A degraded read (store unreadable or no CA store, or the capacity bound full) attempts result=failure with ... as_of=db_now store degraded or capacity in use (MCP included) and the caller gets 503 / a retryable error (never zeros). Set-and-proceed: a lost row sets audit_persisted: false (REST/MCP) and Sec-Audit-Failed: true, and the dashboard shows the “Audit not recorded.” notice. A refusal for want of Inventory:Read is written by the access gate (auth.fleet_read_required), not under this verb. Gate Inventory:Read, unconfined. |
crypto.ca.certificates.view | Security | A page of Yuzu-issued certificates behind one count was read — emitted by GET /api/v1/ca/issued (with or without a filter parameter: kind, days, week, bucket, as_of or cursor), by the /crypto drill panel (/fragments/crypto/ca/certificates, every Load more page included) and by the MCP tool list_issued_certs (same verb). For a filtered read target_id is kind=<kind>[ days=<n>][ week=<YYYY-Www>][ bucket=<b>]; result=success carries [cid=<cid> ]ws=crypto_widget_windows_v1 as_of=<epoch>[ pinned] kind=<kind>[ days=<n>][ week=<w>][ bucket=<b>] rows=<n> page=<first|continuation> total=<n> (pinned when the page’s as_of was pinned - passed by the caller, or carried by the cursor of a Load more page - so a paged walk shows one as_of throughout; page=continuation on every page read with a cursor, since a cursor is not authenticated and per-page audit is what records a hand-made walk). On GET /api/v1/ca/issued and list_issued_certs a confined caller, or one with no Inventory:Read grant at all, is refused and written as result=denied (`[cid= |
crypto.dashboard.layout.update | CryptoDashboard | An operator replaced their own /crypto widget layout — PUT /api/v1/crypto/dashboard/layout (the dashboard’s Add, remove, move and Reset buttons) or the MCP tool set_crypto_dashboard_layout (same verb). target_id the operator’s user name (only ever the caller’s own layout). Audit-first: a result=attempted row (detail=[cid=<cid> ]widgets=<n>[ expected_version=<n>]) is written before the change, and if it cannot be the layout is not changed (503, Sec-Audit-Failed: true); then result=success ( version=<n> appended, the new stored version) or result=failure with outcome=<o> appended: conflict (a stale expected_version, 409), corrupt, degraded (store fault), invalid or unknown. outcome=unknown means the database connection dropped after the save was sent, so the server cannot tell whether it was stored; the caller is told save outcome unknown — reload to check, and the stored layout (or the next success row) is the evidence of what happened. An invalid layout (400) writes no row. A service-scoped token’s attempt is written result=denied (service-scoped token refused) and changes nothing. A cookie-authenticated write naming another site is refused 403 and written as csrf.denied (target_id crypto.dashboard.layout.update). Authentication only: the layout chooses what is drawn, and every widget’s data read keeps its own gate. Through the MCP tool, two further generic rows can appear and carry no crypto.dashboard.layout.update row: mcp.set_crypto_dashboard_layout with result=failure when the caller’s identity could not be resolved (the layout is not changed), and mcp.<tool> with result=denied when arguments is not a JSON object (null, array or string), which every MCP tool served from the registry now writes before the tool runs. |
crypto.dashboard.layout.access_denied | CryptoDashboard | A service-scoped API token tried to read a /crypto dashboard layout (GET /api/v1/crypto/dashboard/layout). Always result=denied, detail service-scoped token refused, target_id empty: a service-scoped token’s layout key would be its minter’s user name, shared by every token of that minter, so the route refuses it. The MCP twin refuses the same token at its own gate. The refusal does not move yuzu_auth_service_scope_default_denied_total. |
inventory.software.export | Inventory | A software file export — emitted by GET /api/v1/inventory/software/catalogue?format=csv (the title catalogue) and by GET /api/v1/inventory/software?format=csv|json (the per-install rows; result=success carries installs format=<csv|json> rows=<n> truncated=<true|false>, target_id is the raw query’s target, and a post-filter drop on a scope wider than the push-down bound writes a paired denied row as for inventory.software.query). REST-only (an agent pages the same data through the MCP cursor); machine-scope, so set-and-proceed: the file is still served and the response carries Sec-Audit-Failed: true (csv; the JSON export sets audit_persisted: false in its body instead) when this row cannot persist. Written on every export call that serves a file. target_id is the query’s target (as for inventory.software.catalog); result=success carries titles format=csv rows=<n> truncated=<true|false> (rows is the number of data rows written, truncated is true when the walk reached limit (ceiling 10,000), so rows may have been left behind). Same paired-row convention as inventory.software.query: a per-device titles export for a device outside the caller’s scope writes an empty-file success row (rows=0) and a paired result=denied row in the same request, every time; the per-install export writes its paired result=denied row only when the post-filter dropped devices (a scope wider than the push-down bound, where devices_omitted is non-zero) or the request named an agent_id outside the caller’s scope; a store degrade emits result=failure (503), and so does a fleet export requested before the first catalogue refresh (detail says building; 503 with retry_after_ms, no file). result=denied is also written for a management-group-confined caller asking for the fleet export (under this verb), and for a service-scoped token on ?format=csv (under inventory.software.catalog, 403, target_id=fleet, written before the query is parsed). A gate-level denial is audited under auth.fleet_read_required, as for the catalogue. Sec-Audit-Failed is also set when only the denied row of a paired out-of-scope export failed to persist. Gate Inventory:Read. |
inventory.device.software | Agent | An operator opened the installed software lens of a device’s Hardware CI record — GET /fragments/hardware/ci?id=<agent_id>&lens=software. Every other CI-record lens emits inventory.device.ci instead; this verb is the lens=software case only. Emitted through emit_behavioral_audit on the behavioural-PII audit tier (the HTML fragment is set-and-proceed: it renders and carries Sec-Audit-Failed if the row cannot persist). target_type=Agent, target_id is the agent_id; result is success, or failure when the device roster read failed; detail is hardware CI lens=software state=found|absent|store degraded (with await=<n> on a sync-now poll), or state=roster unreadable (roster read failed and no CI record could be shown) / state=found identity=roster unreadable (CI record served, identity degraded); state is the state of the device’s CI record (device_ci), not of the installed-software read itself, so state=found does not say whether the software read succeeded). Gated per device on Inventory:Read for that device’s management group (scoped_perm_fn — the tier + management-group chokepoint), so an operator only reads a device in their scope. |
inventory.devices | Inventory | An operator opened the Hardware list (GET /fragments/hardware/list) or called GET /api/v1/hardware — the device roster (hostnames + agent_ids + OS + online/last-seen) plus the device-CI columns (serial, model, CPU, RAM) joined from DeviceInventoryStore. Because serial is a device-persistent identifier (GDPR personal data per ADR-0016), the result=success row is emitted via the behavioural-PII audit tier (emit_behavioral_audit): the REST route fails closed (503 + Sec-Audit-Failed, nothing served) when the row cannot persist, the HTML fragment is set-and-proceed (renders, carries Sec-Audit-Failed). Gate require_fleet_read (Inventory:Read, admit-then-filter): per-operator management-group confinement of the roster is effective; a gate-level denial is audited under auth.fleet_read_required, not this verb. target_type=Inventory, target_id=fleet; result=success carries hardware list[ (REST)]: devices=<n> omitted=<m> (omitted = rows outside the caller’s scope); result=failure carries hardware roster provider unwired (persisted best-effort via try_persist_audit, no Sec-Audit-Failed; REST answers 503 for the unwired provider itself, not for the audit) or hardware roster read failed (the roster store read failed; emitted via emit_behavioral_audit, so a persist failure carries Sec-Audit-Failed; the REST twin answers 503 with retry_after_ms, the fragment renders the “roster unavailable” banner). (Distinct from inventory.device.ci / inventory.device.software, the per-device record verbs.) MCP emitter: the tool list_hardware_ci is a third emitter of this verb, behind the same Inventory:Read fleet-read gate and with the same target_type=Inventory, target_id=fleet. result=success carries hardware list (MCP): devices=<n> omitted=<m> for a global-read caller (devices is the number of rows matching the filters before paging, omitted as above, always 0 there) and hardware list (MCP): devices=<n> scope=authority_scoped for a confined caller, whose hidden-device count is deliberately not recorded; result=failure carries hardware provider unwired (the tool answers -32603) or hardware roster read failed (-32603 with retry_after_ms). The MCP emitter writes through try_persist_audit (MCP has no response-header channel, so there is no Sec-Audit-Failed) and fails closed: it attempts BOTH this row and the generic mcp.list_hardware_ci row (below), and if either does not persist the data is withheld and the call answers -32603 audit subsystem unavailable, request not served. That is stricter than the set-and-proceed MCP default (audit_persisted:false). A refused argument writes only the generic row, never this one. If this row persists but the paired generic mcp.list_hardware_ci row cannot be written, the response is withheld yet a lone success row remains: the log can over-record a read, never under-record one, and each retry adds another such row, so count served reads from the pair, not from this row alone. |
inventory.device.ci | Agent | An operator opened one device’s Hardware CI record — GET /fragments/hardware/ci?id=<agent_id> for every lens except software (overview, tags, dex, guardian, live, actions, or any unrecognised value; lens=software emits inventory.device.software) — or called GET /api/v1/hardware/{id}. The record carries device-persistent identifiers (GDPR personal data per ADR-0016: serial, system UUID, MACs), so this uses the behavioural-PII audit tier (emit_behavioral_audit) — same as dex.device.view/echo.dns.read/echo.arp.read. target_type=Agent, target_id is the agent_id; result is success, or failure when the device roster read failed; detail is hardware CI lens=<lens> state=found|absent|store degraded ( await=<n> appended on a sync-now poll) for the fragment, hardware CI record (REST) state=<…> for REST. When the device roster read failed, state is roster unreadable (nothing else could be shown, which the two surfaces judge differently. REST: the device was not otherwise found, meaning a CI record that is readable and absent, no software and no tags, and the answer is 503, not 404. Dashboard fragment: no CI record could be shown, meaning CI absent or the CI store degraded, whatever software or tags exist) or <found|absent|store degraded> identity=roster unreadable (a record was served with a degraded identity: REST 200 with identity_state: degraded; the dashboard fragment emits only the found variant). result reflects only the roster read: with a healthy roster these verbs record success and state=store degraded or absent for the CI store. absent = the device hasn’t completed its first device_ci sync (not a failure); store degraded = the store could not be read. The fragment is gated per device on Inventory:Read for that device’s management group (scoped_perm_fn) and is set-and-proceed; REST is gated by require_fleet_read scope (an out-of-scope id is 404) and fails closed (503 + Sec-Audit-Failed) when the row cannot persist. MCP emitter: the tool get_hardware_ci is a third emitter of this verb, gated by the same Inventory:Read fleet-read scope, with target_type=Agent and target_id the agent_id. Its detail is hardware CI record (MCP) state=<found|absent|store degraded>, with identity=roster unreadable appended (and result=failure) when the record is served with a degraded identity, or hardware CI record (MCP) state=roster unreadable with result=failure when nothing could be shown (the tool answers -32603 with retry_after_ms, never a not-found). The MCP tool also writes hardware CI record (MCP) state=software or tags unreadable with result=failure when the device is absent from the roster, its CI row answered absent, and the software or tag read could not be completed: that case is a retryable -32603 too, not a terminal not-found (the REST route answers 404 there). An unwired provider writes result=failure hardware provider unwired (empty target_id). The MCP emitter fails closed exactly as the REST route does: it attempts BOTH this row and the generic mcp.get_hardware_ci row (below), and if either does not persist the record is withheld and the call answers -32603 audit subsystem unavailable, request not served. A refused argument, an out-of-scope id or a not-found id writes only the generic row, not this one. As for inventory.devices, a lone success row can accompany a withheld response when the paired generic row cannot be written. |
inventory.sync.request | Agent | An operator clicked Sync now on a device’s Hardware CI record (or called POST /api/v1/hardware/{id}/sync directly) to request an out-of-cycle daily-sync push for one device instead of waiting for the ~24h cadence. Gated on Execution:Execute scoped to the device (scoped_perm_fn) — a sync-on-demand is a device action, not an inventory read. target_id is the agent_id; source (body field, default all) is one of installed_software / app_perf / device_ci / software_licensing / all. result and detail vary by outcome: result=denied for a non-JSON-object body (detail=malformed JSON body, 400), a malformed source (detail=bad source=<value>, 400, or detail=source field is not a string, 400), or an agent whose reported version predates sync-on-demand (detail=agent_version=<v> predates sync-on-demand, 409 — needs 0.13.1 or later); result=failure (detail=sync dispatch unwired, 503) if the server has no sync-dispatch mechanism wired; result=no_agents if the agent isn’t currently connected (detail=agent_offline, 503); result=requested (detail=source=<source>, 202) once every precondition passes (a device id over 256 bytes or containing a byte below 0x20 is refused with 400 before the permission check and writes no audit row), audited before the command is dispatched to the agent — emit_behavioral_audit (the same helper device.live.* uses) fails the request CLOSED with 503 + Sec-Audit-Failed if this row cannot persist, and the command is never dispatched in that case. If the registry then refuses the dispatch itself (agent connected per the roster but unreachable), the response is 503 with no second audit row — the requested row is the durable evidence that the operator asked; whether the agent was actually reached is visible only in the HTTP response (command_id is minted by dispatch and returned in the response body, never persisted to the audit row), the same one-row-per-request convention /api/v1/dex/devices/{id}/live uses. Fan-out form: the Software page’s Sync now (POST /api/v1/hardware/sync) is gated on a global Execution:Execute grant (an operator limited to management groups is refused before anything is written here) and confines its targets to the caller’s visible set. It writes the same verb once per request with target_type=Scope and target_id of __all__, group:<id> or expression (a single named device is target_type=Agent with the device id). detail is `scope=<scope, at most 128 bytes; control bytes, spaces, = and , shown as _ so it cannot forge a neighbouring field> arm=<broadcast |
csrf.denied (hardware sync) | Endpoint | A browser (session-cookie) POST /api/v1/hardware/sync or POST /api/v1/hardware/{id}/sync carried no Origin/Referer, or one naming another site, and was refused with 403 by this route’s own copy of the check (the server-wide check answers a cookie-session caller first), which runs after the permission check and before the request was parsed or audited as a sync. target_id is hardware.sync (fleet route) or hardware.device_sync (per-device route). Token callers (API, MCP, engine) are exempt. |
hardware.actions.view | Agent | An operator opened the Actions lens on a device’s Hardware CI record (/fragments/hardware/ci/actions) — the generic action-runner catalogue: every one of a connected agent’s loaded-plugin actions, classified by capability, with parameter-hint forms for the ones this deployment recognises. Gated per device on Inventory:Read (scoped_perm_fn); viewing the catalogue itself additionally needs Execution:Execute for the device — an operator with only Inventory:Read sees a “needs Execute” placeholder instead, which is not audited (only a rendered catalogue is). target_id is the agent_id; result=success carries detail=actions catalogue: <n> rows. Actually running one of the listed actions is a separate event — dispatch goes through the existing POST /api/command route (and its own audit), so this verb records only that the catalogue was viewed. |
hardware.action.result | Agent | A poll of a dispatched Actions-lens command’s result (/fragments/hardware/ci/result), audited once the poll reaches a terminal phase — rendered output, a renderable-but-empty result, a failed command, or a timeout — never on an in-flight “still waiting” tick. Gated the same as hardware.actions.view (Inventory:Read scoped to the device) plus a per-poll Execution:Execute probe; a caller without Execute sees an explanatory placeholder instead, unaudited. target_id is the agent_id; result is always success — it records that the result view was rendered to the operator, not that the underlying dispatched action itself succeeded — with detail=<plugin> command_id=<id> phase=<n> (n is the numeric result phase: 1=rendered, 2=rendered-empty, 3=failed, 4=timed out). The plugin the poll looks for is taken from the command_id on the server (a caption parameter that disagrees, or a prefix that names no known plugin, is a 400); the label a stored reply carries is the server’s own dispatch record, not the id. For a Forensics-tier plugin’s reply, a caller who does not hold a global Forensics:Read gets a permission notice instead of the reply, never a timeout, and a poll that renders such a reply also writes response.forensics.read (below). The permission notice, and the notice shown when the caller’s permission could not be confirmed, leave no audit row of their own. |
csrf.denied (runtime config) | Endpoint | A browser (session-cookie) PUT /api/config/{key} that carried no Origin/Referer, or one naming another site (and not a --csrf-trusted-origin entry). target_id is config. The call answers 403 and changes nothing; API, MCP and engine token callers are exempt. For a cookie-session caller the csrf.denied (central gate) row is written instead. |
csrf.denied (DEX score policy) | Endpoint | A browser (session-cookie) PUT or DELETE /api/v1/dex/score-policy that carried no Origin/Referer, or one naming another site (and not a --csrf-trusted-origin entry), was refused with 403 by this route’s own copy of the check (the server-wide check answers a cookie-session caller first), before the policy document was parsed or applied. target_id is dex_score_policy. API, MCP and engine token callers are exempt. For a cookie-session caller the csrf.denied (central gate) row is written instead, with target_id PUT /api/v1/dex/score-policy or DELETE /api/v1/dex/score-policy. In production the central-gate row is the one written for a cookie session; this row exists only if the central gate is bypassed. |
dex.score_policy.update / dex.score_policy.reset / dex.score_policy.validate | RuntimeConfig | PUT (update), DELETE (reset) and PUT ?dry_run=true (validate) on /api/v1/dex/score-policy (Infrastructure:Write); target_id is dex_score_policy. Results: denied for every refusal of the request (a missing or malformed If-Match, a bad dry_run, an invalid document, a stale revision), with reason if_match_missing, if_match_malformed, dry_run_unsupported, dry_run_invalid, invalid or stale; a real write is audit-first: attempted is stored BEFORE the store is touched (if it cannot be stored the call is refused 503 with Sec-Audit-Failed: true and nothing changes), then success, or failure (reason stale, writer_busy, commit_refused or outcome_unknown); a reset of a policy that was never written records attempted then no_change; validate records one success row (a row the audit store refuses is a 503). detail is a JSON object {if_match, prior_revision, revision, ...}: revision is the revision of the canonical text that was (or, for validate, would be) stored (default for a reset). The whole canonical document is recorded only on the attempted, success and validate rows (null for a reset); every other row (denied, failure, no_change) carries document_bytes instead, the size in bytes of the canonical text proposed (null when no document was validated, as for a reset or a refusal before the document was checked), so a refusal made before the attempted row (a stale If-Match included) never repeats the document in the audit store, while a request that fails after it leaves one full attempted row plus one lean row. A real PUT writes two rows that carry the document (attempted and success), also when the document is identical to the stored one, and a dry run writes one (validate); the document is at most about 185 KB (about 130 KB at the override limit), and rows are kept for the audit retention period (365 days by default), so size the audit store for repeated saves. reason, field (the offending path of a refused document) and dry_run appear where they apply: dry_run is true on the validate row and on the denied row of a refused dry run, so a count of refused updates can exclude refused dry runs, and it holds the neutralised offending value when dry_run itself is what was refused. An outcome_unknown failure row records that the store reported an error after the write was sent and the re-read did not show the requested state: it carries observed_revision (the revision then stored) or reread_error (the re-read failed), and never asserts that the write did not apply. A success row whose detail carries outcome observed_after_store_error means the requested state was observed after a store error; it shows the desired state is in force, not that this request’s commit made it so. A lone attempted row with no outcome row means the result is unknown: GET /api/v1/dex/score-policy and compare its revision; the current state does not prove whether this request committed it. To follow the chain of applied changes, use success rows of verb update or reset only: the prior_revision of one equals the revision of the last earlier such row. denied, failure and validate rows also carry a PROPOSED revision, so they must not be used to follow the chain. Every row is stamped from the session the route resolved. Sec-Audit-Failed: true on the response means a row did not persist: on a 200 the outcome row is missing and only the attempted row stands: for a PUT, or a DELETE that removed a policy, the change was applied and a retry with the old If-Match answers 409; a DELETE of a policy that was never written changed nothing and may be repeated. GET to confirm the state, treat the attempted row as the sole evidence, and check the health of the audit store; on a refusal the denied or failure row of that refusal is missing. GET is not audited; a missing permission is the standing auth.permission_required row. |
config.update | RuntimeConfig | An operator wrote a runtime-config key via PUT /api/config/:key, its twin PUT /api/v1/config/{param}, or the MCP tool set_runtime_config (Infrastructure:Write; the three write the same rows, and an MCP row is followed by the usual mcp.set_runtime_config row). The write is audit-first: a row with result attempted and an empty detail is stored before anything changes (if it cannot be stored the call is refused 503 and nothing changes), then a success row, or a failure row when the store refuses the value (detail is the store’s refusal text, for example the 30 second floor, never the submitted value; a database error records store error; outcome unknown). A lone attempted row with no outcome row means the result is unknown: read GET /api/config (see the PUT section of the REST reference). An unknown key, or a body or integer that fails validation, is refused before any row is written. A browser call from another site is refused and recorded as csrf.denied (target_type=Endpoint, target_id=config). target_id is the key name. For an ordinary key the success row’s detail carries value=<the new value> verbatim, so this row is the change-management evidence for what a setting was changed to and by whom. For a secret-valued key (today oidc_client_secret) detail never carries the real value - the write path records value=<redacted>, and AuditStore::query() additionally redacts the detail of any pre-existing config.update row naming a secret key on read, so rows written before that fix shipped no longer disclose the plaintext through GET /api/v1/audit, the legacy GET /api/audit, or the MCP query_audit_log tool (every reader shares the one read path). The stored row on disk is left unchanged - an audit row is evidence, so this redacts what a caller sees rather than rewriting history. If you set oidc_client_secret before that fix, rotate it: the value was disclosed while it was being recorded in the clear. Rotating durably takes more than one step, and rotating through Settings alone breaks the next restart — see upgrading.md (“Security: rotate oidc_client_secret if it was ever set on this install”) for what to remediate, and authentication.md (“OIDC Single Sign-On”) for the durable and non-durable ways to configure it. The note below about Settings is about where the audit evidence lands, not how to rotate. result=denied (HTTP 403, nothing stored, no preceding attempted row) when the caller rule refuses the write; it is decided before the store is opened and before the body is parsed. For an oidc_* key the caller’s own role must be admin: detail=elevated_session_oidc_setting_blocked for an administrator only through a temporary elevation, detail=admin_role_required_oidc_setting_blocked for a non-administrator holding Infrastructure:Write, detail=token_session_oidc_setting_blocked for an MCP-tier token or a service-scoped token (whatever its creator’s role). For the seven administrator-only keys (plugin_signing_required, dex_alert_routing, dex_blast_min_devices, dex_blast_window_seconds, dex_blast_cooldown_seconds, dex_cohort_export_key, and audit_retention_days, which has no Settings route and is administrator-only by design because it is the stored audit-retention setting, which is stored only today: the enforced window is --audit-retention-days plus a restart) the caller must hold the admin role as an administrator account (a session or an API token that is not MCP-tier; an active elevation counts, as on the Settings pages; the account’s own legacy role must be admin, an RBAC Administrator grant alone does not count): detail=token_session_admin_setting_blocked for an MCP-tier or service-scoped token, detail=admin_role_required_setting_blocked for a session whose effective role is not admin. In production a service-scoped token and a readonly or operator MCP token are refused earlier by the permission gate (auth.permission_required; on MCP the generic tier and service-scope gates refuse them and leave only an mcp.set_runtime_config denied row), so the callers that produce these rows are a supervised-tier MCP token (even one minted by an administrator) and, with RBAC on, a non-administrator role holding Infrastructure:Write. SIEM note: the Settings routes audit the equivalent denial as auth.admin_required, so a rule keyed on auth.admin_required does not see a refusal on this route. To find these refusals, query GET /api/v1/audit?action=config.update&target_type=RuntimeConfig&target_id=<key> (target_id requires target_type) and keep rows whose result is denied, or drop target_id to scan every key. Audit rows record principal, principal_role, principal_class, session_id, source_ip and user_agent but not the MCP tier, so a config.update row cannot tell an MCP-tier write from an administrator API-token write after the fact. A write through set_runtime_config also leaves an mcp.set_runtime_config row (same session, same time), which a REST write does not. Note this verb covers the generic config API only; a change made through Settings emits its own verb instead (settings.dex_alerts.*, plugin_signing.require.changed, and for OIDC oidc.configure on target_type="OidcConfig" with the ISSUER as target_id). Consequence for rotation evidence: a Settings-driven OIDC secret rotation is audited as oidc.configure and does NOT name oidc_client_secret anywhere, so a query for config.update + target_id=oidc_client_secret finds only rotations done through the generic API. Since Settings is the only path that applies an OIDC change to the running server, expect key-level rotation evidence to be absent for the path operators are told to use. |
settings.dex_alerts.cohort_export | RuntimeConfig | An admin set or cleared the cohort metrics export tag key via the DEX → Alerts tab. target_id is dex_cohort_export_key; detail carries export_key=<key> or export disabled. The runtime-config store keeps no history, so this row is the change-management evidence for what cohort labels were exported when. |
echo.db.corruption_quarantined | Agent | (SOC 2 CC7.1/CC7.2) An agent reported, via its heartbeat, that its echo.db was quarantined as corrupt and re-initialised. Principal system, target type Agent (target_id = the device’s agent id), detail = corruption_total=<n> quarantine=<epoch>:<file>; the paths are <agent data_dir>/echo.db and <agent data_dir>/<file> (basename only: the heartbeat tag value cap is 64 bytes and the data dir is agent configuration). One row per (agent, quarantine identity) per server process (another HA node ingesting the same agent can add a second row for the same identity): the server dedups against the audit store itself, so store prunes, server restarts and HA node switches never repeat it. At most one row per agent per 10 minutes per server process (in-memory; a restart or another HA node opens a fresh window), so a rotating identity cannot mint a row per heartbeat; intermediate quarantines inside one window collapse to the newest identity, which is logged on the first heartbeat after the window — for TRUE event frequency, trust the agent’s own cumulative heartbeat.db_corruption_total count (surfaced fleet-wide via yuzu_fleet_echo_db_corruption_agents, see the metrics reference), never a row count, which under-represents by design inside a rate-limit window. Derived from heartbeat tags, so it is agent-asserted, not server-verified: this row corroborates that the agent’s own software believed it hit corruption; it does not rule out an actor with agent-level access forging the same signal, and should not be presented as conclusive evidence against tampering. |
echo.sql | command | An operator ran an Echo SQL query from the Echo dashboard (POST /api/dashboard/echo-execute). The query was dispatched to at least one agent; target_id is the command id and detail reads Echo SQL -> <n> agent(s). One row per query that reached an agent; a query that reached none writes no row. Before 2026-10 this action was written as tar.sql; old rows keep that spelling and an echo.sql filter matches both. |
echo.status.scan | Infrastructure | An operator clicked Scan fleet on the Echo dashboard page (/echo). An echo.status command was dispatched to the operator’s visible-agent set. detail carries the count of agents the dispatch reached (e.g. dispatched to 47 agent(s) in scope). One row per Scan button press; no row on page-load Refresh. |
echo.source.reenable | Infrastructure | An operator clicked Re-enable on a row of the Echo retention-paused list and an echo.configure command with <source>_enabled=true was dispatched to a single device. detail carries `device= |
echo.source.purge | Infrastructure | An operator clicked Purge data on a row of the Echo retention-paused list (Phase 15.A) and a destructive echo.purge_source command was dispatched to a single device — dropping all warehouse rows for that source (<source>_{live,hourly,daily,monthly}) while leaving the collector paused. Gated on Infrastructure:Delete (a higher tier than re-enable) plus the operator’s RBAC visibility on the device; the operator must type the device hostname to confirm. detail carries device=<id> source=<process|tcp|service|user> on success (rbac_denied on the permission gate; scope_violation/agent_not_connected on failure, same 404-collapse as re-enable). The agent refuses (source_not_paused) if the source is still enabled, or (config_unreadable) if its enable flag cannot be read; nothing is purged in either case. rows_deleted is computed agent-side and appears in the command’s response record, not this dispatch row. The structured REST equivalent (POST /api/v1/echo/retention-paused/purge) writes this verb with result=requested before dispatch (audit-on-open, fail-closed — no dispatch if the row can’t persist), plus detail cid=<correlation-id>. Caveat: a purge issued through the generic POST /api/command escape hatch is currently audited under the generic command.dispatch verb (with detail echo:purge_source), not this echo.source.purge verb — SIEM rules keyed only on echo.source.purge will miss that path (tracked in Tr3kkR/Yuzu#1787). |
echo.process_tree.read | Agent | An operator reconstructed a process tree for one device from its local Echo warehouse via the Echo dashboard (/echo, Frame 3). Usage-class behavioral telemetry — surfaces process names + PIDs + owning user + per-process remote IP:port, and (on Linux/macOS) command lines. Emitted on two points so the access is recorded even on failure (parity with device.live.*): a dispatch row at the live-query send (result=dispatched when ≥1 agent reached, result=no_agents when offline; detail = dispatch preset=<p> command_id=<id>), and a success row after the tree renders (detail = `preset= from= |
echo.process_tree.detail | Agent | An operator opened the detail panel for one process node from a cached reconstruction (a row click in /echo Frame 3). Usage-class — surfaces that node’s path/command line (Linux/macOS), owning user, anomaly evidence, and full per-PID remote IP:port table. One row per drilldown (intentionally per-click, the works-council “who viewed which process” posture; mirrors dex.signal.view). target_id is the agent_id; detail = node=<id> os=<windows|linux|macos|?>. Gated identically to a reconstruction — Infrastructure:Read + Execution:Execute scoped to the device — and bound to the originating session: a predicted or leaked cache token can’t be replayed by a different operator. |
echo.dns.read | Agent | The DNS-cache panel was rendered for a device in the Echo process-tree pane (GET /fragments/echo/process-tree/device-net, ADR-0015). Usage-class telemetry — the device DNS resolver cache reveals which domains the host resolved (device-level state; no per-process attribution). Emitted at dispatch (result=dispatched when ≥1 agent reached, result=no_agents when offline; detail = command_id=<id>). target_id is the agent_id. Requires Infrastructure:Read + Execution:Execute scoped to the device. Separately countable from echo.arp.read for works-council access-audit purposes. |
echo.arp.read | Agent | The ARP-table panel was rendered for a device in the Echo process-tree pane (same route as echo.dns.read, ADR-0015). Surfaces the host ARP / neighbour table (IP↔MAC bindings per interface) — Layer-2 topology, lower sensitivity than DNS but security-relevant (ARP-spoofing forensics). Dispatch-time emission + gating identical to echo.dns.read; kept a distinct verb so DNS PII access stays separately countable. |
echo.sources.read | Agent | An operator opened the Echo Capture sources frame for a device (GET /fragments/echo/capture-sources/load, ADR-0015), dispatching echo.status + echo.compatibility to read the device’s per-source enable/support state. detail = command_id=<id>. target_id is the agent_id. Requires Infrastructure:Read + Execution:Execute scoped to the device. |
echo.sources.configure | Infrastructure | An operator pushed staged capture-source enable/disable changes via the Echo Capture-sources frame (POST /fragments/echo/capture-sources/push, ADR-0015) — one row per changed source (each an echo.configure <source>_enabled dispatch). detail = <source>_enabled=<true|false> command_id=<id>. A change to dns (usage-class PII) in either direction is captured here. Requires Infrastructure:Read + Execution:Execute scoped to the device. |
execution.visualization.fetch | Execution | Chart-ready JSON was rendered for an execution’s response set via GET /api/v1/executions/{id}/visualization (issue #253 / #587). target_id is the command_id; detail carries <definition_id> index=<N> on success, or <definition_id> reason=<r> on the failure path where <r> is one of missing_definition_id, malformed_definition_id, definition_not_found, no_visualization, index_oor. Corrected (PR review, 2026-09-01): the success row no longer carries scope_dropped — a scope drop now emits a SEPARATE result=denied row on this same action (detail=<definition_id> scope_dropped=<N>), paired with the result=success row for the same request, matching the shape server.cpp’s legacy responses routes and MCP query_responses/aggregate_responses already use. A SIEM rule must treat denied+success as a pair for one call, not look for scope_dropped inside the success detail. Permission gate: require_fleet_read("Response","Read") (#1634 — replaced the flat Response:Read gate). Management-group scoping is effective for normal operators (#1634, closed) — the visible-agent set is resolved and pushed into the SQL query before the row cap, then the chart transform runs only over in-scope rows; a management-group-confined caller genuinely sees only their in-scope agents’ data, and the paired denied row fires under ordinary operation whenever a confined caller’s execution spans agents outside their groups, not only on RBAC-store corruption. rows_capped now reflects the CALLER’s own scoped cap hit. The command.dispatch event (above, when emitted from the dashboard) appends definition_id=<id> to its detail when the dashboard reverse-resolves a chart-bearing definition, so SIEM can join command.dispatch to the subsequent execution.visualization.fetch by definition_id. |
response.read | Execution | A management-group scope filter dropped one or more out-of-scope agents’ rows from a legacy response read — GET /api/responses/{id} (surface=get), GET /api/responses/{id}/aggregate (surface=aggregate), or GET /api/responses/{id}/export (surface=export) (#1634) — or from the dashboard POST /api/dashboard/group-from-results materialiser dropping out-of-scope agent ids before they enter the new management group (surface=group_from_results, #1712). Emitted as result=denied when a drop occurred. On group_from_results that is the ONLY row (the happy, nothing-dropped path is not separately audited there); all three legacy surfaces (get, aggregate, export) and the three v1 surfaces also write a result=success row (below); target_id is the instruction_id on the legacy surfaces or the command_id on group_from_results; detail carries scope_dropped=<N> surface=<get|aggregate|export|group_from_results> where <N> is the count of DISTINCT out-of-scope agents filtered out. The MCP aggregate_responses tool records the equivalent drop as its own mcp.aggregate_responses result=denied row; the visualization endpoint emits it as a SEPARATE result=denied row on execution.visualization.fetch (see that row above), not inline in a success detail. Permission gate: require_fleet_read("Response","Read") on the legacy get/aggregate/export surfaces (#1634 — replaced the flat gate) — but group_from_results gates on ManagementGroup:Write, with Response:Read supplying only the visibility filter, a different combination from the other surfaces this row covers. Current trigger scope — same shape on every surface now (#1634, closed): on get/aggregate/export, per-management-group scoping is effective for normal operators — a confined caller is admitted and this verb fires under ordinary operation whenever their execution spans agents outside their groups, not only on RBAC-store corruption. group_from_results (#1712) remains the pre-existing exception in gate SHAPE (its admission gate, ManagementGroup:Write, is a different permission from the one supplying its scope, Response:Read) but not in reachability — both classes of surface now fire this verb under normal operation, not only on store corruption. Added (#2146 A2-R2): the REST v1 twins GET /api/v1/responses/{id} (surface=v1_get), .../aggregate (surface=v1_aggregate), and .../export (surface=v1_export) also emit this verb, extending the surface= set above. Like the three legacy surfaces (below), these three v1 surfaces additionally emit a result=success row on every served read (REST fail-closed on an audit-persist failure - a 503 if the audit write itself fails) - so the “result=denied only” rule above applies to group_from_results only. The success row’s detail is a DIFFERENT shape from the denied rows’ scope_dropped=<N> surface=<...> string - it carries no surface= token at all, just "REST v1 response <query|aggregate|export> cid=<correlation_id>" (the three v1 handlers’ own action names, not the v1_get/v1_aggregate/v1_export tokens used on the denied path) - a SIEM rule matching on surface= will not see the success rows. A SIEM rule keyed on response.read must now expect both result values: success = a v1 or legacy read was served, denied = a scope-drop occurred on any of the seven surfaces. A rule that matches on surface= sees only the denied rows: select success rows with action=response.read result=success and tell the route apart by the detail prefix (REST v1 response <query|aggregate|export> cid= for v1, legacy response <query|aggregate|export> cid= for the legacy routes, where query is the legacy GET /api/responses/{id}). A SIEM alert keyed on this verb should expect result=success rows routinely: one per served read on every legacy and v1 response route for every caller, confined or not; result=denied rows appear only when a scope-drop occurred. A request rejected for a malformed numeric query parameter (400 on REST, -32602 on MCP query_responses, #4644) is not audited. All three legacy routes now write a result=success row (#4644): GET /api/responses/{id} (detail="legacy response query cid=<correlation_id>"), GET /api/responses/{id}/aggregate (detail="legacy response aggregate cid=<correlation_id>") and GET /api/responses/{id}/export (CSV and JSON, detail="legacy response export cid=<correlation_id>") each emit response.read / result=success / target_type=Execution / target_id=<instruction_id> on every served read, including an unconfined (global) caller and a read that returns no rows, after the store read and before any body is built, so each is countable together with its v1 twin’s row. Before this change a global caller left NO row on get and aggregate, and a scoped caller left only the scope-drop denied row. All three are fail-closed like the v1 twins: if the success row, or the scope-drop denied row, cannot be persisted, or the audit pipeline throws, the route answers 503 (A4 envelope, retry_after_ms: 5000, Sec-Audit-Failed: true) and serves no data; the scope-drop denied row used to be set-and-proceed on get and aggregate. A request rejected with a 400 for a malformed parameter writes no row. A 503 for an unavailable or degraded response store writes no success row, and writes no row at all when it happens before any audit row is written: on legacy get and export both the scope-drop denied row and the success row are written only after the store read, so a degraded store leaves nothing. On legacy aggregate and on all three v1 routes (v1_get, v1_aggregate, v1_export) the scope-drop denied row is written BEFORE the main store read, so a confined caller with a drop whose main read then degrades leaves a denied row and a 503, with no success row; a failed scope lookup (distinct_agent_ids) precedes that row and leaves nothing. |
response.forensics.read | Execution | A read of stored command responses served rows written by a Forensics-tier plugin (app_usage, browser_inventory, execution_artifacts, privacy_permissions, remote_access_audit; the set is derived from the capability catalogue, every plugin whose actions sit on the Forensics securable). Written once per request next to the neighboring audit row (after response.read / execution.detail.fetch; before the execution.visualization.fetch success row on the visualization route), and only when the rows actually returned to the caller include at least one such row (a row labelled ~unverified, a response for which the server holds no dispatch record, counts as one and appears in plugins= as ~unverified): a caller who does not hold Forensics:Read is served none and leaves no response.forensics.read row, and a request that returns only ordinary-plugin rows leaves none. A caller using an API token, an MCP token or an engine principal that does not hold a global Forensics:Read still leaves one auth.permission_required denied row (see the authentication guide) per read attempt, so automation that polls stored responses produces denial rows; an interactive browser sign-in (local, OIDC or SAML) does not. Kept as a separate verb so reads of this class of data stay separately countable for works-council access-audit purposes (the same reason device.live.* is split per kind). What is counted is a read request that served such rows, not a viewing and not a row: an executions drawer that stays open on a running execution re-fetches its detail fragment after each state-change event (debounced to at most about twice a second), and each refresh that serves such a row writes a row of its own; an export’s <n> is taken before the export’s byte cap trims the file, so it can exceed the rows the file contains. The token-caller denial rows above are one per read attempt, so a token poller that reads once per second leaves about 86,400 auth.permission_required rows a day (arithmetic from the one-row-per-attempt rule, not a measurement); size the audit retention and any SIEM ingest for that before such automation runs against a tier it does not hold. result=success; target_type=Execution, target_id is the execution_id or instruction_id the request named; detail is forensics_rows=<n> plugins=<comma-separated plugin names> surface=<s>, where <n> is the number of served rows and <s> is the transport that served them, one of a closed set chosen by the server (never taken from the request): rest (the v1 routes), legacy (the unversioned /api/responses routes), mcp, dashboard (the results fragment), drawer (the executions drawer), executions_page (the Executions page detail), hardware (the Hardware result poll) or bundle (bundle collation), so a dashboard view of the rows can be told from an API read without parsing paths. Surfaces: the legacy GET /api/responses/{id} and /export; REST v1 GET /api/v1/executions/{id}/responses, /api/v1/responses/{id}, /api/v1/responses/{id}/export and /api/v1/executions/{id}/visualization; MCP query_responses; the dashboard results fragment (rows on the rendered page), the executions drawer / page detail, the Hardware Actions-lens result poll GET /fragments/hardware/ci/result, and bundle collation (GET /api/v1/bundles/{id}, MCP get_bundle_result). On that poll the row is written alongside hardware.action.result, only when the output or failure text it renders came from a Forensics-tier plugin, and its target_id is the poll’s command_id; a poll that is still waiting renders no reply and writes none. Aggregate, facet-option and count reads (including /aggregate, aggregate_responses, the group agent-count preview and the dashboard filter-bar fragment GET /fragments/results/filter-bar) write no row; facet options can list column values of Forensics-tier rows to a holder of Forensics:Read, and that read is not audited. The dashboard create-group-from-results action (POST /api/dashboard/group-from-results) takes its membership from the agents that match its filters over the rows the caller may read; it writes its own group.create_from_results row but no response.forensics.read row, even when those rows are Forensics-tier ones (a documented gap in audit coverage). Bundle collation (surface=bundle): a collation writes the row only when at least one step that responded was run by a Forensics-tier plugin, <n> counts those responded steps, and target_id is the bundle id; it is written next to the existing bundle.collate row (after it, on REST). A collation for a caller without Forensics:Read shows such a step as pending, serves none, and writes none; a bundle with no Forensics-tier step writes none. Every poll of a bundle that serves such a step writes the row again. Persistence by surface: the REST routes write this row before any body is returned, and fail closed (503 + Sec-Audit-Failed: true, no data served) when it cannot persist, so the neighboring row can already exist for a request that served nothing and a retry writes it again; MCP serves the data and reports audit_persisted: false (on get_bundle_result, only for this row; the existing bundle.collate row keeps its previous behavior); the dashboard fragments, including the Hardware result poll, serve the page and set Sec-Audit-Failed: true. |
execution.read | Execution | A management-group-confined caller was refused a legacy /api/executions/{id} read — GET .../{id} (surface=detail), .../summary (surface=summary), .../agents (surface=agents), or .../children (surface=children) (#3789). Emitted only when the requesting caller’s fleet-read scope was engaged (require_fleet_read’s gate.scope) — an UNCONFINED caller’s plain “this id has never existed” 404 is not a confinement decision and writes no row, so this verb’s rate is a measure of confinement actually firing, not of ordinary 404 traffic (a caller-global-grant/admin holder therefore never appears here for a bad id). GET /api/executions (list) never emits this verb — its confinement is a silent SQL WHERE-clause narrowing with no per-row audit, same as every other pushdown-filtered list read. target_id is the execution id; detail="not found or outside caller's fleet-read scope surface=<detail|summary|agents|children> cid=<correlation_id>" — the detail is deliberately IDENTICAL whether the execution never existed or exists but is fully out of scope (a distinguishing detail string would be a same-response-shape oracle a confined caller could probe). result=denied. Two more emitters (#2146 A2-R1), same verb, different detail shape: REST v1’s GET /api/v1/executions/{id}/children twin reuses the identical detail="... surface=children cid=<correlation_id>" string (indistinguishable in the audit trail from the legacy /children route’s own denial); MCP’s get_execution_children tool also denies under this SAME verb but with its own detail text, detail="MCP children query: not found or outside caller's fleet-read scope" (no surface=/cid= tokens - MCP has no per-request correlation id to embed here). Permission gate: require_fleet_read("Execution","Read") (#3789 — replaced the flat Execution:Read gate on these four routes; the two new #2146 A2-R1 emitters use the same gate). |
execution.rerun | execution (lowercase — matches this verb’s pre-existing success/failure rows, NOT the Execution-capitalized convention execution.read and most other verbs in this table use; a target_type=Execution SIEM filter will not match any row this verb ever writes) | POST /api/executions/{id}/rerun. result=success: target_id is the NEW rerun execution’s id (not the original). result=denied (#3789, confined callers only — an unconfined caller’s failed rerun falls through unaudited to create_rerun’s own pre-existing 400, unchanged from pre-#3789 behavior): the original execution failed the mutation-admission rule in execution_scope_rules.hpp (admit_confined_mutation — nonexistent, zero-visible, an incomplete target-response ledger, or any out-of-scope targeted agent all collapse to this SAME outcome, by design, to avoid a hidden-cohort oracle); target_id is the ORIGINAL execution id; detail="not found or outside caller's fleet-read scope cid=<correlation_id>". Permission gate: require_permission("Execution","Execute") ahead of require_fleet_read("Execution","Read") for scope (the fleet gate is structurally Read-only, authz_gates.cpp, and cannot replace the Execute check). |
execution.cancel | execution (lowercase — same casing anomaly as execution.rerun above) | POST /api/executions/{id}/cancel. result=success/result=failure cover the pre-#3789 mark_cancelled outcome, unchanged. result=denied (#3789, confined callers only — see execution.rerun above for the identical admission rule and no-oracle rationale) uses the same target_id/detail shape as execution.rerun’s denied row. An UNCONFINED caller hitting a genuinely nonexistent id also gets a plain 404 with no audit row — the response CODE differs from execution.rerun’s unconfined-not-found 400 (see above), but both write no audit row in this case. This closes what mark_cancelled alone would otherwise report as a false success on a zero-row UPDATE. Permission gate: same as execution.rerun. A second, system-initiated source (#4982): the clock-guarded reap_stuck_running_executions() background sweep (~15-minute cadence, see docs/clock-guarded-retention.md) also writes this action when it force-cancels an execution permanently stranded at status='running'/agents_targeted=0 — principal=system (no HTTP session/token principal exists for a background writer), target_id=the execution id, result=success, detail=reap_stuck_running_executions (distinguishing it from the operator route’s rows, which carry no detail). A background actor silently transitioning a command’s terminal state would otherwise leave no audit trail at all — see docs/user-manual/metrics.md’s yuzu_exec_tracker_stuck_reap_total for the paired real-time signal. |
execution.detail.fetch | Execution | Was fully distinct from execution.read above (that verb covered only the legacy /api/executions/* family; this one covered the v1 twins, #4030) - no longer true as of #2146 A2-R1: the new REST v1 GET /api/v1/executions/{id}/children twin emits execution.read on denial (see that row above), not this verb, so the split is now per-ROUTE, not per-transport-generation. GET /api/v1/executions/{id}?include=agents (per-agent status/duration expansion) and GET /api/v1/executions/{id}/responses both write result=success rows under this verb on their 200 path (response body/agent-identity disclosure); the bare GET /api/v1/executions/{id} (no include) does not audit its 200 path at all, only result=denied on the 404/scope-denial branch, matching execution.read’s no-oracle shape (identical detail text whether nonexistent or out-of-scope). A third, unaudited branch (#2146 A2-R1 governance fix): a transient tracker degrade (pool/query failure distinguished via get_execution_checked) on the bare detail route now answers 503 and writes NO row at all under this verb, either success or denied - do not mistake a quiet period on this verb for “no denials occurred,” since a degrade window also produces silence here. Both audited call sites REST fail-closed (503 + Sec-Audit-Failed: true) on an audit-persist failure — do not confuse with workflow_execution.detail.fetch below, a set-and-proceed posture. Permission gate: require_fleet_read("Execution","Read") on the detail route, require_fleet_read("Response","Read") on /responses. |
workflow_execution.detail.fetch | WorkflowExecution | GET /api/v1/workflow-executions/{id} and MCP get_workflow_execution (#4030) — WorkflowEngine’s own multi-step run record, a different data model from Execution. result=success on an admitted read; result=denied (#4030 Gate 8 fix) on a confined caller with no agent visible in the execution’s agent_ids — the denied row’s detail text is non-distinguishing (identical whether the execution is out-of-scope or would otherwise 404), but unlike execution.read/execution.detail.fetch above, a genuinely nonexistent id writes no row at all: the mere presence of a denied row for a given id is therefore itself an existence signal to a caller who also holds AuditLog:Read (narrow, existence-only; tracked open, not closed by this fix round). Set-and-proceed, NOT fail-closed — this file’s AuditFn/MCP path predates the checked-bool try_persist_audit/emit_behavioral_audit helpers execution.detail.fetch above uses; an audit-persist failure on an admitted read does not block it (MCP surfaces audit_persisted:false on the response payload when it happens; REST has no equivalent signal). Permission gate: require_fleet_read("Workflow","Read"). |
ota.package.uploaded | UpdatePackage | An admin uploaded an agent OTA update binary via Settings → OTA Updates. target_id is <platform>/<arch>/<version>; detail carries file=<name> sha256=<hex> signed=true|false rollout=<N>%. signed= is the field to alert on. Re-uploading a package with the Signature field left empty removes the previous detached signature, silently downgrading that package from signed to unsigned for the entire fleet — a signed=false row for a package that was previously signed=true is that transition, and it is the only durable record of it. The SHA-256 is the content identity of the binary every endpoint will execute. |
ota.package.rollout_changed | UpdatePackage | An admin changed the rollout percentage of an agent OTA update package via Settings → OTA Updates. target_id is <platform>/<arch>/<version>; detail carries from=<N>% to=<M>% mandatory=true|false. The from= value is the point of this row, and it is the value the write actually replaced — the row is read under a lock and updated in the same database transaction, so a concurrent change to the same package cannot land between the two and leave from= naming a value that was never replaced. A rollout of 0% cannot, on its own, distinguish a mandatory security patch that was deliberately pulled back from one that was never rolled out — from=100% to=0% mandatory=true can, and that is the scenario this audit exists to make reconstructable. The percentage is clamped to 0–100 server-side and the row reports the clamped, stored value, not the requested one. Three outcomes, and only one of them is evidence of a change. result=success — the package was found and the write committed; detail carries from=/to=. result=denied with detail=not_found — the store reported the package as absent, so nothing changed; this is the same shape the file’s other rejection branches use, and the result == "denied" filter under Result vocabulary surfaces it along with every other probe. result=failure — the change did NOT take effect, for one of two reasons named in detail: outcome=write_not_committed (the package was found but the registry write did not commit, so detail carries attempted_from=/attempted_to= rather than from=/to=), or outcome=store_unavailable existence_unknown=true (the registry could not be read at all). The second is deliberately NOT denied: the store’s reads fail soft, returning an empty result on a degrade, so treating that as absence would make a database blip both manufacture enumeration alerts and assert in the evidence record that a package which exists did not. |
ota.package.deleted | UpdatePackage | An admin deleted an OTA update package. target_id is <platform>/<arch>/<version>; the binary and its signature sidecar are both removed. result=success when a matching package was found and both files were dealt with cleanly, with detail naming each outcome separately — binary removed, signature sidecar removed, or ... already absent for either half where the file was not there to begin with (a package whose binary was removed out of band is a different fact from one this call deleted). result=partial when the registry row was removed but a filesystem delete failed: detail=registry row removed; binary delete failed: <reason> (and/or the sidecar equivalent). The package stops being served either way — the row is gone — but the leftover file is still on disk and needs manual removal, and this row is the only record of that. A delete naming a package that does not exist removes nothing and records result=denied with detail=not_found — the same shape this file’s other rejection branches use, so the result == "denied" filter described under Result vocabulary surfaces it along with every other probe. |
plugin_signing.bundle.uploaded | PluginTrustBundle | An admin uploaded a PEM trust bundle via Settings → Plugin Code Signing. detail carries <N> cert(s), sha256=<hex> on success. On result=failure (PEM validation rejected the input — empty body, missing markers, no parsable cert, hashing failure, etc.) detail carries the validation error string. Audit row is emitted on both branches so a SIEM rule on failure can detect malformed-upload probing. |
plugin_signing.bundle.cleared | PluginTrustBundle | An admin removed the trust bundle (Settings → Plugin Code Signing → Remove). detail carries file removed (a file existed and was deleted) or no file present (clear was a no-op). Always emits success unless the require-flag DB write fails first, in which case result=failure and detail carries the store error — and the file is not removed (two-phase commit prevents the disk/DB-flag desync). |
plugin_signing.require.changed | RuntimeConfig | An admin toggled “Require signed plugins” via Settings. target_id is plugin_signing_required, detail carries the new value ("true" or "false"). Emitted on every change including no-op idempotent re-saves. |
plugin_config.kill_switch.set | PluginConfig | An operator flipped a plugin’s dispatch kill switch via PUT /api/v1/plugin-config/{plugin}/kill-switch or the MCP set_plugin_kill_switch (PluginConfig:Write) — how a default-off plugin such as privacy_permissions is enabled. target_id is <plugin> (<plugin>.<action> for an action-scoped switch, with an @<os> suffix for a per-OS switch); detail is `enabled=true |
response_template.create | InstructionDefinition | A response-view template was created via POST /api/v1/definitions/{id}/response-templates (issue #254 / Phase 8.2). target_id is the definition id; detail is the new template id on success, or reason=<r> on the audit path. RBAC denials, 400 (malformed id / invalid JSON / validation), 404 (definition not found), and 413 (body too large) emit result=denied; 500 (persist failure) emits result=failure. Reasons: malformed_definition_id, body_too_large, invalid_json, definition_not_found, validation_failed, persist_failure. Permission gate: InstructionDefinition:Write. |
response_template.update | InstructionDefinition | A response-view template was replaced via PUT /api/v1/definitions/{id}/response-templates/{tid} (issue #254). target_id is the definition id; detail is the template id on success, or reason=<r> on the audit path. 4xx branches emit result=denied; 500 persist failure emits result=failure. Reasons: malformed_id, reserved_id, body_too_large, invalid_json, definition_not_found, template_not_found, validation_failed, persist_failure. Permission gate: InstructionDefinition:Write. |
response_template.delete | InstructionDefinition | A response-view template was removed via DELETE /api/v1/definitions/{id}/response-templates/{tid} (issue #254). target_id is the definition id; detail is the deleted template id on success, or reason=<r> on the audit path. 4xx branches emit result=denied; 500 persist failure emits result=failure. Reasons: malformed_id, reserved_id, definition_not_found, template_not_found, persist_failure. Permission gate: InstructionDefinition:Write. |
offload_target.create | OffloadTarget | An offload target was created via POST /api/v1/offload-targets (issue #255 / Phase 8.3; ADR-0059). On success: target_id is the assigned numeric id (string-form), detail is name=<n> url=<scheme>://<host>[:<port>] url_fp=<12 hex> (the same shape as the delete row, so the record shows where data was sent; the REST route and the MCP create_offload_target tool write the same shape). The destination URL is a bearer secret, so the row never carries its path or query: url_fp is the first 12 hex characters of the URL’s SHA-256, enough to tell whether two rows (or a row and a candidate URL) name the same destination and too little to recover it. Rows written by earlier builds carry the full url=<u> or the bare name, so a SIEM parser should accept all three. Those older rows are credential-like for as long as they are retained: rotate any Slack-style hook URL that appears in one at the provider, or record the decision to leave it. Each create also writes a result=attempted row BEFORE the target is stored (same detail, except target_id is the name made audit-safe, and only url_fp= is written when the URL has no plain scheme://host[:port]); if that row cannot be written the create is refused (REST 503 with Sec-Audit-Failed: true, MCP an error with a retry hint) and nothing is stored (ADR-0033 section 9). A cookie-session POST whose Origin/Referer names another site, or carries neither, is refused 403 and audited as csrf.denied (target_id is the route). On 400: target_id is the submitted name made audit-safe (as for attempted), result=denied with detail ∈ validation_failed (invalid URL scheme, embedded user:password@, batch_size outside 1 to 10000, control bytes in a free-text field, or duplicate name), invalid_field_type (a present-but-wrong-typed JSON field, e.g. batch_size sent as a string, float, boolean or null), or invalid_auth_type (an auth_type value outside none/bearer/basic/hmac). A missing or empty name or url is a 400 with no audit row. The MCP tool writes a denied row for a refused batch_size too: invalid_field_type for a wrong type or an integer above 9223372036854775807 (REST records validation_failed for that one value), and validation_failed for any other out-of-range integer, the latter only when name and url are present. A wrong-typed batch_size is audited even when name is missing, so that row can carry an empty target_id. On 503 (store degraded): detail ∈ store_unavailable (not open / pool exhausted) or db_error (a query against an open store failed) — distinct in the audit record even though both collapse to the same 503 HTTP status (#3097 classification). RBAC denial is emitted by the permission gate before this handler is reached. Permission gate: Infrastructure:Write. |
offload_target.delete | OffloadTarget | An offload target was deleted via DELETE /api/v1/offload-targets/{id} (issue #255; ADR-0059). On success: target_id is the numeric id, detail is name=<n> url=<scheme>://<host>[:<port>] url_fp=<12 hex> (snapshotted before the delete so the record survives it, in the same non-secret form as offload_target.create; empty if that pre-delete snapshot read itself degraded), result=success. A result=attempted row with the same detail is written first; if it cannot be written the delete is refused (503 + Sec-Audit-Failed: true) and the target stays. On 404 (id not found, or numeric overflow on the path segment, which has no attempted row): detail=not_found, result=denied — the operator’s attempt is recorded so SIEMs can surface probing of non-existent offload targets. On 503 (store degraded): detail ∈ store_unavailable/db_error, same #3097 distinction as offload_target.create above. Permission gate: Infrastructure:Write. |
webhook.create | Webhook | An operator created a webhook via POST /api/webhooks (the Integrations page uses this route). target_id is the new webhook id on result=success, empty on failure. detail is url=<scheme>://<host>[:<port>] url_fp=<12 hex> (url= is omitted when no host can be shown, see offload_target.create): the destination URL is a bearer secret, so the path and query are never recorded; older rows carry the full URL: rotate any hook URL seen in one at the provider, or record the decision to leave it. A result=attempted row (same detail) is written BEFORE the webhook is stored; if it cannot be written the create is refused (503 + Sec-Audit-Failed: true) and nothing is stored. result=failure with detail invalid_url (400: not http(s), embedded credentials or control bytes), url_required, invalid_json, store_unavailable or db_error. Permission gate: Infrastructure:Write. |
webhook.delete | Webhook | An operator deleted a webhook via DELETE /api/webhooks/{id}. target_id is the id. detail is the pre-delete snapshot in the webhook.create form (empty if the snapshot read degraded), result=success; result=failure with detail not_found (404, including an id too large for a 64-bit integer, which has no attempted row), store_unavailable or db_error. A result=attempted row is written first; if it cannot be written the delete is refused (503 + Sec-Audit-Failed: true). Permission gate: Infrastructure:Write. |
settings.mcp | MCP | An administrator changed the MCP switches via POST /api/settings/mcp. target_id is mcp_settings; detail is enabled=<bool>, read_only=<bool>. A result=attempted row is written BEFORE the switches move, and if it cannot be written the change is refused (503 + Sec-Audit-Failed: true); result=success follows. A malformed body (enabled and read_only must each appear once as true or false) is refused 400 with nothing changed and no row. A cookie-session post whose Origin/Referer names another site, or carries neither, is refused 403 (csrf.denied); token callers are not gated. |
csrf.denied (integrations writes) | Endpoint | A browser (session-cookie) POST/DELETE to /api/webhooks, /api/v1/offload-targets, POST /api/directory/sync, PUT /api/directory/group-mappings or POST /api/settings/mcp that carried no Origin/Referer, or one naming another site, was refused with 403 before anything was parsed or written. target_id is the route. Token-authenticated callers are not gated; an Origin or Referer matching a --csrf-trusted-origin entry is accepted. For a cookie-session caller the csrf.denied (central gate) row is written instead. |
viz.fleet_topology | FleetTopology | Fleet topology snapshot was requested via GET /api/v1/viz/fleet/topology, GET /fragments/viz/fleet/topology (PR 3 of feat/viz-engine ladder), or the MCP twin get_fleet_topology (api-parity #2146 Batch B3 — via the domain verb, not the generic mcp.<tool> action). target_id is empty (the action operates on the whole fleet). On success: detail is `machines= |
viz.fleet_topology.invalidate | FleetTopology | The fleet topology cache was explicitly invalidated because ?fresh=1 (REST) or fresh:true (MCP get_fleet_topology) was passed on the request. Emitted as a separate row immediately before the corresponding viz.fleet_topology row so the operator-driven flush is its own evidence event. target_id empty, detail empty (REST) or via MCP get_fleet_topology (MCP), result=success. Permission gate: Response:Read (inherited from the parent request). |
viz.host_topology | HostTopology | Per-host topology slice was requested via GET /api/v1/viz/host/{id}/topology or the MCP twin get_host_topology (api-parity #2146 Batch B3). target_id is the agent id. On the --viz-disable kill switch: detail=kill_switch[ via MCP], result=denied. On success: detail is fragment=1 or empty (REST) or via MCP (MCP), result=success. On not-found (agent id absent from the current live snapshot - no durable stale fallback here, unlike viz.fleet_topology): detail=not_found[ via MCP], result=failure (an authorized-but-absent lookup, not a rejection). On internal error (store null / fetcher threw / null sentinel): detail is store_null / fetch_threw / snap_null (optionally suffixed via MCP), result=failure. On MCP only: a missing/empty agent_id argument answers kInvalidParams with NO audit row at all (checked before the kill switch; REST cannot produce this state since agent_id is a required path segment). Permission gate: Response:Read. |
product_pack.install | ProductPack | A product pack install was attempted via POST /api/product-packs. On success: target_id is the new pack id, result=success; detail is empty UNLESS the install was partial (#3479 — at least one document failed alongside ones that succeeded), in which case detail carries "<failed>/<total> item(s) failed to install" (the per-item reasons live in the response body’s errors array, not the audit row). On rejection: target_id is empty (install failed pre-id-generation; pack name is attacker-controlled YAML and is deliberately not echoed into target_id), detail is the operator-facing error message, result=denied. Three rejection classes share result=denied: a signature/policy rejection (#802 / W7.4 — "pack '<name>' is unsigned and signature enforcement is enabled (set --allow-unsigned-packs / ... to bypass)" / "signature verification failed for pack '<name>' — content may have been tampered with" / "pack '<name>' has signature but no publicKey — cannot verify"), a mid-install database failure (store unreachable, or the pack/item persist transaction failed — detail carries a generic message, never the raw database error, ADR-0054; since #3481, when a compensation attempt actually ran — the real REST route always supplies compensate_fn — detail also carries a (compensated N/M item(s)) suffix, same as the duplicate-item-id message below and in the REST response body, so an auditor can tell whether an orphan was left behind directly from the audit row), and, since #3481, an idempotency-key conflict (the same Idempotency-Key header reused with a different request body — "idempotency key already used with a different request body"). SOC 2 CC6.7: every request that reaches ProductPackStore::install() is logged regardless of outcome — this does NOT cover a request rejected before install() is ever called (an unparseable JSON body, the store being unavailable, or, since #3481, an oversized Idempotency-Key header): those return their error directly with no audit row, the same pre-existing convention this endpoint has always used for request-validation failures. Permission gate: ProductPack:Write. |
product_pack.uninstall | ProductPack | A product pack uninstall was issued via DELETE /api/product-packs/{id}. target_id is the pack id. On success: detail is empty, result=success. On rejection (ADR-0054): detail is the operator-facing error message, result=denied — either the id does not exist, or a database failure occurred (store unreachable, or the item-delete transaction failed; detail carries a generic message in the latter case, never the raw database error). Pre-migration, a rejected uninstall was not audited at all — this closes that SOC 2 CC6.7 gap, mirroring product_pack.install’s own denied-row precedent. Permission gate: ProductPack:Delete. |
server.viz_disabled | FleetTopology | Emitted ONCE at server startup when --viz-disable / YUZU_VIZ_DISABLE=1 is set. principal=system, target_id=viz, detail describes the flag source, result=success. The matching [VIZ] viz endpoint disabled by configuration warn line in operator logs covers the same event; the audit row makes the disabled posture recoverable from the audit store on a deployment with no viz traffic. |
server.default_certs_generated | Server | Emitted ONCE on the boot that generated the built-in default CA + leaves (the zero-config first-boot set). principal=system, target_id=default-certs, detail carries ca_fingerprint=<SHA-256>, result=warning (the install is running on built-in certificates). |
server.default_certs_in_use | Server | Periodic (about every 300 s) reminder while the server is serving built-in default certificates. principal=system, target_id=default-certs, result=warning. |
server.default_certs_server_identity_upgraded | Server | Emitted ONCE on the boot that re-issued an existing install’s default-server.pem in place to add the server-only identity URI SAN yuzu://<ca-fingerprint>/server (#6016) — same root, same key, so existing gateway pins stay valid. principal=system, target_id=default-certs, detail=Re-issued default-server.pem with the server identity URI SAN (same root, same key); superseded_serial=<hex> new_serial=<hex> superseded_revoked=<true|false> ca_fingerprint=<SHA-256>. result=success when the superseded serial is revoked in the CA inventory (reason superseded: bookkeeping, not a key compromise); result=warning with superseded_revoked=false when that revoke could not be confirmed — the old row may still be active (unless replacement_withdrawn=true, below) and should be revoked from the CA page or POST /api/v1/ca/revoke (it is not retried: the next boot finds the set intact). When an operator revoked the old serial for a real reason (for example key compromise) while the upgrade ran, the row is a warning with superseded_revoked=false and replacement_withdrawn=true: the new certificate over the same key was revoked too, so re-key the set (see Upgrading). If the detail carries replacement_withdraw_failed=true instead, revoking the new certificate failed or matched nothing (the error log names its serial): revoke it manually and re-key. Not emitted on a fresh install (the leaf is minted with the URI; see server.default_certs_generated) or when the upgrade was skipped. |
server.unsigned_packs_allowed | ProductPack | Emitted ONCE at server startup when --allow-unsigned-packs / YUZU_ALLOW_UNSIGNED_PACKS=1 is set (#802 / W7.4). principal=system, target_id=signature_enforcement, detail describes the flag source, result=success. Pairs with the [SECURITY] product pack signature enforcement DISABLED by configuration warn line in operator logs. SIEM rules can detect a server running with the relaxed posture even on deployments with no pack-install traffic. |
server.unsigned_definitions_allowed | InstructionDefinition | Sibling of server.unsigned_packs_allowed, emitted ONCE at server startup when --allow-unsigned-definitions / YUZU_ALLOW_UNSIGNED_DEFINITIONS=1 is set (#1073 / W7.4 sibling-gap closure). principal=system, target_id=signature_enforcement, detail describes the flag source, result=success. Pairs with the InstructionStore: signature enforcement DISABLED by configuration warn line in operator logs. Same SIEM use case: identifies servers running with the relaxed instruction-import posture. |
config.admin_group_set | AuthConfig | Emitted ONCE per configured flag at server startup when --oidc-admin-group and/or --saml-admin-group is non-empty (#1829). principal=system, target_id is oidc or saml (one row per configured provider — up to two rows on a server with both wired), detail=provider=<oidc|saml>;admin_group=<value> (the already-trimmed group value — a group identifier, not a secret), result=success. Makes the standing “which group grants admin via SSO” posture recoverable from the audit store even on a deployment with no SSO logins yet. |
mcp.session.* | McpSession | MCP Streamable HTTP session lifecycle (track 2f PR 1/2/3b) — initialize, GET/DELETE /mcp/v1/, and the streamed-POST admission path all funnel through this one action family. No RBAC permission gate — this is a transport-level pre-auth/session check, not an authorized operation. mcp.session.open: a session was minted on a successful initialize; target_id is the new session id’s first 8 characters, detail empty (the principal is derived from req by the audit layer, not stamped from the minted id — the session-id prefix in target_id is what correlates an open row to its later close/reject rows, governance CONS-N2). mcp.session.close, result=success: either an explicit DELETE /mcp/v1/ (target_id=session-id prefix, detail empty), or the server force-terminating the session because a GET resume’s Last-Event-ID cursor fell outside the replay window (detail=reason=replay_window_exceeded cid=<cid>, #2793) — result=success here describes the close itself succeeding, not the triggering request, which still answers 404 to the client. mcp.session.reject, result=failure: every denial across all three methods and both the pre-admission and streamed-POST-internal admission checks — Origin not allowed, unsupported MCP-Protocol-Version, a missing or presented-but-invalid Mcp-Session-Id (unknown, expired, or another principal’s — one answer for all three, no cross-principal existence oracle, Decision 15(a)/CH-8), a concurrency cap — on initialize (per_principal_cap/global_cap/id_generation, McpSessionRegistry’s own session-mint tracking) or on GET’s stream attach (per_principal_stream_cap/global_stream_cap, the shared cross-surface StreamBudget — a distinct reason vocabulary from the mint-time cap despite the similar names), GET-specific causes (not_acceptable, stream_handover_pending, terminal_poisoned), and the streamed-POST admission denials (--mcp-enable-streamed-post, 2f PR 3b) — post_per_principal_cap/post_global_cap/post_pin_slots/post_duplicate_request_id/post_unknown_session, whose detail additionally carries surface=post dispatched=false since a streamed-POST denial happens before any execution row exists. target_id is empty on every cause that rejects before a session is read at all — Origin, protocol version, missing_session_header (there is no header to reflect), and the initialize mint-cap family (rejection happens before initialize even looks for one) — and carries the sanitized session-id prefix on every other cause: unknown_session (any method — the presented, not-yet-validated id), and, on GET specifically, not_acceptable/stream_handover_pending/terminal_poisoned/the stream-attach cap pair and the streamed-POST denials (all reached only once validate_and_touch has already confirmed a real, live session, so the id is never empty there). detail=reason=<X> cid=<correlation_id> for every cause. The full closed reason= set (and which values are mcp.session.close vs mcp.session.reject) is in docs/observability-conventions.md. Every presented session-id prefix reaching any of these three verbs is sanitized (sanitize_detail_value) at every call site that reads one from the Mcp-Session-Id header, since it is attacker-controlled until it validates — raw bytes could otherwise inject the ;/= field separators a k=v;k=v detail string relies on (#2917 closed the one remaining unsanitized site, on POST’s presented-session check). mcp.session.open’s target_id is the exception: it is the server’s own freshly-minted id, not attacker input, so it is stamped unsanitized. |
mcp.bridge.* | Execution | Server-side lifecycle events of the MCP progress bridge (track 2f PR 3a), NOT operator actions - emitted from the background sweep/projector (and, for the three client-driven verbs mcp.bridge.cancel, mcp.bridge.dispatch_failure and mcp.bridge.pin_displaced_for_admission, from the request thread that lost, unwound, or was admitted), so principal=system for the bridge’s own background work (sweep/projector) - except the three client-driven verbs, EVERY mcp.bridge.cancel row (whether the cancel detached a live streamed response or was consumed at arm() - as of the SSE-on-POST rung both carry the canceller; the session gate binds the record’s principal to the authenticated caller, so the two cannot diverge) mcp.bridge.dispatch_failure, and mcp.bridge.pin_displaced_for_admission (#2740 — a streamed admission reclaimed a session slot from an already-committed final no wire took delivery of: either a parked record’s, or an ORPHAN pin whose owning record a teardown erased without unpinning. target_id is the displaced final’s execution_id, empty for an orphan because no record survives to name it, and the detail says which case it was), which carry the authenticated caller as principal so a cancel is attributable to the client that asked for it (Decision 15(j) non-repudiation) - and target_id is the execution_id - except in two cases where target_id is empty: a record reaped by the arming reaper before subscribe() assigned an execution id, and a pin_displaced_for_admission row for an ORPHAN pin, whose owning record no longer exists to name one — that row carries released ring event_id=<n> in its detail instead, which is per-session and so must be read together with the row’s own session context (that row is therefore not joinable to an execution; the detail still identifies the disposition). result is normally success, but every verb that routes through the claimed-teardown path (all of them except mcp.bridge.cancel, mcp.bridge.dispatch_failure and mcp.bridge.pin_displaced_for_admission, which are transitions rather than teardowns) emits result=failure when the teardown could not settle one of the three resources it owns (detail begins teardown incomplete: ... and names what is retained - see yuzu_mcp_bridge_teardown_incomplete_total{reason} and docs/ops-runbooks/mcp-bridge-teardown-recovery.md), or when the terminal-frame publish ladder poisoned the session, threw, or was never reached (detail states which, and never asserts a delivery or a poisoning that did not happen - #2506). Upgrade note: before v0.13.0 these rows were unconditionally stamped result=success regardless of outcome; a SIEM rule that assumed the constant needs updating. The verb family: mcp.bridge.done_reap (a settled GET-only record was reaped), mcp.bridge.session_dead (a record torn down because its session idle-expired), mcp.bridge.arming_reaped (an orphaned reserved-but-never-armed record was reclaimed by the age reaper), mcp.bridge.cancel (a cancel took effect - either consumed at arm, or detaching a LIVE streamed response), mcp.bridge.dispatch_failure (a request failed AFTER its work was dispatched, so its record was PARKED rather than abandoned - the execution keeps running and its result stays fetchable by execution_id; unlike the pre-dispatch failure paths this deliberately does NOT cancel the execution), mcp.bridge.pin_acked and mcp.bridge.forced_expire (parked-result teardown), and mcp.bridge.pin_displaced_for_admission (#2740 — a streamed admission reclaimed a session slot; carries the admitting principal). Correlate the last two: since #2740 a released pin is no longer proof a client consumed anything, so a pin_acked row that follows a pin_displaced_for_admission row for the same execution records a reclaim, NOT an ack — the action name predates the reclaim and is kept for taxonomy stability. mcp.bridge.shutdown_reap (#2517) is the one AGGREGATE exception to the per-execution shape above: emitted at most once per process shutdown, with target_id/execution_id empty and detail naming the COUNT of records reclaimed ("poisoned N claimed-but-unpublished record(s) at shutdown...") — a claimed-but-unresolved teardown shutdown()’s own walk found abandoned by a raced sweep, rather than a per-record row (which would re-enter the teardown’s exactly-once arbitration). Always result=success, principal=system. No metric backs it (the process is exiting before any scrape); a shutdown-time reclaim that WAS already resolved gets no row at all (the ordinary silent-reclaim case, docs/observability-conventions.md’s comp-S1 bullet). mcp.bridge.teardown_retry (#2513) is the audit_action a LATER sweep’s retry pass uses when it re-enters a teardown a prior attempt left incomplete - never the original claim verb (session_dead, pin_acked, etc.), which already carries its own row for the first attempt. result on the attempt that finally settles the record (detail says "recovered on a retry attempt" - a static literal, not the attempt count, since teardown_claimed is noexcept end to end and every string on it must be allocation-free outside a contained boundary) is NOT unconditionally success - it mirrors the terminal’s fate exactly as it would on a first attempt, per the general rule two sentences up: success only when the terminal was delivered (published, or none was owed), failure when it was not, independent of whether the retention strand itself resolved. A retry can therefore settle the record (mechanically recovering the subscription/charge/erase, and counting outcome="recovered") while replaying a terminal that a PRIOR attempt already poisoned or never built - that row still carries result=failure with the same "recovered on a retry attempt" detail, since the retention strand and the terminal’s fate are independent facts. result=failure on a retry whose retention strand fails again, same detail shape (mechanical failure plus a "(retry-eligible on a later sweep)" or "(retry budget exhausted; retained until shutdown)" suffix, chosen from two static literals per bail site, never built at runtime) as the first attempt’s row, until Config::teardown_retry_max is reached, at which point the exhausted variant fires and no further retries are attempted for that record (see yuzu_mcp_bridge_teardown_retry_total{outcome}). All of these are LIVE as of the SSE-on-POST rung (2f PR 3b), which wired their callers. The operator action that starts progress streaming (execute_instruction) is separately audited under its own mcp.execute_instruction verb with the real principal - the bridge does not replace it. |
mcp.summarize_working_set | mcp_tool | The MCP tool summarize_working_set was called (#3526, #4753). target_id=summarize_working_set. result=success carries detail=<kind>:<id> (the caller-supplied id is neutralised for k=v and CR/LF forgery and capped at 128 bytes, for every kind). The scope-collapse result=denied row is written only for a CONFINED caller (management-group scoped) on kind=execution who names an execution id that is absent or outside the caller’s fleet-read scope; detail=not found or outside caller's fleet-read scope: <id> (id neutralised and capped at 128 bytes) and no success row is written for that call. Other denied rows on this action (tier refusal, permission-gate refusal, service-scoped default-deny) are separate and can occur regardless of RBAC state. The id argument is advertised with maxLength 128, and a NUL byte in a kind=execution id is rejected with an invalid-params error before any lookup. The denied row cannot tell a typo from an out-of-scope probe, by design (the tool’s narrative for such an id reads exactly like an absent one, so existence is not an oracle). An UNCONFINED caller’s absent id stays result=success. The scope-collapse denied row is therefore only emitted for a confined caller, so it is never written with RBAC off. Permission gates: Infrastructure:Read first, then Execution:Read through the fleet-read gate for kind=execution. |
mcp.list_hardware_ci | mcp_tool | The MCP tool list_hardware_ci was called (twin of GET /api/v1/hardware). target_id=list_hardware_ci. Gate Inventory:Read through the fleet-read gate (a gate-level denial is audited under auth.fleet_read_required, not here). Written by every call except one the fleet-read gate refused. result=success carries the same detail as the paired inventory.devices row (hardware list (MCP): devices=<n> omitted=<m>, or devices=<n> scope=authority_scoped for a confined caller) and is written together with it; the tool fails closed, so if either row cannot be written the data is withheld (see inventory.devices). result=failure carries hardware roster read failed or hardware provider unwired, each paired with a failure row on inventory.devices when the domain audit sink is wired. A refused argument (a wrongly typed or out-of-range q, os, status, sort, dir, tag, offset or limit, an enum value that is not exactly lowercase, or a tag that is not a valid tag key) writes ONLY this generic row, result=denied, detail=invalid arguments: <reason> (or invalid arguments: tag key); no inventory.devices row is written for a refusal. A non-object arguments value is refused earlier by the registry’s central check (result=denied, detail=arguments must be a JSON object correlation_id=<cid>). |
mcp.get_hardware_ci | mcp_tool | The MCP tool get_hardware_ci was called (twin of GET /api/v1/hardware/{id}). target_id=get_hardware_ci; the requested device is in the paired inventory.device.ci row’s target_id, not here. Gate Inventory:Read through the fleet-read gate, then a per-id scope check. result=success or result=failure carries the same detail as the paired inventory.device.ci row (hardware CI record (MCP) state=<...>) and is written together with it; the tool fails closed (see inventory.device.ci). Three refusals write ONLY this generic row: result=denied invalid arguments: agent_id (missing, not a string, empty, over 256 bytes or containing a byte below 0x20); result=denied device out of the caller's scope; and result=failure not found. The caller receives the identical -32602 not found for the last two (no existence oracle). For a confined caller the two cases are also indistinguishable in this log, because a nonexistent id is outside the caller’s visible set and takes the out-of-scope branch; a confined caller reaches the not found row only for an id inside its own visible set, so neither row implies that the id exists. The out-of-scope and not-found details carry the requested id as id=<agent_id> (passed through the audit token filter), which is written only after the id has passed the audit-safe check. |
mcp.list_quarantined_devices | mcp_tool | The MCP tool list_quarantined_devices was called (twin of GET /api/v1/quarantine). target_id=list_quarantined_devices. Gate Security:Read through the fleet-read gate (a gate-level denial is audited under auth.fleet_read_required). This is the only audit row for the read: the REST GET /api/v1/quarantine writes no row at all, and the tool has no domain verb. It is set-and-proceed: result=success carries limit=<n> returned=<n> more=true|false, and if the row cannot be persisted the data is still returned with audit_persisted:false in the result. result=failure carries quarantine list unavailable; store degraded (the tool answers -32603 with retry_after_ms and the envelope carries audit_persisted:false if this row was dropped) or dependency unwired; failing closed. A limit argument that is not a JSON integer from 1 to 500 is refused with -32602 and writes result=denied invalid arguments: limit; a non-object arguments value is refused by the registry’s central check (result=denied, detail=arguments must be a JSON object correlation_id=<cid>). |
approval.read | Approval | An approval ticket was read through the REST approval routes: a single fetch (detail status=<status>), the list (surface=list count=<n>) or the pending count (surface=count count=<n>). result=success; set-and-proceed, so a row that cannot be stored does not stop the read |
bundle.dispatch | Execution | A bundle was dispatched by POST /api/v1/bundles. target_id is the correlation id and detail is agent=<id> steps=<n> on result=success; result=failure (and a 503) when the dispatch threw |
bundle.collate | Execution | A bundle result was collated by GET /api/v1/bundles/{id}. result is success (detail complete=0 or 1), denied (not found or not owned) or failure (response store degraded) |
compliance.agent_statuses.view | Policy | The per-device statuses of one compliance policy were read, by the REST read or the dashboard fragment. target_id is the policy id; result=success; detail carries agents=<n>. A row that cannot be stored fails the REST read with 503 and no device data |
echo.retention_paused.view | Infrastructure | A service-scoped API token was refused on the fleet-wide Echo retention-paused read. result=denied only; a successful read writes no row |
device_token.create / device_token.revoke | DeviceToken | A device token was created or revoked. result is success or failure (detail names the cause). A revoke of an unknown id is not audited. The routes exist only when a device token store is wired, which this build does not do |
api.v1.events.subscribe | Execution | A subscription to GET /api/v1/events for one execution. result=success (detail correlation_id=<id>), or denied when the execution does not exist or is outside the caller’s fleet-read scope |
license.activate / license.remove | License | A license was activated (target_id is the new license id, detail the organization) or removed. result=success; set-and-proceed. The routes exist only when a license store is wired, which this build does not do |
management_group.update | ManagementGroup | A management group was updated by PUT /api/v1/management-groups/{id}. target_id is the group id and detail the group name; result=success |
plugin_config.set / plugin_config.delete | PluginConfig | A plugin config value was upserted or deleted. Written twice: result=attempted BEFORE the change (if it cannot be stored the request is refused with 503 and nothing changes), then success or failure. detail for a set records only the length of the value |
plugin_secret.set / plugin_secret.delete | PluginSecret | A plugin secret was sealed or deleted. Same attempted-then-outcome pattern as plugin_config.set. The value is never recorded |
preflight.run.view | Scope | A service-scoped API token was refused on the pre-flight runs list. result=denied only |
result_set.create | ResultSet | A result set create or re-evaluate was attempted. result is success, denied or failure and detail carries a reason token. The success row is written after the set exists |
result_set.pin / result_set.unpin / result_set.delete | ResultSet | A result set was pinned, unpinned or deleted. result=success; a pin refused at the pin limit is denied and a failed pin or unpin is failure |
result_set.access | ResultSet | An id was not found or not owned by the caller. result=denied, detail not found or not owned |
result_set.list / result_set.members / result_set.lineage | ResultSet | The store read behind the list, member or lineage route failed. result=failure only; successful reads are not audited |
result_set.heal | ResultSet | A re-evaluate found a stored query that nests too deeply and discarded it. result is success if it was healed, else failure |
result_set.create.access_denied and the other result_set.<op>.access_denied verbs (list, detail, members, lineage, pin, unpin, delete) | ResultSet | A service-scoped API token was refused on that result set route. result=denied |
scope.preview.access_denied | ResultSet | A service-scoped API token called POST /api/v1/scope/preview and was refused. result=denied |
sle.agent.decommission | Agent | A durable-erasure decommission of one agent. Written first with result=attempt (if it cannot be stored the erase is refused with 503 and Sec-Audit-Failed), then success or partial with the erase counts |
ca.cert.issued | CodeSigningCertificate | A code-signing certificate was issued (result=success, target_id the serial) or refused (denied for a weak key or a bad validity, otherwise failure; target_id the requested label) |
ca.root_csr.exported | CaRoot | The CA signing request was exported. result=success, or failure when generating it failed |
kek.rotate / kek.rewrap | Secret | The secrets key-encryption key was rotated or the stored secrets were re-wrapped. target_id=kek; result is success (detail new_version=<n> or rows_rewrapped=<n>) or failure |
settings.tls.read / settings.https.read / settings.analytics.read / settings.plugin_signing.read | TlsConfig / TlsConfig / AnalyticsConfig / PluginSigning | One of the REST settings reads (/settings/tls, /settings/https, /settings/analytics, or the v2 plugin signing policy call). result=success. A row that cannot be stored fails the read with 503 and no data |
software_package.create | SoftwarePackage | A software package was created (result=success) or a package field was rejected (denied). The routes exist only when a software deployment store is wired, which this build does not do |
software_deployment.create / software_deployment.start / software_deployment.rollback / software_deployment.cancel | SoftwareDeployment | A software deployment was created, started, rolled back or cancelled. result=success. Same availability note as software_package.create |
upload_grant.mint | UploadGrant | An upload grant was minted. result=success (target_id is the grant id, detail agent_id=<id>) or failure |
upload_grant.session_open / upload_grant.chunk / upload_grant.commit / upload_grant.cancel | UploadGrant | The device side of an upload. session_open is written for a success or a refused redemption; chunk only for a failure (expired, size_exceeded); commit for a success or a failure (expired, hash_mismatch); cancel for a success or an expired session. target_id is the upload id, or the grant id for a redemption |
auth.admin_required | (empty) or endpoint | A token or non-admin caller was refused on an admin-only route. result=denied; the full detail strings are in the authentication manual |
Reconciling approval decisions. After an audit-store fault, list the approval tickets that have more attempted rows than outcome rows, then check each one’s real status (the Approvals page, or GET /api/v1/approvals/{id}). Compare counts per target_id; do not test whether an outcome row merely exists. Timestamps are epoch seconds; set the window to the incident and leave out the last minute, where a request may be in flight. Start the window before the first decision on the tickets you care about, and re-run a suspect ticket over its whole life: a window that starts inside a ticket’s life can cut an early attempted row while keeping a later outcome row, and that surplus outcome row can offset one lost success row. Row timestamps are the writing replica’s clock, while the window is measured on the database now(), so widen the window by the observed skew when a clock anomaly is suspected. A returned ticket is suspect, not proven: an outcome row was lost, or the server stopped between the attempted row and the decision. The same query, with the surrounding procedure, is in docs/ops-runbooks/audit-store-clock-guard.md (YuzuAuditPersistFailures).
SELECT target_id,
count(*) FILTER (WHERE result = 'attempted') AS attempted_rows,
count(*) FILTER (WHERE result = 'success'
OR (result = 'denied' AND detail NOT LIKE 'approver_assurance:%'))
AS outcome_rows
FROM audit_store.audit_events
WHERE action IN ('approval.approve', 'approval.reject')
AND timestamp >= extract(epoch from now() - interval '24 hours')::bigint
AND timestamp < extract(epoch from now() - interval '1 minute')::bigint
GROUP BY target_id
HAVING count(*) FILTER (WHERE result = 'attempted')
> count(*) FILTER (WHERE result = 'success'
OR (result = 'denied' AND detail NOT LIKE 'approver_assurance:%'))
ORDER BY target_id;
Result vocabulary. result is predominantly success or denied, with failure for a failure the handler audits itself. partial means the change committed but could not be fully delivered or applied (api_token.rotate, engine_principal.credential.rotate, engine_principal.credential.mint, and others named in their rows); it is never failure, because the change exists. attempted is written FIRST by operations that fail closed on audit (among them the bell, the approvals approve/reject decision, the MFA clears (mfa.disabled from Settings and mfa.reset.breakglass from the --mfa-reset command: each writes an attempted intent row before the factor is touched, so every clear leaves two rows and a blocked: self-target refusal leaves one error row with no attempted row), the plugin-config writes, the instruction-set and dashboard dispatch writes, the Guardian policy-generation raise (guaranteed_state.policy_generation.raise), the DEX score policy writes (dex.score_policy.update, dex.score_policy.reset), and the Integrations writes: webhook.*, offload_target.*, directory.sync, directory.group_mapping.update, settings.mcp): an outcome row follows it (a rejected request leaves attempted then denied or failure), and an attempted row with no outcome row means “started, outcome unknown”. A “failures only” filter of result != "success" therefore also matches every attempted row; exclude it, or key on the outcome row. no_change is an outcome that is not a failure: a dex.score_policy.reset of a policy that was never written records attempted then no_change, and the same filter matches it too. On the Integrations writes, a REST 2xx that carries Sec-Audit-Failed: true means the OUTCOME row was lost after the change was made: the attempted row is then the only record that it happened. The set is not closed in practice - ok, error, warning and skipped also appear on specific verbs. denied is the usual token for an authorization or validation rejection with a real principal behind it, but that is a convention rather than an invariant: enrollment.token_consumed and engine_principal.credential.rotate both use failure for what are really rejections, and instruction.scope_resolution_failed was emitting a self-audited failure before the mcp.bridge.* family existed. Unlike those still-standing violators, the mcp.<tool_name> unknown-tool row was corrected to denied in #2445 — it used to audit failure for a client-caused rejection (the caller named a tool that doesn’t exist), inconsistent with most other denials on the tools/call surface. That surface still has several of its own violators, unchanged by #2445 and not fully enumerated here (tracked for a full sweep in #3176): set_tag’s validation-failure rejection, execute_bundle’s out-of-scope rejection, and the access-review campaign family’s not-found rejections are all client-caused but audit failure. delete_tag’s not-found rejection is client-caused too but does NOT belong on this list — it audits a distinct third token, not_found, not failure. So a SIEM rule that surfaces failure branches by filtering on denied alone will miss several families, including every background-actor row. Filter on result != "success" when you want all of them. Note GET /api/v1/audit does not currently expose a result query parameter, so that filter has to be applied by the SIEM after export rather than server-side. denied is used for RBAC rejections and for every 400/404/409 branch where the handler explicitly audits the failure — including Guardian rule.create (400 missing fields, 409 conflict), rule.update (400 invalid body, 404 not found, 409 conflict), rule.delete (404 not found), push (400 non-object body), and the Phase 8.3 offload_target.create (400 validation_failed) / offload_target.delete (404 not_found) handlers. SIEM rules that filter on result == "success" will match every completed mutation including guaranteed_state.push (which returns 202 rather than 201/200 because agent fan-out is asynchronous). To surface probe/fuzz traffic on the REST surface, filter on result == "denied" scoped to the actions you care about — every mutating branch produces a row.
The /auto pre-flight verbs add two non-denial outcomes: no_devices (a preflight.run whose scope resolved to no visible devices — an operator no-op, not a rejection) and noop (a preflight.run.delete whose run id was unknown or not owner-visible — a no-op rather than a permission denial, which keeps a non-owner from distinguishing “exists” from “doesn’t exist”). Neither is a denied; SIEM rules counting denials should not include them.
Behavioural-PII access-audit rows: result=success means “authorised and attempted,” not always “disclosed” (#4858). The per-device / per-signal PII routes (dex.device.view, dex.signal.view, guardian.device.view, dex.app_perf.devices.view, and their MCP twins) split across postures by (verb, surface), not by verb alone — a SIEM/auditor reading raw rows must know which applies to a given row before drawing a conclusion from result alone:
- Pre-read (most dashboard fragments and REST gates, e.g.
dex.device.view,dex.signal.view, the MCP toolget_dex_device_app_perf’sdex.device.app_perf.viewrow, and the MCP toollist_guardian_events’sdex.device.viewrow). The audit row is written BEFORE the underlying store read runs, with a literalresult=successasserting only that the request was authorised and the read was ATTEMPTED. If the store then degrades (seeyuzu_server_guardian_read_degrade_total/yuzu_server_app_perf_read_degrade_totalindocs/user-manual/metrics.md), the dashboard renders a placeholder or the REST/MCP caller gets a503/retryable error — on top of an audit row that already recordedsuccess. Do not readsuccesson these verbs as proof any PII was actually disclosed; corroborate against the read-degrade metric for the same time window if that matters to your investigation. - Post-read by outcome (e.g.
dex.app_perf.devices.viewon every surface, andguardian.device.viewon REST and the MCP tool). The audit row is written AFTER the read, withresultset tosuccess/failureby the read’s actual outcome — heresuccessdoes mean the read itself succeeded (though a genuinely-empty result, e.g. an unrecognisedagent_id, is stillsuccess— a legitimate empty answer, not a failure).guardian.device.viewis surface-dependent: on REST and MCP it is this post-read-by-outcome posture, but the device-page Guardian lens dashboard fragment (GET /fragments/device/guardian) emits the SAME verb pre-read with a constantresult=successinstead (the first bullet’s shape) — the verb name alone does not tell you which posture produced a given row; check which surface emitted it. - Post-read constant success (the MCP
get_dex_device_scoretool’sdex.device.viewrow, and the MCPget_dex_device_historytool’sdex.device.viewrow). The row is written AFTER the read but always recordsresult=successregardless of the read’sdegradedoutcome (the MCP set-and-proceed convention for the AUDIT row — there is no response-header channel). This is distinct fromaudit_persisted, a SEPARATE signal for a SEPARATE failure mode:audit_persisted:falseappears in a successful tool response body only when the audit write itself failed to persist (the row never made it to storage); it is not present when the underlying read degraded instead (that case returns ana4_errorresponse —get_dex_device_score/get_dex_device_app_perfboth — with noaudit_persistedfield at all, since there is no success body to carry it in). Do not conflate “the audit row didn’t persist” (audit_persisted:false) with “the read degraded” (an error response, no such field) — corroborate a degraded MCP read against the store’s own read-degrade metric instead.
The Hardware CI-record verbs inventory.device.ci and inventory.device.software add one case to the post-read-by-outcome group: when the device roster read failed, the row carries result=failure even though the record itself is still served (the device identity is shown as unavailable); key on the identity=roster unreadable detail token, not on result alone. The not-served arms (nothing else shown, by the two predicates in the inventory.device.ci row: the REST record’s 503 and the dashboard fragment’s “Device roster unreadable” page) carry state=roster unreadable with the same result=failure, so a SIEM rule should key on both tokens. The MCP tool get_hardware_ci writes the same state=roster unreadable token (with (MCP) in the detail prefix) and the same result=failure on its inventory.device.ci row, and list_hardware_ci writes result=failure hardware roster read failed on inventory.devices; each also writes a matching result=failure generic mcp.<tool> row. get_hardware_ci additionally writes state=software or tags unreadable (also result=failure) when the device is otherwise absent and only the software or tag read failed, so a SIEM rule should key on all of these tokens.
This split is a known, accepted state across surfaces, not a defect in any one of them; converging on one posture is deferred to the WS-B2 audit-relocation step (ADR-0031). See server/core/src/rest_audit.hpp’s file banner for the authoritative, code-level statement of this contract.
The /auto deploy verbs share this pattern: deployment.create emits resumed (re-attached to an in-flight deployment for the same run instead of creating a duplicate) and no_devices (empty go-cohort after scope re-intersection); deployment.delete emits noop (unknown / not-owner-visible id). None is a denied. deployment.advance is high-volume (one row per poll tick); SIEM rules counting operator-initiated actions should treat a same-deployment_id burst as one session, not many.
gRPC audit-failure signal. REST surfaces set the Sec-Audit-Failed: true response header (and an audit_emitted=false body field) when an audit row fails to persist. The gRPC Register/Subscribe paths carry the equivalent as trailing metadata x-yuzu-audit-failed: true (#1063; the key is a shared constant in grpc_audit_signal.hpp so it cannot drift between the direct-agent and gateway-proxy services). In both transports the underlying operation still completes — enrollment proceeds, a peer-mismatch Subscribe is still rejected — only the SOC 2 evidence chain is degraded for that one request. SRE alerting should cover both signals; SIEM pipelines ingesting gRPC telemetry should watch for the x-yuzu-audit-failed trailer on Register/Subscribe RPCs. The gateway-upstream peer-authorization guard deliberately does not set this trailer on its refusals (see session.gateway_peer_denied above): a refused call is decided before its audit row is written and its counter is complete either way.
REST API
Who may read the audit trail. Reading it needs the AuditLog:Read
permission, on every surface: the REST routes below, the dashboard viewer, and
the MCP query_audit_log / audit_engine_no_admin tools and
yuzu://audit/recent resource.
- RBAC disabled (the shipped default): only an
admin-role session may read the trail (#5771). Any other authenticated session gets403, audited with adetailbeginningtopology floor: non-admin role denied AuditLog:Read(match on the prefix: an MCP-tier token’s row appends(mcp_tier=<tier>)). Engine principals and service-scoped tokens are refused as before. - RBAC enabled:
AuditLog:Readis an ordinary grant. The seededAdministrator,PlatformEngineer,Operator,ITServiceOwnerandViewerroles hold it; a custom role can be granted it. A holder sees every row.
See RBAC for the floor and for how to grant a non-admin the read.
GET /api/v1/audit
Query audit events with filtering and pagination. Maximum page size is 1000.
Query parameters:
| Parameter | Type | Description |
|---|---|---|
limit | integer | Number of events to return (default 100, max 1000) |
principal | string | Filter by principal username |
action | string | Filter by action (exact match) |
target_type | string | Filter by target type (exact match) |
target_id | string | Filter by target id (exact match); requires target_type |
since | integer | Inclusive lower bound, epoch seconds. Omit for no bound; 0 is accepted and matches the same rows as omitting it. Negative is 400 |
until | integer | Inclusive upper bound, epoch seconds. Omit for no bound; 0 or negative is 400 |
offset | integer | Rows to skip, 0..10000 (default 0) |
The feature behind the echo.* actions was named TAR before 2026-10. Audit rows written before the rename keep their tar.* action spelling and are never rewritten; an action filter or action_prefixes entry that starts with echo. matches both spellings, while a tar.* filter matches only the old rows.
A negative since, and an until of 0 or below, are rejected (400 on /api/audit and GET /api/v1/audit, -32602 on MCP query_audit_log; to=0 is 400 on GET /api/v1/audit/auth-sample): the bounds are presence-tracked, and a literal -3600 or until=0 used to mean no bound and returned the whole log. Omit the parameter for no bound; since=0 (from=0 on the auth sample) is accepted and matches the same rows as omitting it. A strict-parse 400 on these audit and analytics reads (GET /api/audit, GET /api/v1/audit, GET /api/v1/audit/auth-sample, GET /api/analytics/recent) writes no audit row, and neither does one on the notification and patch lists, the legacy policy lists, the pre-flight run form, the OTA rollout change, the DEX blast-radius thresholds, the API-token form, or the DEX signal, DEX perf and network device lists, because each is answered before any audit write. These routes audit their rejection instead, with a result=denied row for a malformed or out-of-range number (see their rows): POST /api/settings/enrollment-tokens, POST /api/settings/enrollment-tokens/batch, POST /api/v1/offload-targets and POST /api/v1/guaranteed-state/rules (an out-of-range version). GET /api/v1/guaranteed-state/events parses limit and offset before it writes its dex.device.view row, so its 400 leaves no access row.
Example --- fetch the 20 most recent events:
curl -s -b cookies.txt \
'http://localhost:8080/api/v1/audit?limit=20' | jq .
Response:
{
"data": [
{
"id": 4101,
"timestamp": 1710849600,
"principal": "admin",
"action": "tag.set",
"result": "success",
"target_type": "Tag",
"target_id": "agent-001:environment",
"detail": "Production"
}
],
"pagination": {
"total": 20,
"start": 0,
"page_size": 100
},
"meta": {
"api_version": "v1"
}
}
total is the number of rows returned in this response, not the number of
rows that matched. start echoes offset and page_size echoes limit; a
page shorter than limit is the last page.
The MCP query_audit_log tool also returns has_more, true when more matching rows exist
past the page. Rows are ordered newest first and
rows with an identical timestamp break by id (newest first). On an unchanging
result set offset paging neither repeats nor skips rows; the log is live, so a
new event or a retention delete between page fetches shifts later pages. Pin the
window with a fixed until in the past and use id to de-duplicate. offset is
capped at 10000 and never clamped (a larger value is a 400). To read deeper,
set until to the oldest timestamp already seen (inclusive), restart at
offset=0, and de-duplicate on id.
Example --- filter by principal and action:
curl -s -b cookies.txt \
'http://localhost:8080/api/v1/audit?principal=admin&action=auth.login&limit=50' | jq .
Example --- find all failed login attempts:
curl -s -b cookies.txt \
'http://localhost:8080/api/v1/audit?action=auth.login_failed' | jq .
GET /api/audit (legacy)
The legacy endpoint caps limit at 1000 and rejects offset above 10000, the
same ceilings as /api/v1/audit. It answers 400 for a negative since, an until of 0 or below, an
inverted window, and a string filter holding a NUL or invalid UTF-8 (the v1 route does the
same for the string filters). New integrations should prefer /api/v1/audit, which also
rejects a target_id given without target_type.
Query parameters:
| Parameter | Type | Description |
|---|---|---|
limit | integer | Number of events to return (default 100, max 1000) |
offset | integer | Offset for pagination, 0..10000 (default 0; larger is a 400) |
principal | string | Filter by principal username |
action | string | Filter by action (exact match) |
target_type | string | Filter by target type |
target_id | string | Filter by target identifier |
since | integer | Only events after this Unix timestamp |
until | integer | Only events before this Unix timestamp |
The legacy response envelope differs from v1:
{
"events": [ ... ],
"count": 20,
"total": 150
}
The legacy response includes additional fields per event not present in the v1
response: principal_role, source_ip (id is on both).
curl -s -b cookies.txt \
'http://localhost:8080/api/audit?limit=1000&principal=admin' | jq .
Dashboard viewer
The audit trail is readable from the dashboard at Access > Audit and reviews
(/access/audit). The page is open to every signed-in user, but each panel is gated by its own
permission and a panel your account cannot read says so instead of rendering empty.
Audit log. Needs AuditLog:Read (admin-only while RBAC is disabled). It reads the same
store, with the same gate, as GET /api/v1/audit and offers a subset of its filters (below), and shows only the
fields that route returns: time, principal, action, target, result and detail, plus the
event id in the row detail. The source address and the principal’s role, which the legacy
GET /api/audit route also returns, are not shown. Filters:
- a time range of the last hour, 24 hours or 7 days (the page sets
since;untilis open); - Principal and Action, both exact matches, not searches.
Rows are newest first, 50 to a page, and the page does not show a filtered total. Paging
stops at row 10,000 (the same offset ceiling as the REST routes); the page says so and asks
you to narrow the time range, it never silently shows a different page than the one asked
for. The log is live, so an event written while you page can shift later pages. If the audit
store does not answer, the panel is a red box, never an empty table: an unreadable log is not
the same thing as no activity. Reading the log writes no audit row, exactly like the REST
route.
Authentication sample. A random sample of sign-in, MFA, session and engine-principal
events for evidence, drawn only when you press Draw sample (never on load). It is
GET /api/v1/audit/auth-sample with the window left open; the sample size is 1 to 1000. The
panel states how many candidates the sample was drawn from and whether the pool was capped to
the newest events. Each draw is itself recorded as audit.auth_sample.exported; if that row
cannot be written the panel says so.
Access reviews. The same page lists review campaigns, shows a campaign’s frozen grants
(undecided first) with its summary, and shows the current grant population on request. It needs
AccessReview:Read (the Administrator and Reviewer roles; admin-only while RBAC is disabled), a
different permission from the audit log: holding AuditLog:Read does not grant it. A caller who
also holds AccessReview:Attest can open a review, attest or flag a grant (with an optional
justification) and close a campaign; each is a call to the existing /api/v1/access-reviews
routes. Flag for revocation records evidence only; it never revokes the grant. Closing is
one-way and may leave grants undecided. /access/audit?campaign=<id> opens a campaign directly.
Viewing writes the same rows as the REST reads: access_review.list for the list,
access_review.get for a campaign and access_review.exported (format=view) for the current
grants, so opening a campaign leaves a row in the trail. If the access review store does not
answer, the panel is a red box, never “no reviews”. The campaign list loads on every page view,
so a refused browser session writes no denied-access row for it; opening a campaign (including
by the ?campaign=<id> link) and reading the current grants are deliberate requests, and a
refused caller writes the ordinary permission-denied row for each. A refused API or MCP token
writes one on every fragment, and an engine-class session is refused on all three with an
access_review.* row, result=denied, detail=engine_session_denied. Settings keeps an older
Access Reviews panel; viewing it writes no access_review.* row, so use this page when the
reading itself must be on the record.
Not available yet on this page: a campaign due date (campaigns have no due-date field) and a
button that exports a campaign’s decisions (the Export decisions button is disabled). A
campaign’s complete decisions are in the JSON from GET /api/v1/access-reviews/{id}; the
current grants download as CSV from the Current grants panel.
Retention. Shows the configured retention period (--audit-retention-days, 365 days by
default) and that cleanup runs hourly. It needs ServerConfig:Read, which is
Administrator-only, so it is fetched on request rather than on every page view. It is read only.
Retention and cleanup
Audit events are retained for 365 days by default. A background thread runs
every hour and deletes events whose ttl_expires_at has passed. That TTL is
stamped once, when the row is written, as insert time + retention window — it
is not derived from the event’s own timestamp, and nothing ever rewrites it.
Deletion is permanent --- there is no soft-delete or archive step.
To preserve audit data beyond the retention window, export events periodically using the REST API or forward them to an external system (see Planned Features below).
The retention clock guard
Which alert sent you here? Find it in the table below — they report different
conditions and the triage differs. The current rule set is
docs/prometheus/yuzu-alerts.yml.
| Alert | What it means | Go to |
|---|---|---|
YuzuServerRestartLoop | The server has restarted more than 3 times in the last 3 hours --- a process-health signal, not itself an audit-store fault, but a common CAUSE of the retention alerts below. | ops-runbooks/audit-store-clock-guard.md. |
YuzuAuditRetentionClockAnomaly | A pass declined. Nothing was deleted. | Read on. |
YuzuAuditRetentionFailing | The pass is erroring, not declining. | yuzu_server_audit_cleanup_failed_total in the metric table below. |
YuzuAuditRetentionNotRunning | The reaper is not running on a database that has run before --- retention is unenforced and the audit store grows without bound. Fires at every restart cadence and stays firing while the reaper is dead (the #2854 rung D redesign closed the old grace’s blind band; coverage is machine-checked against prometheus/blind-band-measurement.md’s committed manifest on every PR). | Triage is in ops-runbooks/audit-store-clock-guard.md --- start there: it opens with the routine, self-clearing causes (a Prometheus restart false-pages for ~45 minutes) before the real diagnosis. Then yuzu_server_audit_retention_passes_total in the metric table below. |
YuzuAuditRetentionNeverRan | NO retention pass has EVER run against this database --- not “not recently”: the seeded stamp still reads 0 after three hours. Almost always a store that failed to open at boot, or a fresh install crash-looping before its first pass. | ops-runbooks/audit-store-clock-guard.md. |
YuzuAuditRetentionMetricMissing | The liveness metric is not being scraped at all, so YuzuAuditRetentionNotRunning cannot fire for anyone. Check up for the Yuzu target first — if it is down, or the target has left service discovery, this is an outage rather than just a monitoring gap, and on a single-server Prometheus this may be the only rule firing. | The three-state triage is in ops-runbooks/audit-store-clock-guard.md — start there; then yuzu_server_audit_retention_passes_total in the metric table below. |
YuzuAuditRetentionStateNotPersisting | The durable clock reading cannot be written, so detection will not survive a restart. | yuzu_server_audit_retention_persist_failed_total in the metric table below. |
YuzuAuditRetentionCapBinding | Expiry is outrunning the drain. | Capacity. |
YuzuAuditRetentionAnchorNotSurviving | The durable clock reading keeps being lost, so the guard re-bootstraps instead of comparing. Nothing was deleted by those passes. | ops-runbooks/audit-store-clock-guard.md. |
Retention is driven by the server’s wall clock. A cleanup pass that looks like it
would destroy the audit trail declines instead of deleting: it logs a warning and
adds one to yuzu_server_audit_clock_anomaly_skips_total. A decline deletes
nothing. It is the protection working, not evidence lost. The rest of this
section is about declines.
Retention sweeps fleet-wide from a single process. On the PostgreSQL
substrate every replica runs the cleanup thread, but each pass first tries to
take an advisory lease (pg_try_advisory_xact_lock('audit_store:reap')); only
the lease winner sweeps, so exactly one process prunes per tick across the whole
fleet. The 25,000-rows-per-pass cap therefore stays calibrated as a single
drain rate — it can never become “N replicas × 25,000.” The clock guard’s dedup
state now lives durably in the audit_retention_meta table (the previous pass’s
serialized fact-set, alongside the last clock reading), so a condition reported
by one replica is not re-reported by another, and the state survives a restart
or a failover to a different replica. (Previously, on the single SQLite server,
this dedup state was an in-process member — correct for one writer, but it does
not port to a multi-replica store.)
Dedup-semantics change (multi-replica correctness). Because the dedup state
is now shared across replicas, an identical-magnitude clock step that repeats
with the same fact-set is reported once and then stands down, rather than
re-incrementing yuzu_server_audit_clock_anomaly_skips_total on every pass as
the single-process guard did. A genuinely different anomaly (for example a
dead-CMOS boot followed by an NTP correction — a distinct fact-set) still
reports; the catastrophic whole-window-wipe protection is fully preserved. Only
a second identical clock step re-emitting the counter is lost. If you
previously alerted on the cadence of that counter (a fresh increment every
pass), alert on a sustained non-zero increase over several consecutive passes
instead — see “Is retention actually stalled?” below.
Start with the log line. Every decline logs an AuditStore: warning naming
which condition fired, and for an elapsed-time decline it prints the actual gap
against the threshold. That answers most of the checklist below without guesswork,
so read it before working down the list.
Was this expected?
- Was
--audit-retention-days(orYUZU_AUDIT_RETENTION_DAYS) reduced and the server restarted recently? Expected. Narrowing the enforced window narrows this guard’s survivor horizon, so a pass declines by design. It is a one-off; the backlog then drains at the capped rate. A runtimePUT /api/config/audit_retention_daysdoes not do this: it only stores the value, and the store keeps the window it was built with. - Was the server down, suspended, or snapshot-paused for more than 7 days? Expected. Elapsed time cannot distinguish a long outage from a forward clock jump, and the guard deliberately treats both alike. The log line gives you the measured gap, so you do not have to reconstruct it.
- Is the whole table already past its window? Expected, once. A pass that would expire every datable row declines before draining --- a restored backup older than the retention window is the common way to meet this.
- Does the log line say the stored reading was unusable? The pass re-anchors on the current reading and the next one proceeds. The log line names both possible causes; a reading ahead of now is what a backward clock movement looks like, so do not rule out the clock here --- continue to rung 5.
- Otherwise, check host time synchronisation ---
chronyc trackingorw32tm /query /status--- and look for a restored VM snapshot, a dead CMOS battery, or a hand-set date. Fix the clock first: the drain resumes on its own once readings are stable. - If the clock reads fine but declines keep coming, look for two things
setting it. A single correction declines a pass or two and then drains. Only
a clock that keeps moving holds the guard declining --- a hypervisor sync
fighting the guest’s NTP, two NTP sources disagreeing, a failing RTC being
corrected over and over. Between corrections such a host looks healthy, so
chronyc trackingalone will not show it; check for more than one time source and look at the correction history.
Is retention actually stalled? Read the counters, not this alert’s firing
state. YuzuAuditRetentionClockAnomaly evaluates increase(...[1h]) against a
pass period slightly longer than an hour, so it can resolve and re-fire on its own
whether or not anything is wrong.
Retention is stalled when, sustained over several consecutive passes:
yuzu_server_audit_clock_anomaly_skips_totalrises on every pass, andyuzu_server_audit_retention_passes_totalrises by the same amount over the same window (so every pass is declining, not merely some), andyuzu_server_audit_rows_deleted_totalstays flat.
One or two consecutive declines is normal and is not this state. Do NOT look to
yuzu_server_audit_retention_cap_reached_total to confirm a backlog: it only
moves on a pass that actually deleted, so it is flat by construction in exactly
the state you are testing for.
Whether that stall matters is a separate question, and there is no metric for
it --- confirm a backlog exists by querying for events older than the retention
window (see Protecting evidence below), or by watching the audit_store schema
grow in PostgreSQL. A store with nothing to delete shows the same two counters.
If skips and yuzu_server_audit_rows_deleted_total are both flat, retention
is not declining --- it is failing or not running at all; check
yuzu_server_audit_cleanup_failed_total and
yuzu_server_audit_retention_passes_total.
If skips rises by less than passes over the same window, some passes are
declining and some are not. Read yuzu_server_audit_rows_deleted_total to see
which: rising means the rest are draining normally, which is the ordinary
post-reduction picture in rung 1 and is healthy; flat means the rest are failing,
so check yuzu_server_audit_cleanup_failed_total. Compare increase() over a
window covering several passes --- never the raw values, which are
since-process-start and make skips smaller than passes on any healthy store.
Protecting evidence right now.
-
To export a time range, use
/api/audit, not/api/v1/audit. The v1 endpoint takes onlylimit(hard-capped at 1000),principalandaction— it cannot express a range and cannot paginate, so it will silently hand you the first 1000 rows of a much larger set. (The periodic-export recipes further down this page use v1 deliberately --- they forward a rolling tail on a schedule, which is a different job from pulling a specific window once.) The legacy endpoint takessince,until,limitandoffset, and is the only way to page a range out before the next cleanup interval:# Page out everything in a window, 1000 rows at a time. # FROM/TO are unix seconds. Stops on the first empty page. OFF=0 while :; do if ! curl -sS -f -H "Authorization: Bearer $TOKEN" \ "$SERVER/api/audit?since=$FROM&until=$TO&limit=1000&offset=$OFF" \ -o "audit-$OFF.json"; then echo "EXPORT INCOMPLETE at offset $OFF" >&2 # token expiry, 503, reset exit 1 fi grep -q '"count":0' "audit-$OFF.json" && { rm -f "audit-$OFF.json"; break; } OFF=$((OFF+1000)) doneFail loudly rather than quietly: a mid-export 401 or 503 otherwise leaves a directory of files that looks like a finished run. The response’s
totalfield cannot confirm completeness --- it isCOUNT(*)over the whole table, not over yoursince/untilwindow, so it will not match your export and is not a check. Count the pages you fetched instead. -
If a server ran with a backward-skewed clock, export before the next pass. Rows written while the clock was behind carry an already-past TTL, so once the clock is corrected they are the oldest rows in the table and the first the paced delete takes --- at
infolevel, indistinguishable from routine ageing-out. That is the window an auditor asks about first, because it covers a host fault. -
Ship the
AuditStore:warnings to your durable log store. The alert rules fire onincrease()over a rolling window, so a one-time warning stops being visible in the counter once it ages out and the process restarts. The log is the evidence record; the metric is not.
Once starvation is confirmed. Fix the clock; that is the whole remediation. The drain resumes on its own within a pass or two of the readings becoming stable, paced at the cap, and nothing needs to be deleted by hand.
Do not delete rows yourself from the live store, however large it has grown. Over-retention is the SAFE direction here and it is deliberate: retention is a floor, not a ceiling, and the guard errs toward keeping evidence longer than configured because this is an evidence store. Rows older than the configured window are the guard working, not a fault. If disk is the immediate problem, treat it as a capacity incident (see below) rather than an audit one.
This is distinct from a legacy audit.db SQLite file, if one still exists
(see docs/user-manual/upgrading.md): its permissions are forced to
owner-only at boot (POSIX only, a no-op on Windows, #3593) and it is opened
read-only purely to count rows for a diagnostic warning, but its content is
never imported or otherwise read, and it is never renamed, moved, or written
to. It stays at its configured path and is your own operator-managed record
going forward. The prohibition above is about the live audit_store schema
in PostgreSQL — there is no live SQLite file to move, and nothing ever moves
this one either.
Safe for evidence, which is not the same as safe for privacy: audit rows
carry principal, source_ip and user_agent. If you are under a retention
CEILING obligation as well as a floor, over-retention is a compliance exposure in
the other direction, and stabilising the clock (which resumes the drain) is the
fix --- not hand-deletion.
Limits.
- Detection is best-effort; the cap is the half that always applies. It bounds any allowed wipe to 25,000 rows per pass unconditionally, whether or not a detector fired --- an instantaneous wipe becomes a paced one plus an operator signal.
- A pass that begins with NO stored reading declines, once (#2579). An ABSENT reading is the ordinary fresh-install case and says nothing about the clock; an unusable one is a separate, stronger signal and still reports as corruption. But an absent reading together with rows ALREADY EXPIRED leaves the pass nothing that could rule out a clock which was wrong before the guard ever ran, so it holds back rather than delete. It declines once, warns, and anchors the reading; the next pass has a comparison point and proceeds, paced by the cap as always. With nothing expired there is nothing to hold back and no decline happens, so a fresh install is unaffected. The same one-off decline recurs wherever the reading goes missing again: after a failed anchor persist plus a restart, or after restoring a pre-v3 snapshot. The rule itself is stated once, above; this bullet deliberately does not restate it.
- Its signal is separate on purpose. The decline raises
yuzu_server_audit_retention_bootstrap_declines_total, NOTyuzu_server_audit_clock_anomaly_skips_total. The latter drives an alert meaning “the clock moved in a way that would have wiped audit evidence”, and this decline claims nothing of the sort --- only that nothing can yet rule it out. Expect 0 or 1 per database. A value that keeps climbing means the anchor is not surviving, whichYuzuAuditRetentionAnchorNotSurvivingalerts on. Readyuzu_server_audit_retention_persist_failed_totalbeside it, but do not treat that counter as equivalent coverage: when the reading is destroyed OUT OF BAND (a restore from a pre-v3 backup, a rehydrated replica, a disk rollback) the write succeeds every pass and it stays flat at zero. - Before the fix, such a pass deleted up to the cap with no decline and no
clock-anomaly counter, and kept deleting rows stamped before the skew until that
cohort was exhausted --- only those lose time, since a row written under the skew
is stamped from the same wall clock the cutoff is read from and keeps its full
window, so a forward step of
Son a window ofWworks through the pre-skew rows and stops aboutW - Slater. There is no reliable retrospective test for whether a given database was affected, and the obvious checks do not discriminate: a surviving-row floor near the configured cutoff is what a healthy oldest-first reaper looks like;MIN(timestamp)is pinned indefinitely by any row stampedttl_expires_at = 0, which deletion never touches; and a cutoff computed on the affected host moves with the skew. Rows deleted then are not recoverable without a backup predating the pass. Upgrade guidance: upgrading.md § Retention clock guards. - Changing
--audit-retention-daysdoes not re-date existing rows.ttl_expires_atis stamped once at INSERT and never rewritten, so a reduction expires nothing retroactively and reclaims no disk. /healthzdoes not report retention health:stores.auditreflects only whether the database is open, so it reads healthy while retention is failing or not running (#2509). A store running without its index is a further blind spot with no metric at all (#2526). Rows written while the clock was AHEAD sit permanently beyond the datable horizon --- never deleted, never reported, and retained past the stated window (#2510).
Where the rule lives. The decision rule is classify() in
common/include/yuzu/audit_retention_rules.hpp together with the fact construction in
AuditStore::cleanup_once, pinned by an exhaustive truth table in
tests/unit/server/test_audit_retention_rules.cpp and store-level cases in
tests/unit/server/test_audit_store.cpp. This page deliberately does not restate
it, and no behavioural claim belongs in this section without a citing test.
Five correction rounds here produced only transcription errors between the code
and prose paraphrases of it; the paraphrases have been removed rather than
sharpened.
Eight metrics report on the retention guard. All but
yuzu_server_audit_rows_deleted_total ship with an alert rule in
docs/prometheus/yuzu-alerts.yml — since #2854 rung D that includes
yuzu_server_audit_retention_last_pass_unixtime, which is an operand of BOTH
liveness rules (YuzuAuditRetentionNotRunning’s grace and the whole of
YuzuAuditRetentionNeverRan), so a relabel rule dropping it changes what
pages (see the rules file’s “WHAT WOULD BREAK THE ASSUMPTION” block).
rows_deleted_total is read alongside the others (is the backlog moving?)
rather than alerted on directly. Do not collapse the first two:
| Metric | Meaning |
|---|---|
yuzu_server_audit_clock_anomaly_skips_total | A pass declined to delete; nothing was deleted, and each declined pass adds exactly 1. Triggers: it would have expired every datable row; the gap since the previous pass exceeded a fixed 7 days; or the stored reading was not usable — ahead of the clock, negative, present but not an integer, or unreadable. Reducing audit_retention_days can also cause a decline by design. For triage, see The retention clock guard above. |
yuzu_server_audit_cleanup_failed_total | A pass did not fully do its job: an unreadable probe, a failed delete, a refused implausible clock, a closed store, or an exception caught at the thread boundary. Every site INSIDE the pass’s transaction rolls the whole pass back, INCLUDING the post-delete backlog probe: a probe failure after the delete undoes the delete too. The thread-boundary catch is the one exception — it wraps the post-commit bookkeeping (counters, the summary log line) too, so an exception thrown there counts a pass that DID delete rows as failed. Read a rise as “retention is not fully healthy”, not as proof nothing was removed. (On the retired SQLite store the backlog probe was a warning after a committed delete, and this row said so; the substrate change inverted the in-transaction case but not the thread-boundary one.) |
yuzu_server_audit_retention_cap_reached_total | A pass hit the per-pass delete cap, so a backlog remains. Sustained growth means expiry is outrunning the drain and the audit store is growing without bound. |
yuzu_server_audit_rows_deleted_total | Rows deleted by retention. Read alongside the cap counter to tell a draining backlog from a stuck one. |
yuzu_server_audit_retention_bootstrap_declines_total | A pass declined because it had NO stored clock reading while rows were already expired (#2579); nothing was deleted. Counted apart from the clock-anomaly series on purpose - it claims only that nothing can yet rule out a wrong clock, not that the clock moved, so it must not fire an alert that says otherwise. Expect 0 or 1 per database; a climbing value means the anchor is not surviving, so read it with ..._retention_persist_failed_total. |
yuzu_server_audit_retention_persist_failed_total | The durable clock reading could not be written. Detection will not survive a restart while this is rising. |
yuzu_server_audit_retention_passes_total | Passes attempted, including declined and failed ones. The one signal that catches a reaper which is not running at all - in that state the other six counters here stay flat at 0, which looks exactly like a quiet, healthy store. Alert on it NOT increasing. |
yuzu_server_audit_retention_last_pass_unixtime | When the most recent pass with a USABLE clock reading ran; 0 if no pass has ever run on this database (seeded from the durable audit_retention_meta anchor at startup, #2854 - survives a restart, including the first PostgreSQL boot). A pass refused for an implausible now counts as a pass but does not stamp this gauge. A value of -9223372036854775808 (INT64_MIN) means the durable anchor could not be read or trusted as a plausible integer - a distinct anomaly, not a genuine timestamp; it self-corrects at the next pass whose own clock reading is plausible - even if that pass then declines or fails for an unrelated reason - consistent with the “refused for an implausible now” case above, which is the one pass that does NOT self-correct it. |
The first two both leave rows undeleted, so an audit table that never shrinks looks identical either way --- only the pair distinguishes “the guard is protecting the table” from “cleanup is broken”. The third covers the failure the cap itself introduces, which neither of the first two would show.
Capacity
Two different cadences, not one. A pass that does NOT hit the per-pass
25,000-row cap re-arms after the full cleanup_interval_min (default 60
minutes). A pass that DOES hit the cap AND leaves a genuine remainder
re-arms after kAuditBacklogRearmSec (5 seconds) instead — audit_next_wait_s
in audit_store.hpp — and keeps doing so every subsequent pass until a pass
clears the backlog. An earlier revision of this section quoted only the first
cadence as if it were the hard ceiling; it is not — it is the QUIET
threshold, the rate below which cap_reached_total never moves at all.
- Quiet-operation threshold: ~6.9 audit events/second (25,000 rows /
hourly pass, ~600,000 rows/day). Stay under this and the guard never
enters backlog mode —
yuzu_server_audit_retention_cap_reached_totalstays at 0. - The actual drain ceiling is far higher, because backlog mode
self-accelerates: once
cap_reached_totalstarts moving, passes run every 5 seconds instead of every hour, each still capped at 25,000 rows — up to ~5,000 rows/second if the DELETE itself stays fast relative to the 5-second floor. Measured this session (not a promise for every environment — DELETE cost scales with backlog size, hardware, and concurrent load): a 25,000-row capped delete against a 200,000-row backlog took 44 ms, and against a 5,000,000-row backlog took 570 ms — both comfortably inside the 5-second floor, so the achieved cadence there is genuinely bound by the 5-second sleep, not by DELETE execution time. Exceeding ~6.9 events/second is normal, expected, and self-corrects; the store only grows WITHOUT BOUND if the sustained write rate exceeds the backlog-recovery ceiling itself, which is roughly three orders of magnitude higher than the quiet-operation figure this section used to present as the ceiling. yuzu_server_audit_retention_cap_reached_totalrising is not itself a problem — it means backlog mode engaged, which is the system doing its job faster, not falling behind. Falling behind iscap_reached_totalrising and STAYING elevated for a sustained period withyuzu_server_audit_rows_deleted_totalnot keeping pace — seeYuzuAuditRetentionCapBinding(docs/prometheus/yuzu-alerts.yml), which alerts on sustained capping, not on a single capped pass.
The cap (25,000 rows/pass) and the re-arm floor (5 seconds) are both compile-time constants today, so raising either needs an engineering change rather than configuration.
The file-size ceiling is likely to bind before the drain-rate ceiling
does, though this is an estimate rather than a measurement: sustaining even
the QUIET threshold of 6.9 events/second for a 365-day retention window
means roughly 219M rows. At an assumed ~200 bytes/row that is on the order
of 44 GB plus a comparable index footprint in the audit_store.audit_events
table — but row size varies with principal, action, detail, target
and user_agent, so treat the byte figure as an order of magnitude, not a
threshold. Dispatch rows are per request; agent-reported rows such as registry.write
are per device per command. Rows are written for more than operator
requests: agent enrolment, fleet-topology pushes and
rejections, and background schedule execution all write rows too, so fleet
size does influence the rate. Measure your own
yuzu_server_audit_events_total rate rather than assuming an operator-only
workload.
The cap also bounds each pass’s transaction size. The old unguarded delete cleared its whole backlog in one transaction; capping the pass at 25,000 rows keeps each retention transaction bounded, so it commits and lets PostgreSQL recycle write-ahead log between passes rather than accumulating unbounded WAL for one giant delete. (On the legacy SQLite store this was measured at 152 MB of WAL for a 337k-row backlog — extrapolating to about 2 GB at 4.5M — versus about 51 MB for a capped 25,000-row pass; the bound is the same principle on the PostgreSQL substrate.)
Integration patterns
Export to CSV
Use the JSON-to-CSV export endpoint to convert audit query results into a downloadable CSV file:
# 1. Fetch audit events as JSON
curl -s -b cookies.txt \
'http://localhost:8080/api/v1/audit?limit=1000' | jq '.data' > audit.json
# 2. Convert to CSV
curl -s -b cookies.txt \
-H "Origin: http://localhost:8080" \
-X POST \
-H 'Content-Type: application/json' \
-d @audit.json \
'http://localhost:8080/api/export/json-to-csv' -o audit.csv
Periodic export with cron
#!/bin/bash
# /etc/cron.daily/yuzu-audit-export
DATE=$(date +%Y-%m-%d)
curl -s -b /opt/yuzu/cookies.txt \
"http://localhost:8080/api/v1/audit?limit=1000" \
| jq '.data' > "/var/log/yuzu/audit-${DATE}.json"
Splunk HTTP Event Collector
Poll the audit API and forward events to Splunk HEC:
# Fetch recent audit events and forward each to Splunk
curl -s -b cookies.txt \
'http://localhost:8080/api/v1/audit?limit=100' \
| jq -c '.data[]' \
| while read -r event; do
curl -s -X POST \
-H "Authorization: Splunk YOUR-HEC-TOKEN" \
-d "{\"event\": ${event}}" \
https://splunk.example.com:8088/services/collector/event
done
Planned features
| Feature | Roadmap | Description |
|---|---|---|
| Event subscriptions / webhooks | Phase 7, Issue 7.12 | Push audit events to external HTTP endpoints in real time |
| System notifications | Phase 7, Issue 7.4 | Alerts for health, capacity, and compliance events |