Skip to main content

Audit Events (Developer Guide)

This guide covers the write side of the audit log: when a change must emit an audit event, and how to emit it correctly. For the read side - scopes, read surfaces, permissions and the AuditEvent field list - see Audit Log; for the operator view see Admin · Audit Log.

For audit data retention, TimescaleDB design, and deletion paths, see the data design standard.

The rule​

Sign-in, privacy and support events​

The current catalog in apps/api/src/db/audit.rs contains these event types. Their protocol twins call the same security service and emit with the same scope. Event metadata contains identifiers and outcomes where useful, never passwords, codes, Turnstile tokens or sign-in links.

ScopeEvent types
User: email and passworduser:email_login_added, user:email_verified, user:email_login_requested, user:email_login_changed, user:email_login_cancelled, user:password_changed, user:password_reset_requested, user:password_reset_completed, user:login_method_removed
User: 2FA and confirmationuser:mfa_enabled, user:mfa_disabled, user:mfa_factor_added, user:mfa_factor_removed, user:mfa_recovery_codes_regenerated, user:mfa_recovery_code_used, user:mfa_challenge_failed, user:mfa_trusted_device_revoked, user:step_up_verified
User: profile identityuser:profile_identity_changed (metadata changed = list of source / display_name / avatar, plus source = own / connection for a source switch; never a name or picture URL)
User: privacyuser:data_export_requested, user:data_export_cancelled, user:data_export_downloaded, user:deletion_scheduled, user:deletion_cancelled
Accountaccount:member_left, account:subscription_cancel_requested, account:dissolution_scheduled (metadata grace_days, scheduled_for), account:dissolution_cancelled (metadata scheduled_for, the cancelled date), account:identity_changed (metadata changed = list of source / name / avatar, source_kind, source_platform), account:dissolved (metadata via = immediate / scheduled, deleted_history_row_count, deleted_object_count, pending_object_count)
Systemsystem:user_mfa_reset, system:support_code_viewed, system:support_code_checked, and system:user_deleted for the completed self-service deletion

user:deletion_scheduled carries grace_days (0, 7 or 30) and scheduled_for. The three dissolution events are account-scoped with the owner as actor and are emitted identically by GraphQL and REST. account:dissolved with via: "immediate" comes from dissolveAccount / DELETE /v1/accounts/{id} or a graceDays: 0 schedule; via: "scheduled" comes from the account security worker when the grace period ends, with the owner who scheduled it as actor. user:login and user:login_failed include the sign-in method; a login that needs 2FA is recorded only after the challenge succeeds. Support-code reveal and check include the target user and result, without the code. system:user_mfa_reset carries the required operator reason. The current code's event catalog, emitter calls and REST/GraphQL guards should be checked together before modifying this list.

Security- and lifecycle-relevant actions emit an audit event in the same change that ships them. An audit trail that the documentation promises but the code never writes is a compliance defect, not a missing nice-to-have.

Treat "does this need an audit event?" as a standing review question, alongside RBAC gating and GraphQL ↔ REST parity.

When to emit​

Emit when an action is security-relevant, tenant-visible, or irreversible:

CategoryExamples
Authentication & identityLogin, failed login, MFA enable/disable, OAuth grant
CredentialsAPI-key create/revoke, channel connection added/removed
Permission structureRole create/update/delete, member role assignment
Destructive / GDPRData erasure, retention purges, account deletion
Platform operationsAdmin actions, feature-flag flips, plan changes

Do not emit for:

  • Reads and list queries - the audit log records changes, not access.
  • High-volume telemetry (chat messages, stream events, metrics). The audit log is a security record with a compression policy, not an analytics stream.
  • Purely internal bookkeeping with no security or tenant-visible consequence.

How to emit​

The canonical API lives in apps/api/src/db/audit.rs:

crate::db::audit::emit(&tsdb.0, crate::db::audit::AuditEventFields {
account_id: Some(account_id),
user_id: actor_user_id,
event_type: crate::db::audit::event_types::ACCOUNT_ROLE_CREATED,
scope: crate::db::audit::AuditScope::Account,
ip_address: origin.ip_address.as_deref(),
user_agent: origin.user_agent.as_deref(),
country: origin.country.as_deref(),
city: origin.city.as_deref(),
metadata: serde_json::json!({ "role_id": role_id, "slug": slug }),
})
.await;

Use db::audit::emit, never insert_audit_event, in new code. emit is best-effort by design: it logs a tracing::warn! and swallows the error, because a failed audit insert must never fail - or roll back - the user-facing mutation that triggered it.

Catalog first, call site second​

Every emit references a constant from db::audit::event_types. Add the constant to the catalog before the call site; never inline a string literal. The catalog is the single source of truth that the docs and the admin event-type filter are checked against.

Naming is domain:action (account:role_created, user:login, system:feature_flag_updated), where domain matches the row's scope. The two legacy GDPR types (youtube_member_erasure, chat_pii_erasure) stay un-namespaced on purpose - the admin filter pins those exact strings, so renaming them is a coordinated change, not a drive-by.

Scope is explicit, never inferred​

scope is set by the writer on every row and is NOT NULL with no default, so a writer that forgets fails closed. Never infer scope at read time from which of account_id / user_id happens to be populated - that inference leaks authorization boundaries between tenants.

ScopeUse for
AuditScope::UserPersonal security events, readable self-only
AuditScope::AccountTenant actions, gated on account audit-log:read
AuditScope::SystemPlatform-operator / cross-account actions

One event, one scope. An account-context action with an actor lives only in the account log (the actor is shown via user_id); it is never additionally mirrored into that user's personal log.

Actor, target, metadata​

user_id is always the actor, never the target. The target and any context go in metadata (role_id, target_user_id, platform, …). Never put secrets, tokens, credentials, or raw PII in metadata - audit rows are readable by every account member holding audit-log:read.

Some of those id keys (target_user_id, member_id, from_account_id, to_account_id, target_account_id, role_id) are resolved to display names at read time - see Read-time UUID → name resolution.

Origin enrichment​

Resolve the request origin instead of hand-building it:

  • GraphQL - crate::graphql::audit_origin_from_ctx(ctx)
  • REST - AuditOrigin::resolve(state.geoip.as_ref(), crate::routes::auth::extract_client_ip(req), crate::routes::auth::extract_user_agent(req))

The resolved AuditOrigin carries the client IP, the request user agent, and the GeoIP country / city. Read all four off the origin at the call site (origin.user_agent.as_deref(), not None) so every request-originated audit row records the user agent, not just user:login* - the admin and per-user Account-Activity views derive device / browser / OS from that string at render time (ZAF-1412, closing the ZAF-1023 backfill deferral). GeoIP is fail-open: country / city stay None when GeoIP is disabled or the IP is private, and user_agent stays None when the header is absent. Never block or fail a mutation on origin resolution.

Background emitters have no request, so their origin - and therefore user_agent - is None: the token-refresh worker (services/reconnect.rs) and the Stripe webhook plan-change emitter (routes/billing.rs) build the fields with every origin column None. That is correct, not a gap - there is no client to attribute. The GDPR-erasure services (services/chat_privacy.rs, services/youtube_memberships.rs) are the opposite case: they receive a request-derived origin from their admin REST/GraphQL callers, so they do record the user agent.

Shopify embedded actions record the user agent and no IP. The three events the embedded app writes (POST /v1/shopify/embedded/link/complete, PUT /v1/shopify/embedded/settings, POST /v1/shopify/embedded/disconnect) build their origin with routes::shopify_embedded::audit_origin, which keeps ip_address, country and city None and records only the request's own User-Agent (the merchant browser's, forwarded by apps/shopify). The actor is identified by shopify_user_id + shop_host in the metadata instead. See How the client IP is resolved for why.

How the client IP is resolved​

Both helpers end up in lo_api::client_ip::resolve, which reads the headers in one of two orders depending on who is calling:

CallerPrecedence
First-party server hop (a valid X-Internal-Token)X-Lumio-Client-IP → X-Forwarded-For (first hop) → X-Real-IP → CF-Connecting-IP → peer address
Everything else (a direct browser/client request)CF-Connecting-IP → X-Forwarded-For (first hop) → X-Real-IP → peer address

Almost no audit event is emitted for a request the end user made directly against the API. The browser talks to a Next.js route handler or server component, which then calls the API server-to-server - so from the API's point of view the request peer is the web/admin/id service. Those services resolve the user's origin (cf-connecting-ip → x-real-ip → first hop of x-forwarded-for, in apps/{web,admin,id}/src/lib/client-ip.ts) and forward it on that internal hop.

The Shopify embedded scope (/v1/shopify/embedded/*) is the exception: it records no IP and no geo, only the user agent. The scope is mounted outside the /v1 middleware stack, so TrustedInternalCaller is never set on it, and apps/shopify holds no X-Internal-Token. The only IP the API could read there is the apps/shopify server's egress address or a header the client chose (CF-Connecting-IP, X-Forwarded-For, X-Lumio-Client-IP). Giving apps/shopify the shared internal token would also unlock IP/UA trust and the choice of the proxied:{ip} rate-limit bucket on every /v1 route, so it does not get one (AppSec ZAF-2996 S2). apps/shopify forwards User-Agent only. The actor of those rows is identified by shopify_user_id + shop_host in the metadata.

The forwarded value has to outrank CF-Connecting-IP on that hop. When the internal call itself travels through Cloudflare - a public LUMIO_API_URL - the edge stamps CF-Connecting-IP with the web server's own egress IPv4/IPv6, and preferring it wrote the web server's address into every audit_events.ip_address and every personal Account-Activity row instead of the user's (ZAF-1208).

Why a private header and not just X-Forwarded-For​

X-Forwarded-For is the header every reverse proxy in the chain feels entitled to rewrite, so it cannot be relied on to carry a value end to end. The staging Caddy rewrites it explicitly:

reverse_proxy lumio-api:3000 {
header_up X-Forwarded-For \{http.request.header.CF-Connecting-IP\}
}

header_up <field> <value> replaces the field - +field would append - so whatever apps/web forwarded is discarded and overwritten with CF-Connecting-IP, which on the web→API hop is the web container's own egress address. Forwarding X-Forwarded-For alone is therefore not enough: the value never survives to the API.

So the first-party services send the origin under a private name as well, X-Lumio-Client-IP (lo_api::client_ip::CLIENT_IP_HEADER, mirrored as CLIENT_IP_HEADER in each app's lib/client-ip.ts). No proxy or CDN rewrites a non-standard header, so it arrives intact - the fix holds with no infrastructure change, and keeps holding if the proxy in front of the API is swapped. X-Forwarded-For is still sent next to it, for rate-limit bucketing, request logging, and hops that reach the API with no rewriting proxy in between (local dev, direct container-to-container networking). Use clientOriginHeaders(ip, ua) rather than setting either header by hand — it carries both the IP and the User-Agent (below) as one first-party origin.

For a direct request the order is reversed on purpose: CF-Connecting-IP is set by the edge and a client cannot forge it, while X-Forwarded-For is client-settable. X-Lumio-Client-IP is ignored entirely outside the trusted-internal branch, so an outside client cannot use it to forge its own audit origin. That branch does not widen the trust boundary either — TrustedInternalCaller is only inserted on a constant-time match against the configured X-Internal-Token secret. Rate-limit bucketing does not use this helper, because its X-Forwarded-For / X-Real-IP fallbacks are client-settable on a direct request. It uses lo_api::client_ip::throttle_key, which reads only X-Lumio-Client-IP on a trusted-internal hop and only CF-Connecting-IP, else the peer address, otherwise. A forged header therefore never mints rate-limit buckets (see Authentication → Rate Limits).

How the client User-Agent is resolved​

The User-Agent has the same first-party-hop blind spot as the IP, and the same fix (ZAF-1414, the twin of ZAF-1208). Node's global fetch sends User-Agent: node, so a request that reaches the API through a Next.js service carries node as its own User-Agent — and without a forwarding channel sessions.user_agent and every audit user_agent would record the web container, not the browser. crate::routes::auth::extract_user_agent (used by the REST session handlers and the audit emitters) and the GraphQL UserAgent context datum both resolve through lo_api::client_ua::resolve, which reads the headers in one of two orders depending on who is calling:

CallerPrecedence
First-party server hop (a valid X-Internal-Token)X-Lumio-Client-UA → request User-Agent
Everything else (a direct browser/client request)request User-Agent

The first-party services forward the browser UA under the private X-Lumio-Client-UA (lo_api::client_ua::CLIENT_UA_HEADER, mirrored as CLIENT_UA_HEADER in each app's lib/client-ip.ts) for the same reason the IP uses a private name: the standard User-Agent header on that hop is Node's node, so it cannot carry the browser UA. Like X-Lumio-Client-IP, it is honoured only inside the trusted-internal branch — TrustedInternalCaller, gated on the same constant-time X-Internal-Token match — so an outside client cannot use it to forge the UA recorded against an audit event or a session. A blank value on either source resolves to None (the user_agent column stays NULL).

INTERNAL_TOKEN gates both the IP and the UA

The trusted-internal branch is entered only when the caller presents a valid X-Internal-Token. If INTERNAL_TOKEN is not set in an environment, the first-party hop is not recognised as trusted: X-Lumio-Client-IP / X-Lumio-Client-UA are both ignored, and the IP and the UA fall back to the edge/server values (CF-Connecting-IP and the request's own node). Provision INTERNAL_TOKEN on the API and the matching secret on web/admin/id, or every web-proxied audit row and session records the web container's origin.

Where the rows live​

Audit events are written to the TimescaleDB pool (state.tsdb / lo_graphql::health::TsdbPool), not the primary Postgres pool. The audit_events hypertable and its migrations live in apps/api/tsdb_migrations/, not apps/api/migrations/.

Retention is two native TimescaleDB scheduled jobs, both daily and always on:

  • anonymise_old_audit_events (tsdb_migrations/20260823000002_anonymise_audit_events_retention.up.sql) nulls ip_address / user_agent / country / city on rows older than 12 months.
  • purge_old_audit_events (tsdb_migrations/20261011130000_purge_old_audit_events.up.sql) deletes rows older than 24 months. The erasure-proof types are kept until 36 months; whole chunks past 36 months are dropped with drop_chunks.

So metadata lives for 24 months (36 for an erasure proof) and is never anonymised before that - do not put anything in it that must disappear sooner, and never secrets or raw PII.

The erasure-proof list has one source of truth: db::audit::event_types::ERASURE_PROOF. The purge job reads the same list from its config -> 'erasure_proof_types', and tests/tsdb_audit_purge.rs asserts the two are equal. A new erasure-proof type therefore needs the constant and a TSDB migration that runs alter_job(<job_id>, config => '{"erasure_proof_types": [...]}'). The job fails closed: if that config key is missing or empty, it raises before deleting anything, so an alter_job mistake cannot delete erasure proofs at 24 months. See Audit Log → Retention.

Events that originate outside the API (apps/id ingest)​

Some user-scope security events happen in apps/id (NextAuth, TypeScript), not in the Rust API. Today apps/id emits web login and OAuth grant rows; the ingest endpoint also whitelists 2FA lifecycle rows, but no current product flow calls it for MFA. The Rust API is the single audit writer, so apps/id does not write audit_events itself - it posts each event server-to-server to POST /v1/internal/audit-ingest after the auth event, and the endpoint writes the row via insert_audit_event.

The user-scope login events apps/id cannot ingest are emitted in-process from the Rust /auth handlers instead, because they need a subject apps/id does not have at the NextAuth callback:

  • user:login for the native (PKCE) flow - POST /v1/auth/authorize returns only an auth code; the subject/JWT is minted later when the native app redeems the code against POST /v1/auth/token/exchange, directly against the API. That redemption handler is the single authoritative user:login emit point for the native flow (the web flow stays on apps/id ingest); metadata.method = "pkce".
  • user:login for the GraphQL exchangeToken mutation (ZAF-1023) — graphql/auth.rs exchange_token emits user:login inline on success with metadata.method = "graphql_exchange". This is the GraphQL path's only login-success writer and must not be removed: exchangeToken has no first-party caller (apps/id, web, and admin all use REST /auth/token), so the apps/id audit-ingest that covers the REST twin's success (see below) never observes it.
  • user:login_failed - a rejected OAuth credential has no resolved user at the NextAuth callback (bad credentials are rejected upstream by the provider). The Rust REST exchange_token / authorize handlers and the GraphQL exchangeToken mutation (ZAF-1023, at parity) emit it on a failed provider-token validation, and token_exchange emits it on a PKCE mismatch. The subject is resolved from the existing login connection for the (unverified) provider identity when one exists - so a failed attempt against a known user lands in that user's personal-security log - otherwise the row is unattributed (user_id = NULL), never leaked into an arbitrary user's log. These emit via the in-process best-effort emit, not the ingest endpoint. Both paths capture the request User-Agent (the GraphQL context grows a UserAgent datum for this).

This is the one sanctioned use of the internal ingest endpoint, and the reasons it deviates from the in-process emit pattern:

  • Auth is a System key with the narrow audit:ingest grant. Every non-System principal is rejected 403 (anonymous 401) so no public/user route can forge audit rows; a System key without the grant is 403 too. Provision apps/id's key with audit:ingest (or audit:* - a bare *:* does not grant it).
  • The endpoint forces the shape. It whitelists the five user-scope catalog types above and forces scope = user, user_id = <subject>, account_id = NULL; a caller cannot widen scope, attach a tenant, or write an account:* / system:* type.
  • It uses insert_audit_event, not emit, on purpose. Here the audit write is the request's whole purpose (there is no user-facing mutation to protect), so the endpoint propagates a failed insert to apps/id as a real error rather than silently swallowing it. This is the deliberate exception to the "emit, never insert_audit_event" rule above - do not copy it into an in-process emitter.

The apps/id call sites live in apps/id/src/auth.ts (NextAuth signIn callback) via the best-effort helper emitUserAuditEvent in apps/id/src/lib/audit.ts. The helper posts to the ingest endpoint with apps/id's System key (env LUMIO_AUDIT_INGEST_KEY, an lm_sys_* key with audit:ingest), forwards the end-user's IP and user-agent (the request peer is the id service, not the acting user), and never blocks or fails the auth flow - a timeout or non-2xx response is logged and swallowed.

LUMIO_AUDIT_INGEST_KEY is carried by apps/id/.env.example and dev-stack/staging/.env.id.example, and read through getAuditIngestKey() in apps/id/src/lib/config.ts.

Provisioning the ingest key

LUMIO_AUDIT_INGEST_KEY must hold an lm_sys_* System key that carries the audit:ingest grant. Mint one from the admin app under System → System-API-Schlüssel: audit:ingest is part of the System-key assignable-scope registry (lo_auth::rbac::system_key::get_assignable_permission_infos()), so the admin create dialog offers it and both create paths accept it. Set the resulting secret as LUMIO_AUDIT_INGEST_KEY in apps/id's environment. Until it is set the apps/id trail below is not written - the fail-loud signals in the next paragraph are what make that state visible.

Fail-loud when the ingest key is unset. A missing/misconfigured LUMIO_AUDIT_INGEST_KEY disables the whole apps/id audit trail - every user-scope security event is silently dropped while the app still looks healthy, a DSGVO Art. 5(2)/32 accountability defect. To make that state observable without ever blocking login, apps/id surfaces it two ways:

  • Log once per process. When the key is unset and NODE_ENV === "production", emitUserAuditEvent emits exactly one console.error ([audit] LUMIO_AUDIT_INGEST_KEY is unset in production - user-scope security events are being dropped.) for the process lifetime, then returns. Local dev with no key stays silent, and a per-call skip for a missing subject user_id stays silent (it is a normal skip, not an operator condition).
  • Health field. GET /api/health on apps/id returns audit_ingest_configured (a boolean — Boolean(process.env.LUMIO_AUDIT_INGEST_KEY), the key value is never printed), so the disabled state is pollable/alertable, not just log-greppable.

Wired today (both carry a resolved Lumio user_id):

  • user:login - successful web login, after the provider token is exchanged for a Lumio JWT; the subject is decoded from the issued JWT's sub claim.
  • user:oauth_granted - an already-authenticated user links/reconnects a provider connection (OAuth consent); the subject is decoded from the session JWT.

The Rust security service emits user:mfa_enabled / user:mfa_disabled when a user enables or disables two-factor authentication. The first app or passkey emits user:mfa_enabled; further methods emit user:mfa_factor_added. Removing one while another remains emits user:mfa_factor_removed; removing the last emits user:mfa_disabled. Metadata includes method (totp or passkey) and a non-secret factor_id; further additions/removals include the resulting factor_count, additions may include aaguid, and removals include trusted_devices_revoked. The user-chosen label is never logged. These user-scoped events do not rely on apps/id audit ingest.

Profile email-change event (in-process, ZAF-913)​

A change of the profile email is the classic account-takeover vector (email change → password-reset link → takeover), so it is a personal-security event worth recording. The profile email is not typed in (updateMe / PATCH /v1/users/me refuse email); it changes only through the email sign-in and the platform-address "Remove":

  • user:email_changed (AuditScope::User) - emitted in-process by the security service (services/security/email_login.rs), which REST and GraphQL share: when the email sign-in is set up and its address becomes the profile email (source: "email_login"), when the email sign-in is removed and the profile email falls back to the remembered platform address or to none (source: "platform:<provider>" / "none"), and when a platform address is removed (cleared: true). A changed sign-in address is recorded as user:email_login_changed. Actor = the acting user (user_id), no tenant account_id. The address is PII and is never written to metadata - the row carries only a had_previous_email boolean and the source / cleared flag. Best-effort, so a failed audit insert never rolls back the change.

Token-refresh reconnect events (in-process, ZAF-754)​

The Token Refresh Worker (apps/api/src/workers/token_refresh.rs) is the automatic producer of the reconnect signal. When a refresh fails terminally (invalid_grant / unauthorized_client / HTTP 400/401) it flips reconnect_required false→true and, on that single transition (services::reconnect::notify_reconnect_required), emits:

  • account:connection_reconnect_required (AuditScope::Account) for a channel or bot connection - actor is the account, metadata carries platform
    • connection_type + reason.
  • user:login_reconnect_required (AuditScope::User) for a login connection - subject is the owning user.

reason is token_refresh_terminal for this path. The Twitch EventSub and Kick subscription workers are a second producer for channel connections: after a provider HTTP 401 they force one refresh (oauth::refresh_rejected_connection_token), and when that is rejected or the new token gets a second 401 they flag the row and emit the same event through services::reconnect::ReconnectNotifier with reason = provider_rejected_token.

There is no HTTP request behind the worker, so no IP/GeoIP origin is attached. The same transition also sends the lo_email reconnect email. These have no GraphQL/REST counterpart (the worker is the only trigger), so protocol parity does not apply.

Extension chat moderation​

  • account:extension_moderation (AuditScope::Account) - an installed extension ran chat:delete, chat:ban, chat:unban or chat:timeout through the action dispatcher, and the platform applied it. Emitted by services::extension_chat::emit_moderation_audit (apps/api/src/services/extension_chat.rs) at the end of extension_chat::moderate, which every caller reaches through services::extension_actions::execute_action:

    • widgets and apps (useLumioAction) - REST POST /v1/extension-installs/{install_id}/actions/{action} and GraphQL executeExtensionAction write the same row; the origin is the extension host's call;
    • bot modules - the actions of a handler result or a defineModerate decision, run through the internal POST /v1/internal/bot-modules/{install_id}/actions (services::bot_module_actions). The origin is empty: the call is service-to-service, and the worker's address is not the actor's.

    user_id is NULL: the actor is the extension, and the account's bot carried the action out. metadata = source (extension for widgets and apps, bot_module for bot modules), extension_id, install_id, action (delete | ban | unban | timeout), platform, target_user_id (null for delete), duration_secs (timeout only). The reason text is never written.

  • A bot module's chat:delete is not audited (extension_chat::is_audited): a moderation module deleting a spam raid would flood the 12-month security log. It is recorded in the install's log (extension_install_logs) only. Bot-module bans, unbans and timeouts, and every widget/app chat:delete, are audited.

  • A refused action (bot_not_connected, bot_scope_missing, moderation_forbidden, platform_not_supported, …) writes nothing.

  • Human moderation (moderateChat / POST /v1/chat/moderate) emits no audit event; it is recorded in platform_moderation_log. An extension acting on the tenant is a third party, so its moderation is a security record as well. Extension chat:send emits nothing (high-volume telemetry class).

  • Coverage: apps/api/tests/audit_emitters.rs (extension_moderation_emitter_is_account_scoped_without_actor_or_reason), the no-row-on-refusal checks in apps/api/tests/zaf2391_extension_actions.rs and the bot-module path in apps/api/tests/zaf2398_bot_module_actions.rs.

Automation webhook target refused​

  • account:webhook_target_blocked (AuditScope::Account) - a manual run of an automation reached a webhook_call node whose target apps/api/src/webhook_target.rs refused (invalid_url, scheme_not_allowed or private_target). Emitted by RedisActionDispatcher::call_webhook (apps/api/src/dispatch.rs) when the dispatcher carries a WebhookAudit context. Both protocols build that context in one place, services::automation_execute::execute_manual, so GraphQL executeAutomation and REST POST /v1/automations/{id}/execute write the same row: the member as user_id, the request origin, and metadata = automation_id, reason, scheme. The host, path and query are tenant-templated and can carry secrets, so they are never written.
  • Event- and timer-triggered runs (workers/automation.rs, workers/automation_timer.rs) carry no audit context: a misconfigured target would repeat the refusal on every trigger. They log it server-side only.
  • Covered by apps/api/tests/automation_webhook_target.rs.

Active-account switch (ZAF-908)​

  • user:active_account_switched (AuditScope::User) - the acting user switched (or cleared) their session's active_account_id. This is identity-relevant: it changes the RBAC scope every subsequent request on that session runs under, and re-mints the session cookie (with the same session_id, so revocation still reaches it - see features/sessions.md). The actor is the user; the from/to account ids, a cleared flag, and the session_id of the device that switched ride in metadata (account selection is per-session since ZAF-1416, so the log records which device changed; the session id is an opaque uuid - no secret/PII). account_id is NULL: a personal switch is not a tenant action, so it stays out of the switched-to account's audit-log:read log (an account admin must not be able to enumerate who is switching into their account).
  • Emit sites - GraphQL updateMe (apps/api/src/graphql/auth.rs) and its REST twin PATCH /v1/users/me (apps/api/src/routes/users.rs), on the same condition that re-mints the cookie (active_account_id set, or clear_active_account). Same event type, scope and metadata on both protocols (parity). Both use the standard best-effort in-process emit.

Session-lifecycle events (ZAF-911)​

Ending or revoking a session is a personal security action on the user's own credential - "log out everywhere" is exactly what the audit log exists to record. Both events are AuditScope::User, keyed to the acting user with no tenant account_id, and go through the shared user-scope helpers so REST and GraphQL write the same shape:

  • REST - crate::routes::audit::emit_user_audit(state, req, actor_user_id, event_type, metadata)
  • GraphQL - crate::graphql::emit_user_audit(ctx, actor_user_id, event_type, metadata)

Catalog constants (db::audit::event_types):

  • user:logout - the user ended one of their own sessions via a logout flow: GraphQL logout(refreshToken) / logoutSession, REST POST /v1/auth/logout. metadata carries the ended session_id (when the deleted row is known) and the method (refresh_token | session). For the refresh-token flow the actor and session id come from the deleted session row (delete_session now RETURNING id, user_id), so an expired-but-authentic logout still records the row; logoutSession takes them from the JWT claims.
  • user:session_revoked - the user revoked session(s) from session management: GraphQL deleteSession(id) / deleteAllOtherSessions, REST DELETE /v1/users/me/sessions/{id} / DELETE /v1/users/me/sessions. metadata distinguishes a single revoke (session_id, revoked_all: false) from a bulk revoke (count, revoked_all: true).

Emit is gated on an actual change. A logout that matched no session, a single revoke of a not-found id, and a bulk revoke that removed zero rows all write nothing - a no-op is not a security event.

No-secret rule. metadata carries only the non-secret session_id / count / method / revoked_all - never the refresh token, the session token_hash, or any PII (every reader of the user log is the user themselves, but the discipline is the same as every other emitter).

Parity. The refresh-token logout / POST /v1/auth/logout pair and the deleteSession / deleteAllOtherSessions GraphQL mutations and their DELETE /v1/users/me/sessions[...] REST twins emit identical event type, scope and metadata on both protocols. logoutSession is a GraphQL-only convenience (the REST logout is refresh-token driven); it emits the same user:logout event tagged method: "session", documented at the call site.

Credential & access lifecycle events (ZAF-772)​

The token / overlay / widget credential lifecycle and the per-resource access grant/revoke are emit-worthy credential and permission-structure changes (AGENTS.md §Audit Logging). All are AuditScope::Account, keyed to the acting account with the actor recorded, and go through the shared account-scope helpers so REST and GraphQL stay byte-for-byte in parity:

  • REST - crate::routes::audit::emit_account_audit(state, req, account_id, actor, event_type, metadata)
  • GraphQL - crate::graphql::emit_account_audit(ctx, account_id, actor, event_type, metadata)

Catalog constants (db::audit::event_types):

  • Popout tokens - account:popout_token_created / _updated / _deleted (a soft-revoke via update carries "revoked":true; no separate revoke type). An update that sets or changes the token's user binding - including an unassign, which binds the editor - carries "bound_user_id" (opaque uuid), identically on GraphQL updatePopoutToken and REST PATCH /v1/tokens/{id} (ZAF-1922).
  • Account service keys (lm_svc_*) - account:service_key_created / _updated (label rename) / _revoked / _rotated. Emitted by GraphQL createAccountServiceKey / updateAccountServiceKey / deleteAccountServiceKey / rotateAccountServiceKey and REST POST / PATCH / DELETE /v1/service-keys / POST /v1/service-keys/{id}/rotate (protocol parity). Account-scoped (the key is a tenant credential, unlike the personal user:api_key_* events); the actor is the member who performed the action. Metadata carries only service_key_id and the short, non-secret lm_svc_* prefix - key_prefix, or old_key_prefix + new_key_prefix on _rotated (which regenerates the secret in place and returns the new full key once) — never the full key or its hash.
  • Overlay access tokens - account:overlay_token_rotated / _revoked.
  • Shared-overlay links (lm_share_*) - account:shared_link_created / _revoked / _extended (extending credential validity is a security-relevant lifecycle change).
  • Widget access tokens - account:widget_token_created (create + duplicate) / _rotated / _revoked.
  • Per-resource access - account:overlay_access_granted / _revoked and account:widget_access_granted / _revoked. Grant is an upsert: the resulting role in metadata covers both first-grant and role-change (no pre-read, no separate *_changed type).
  • History report share links (lm_share_*, ZAF-780) — account:history_shared_link_created / _extended / _revoked. The same lm_share_* credential lifecycle as the overlay share link, keyed to a history session/report instead of an overlay (create/revoke carry the lm_share_* token_prefix, extend carries the new expiry). Metadata is the non-secret session_id / link_id (+ expires_at / token_prefix) and the effective non-secret options (legacy links use {legacy: true, include_revenue: false}) - never the token string/hash or the argon2 password_hash.
  • History revenue assumptions - account:history_revenue_settings_updated, emitted only for a real change by GraphQL updateChannelHistoryRevenueSettings or REST PUT /v1/history/revenue-settings. Scope is account, actor is the writing user, and metadata is {"changed_fields": ["field", ...]} (sorted). Scalar settings, including the three YouTube creator shares and Kick/YouTube ad assumptions, are listed by name; any added, removed or repriced YouTube membership level adds youtube_member_levels (level names are matched ignoring case; reordering alone is no change). The values, level names and prices are left out because the assumptions are gated behind history:revenue-read while the audit log is readable with audit-log:read. A read or unchanged write emits nothing. Fixed USD platform values are not editable and do not appear in the event.
  • History session erasure (ZAF-780) - account:history_session_deleted, a destructive GDPR Art. 17 deletion of a stored stream session and its data (history:delete). Metadata is the session_id only. Kept distinct from the chat-PII (chat_pii_erasure) and YouTube-member (youtube_member_erasure) erasure events, which erase different subject data on different surfaces. Those two carry a count per erased table in metadata (deleted_message_count, deleted_event_count, deleted_platform_user_count, deleted_user_note_count, deleted_moderation_log_count — whichever tables the path reaches; ZAF-2181; on the subject path deleted_moderation_log_count counts the non-AutoMod rows next to deleted_automod_moderation_log_count, so no row is counted twice, ZAF-2566), so an erasure is provable after the fact. The instructed subject erasures (erase_chat_subject, the YouTube member erasure) act in one instructing account only and write exactly one AuditScope::System row per request (account_id column null; metadata.target = "account", metadata.account_id = target account, plus case_id, instruction_reference, initiated_by (channel | subject), subject_kind, platform and the counts; the instructed subject erasure also reason) - never the subject's platform user id, member channel id, Lumio user id, login or name, and no per-account rows. The Lumio-only erasure (erase_chat_subject_at_lumio) writes one chat_pii_erasure System row with metadata.target = "lumio", instruction_reference and reason, and no account. The record erasure (record_erasure::erase_record) writes one System row per erased record with metadata.target = "record", record_kind, record_id, the target account_id, case_id, instruction_reference, reason and its counts, and none for a record that was not found; see Audit Log › Event types.
  • Setting-preset account sharing (ZAF-1287 / ZAF-622 §19) - account:setting_preset_shared / account:setting_preset_unshared. Sharing a personal preset with an account makes the owner's saved view (whose payload can hold viewer names) visible to every member, so it is a tenant-visible disclosure gated presets:share. Both are account-scoped: shared is keyed to the account it is shared into; un-shared is keyed to the account it was shared into (fires only when a share was actually removed). Reachable on both GraphQL (shareSettingPreset / unshareSettingPreset) and REST (POST / DELETE /v1/setting-presets/{id}/share) → identical type/scope/metadata. Metadata is the non-secret preset_id, kind (chat | events | music) and name - never the preset payload.

No-secret rule (compliance-critical). Metadata carries only non-secret identifiers - token_id, resource ids (overlay_id / widget_id / link_id / session_id), target_user_id, role, expires_at, flags - plus the short, non-secret lm_* token_prefix where the row exposes one. It never carries a full token string, token_hash, AES material, an argon2 password_hash (history share links carry one), or raw PII. The widget rotate/revoke DB helpers surface only the token string (never logged), so those rows carry widget_id only - the helper signatures are deliberately not widened. The apps/api/tests/audit_emitters.rs::zaf749_token_metadata_excludes_secrets and ::zaf780_history_metadata_excludes_secrets regression guards fail if any lm_… value leaks outside token_prefix (or a password/secret key appears).

The platform-operator account delete (DELETE /v1/admin/accounts/{id} / adminDeleteAccount) emits system:account_deleted (AuditScope::System, no tenant account_id, operator as actor, deleted account id, deleted_history_row_count, deleted_object_count and pending_object_count in metadata, identical on both twins) - the CASCADE leaves no account member to read an account-scoped row, and audit rows live in a separate TimescaleDB the CASCADE does not erase. Owner self-service dissolve stays account:dissolved (Account scope) - the two are distinct events so exactly one emitter fires per path.

The orphaned extension upload sweep (POST /v1/admin/storage/orphaned-extension-uploads/sweep / adminSweepOrphanedExtensionUploads, gated by admin:privacy-erase) emits system:orphaned_storage_swept (AuditScope::System, operator as actor) only when apply is set - the dry run is a read and records nothing. Metadata carries kind (extension_uploads), orphaned_object_count and queued_object_count.

The automatic refund of an undeliverable checkout (services::purchase_refund::refund_undeliverable, called from the Stripe checkout.session.completed webhook for CheckoutInstall::Detached and CheckoutInstall::NotInstallable) emits extension:purchase_auto_refunded (AuditScope::System, no actor, account_id = null) once Stripe accepted the refund. A refused or failed refund writes nothing and is logged with error!. Metadata: purchase_id, cause (detached | not_installable), amount, currency and stripe_refund_id, never buyer data. The domain is extension: (subject domain, like privacy:), not system:. The webhook is the only path: no GraphQL or REST mutation refunds a purchase. The emitter calls Stripe, so it has no DB test; the ledger side (charge.refunded on a detached row) is covered by apps/api/tests/zaf2717_checkout_after_extension_delete.rs.

The chatter objection path (GDPR Art. 21, services::chat_privacy::add_chatter_objection / remove_chatter_objection, shared by POST / DELETE /v1/admin/privacy/chat/objections… and GraphQL addChatterObjection / removeChatterObjection) emits privacy:chatter_objection_added / privacy:chatter_objection_removed (AuditScope::System, operator as actor) through db::audit::emit. The emit lives in the service, so both protocols write the same type, scope and metadata: platform, reason (objection) and already_erased - never the platform user id or login, which are personal data. The domain is privacy: (subject domain, like crawler:*), not system:. The objection writes the global chatter_suppressions list, which acts on Lumio Stats only. Adding a chatter to the account's account_chatter_suppressions list as part of an instructed erasure emits no second event: the chat_pii_erasure / youtube_member_erasure row carries suppressed: true. Covered by apps/api/tests/audit_emitters.rs::chatter_objection_emitters_are_system_scoped_without_identifiers.

The withdrawal of a channel erasure (services::chat_privacy::withdraw_channel_suppression, shared by POST /v1/admin/privacy/chat/erase/withdraw and GraphQL withdrawChannelChatterSuppression) emits privacy:channel_suppression_withdrawn (AuditScope::System, operator as actor, account_id = null) through the same service-level emit, only after the row is deleted: a refused withdrawal (404, or 409 for an erasure the person asked for) writes nothing. Metadata: target (account), account_id, instruction_reference and platform - like the chat_pii_erasure row it undoes in part, never the platform user id or login. Covered by apps/api/tests/chatter_suppressions.rs::withdrawal_lifts_only_that_channels_suppression and ::withdrawal_refuses_a_subject_initiated_erasure.

The account permission-override set/remove (PUT / DELETE /v1/admin/accounts/{id}/permission-overrides / adminSetAccountPermissionOverride / adminRemoveAccountPermissionOverride, gated by accounts:overrides-edit) emit system:account_permission_override_set / _removed (AuditScope::System, operator as actor, no tenant account_id) via the same emit_system_audit helper the feature-override path uses. A platform operator granting/revoking an account-scoped permission override is a cross-account operator action on the escalation surface hardened in ZAF-748 (only registry-known, non-wildcard permissions are accepted), so it belongs on the operator log. The target account_id and permission ride in metadata - the set path also records granted and the operator's optional reason; never a secret. Remove emits only when a row was actually removed. This is the account-level sibling of the per-user system:user_permission_override_set / _removed events.

Discord server events (ZAF-2152)​

The global Lumio Discord bot joins servers through a verified invite (see Bot control). All four events are AuditScope::Account, keyed to the server's account, and written by the shared service services::discord_guild_install, so every path records the same shape. Metadata: guild_id, guild_name, and reason: "taken_over" on a takeover - never the user token of the invite (it is revoked right after the exchange).

  • account:discord_guild_connected - verified invite of a new server; actor = the inviting member. Written on the unified OAuth exchange (POST /v1/connections/oauth/discord/exchange), which is REST-only like every bot exchange (the browser callback calls it; there is no GraphQL twin).
  • account:discord_guild_reconnected - the same account invites a server it already has (after the bot was removed, or to grant missing permissions); settings stay. Same REST-only exchange.
  • account:discord_guild_disconnected - disconnectDiscordGuild (GraphQL) and DELETE /v1/discord-guilds/{guild_id} (REST), gate bot-connections:delete, full parity; only on a real removal. On a takeover the previous account gets this event too, with no actor - the new tenant's user is never written into another tenant's log.
  • account:discord_guild_bot_removed - the bot was removed from the server on Discord's side. Written by the bot's system-key mutation removeDiscordGuildByBot (bot-fleet path, no REST twin), no actor. The row is marked, not deleted.

An operator deleting a server connection (adminDeleteDiscordGuild / DELETE /v1/admin/discord-guilds/{guild_id}) writes the existing system:account_bot_connection_deleted (System scope, metadata target_account_id, platform: "discord", guild_id) on both protocols.

Credential & operator-RBAC events (ZAF-886)​

These close the ZAF-880 finding #1 gap: credential- and permission-structure tables that lived next to already-audited ones but emitted nothing. Each fires on both protocols through the scope-appropriate shared helper.

  • Per-account app credentials - account:connection_credentials_upserted (AuditScope::Account, gate connections:create). save_credentials / saveAppCredentials write AES-256-GCM client_id/client_secret into app_credentials; this is the credential level, distinct from the token level account:connection_added. Metadata: platform, credentials_id (row id), updated (insert vs update) - never the client_id/client_secret (plain or ciphertext). The DELETE counterpart is account:connection_credentials_deleted (ZAF-981, gate connections:delete): delete_credentials / deleteAppCredentials remove the row and, on the same path, tear down the associated channel connection - so that path emits both account:connection_removed (when a connection row was removed) and account:connection_credentials_deleted (when a credential row was removed), each fail-closed on a real removal, with full REST↔GraphQL parity. The deleted row leaves no id, so its metadata is platform only.
  • Integrations (Shopify) - account:integration_connected / account:integration_disconnected / account:shopify_settings_updated (AuditScope::Account, ZAF-2932, ZAF-2988). All three are written in services::shopify, which the GraphQL and REST twins and the embedded routes (/v1/shopify/embedded/*) call, so every surface emits the same shape. Connected: when a link completes (POST /v1/shopify/embedded/link/complete); actor = the Lumio user who confirmed it (shopifyLinkConfirm / POST /v1/connections/shopify/link/confirm); metadata platform, shop_host, scopes, source (shopify_admin), shopify_user_id (the Shopify staff member's sub). A token re-exchange of an existing link emits nothing. Disconnected: actor = the member (disconnectShopify / DELETE /v1/connections/shopify), or none from the embedded app (POST /v1/shopify/embedded/disconnect, + shopify_user_id) or for Shopify's app/uninstalled webhook; metadata platform, shop_host, reason (user / shopify_admin / app_uninstalled). Emitted only when a row was really removed. Settings updated: one event per changed value (customer_display, show_items, show_price; ZAF-2989), none when nothing changed; actor = the Lumio user (updateShopifySettings / PUT /v1/connections/shopify/settings, source: lumio) or none from the embedded app (PUT /v1/shopify/embedded/settings, source: shopify_admin
    • shopify_user_id); metadata platform, shop_host, setting, from, to, source. Never a token or a buyer name.
  • Shopify buyer name hidden - account:shopify_order_name_hidden (AuditScope::Account, ZAF-2989). Written by services::shopify::hide_order_name, which hideShopifyOrderName and POST /v1/connections/shopify/events/{event_id}/hide-name both call; actor = the member (events:edit). Emitted only when a stored label was removed (a repeat hide emits nothing). Metadata platform, event_id; never the name.
  • Shopify compliance webhooks - account:shopify_data_request, account:shopify_customer_redacted, account:shopify_shop_redacted (AuditScope::Account, ZAF-2933). Written by the compliance worker (services::shopify_compliance), once per account that holds or held the store, with no actor: Shopify sent the request. REST-only by nature (Shopify calls POST /v1/webhooks/shopify/compliance; no client surface, so no GraphQL twin). Data request: on its single pass, metadata platform, shop_host, orders_requested, orders_found, orders_with_label. Redactions: only on the completing pass after the 72 h dead-letter window, metadata platform, shop_host (+ orders, a count, for customers/redact). Counts only: never a customer label, order id or customer contact.
  • Developer OAuth clients - oauth_client:created / _updated / _deleted (AuditScope::System, gate oauth-clients:{create,edit,delete}). Metadata: client_id_ref (the DB row id, not the OAuth client_id string), name, redirect_uris, scopes, and (update) changed_fields - never the OAuth client_id string or client_secret.
  • Operator admin roles - admin_role:created / _updated / _deleted / _assigned / _unassigned (AuditScope::System, gate admin-roles:{create,edit,delete}). The admin_roles table is separate from user_roles (whose system:user_role_* events already emit). Metadata: role_id, role_name, permissions (the granted resource:action list — auditing the grant is the point), changed_fields (update); assign/unassign add target_user_id. Assign emits only on a real (non-idempotent) assignment; unassign/delete only when a row was actually removed.
  • Global bot connections - system:global_bot_connection_upserted / _deleted (AuditScope::System, gate bot-connections:{create,delete}). The nil-UUID global bot token, written by exchange_global_bot_oauth and the unified GlobalBot exchange. Metadata: platform, connection_id, bot_username (public handle), source (oauth_exchange) - never the access/refresh token. The upsert emit is a documented single-protocol (REST) emit. Discord has no global bot connection: its bot token is bot configuration (DISCORD_BOT__BOT__TOKEN).
  • Operator identity destruction - system:user_deleted (gate users:delete) and system:user_login_connection_deleted (AuditScope::System). The login-identity removal has full REST↔GraphQL parity — admin_delete_user_login_connection (GraphQL) and DELETE /v1/admin/users/{id}/login-connections/{provider} (routes::admin::admin_delete_login_connection, gate users:edit) emit the same event. Both emit only on a real deletion. Metadata: target_user_id, plus provider for the login-connection case - never email/handle/tokens.
  • Login-identity routing - account:login_connection_assigned (AuditScope::Account, assign_login_connection on both protocols, gate login-assignments:create on the cross-user path; metadata provider + target_user_id + connection_id). Assigning a login decides which identity the account acts as on that platform, so it is exactly as security-relevant as taking it away - the two are a pair, and an audit log that only recorded the removal told half the story.
  • Login-identity removal - account:login_connection_removed (AuditScope::Account, remove_login_assignment, gate login-assignments:delete; metadata provider + target_user_id) and user:login_connection_removed (AuditScope::User, self-service delete_login_connection / disconnectLoginConnection, first-party-gated ZAF-469; metadata provider + connection_id). The self-service removal is a personal security event (User scope, self-only read); the account-member removal belongs to the tenant log. Both emit only on a real removal.

user:oauth_granted is not emitted by the in-API link_provider route - it is emitted by apps/id on provider link/consent via the server-to-server ingest endpoint (see Events that originate outside the API). Wiring a second emit in-API would double-count the same grant.

No-secret rule. As everywhere else, metadata carries only non-secret ids and public handles. The credential/token writers (account:connection_credentials_upserted, oauth_client:*, system:global_bot_connection_upserted) are guarded by apps/api/tests/audit_emitters.rs::zaf886_credential_metadata_excludes_secrets, which fails if a client_id/client_secret/token key or a secret-shaped value (lms_…, cli_…) appears in any stored metadata row.

YouTube Analytics connection and deletion audit​

The channel OAuth exchange writes the existing account-scoped account:connection_added event on every first connect and reconnect. For YouTube, db::audit::connection_event_metadata includes platform, credential_source and optional_scopes_granted: the optional scope names in the actual Google grant. [] means no optional scope was granted; platforms without optional channel scopes omit the key. Never put the token, the full grant or a revenue amount in this metadata.

account:youtube_revenue_data_deleted is account-scoped and emitted through db::audit::emit only when at least one stored YouTube Analytics revenue row is deleted. user_id is the initiating user when known and absent for worker cleanup. Metadata is { "platform": "youtube", "reason": "scope_removed", "rows": 12 }, where rows counts deleted records, not money. The reason is one of disconnected, scope_removed, revoked, auth_unconfirmed_30d, video_deleted, account_deleted, or report_deleted. Connection deletion paths on both GraphQL and REST call the same YouTube revenue cleanup service before deleting the connection; the worker also uses that service for scope, authorization and video checks. deleteChannelHistory and DELETE /v1/history/reports/{id} share services::history::service::delete_session, which deletes the revenue rows of the report's videos (report_deleted, actor = the deleting user) unless another report of the account still shows the same video. See Audit Log.

Personal API-key lifecycle events (ZAF-498)​

A user API key (lm_usr_*) is a personal, self-only programmatic credential a member creates in the dashboard (rows in api_keys with is_system = false, bound to one user_id + account_id). Its create/revoke are therefore AuditScope::User personal-security events, not account-scoped - they land in the owner's self-only log with account_id = NULL, exactly like the login events. They go through dedicated user-scope helpers that mirror the account-scope ones:

  • REST - crate::routes::audit::emit_user_audit(state, req, user_id, event_type, metadata)
  • GraphQL - crate::graphql::emit_user_audit(ctx, user_id, event_type, metadata)

Catalog constants (db::audit::event_types):

  • user:api_key_created - a new key is minted. Emitted by GraphQL createUserApiKey and REST POST /v1/api-keys (protocol parity).
  • user:api_key_updated - a key is renamed (label only). Emitted by GraphQL updateUserApiKey and REST PATCH /v1/api-keys/{id} (protocol parity).
  • user:api_key_revoked - a key is deleted. Emitted by GraphQL deleteUserApiKey and REST DELETE /v1/api-keys/{id}; the revoke paths pre-read the key so the row can carry its prefix, and a missing/foreign key collapses to the same not-found before any row is written.
  • user:api_key_rotated - the owner regenerated a key's secret in place (id, label and scopes unchanged; old secret invalidated immediately, new full key returned once). Emitted by GraphQL rotateUserApiKey and REST POST /v1/api-keys/{id}/rotate (protocol parity); the rotate paths pre-read the key so the row can carry the old prefix. Metadata adds old_key_prefix + new_key_prefix alongside api_key_id.

Per the no-secret rule, metadata carries only api_key_id and the short, non-secret lm_usr_* prefixes (key_prefix, or old_key_prefix / new_key_prefix on rotate) - never the full key or its key_hash. Both emit sites are best-effort side-channels that never fail the mutation.

Developer verification submission (ZAF-943)​

developer:verification_submitted (AuditScope::User) fires when an approved developer submits (or resubmits) their own KYC identity/company verification via submitDeveloperVerification (GraphQL) or POST /v1/developer/verification (REST). This is a personal identity/credential action on the developer's own identity, so it is User-scoped (self-only read via myAuditLog) with the acting developer as user_id and no tenant account_id. The event name follows the subject-domain precedent (oauth_client:*, admin_role:*). Both protocols emit the same event type, scope and metadata (parity).

  • Metadata: verification_id, developer_id, developer_type (individual | company, inferred from whether a company name was given), status, and resubmission (whether a prior record existed) - never the submitted legal name, company name, address, tax id, trade-register id, or document key (all raw PII/KYC material).
  • Reads do not emit. developerVerificationStatus / GET /v1/developer/verification are reads and write no audit row.

Developer balance released on deletion (ZAF-2831)​

  • developer:funds_deletion_released (AuditScope::System, user_id = the operator) fires when an operator releases an unpayable developer balance as a liability and deletes (adminReleaseFundsAndDeleteUser / adminReleaseFundsAndDeleteDeveloper + POST /v1/admin/{users,developers}/{id}/release-funds-delete). Metadata released_balance_id, amount, currency, release_case, built by services::developer_funds_release::audit_metadata for both protocols — never the claim code, the reason, the request reference, or the deleted person's id or e-mail. Emitted after the commit, only when a balance was released; the delete itself still writes system:user_deleted / system:developer_deleted.

Released developer balance settled (ZAF-2836)​

  • developer:funds_released_claim_started (AuditScope::System, user_id = the operator) fires when an operator records that a person presented the claim code (adminStartReleasedBalanceClaim + POST /v1/admin/released-balances/claim), only for the call that sets claim_started_at; a repeat writes nothing. Metadata released_balance_id, amount, currency, built by services::developer_funds_settlement::claim_audit_metadata.
  • developer:funds_released_settled (AuditScope::System, user_id = the operator) fires when an operator credits the released balance to the person's new, verified developer profile (adminSettleReleasedBalance + POST /v1/admin/released-balances/settle). Metadata released_balance_id, developer_id (the new profile), amount, currency, built by services::developer_funds_settlement::settlement_audit_metadata.
  • Neither carries the claim code. Both are emitted after the commit, on both protocols, by the same helpers.

Developer tax details and credit notes (ZAF-2807)​

  • developer:tax_details_updated (AuditScope::User) fires when a developer sets the tax details of their own verification (updateDeveloperTaxDetails / PUT /v1/developer/verification/tax-details). Metadata verification_id, developer_id, has_tax_number, has_vat_id, small_business_declared - never the Steuernummer or USt-IdNr. themselves. Both protocols share routes::developer_verifications::tax_details_audit_metadata.
  • system:developer_credit_note_cancelled (AuditScope::System) fires when an operator records a developer's objection to a self-billed credit note or corrects it (cancelCreditNote / POST /v1/admin/credit-notes/{id}/cancel, gate billing:edit). Metadata credit_note_id, cancellation_id, cancellation_number, reason, developer_id, reissued_id, reissued_number, built by routes::developer_credit_notes::cancel_audit_metadata for both protocols. The automatic cancellation of a failed payout is bookkeeping inside the payout failure (recorded by system:developer_payout_failed); the issue of a note is part of the payout and emits nothing either.

Developer payouts (ZAF-1257)​

Every money movement of a developer payout is recorded. All share services::developer_payouts::payout_audit_metadata (payout_id, developer_id, method, amount, currency, status, the masked payout_reference, stripe_transfer_id, paypal_batch_id, source) and, on failure, failed_audit_metadata (adds refunded, cancellation_number) - never the payee snapshot, the full PayPal e-mail or the IBAN.

  • developer:payout_requested (AuditScope::User, actor = the developer): requestPayout / POST /v1/developer/payouts/request (source request or closing) and the closing payout of scheduleProfileDeletion (closing).
  • system:developer_payout_scheduled (AuditScope::System, no actor): the monthly step of the payout cycle (workers::payout::schedule_monthly_payouts).
  • system:developer_payout_sent (AuditScope::System, no actor): the cycle handed the payout to Stripe (completed) or PayPal (processing).
  • system:developer_payout_completed (AuditScope::System): an operator (completeDeveloperPayout / POST /v1/admin/developer-payouts/{id}/complete, billing:edit, actor = operator, source operator) or the PayPal payouts webhook (no actor, source webhook).
  • system:developer_payout_failed (AuditScope::System): the provider refused the payout (source cycle, adds provider_error), an operator marked it failed (failDeveloperPayout / …/fail), or the PayPal webhook reported it.

The cycle and the webhook emit through services::developer_payouts::emit_system (no request origin); the operator twins through emit_system_audit on each protocol.

Developer-team, extension-access, SE-token & admin single-site events (ZAF-1022)​

Five modules with RBAC-gated mutations on both protocols shipped with zero audit emitters (baseline sweep ZAF-1005 Befund 2); ZAF-1022 wires them, plus a handful of admin single-sites. Scope is set explicitly per surface and never inferred.

Developer self-service team (developer:team_*, AuditScope::User) - a developer managing their own team. Consistent with the existing developer:verification_submitted, these are personal-security events on the acting developer's identity: user_id = the acting developer, no tenant account_id, self-only read via myAuditLog. GraphQL (graphql/developer_teams.rs) and REST (routes/developer_teams.rs) emit the same event/scope/metadata via the shared emit_user_audit helpers. Types: developer:team_created, developer:team_deleted, developer:team_role_created / _updated / _deleted, developer:team_member_role_changed, developer:team_member_removed, developer:team_invite_created / _revoked / _accepted / _declined. Metadata carries only row/entity ids (team_id, role_id, member_id, invite_id, target_user_id), role_slug, permissions (permission strings), and changed_fields (field names) - never the invite email or invite_code/code (log the invite row id instead), and never the team member's PII. The low-security team rename is intentionally not audited (audit log ≠ analytics stream).

Bell invite actions share the same REST and GraphQL dispatcher. Accepting an account invite emits account:invite_accepted with the joining user as actor and the joined account as scope; its metadata includes membership_id, invite_id, role_id, and source: "notification". Declining an existing account invite emits account:invite_declined with invite_id, role_id, and that source. An already-gone account invite produces no decline event.

Accepting a developer-team invite from the bell emits developer:team_invite_accepted with team_id, invite_id, role_id, member_id, and source: "notification" in User scope. Accepting by code emits the same type without that source. Declining an existing team invite emits developer:team_invite_declined in User scope with team_id, invite_id, invite_deleted, and source: "notification"; a missing invite produces no event. invite_deleted is true only when an unused single-use invite is removed. No invite event records an invite code or email.

Developer extension visibility (developer:extension_visibility_changed, AuditScope::User) - a developer moving their extension between private, unlisted, public and paid. Both protocols call the shared services::developer_extensions::emit_visibility_changed after the update, so event, scope and metadata (extension_id, from, to) are identical; nothing is written when the value did not change. A developer key has no member identity: its row has no user_id and names the key in developer_key_id.

Developer extension secrets (developer:extension_secret_set, developer:extension_secret_deleted, AuditScope::User) - a developer setting or deleting one of their extension's owner-scope secrets (credential surface). Both protocols call services::developer_extensions::emit_secret_changed; metadata is extension_id and the secret's key name (plus developer_key_id for a developer key) - never the value or its ciphertext. A delete that removed no row writes nothing. Reads (ctx.secrets.get, listing key names) are not audited.

Transparency tradeoff (§A.1, CTO-accepted). Under User scope a member removal or role change done by a non-owner team admin lands in that actor's self-only log; it does not surface in a team-shared or operator log (no team-audit read surface exists in the product). This satisfies DSGVO Art. 5(2) accountability. A team-visible audit read would be a scope extension (a one-way door) - deferred until such a UI is scheduled.

Extension access (account:extension_access_*, AuditScope::Account) — mirrors the account:{overlay,widget}_access_* families. GraphQL (graphql/extension_access.rs) and REST (routes/extension_access.rs) emit via emit_account_audit with the row account_id = the acting (extension-owner) account and the target account in metadata. Types: account:extension_access_granted / _revoked and account:extension_access_invite_created / _revoked / _accepted. Metadata: extension_id, target_account_id, grant_type, grant_id, invite_id, max_uses, expires_at - never the invite invite_code/code.

StreamElements tokens (account:se_token_*, AuditScope::Account) - an account-owned SE credential. GraphQL (graphql/se_tokens.rs) and REST (routes/se_tokens.rs) emit account:se_token_created (upsert) and account:se_token_deleted. Metadata: token_id, platform, label - never the StreamElements JWT, its encrypted_token ciphertext, or the masked token_hint (which still leaks 8 chars).

OBS integration config (account:obs_config_*, AuditScope::Account) - the account's OBS WebSocket connection settings. GraphQL (graphql/obs.rs saveObsConfig / deleteObsConfig) and REST (routes/obs_integration.rs PUT / DELETE /v1/integrations/obs) emit account:obs_config_saved (upsert) and account:obs_config_deleted (only when a stored config was removed; the metadata then describes the removed config). Both build the metadata with services::obs_remote::obs_config_audit_metadata: mode (local / remote), remote_host (trimmed, null when empty), port, has_password - never the OBS WebSocket password or its ciphertext.

Operator developer-team administration (system:developer*, AuditScope::System) - platform-operator actions gated on developer-*:edit. GraphQL (graphql/admin_developer_teams.rs) and REST (routes/admin_developer_teams.rs) emit via a local emit_system_audit: the acting operator is user_id, no tenant account_id, the target rides in metadata. Types: system:developer_deleted, system:developer_team_deleted, system:developer_team_member_added / _removed / _role_changed, system:developer_revenue_split_set, system:developer_limits_updated, and system:developer_limit_request_reviewed. system:developer_limits_updated is a single, merged event for developer and extension limits, discriminated by limit_scope: "developer" | "extension" in metadata. The limit-request review metadata carries a has_review_notes presence flag only - never the free-text review_notes.

Admin single-sites (system:*, AuditScope::System) - platform-operator actions on user/account rows, distinct from the user-self events. GraphQL (graphql/admin.rs) and REST (routes/admin.rs) emit:

  • system:user_email_changed - operator changed a user's email via admin_update_user / PATCH /v1/admin/users/{id} (patch_user). Emitted only when the stored email actually changed (a display-name-only edit or a no-op re-submit writes nothing); metadata target_user_id + a had_previous_email flag - never the old/new address.
  • system:user_account_creation_override_set - metadata target_user_id + override (allow | deny | default).
  • system:user_max_accounts_override_set - GraphQL-only (admin_update_user_max_accounts_override); patch_user REST does not accept max_accounts, so a GraphQL-only emitter is correct parity. Metadata target_user_id + max_accounts (int | null).
  • system:account_connection_deleted / system:account_bot_connection_deleted — operator deleted an account's channel / bot connection (admin_delete_account_channel_connection / _bot_connection + DELETE /v1/admin/accounts/{id}/connections/{platform} / .../bot-connections/{platform}). Metadata target_account_id + platform; emitted only on a real deletion.
  • system:account_login_connection_deleted - GraphQL-only (admin_delete_account_login_connection, gate accounts:edit); no REST twin exists for the account-level login-connection delete (the REST admin_delete_login_connection targets a single user via the already-audited system:user_login_connection_deleted). Metadata target_account_id + provider; emitted only on a real deletion.
  • system:account_member_added / system:account_member_removed - operator added a user to / removed a member from an account (admin_add_account_member / admin_remove_account_member + POST /v1/admin/accounts/{id}/members / DELETE .../members/{user_id}, gate accounts:edit). Metadata target_account_id, target_user_id (+ the effective role_id on add); add is emitted only on a real insert (re-adding an existing member is a no-op) and remove only on a real removal.
  • system:coupon_created / system:coupon_updated / system:coupon_deactivated - operator coupon CRUD (admin_{create,update,deactivate}_coupon + POST/PUT/DELETE /v1/admin/coupons[/{id}]). Metadata coupon_id plus discount_type (create) or changed_fields (update; field names only, built by the shared db::audit::coupon_changed_fields) - never the redeemable code. Deactivate is emitted only on a real deactivation.
  • system:account_se_token_deleted - operator deleted an account's StreamElements token (admin_delete_se_token + DELETE /v1/admin/accounts/{id}/se-tokens/{token_id}, gate se-tokens:delete). Metadata target_account_id + token_id - never the JWT, ciphertext or token_hint. The tenant self-service twin stays account:se_token_deleted.
  • system:developer_verification_approved / system:developer_verification_rejected - operator KYC review (graphql/developer_verifications.rs + routes/developer_verifications.rs, gate developer-verification:edit). Metadata verification_id (+ target_account_id on approve) - never the free-text rejection reason.
  • system:extension_version_approved / system:extension_version_rejected - operator extension review (graphql/extension_reviews.rs + routes/extension_reviews.rs, gate extension-review:edit). Metadata version_id, extension_id (+ new_status on approve) - never the review notes or reason.
  • system:user_extension_dev_mode_set - operator toggled a user's extension dev mode (set_extension_dev_mode + POST /v1/admin/users/{id}/extension-dev-mode, gate developer-limits:edit). Metadata target_user_id + enabled.
  • system:account_limits_updated - operator changed an account's numeric limit overrides (account_limits), including the setting-preset quota, via either write path: the full upsert (adminUpsertAccountLimits / PUT /v1/admin/accounts/{id}/limits) or the partial resolved-limits update (adminSetAccountLimits / PUT /v1/admin/accounts/{id}/resolved-limits), both gated accounts:edit. Metadata account_id, a path discriminator (upsert | resolved), and the numeric limit fields that were submitted (max_setting_presets, max_overlays, …) - never the free-text notes.
  • system:user_setting_preset_deleted - operator deleted one of a user's setting presets (adminDeleteUserSettingPreset / DELETE /v1/admin/setting-presets/{id}, gate users:edit); emitted only on a real deletion. Metadata target_user_id, preset_id, and kind (chat | events | music) - never the preset payload (it can hold viewer names the user typed).

Per the no-secret rule, none of these payloads may carry a token, ciphertext, token_hint, invite_code/code, OAuth client_id/client_secret, user email, display name, or free-text review_notes. This is enforced by apps/api/tests/audit_emitters.rs::zaf1022_metadata_excludes_secrets_and_pii, and the User-scope isolation by audit_scoped_reads.rs::developer_team_events_are_user_scoped_and_isolated.

Global safe and blocked song lists have no tenant account_id. REST approve_vote_candidate / dismiss_vote_candidate and GraphQL approveReport / dismissReport require the admin-scope copyright:moderate permission and emit copyright:moderation_approved or copyright:moderation_dismissed after a successful decision. Dismissal emits only when a report was actually dismissed. Submitting or confirming a community report does not write a global list entry or a moderation audit event.

Both protocol paths use db::audit::copyright_moderation_metadata. The event is AuditScope::System, with the operator as user_id and a null tenant account_id. Metadata contains spotify_track_id, list (blocked, safe, or null), source (the list row's source, or null on dismissal), and decision (approved or dismissed). It excludes song title, artist, and reporter identity. The emit is best-effort and cannot fail the moderation action.

Operator API-key management (ZAF-1174)​

Platform operators can manage any account's or user's API keys from the admin app - personal lm_usr_* keys (in api_keys) and account service lm_svc_* keys (in account_service_keys) - gated on the admin-scope account-apikeys:* family (AdminPermissionGuard / require_admin_permission). Each operator write is a platform-operator action on someone else's credential, so it is AuditScope::System with the operator as user_id (the actor) and no tenant account_id column - the target ids ride in metadata. These rows never surface in the target account's or the target user's own audit log. GraphQL (graphql/admin.rs) and REST (routes/admin.rs) emit the same event/scope/metadata via the shared emit_system_audit helper.

  • system:api_key_updated - an operator edited a key's label + scopes (adminUpdateApiKey / PATCH /v1/admin/api-keys/{kind}/{id}). Metadata: kind (personal | service), api_key_id, target account_id, target user_id (personal only; null for service), and the resulting permissions.
  • system:api_key_rotated - an operator regenerated a key's secret (adminRotateApiKey / POST /v1/admin/api-keys/{kind}/{id}/rotate). The new full key is returned to the operator exactly once and never stored/logged; metadata adds old_key_prefix + new_key_prefix to the same kind / api_key_id / account_id / user_id ids.
  • system:api_key_revoked - an operator revoked (hard-deleted) a key (adminDeleteApiKey / DELETE /v1/admin/api-keys/{kind}/{id}). Idempotent - a missing key resolves to not-found. Metadata: kind, api_key_id, account_id, user_id (personal only).

Per the no-secret rule, metadata carries only ids and the short, non-secret lm_* prefixes - never the full key, its hash, or PII. These are the operator siblings of the owner-self user:api_key_* (personal) and account:service_key_* (service) events: exactly one emitter fires per path, and an operator action is never mirrored into the target's personal/account log.

Extension kill switch (ZAF-1919)​

account:extension_kill_switch_set (AuditScope::Account) is written by services::extension_kill_switch::set_kill_switch, the one path behind REST POST /v1/bot-modules/kill and GraphQL setBotModuleKillSwitch (both bot-modules:edit, own account only). The actor is user_id; metadata is { "killed": bool }. The state is persisted in account_extension_kill_switch and the row is written before the live pub/sub push, so a failed publish still leaves the change audited. Covered by audit_emitters.rs::extension_kill_switch_emits_account_scoped_row.

Extension uninstall (ZAF-2407)​

account:extension_uninstalled (AuditScope::Account) is written by db::extension_uninstall::emit_uninstalled, which REST DELETE /v1/extension-installs/{id} and GraphQL uninstallExtension (both extension-store:uninstall) call after execute_uninstall_cascade succeeded. The actor is user_id; metadata is { "extension_id", "install_id", "delete_data_immediately": bool }. Deleting the data immediately is irreversible and removes only the account's rows from the extension's shared ext_{extension_id} schema (the schema goes with the last install). Covered by zaf2407_extension_data_per_account_purge.rs::graphql_immediate_uninstall_purges_per_account_and_audits.

Contract declarations​

services::contract_declarations emits on a newly stored declaration, before Stripe work. A repeated client_request_id for the same declaration returns the existing row and emits no second declaration event; one reused for a different declaration is refused (IDEMPOTENCY_KEY_REUSED) and emits nothing. Public billing:withdrawal_declared and billing:cancellation_declared have AuditScope::System and no user actor. A signed-in withdrawal uses AuditScope::Account when an active account exists, or AuditScope::User otherwise. Plan and extension subscription cancellations emit account:subscription_cancel_requested and extension:subscription_cancel_requested in account scope; metadata includes the declaration ID and non-secret contract identifiers and terms, not the free-text reason.

The admin close path emits billing:withdrawal_processed or billing:cancellation_processed with AuditScope::System, operator actor, case ID and result (processed or rejected). Matching and legal-hold extension emit billing:declaration_matched and billing:declaration_retention_extended, also in system scope. GraphQL and REST call the same service and therefore emit the same event and metadata. Receipt sending and list reads do not emit audit rows.

Withdrawal-waiver declaration (ZAF-2690)​

billing:withdrawal_waiver_accepted (AuditScope::Account) is written by services::withdrawal_waiver::record, which both Stripe checkout webhooks call once the account is known: routes/billing.rs::handle_checkout_completed (plans) and routes/webhooks_payments.rs::handle_checkout_completed (extension purchases). It fires only when the proof row in withdrawal_waiver_consents was inserted, so a webhook redelivery of the same session emits nothing. The actor user_id is the buyer from the session metadata; there is no client request, so IP, user agent and GeoIP are empty. Metadata: { "kind", "text_version", "accepted_at", "checkout_session_id", "plan_id" | "extension_id" }. The plan checkout exists on REST and GraphQL, the extension purchase on REST only; the event comes from the shared webhook path either way. Covered by audit_emitters.rs::withdrawal_waiver_emits_account_scoped_row_once.

Account bot and keyword list events​

account:bot_list_updated and account:history_keywords_updated are account-scoped list-change events. GraphQL and REST use the same service outcomes and account audit helpers, so both protocols emit the same metadata only when a row actually changes. Reads, keyword pattern tests, duplicate bot/keyword adds and no-op removals emit nothing.

EventWritersMetadata
account:bot_list_updatedaddAccountBot / removeAccountBot; POST / DELETE /v1/account-botsaction (added/removed), platform, platform_user_id, display_name, source
account:history_keywords_updatedaddHistoryKeyword / updateHistoryKeyword / removeHistoryKeyword; POST / PUT / DELETE /v1/history/keywordsaction (added/changed/removed), keyword_id, keyword, match, case_insensitive

The actor is the calling user when one exists. The keyword text and bot display name are visible to members with audit-log:read; neither event records a chat message, token or credential.

Read-time UUID → name resolution (ZAF-1233)​

Audit rows store ids, never names - denormalizing a display name into metadata at write time is forbidden (every account member with audit-log:read can read metadata, and the name would go stale). Instead the read surfaces resolve the ids to display names at read time, once per page, so the UI can show "name (uuid)" for every entity a page references - not just the actor.

  • Resolver: db::audit::resolve_referenced_names(primary_pool, &rows). It scans the page's rows for the actor (user_id) plus a fixed metadata key allowlist, collects the distinct ids per entity kind, and runs one WHERE id = ANY($1) query per kind - never N+1. audit_events lives in TimescaleDB, but the named entities live in primary Postgres, so the resolver takes the primary pool (state.db / ctx.data::<PgPool>()), not state.tsdb.
  • Kinds & columns: user → users.display_name, account → accounts.name (the human-facing account name the admin surfaces already render), role → account_roles.name.
  • Allowlist: actor user_id, plus metadata keys target_user_id / member_id (user), from_account_id / to_account_id / target_account_id (account), and role_id (account role). Other metadata ids are not resolved. role_id also appears on the system:user_role_*, admin_role:* and developer-team events, which reference other role tables; those simply miss here and fall back to the raw UUID.
  • Misses are omitted. There is no FK from the retained audit row to these tables - a user/account can be CASCADE-deleted while the audit row is deliberately kept - so a lookup that finds nothing means "deleted/unknown"; the id stays on the wire and the client renders the raw UUID (plus a "deleted" label). Resolution is also fail-open: a resolver query error logs and yields an empty map (via resolve_referenced_names_lenient) rather than blanking the audit page.
  • Wire shape (all three surfaces, GraphQL + REST): each event carries a resolved_names list of { id, kind, display_name } (resolvedNames in GraphQL) for exactly the entities that event references, de-duplicated. Present and identical on GraphQL AuditEvent + AdminAuditEvent and REST AuditEventResponse. The raw UUIDs are never removed - resolved_names is additive enrichment.

Consequently, when you add an emit site whose metadata should render a name, use one of the allowlisted keys above so the read surfaces resolve it for free.

Parity and tests​

  • Protocol parity applies. An action reachable on both GraphQL and REST emits the same event type, scope and metadata shape on both. A one-sided emitter is a bug. Where a path exists on only one protocol (for example the OAuth browser-redirect callback, which is REST-only), document why at the call site.
  • Cover the emitter in apps/api/tests/audit_emitters.rs: the row lands with the expected scope, account, actor and metadata.
  • Cover the isolation in apps/api/tests/audit_scoped_reads.rs: a new scope or reader needs a fail-closed negative test proving foreign rows stay invisible.

Adding a new event type - checklist​

  1. Add the event_types::* constant with a domain:action name matching its scope.
  2. Emit it via db::audit::emit at every protocol that can trigger the action.
  3. Set scope explicitly; put the target in metadata; keep secrets out.
  4. Add an emitter test and, for a new scope or reader, an isolation test.
  5. Document the event in Audit Log (and Admin · Audit Log if operators see it).
  6. Add the type to the admin catalog mirror in apps/admin/src/app/(admin)/audit-log/event-types.ts and its auditLog.eventTypes labels in apps/admin/messages/en.json and apps/admin/messages/de.json. The catalog parity test (apps/admin/__tests__/audit/event-types-catalog.test.ts) fails if the mirror omits a backend type. A filter option with no emitter is also a documented lie.