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.
| Scope | Event types |
|---|---|
| User: email and password | user: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 confirmation | user: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 identity | user: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: privacy | user:data_export_requested, user:data_export_cancelled, user:data_export_downloaded, user:deletion_scheduled, user:deletion_cancelled |
| Account | account: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) |
| System | system: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:
| Category | Examples |
|---|---|
| Authentication & identity | Login, failed login, MFA enable/disable, OAuth grant |
| Credentials | API-key create/revoke, channel connection added/removed |
| Permission structure | Role create/update/delete, member role assignment |
| Destructive / GDPR | Data erasure, retention purges, account deletion |
| Platform operations | Admin 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.
| Scope | Use for |
|---|---|
AuditScope::User | Personal security events, readable self-only |
AuditScope::Account | Tenant actions, gated on account audit-log:read |
AuditScope::System | Platform-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:
| Caller | Precedence |
|---|---|
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:
| Caller | Precedence |
|---|---|
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 UAThe 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) nullsip_address/user_agent/country/cityon 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 withdrop_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:loginfor the native (PKCE) flow -POST /v1/auth/authorizereturns only an authcode; the subject/JWT is minted later when the native app redeems the code againstPOST /v1/auth/token/exchange, directly against the API. That redemption handler is the single authoritativeuser:loginemit point for the native flow (the web flow stays on apps/id ingest);metadata.method = "pkce".user:loginfor the GraphQLexchangeTokenmutation (ZAF-1023) —graphql/auth.rsexchange_tokenemitsuser:logininline on success withmetadata.method = "graphql_exchange". This is the GraphQL path's only login-success writer and must not be removed:exchangeTokenhas 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 RESTexchange_token/authorizehandlers and the GraphQLexchangeTokenmutation (ZAF-1023, at parity) emit it on a failed provider-token validation, andtoken_exchangeemits 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-effortemit, not the ingest endpoint. Both paths capture the request User-Agent (the GraphQL context grows aUserAgentdatum 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:ingestgrant. Every non-System principal is rejected403(anonymous401) so no public/user route can forge audit rows; a System key without the grant is403too. Provision apps/id's key withaudit:ingest(oraudit:*- 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 anaccount:*/system:*type. - It uses
insert_audit_event, notemit, 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, neverinsert_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.
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",emitUserAuditEventemits exactly oneconsole.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 subjectuser_idstays silent (it is a normal skip, not an operator condition). - Health field.
GET /api/healthon apps/id returnsaudit_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'ssubclaim.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 asuser:email_login_changed. Actor = the acting user (user_id), no tenantaccount_id. The address is PII and is never written tometadata- the row carries only ahad_previous_emailboolean and the source /clearedflag. 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,metadatacarriesplatformconnection_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 ranchat:delete,chat:ban,chat:unbanorchat:timeoutthrough the action dispatcher, and the platform applied it. Emitted byservices::extension_chat::emit_moderation_audit(apps/api/src/services/extension_chat.rs) at the end ofextension_chat::moderate, which every caller reaches throughservices::extension_actions::execute_action:- widgets and apps (
useLumioAction) - RESTPOST /v1/extension-installs/{install_id}/actions/{action}and GraphQLexecuteExtensionActionwrite the same row; the origin is the extension host's call; - bot modules - the
actionsof a handler result or adefineModeratedecision, run through the internalPOST /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_idisNULL: the actor is the extension, and the account's bot carried the action out.metadata=source(extensionfor widgets and apps,bot_modulefor bot modules),extension_id,install_id,action(delete|ban|unban|timeout),platform,target_user_id(nullfor delete),duration_secs(timeout only). The reason text is never written. - widgets and apps (
-
A bot module's
chat:deleteis 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/appchat: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 inplatform_moderation_log. An extension acting on the tenant is a third party, so its moderation is a security record as well. Extensionchat:sendemits 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 inapps/api/tests/zaf2391_extension_actions.rsand the bot-module path inapps/api/tests/zaf2398_bot_module_actions.rs.
Automation webhook target refused
account:webhook_target_blocked(AuditScope::Account) - a manual run of an automation reached awebhook_callnode whose targetapps/api/src/webhook_target.rsrefused (invalid_url,scheme_not_allowedorprivate_target). Emitted byRedisActionDispatcher::call_webhook(apps/api/src/dispatch.rs) when the dispatcher carries aWebhookAuditcontext. Both protocols build that context in one place,services::automation_execute::execute_manual, so GraphQLexecuteAutomationand RESTPOST /v1/automations/{id}/executewrite the same row: the member asuser_id, the request origin, andmetadata=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'sactive_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 samesession_id, so revocation still reaches it - seefeatures/sessions.md). The actor is the user; the from/to account ids, aclearedflag, and thesession_idof the device that switched ride inmetadata(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_idis NULL: a personal switch is not a tenant action, so it stays out of the switched-to account'saudit-log:readlog (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 twinPATCH /v1/users/me(apps/api/src/routes/users.rs), on the same condition that re-mints the cookie (active_account_idset, orclear_active_account). Same event type, scope and metadata on both protocols (parity). Both use the standard best-effort in-processemit.
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: GraphQLlogout(refreshToken)/logoutSession, RESTPOST /v1/auth/logout.metadatacarries the endedsession_id(when the deleted row is known) and themethod(refresh_token|session). For the refresh-token flow the actor and session id come from the deleted session row (delete_sessionnowRETURNING id, user_id), so an expired-but-authentic logout still records the row;logoutSessiontakes them from the JWT claims.user:session_revoked- the user revoked session(s) from session management: GraphQLdeleteSession(id)/deleteAllOtherSessions, RESTDELETE /v1/users/me/sessions/{id}/DELETE /v1/users/me/sessions.metadatadistinguishes 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 GraphQLupdatePopoutTokenand RESTPATCH /v1/tokens/{id}(ZAF-1922). - Account service keys (
lm_svc_*) -account:service_key_created/_updated(label rename) /_revoked/_rotated. Emitted by GraphQLcreateAccountServiceKey/updateAccountServiceKey/deleteAccountServiceKey/rotateAccountServiceKeyand RESTPOST/PATCH/DELETE /v1/service-keys/POST /v1/service-keys/{id}/rotate(protocol parity). Account-scoped (the key is a tenant credential, unlike the personaluser:api_key_*events); the actor is the member who performed the action. Metadata carries onlyservice_key_idand the short, non-secretlm_svc_*prefix -key_prefix, orold_key_prefix+new_key_prefixon_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/_revokedandaccount:widget_access_granted/_revoked. Grant is an upsert: the resultingrolein metadata covers both first-grant and role-change (no pre-read, no separate*_changedtype). - History report share links (
lm_share_*, ZAF-780) —account:history_shared_link_created/_extended/_revoked. The samelm_share_*credential lifecycle as the overlay share link, keyed to a history session/report instead of an overlay (create/revoke carry thelm_share_*token_prefix, extend carries the new expiry). Metadata is the non-secretsession_id/link_id(+expires_at/token_prefix) and the effective non-secretoptions(legacy links use{legacy: true, include_revenue: false}) - never the token string/hash or the argon2password_hash. - History revenue assumptions -
account:history_revenue_settings_updated, emitted only for a real change by GraphQLupdateChannelHistoryRevenueSettingsor RESTPUT /v1/history/revenue-settings. Scope isaccount, 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 addsyoutube_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 behindhistory:revenue-readwhile the audit log is readable withaudit-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 thesession_idonly. 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 inmetadata(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 pathdeleted_moderation_log_countcounts the non-AutoMod rows next todeleted_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 oneAuditScope::Systemrow per request (account_idcolumn null;metadata.target = "account",metadata.account_id= target account, pluscase_id,instruction_reference,initiated_by(channel|subject),subject_kind,platformand the counts; the instructed subject erasure alsoreason) - 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 onechat_pii_erasureSystem row withmetadata.target = "lumio",instruction_referenceandreason, and no account. The record erasure (record_erasure::erase_record) writes one System row per erased record withmetadata.target = "record",record_kind,record_id, the targetaccount_id,case_id,instruction_reference,reasonand 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 gatedpresets:share. Both areaccount-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-secretpreset_id,kind(chat|events|music) andname- 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) andDELETE /v1/discord-guilds/{guild_id}(REST), gatebot-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 mutationremoveDiscordGuildByBot(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, gateconnections:create).save_credentials/saveAppCredentialswrite AES-256-GCMclient_id/client_secretintoapp_credentials; this is the credential level, distinct from the token levelaccount:connection_added. Metadata:platform,credentials_id(row id),updated(insert vs update) - never theclient_id/client_secret(plain or ciphertext). The DELETE counterpart isaccount:connection_credentials_deleted(ZAF-981, gateconnections:delete):delete_credentials/deleteAppCredentialsremove the row and, on the same path, tear down the associated channel connection - so that path emits bothaccount:connection_removed(when a connection row was removed) andaccount: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 isplatformonly. - Integrations (Shopify) -
account:integration_connected/account:integration_disconnected/account:shopify_settings_updated(AuditScope::Account, ZAF-2932, ZAF-2988). All three are written inservices::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); metadataplatform,shop_host,scopes,source(shopify_admin),shopify_user_id(the Shopify staff member'ssub). 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'sapp/uninstalledwebhook; metadataplatform,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_adminshopify_user_id); metadataplatform,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 byservices::shopify::hide_order_name, whichhideShopifyOrderNameandPOST /v1/connections/shopify/events/{event_id}/hide-nameboth call; actor = the member (events:edit). Emitted only when a stored label was removed (a repeat hide emits nothing). Metadataplatform,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 callsPOST /v1/webhooks/shopify/compliance; no client surface, so no GraphQL twin). Data request: on its single pass, metadataplatform,shop_host,orders_requested,orders_found,orders_with_label. Redactions: only on the completing pass after the 72 h dead-letter window, metadataplatform,shop_host(+orders, a count, forcustomers/redact). Counts only: never a customer label, order id or customer contact. - Developer OAuth clients -
oauth_client:created/_updated/_deleted(AuditScope::System, gateoauth-clients:{create,edit,delete}). Metadata:client_id_ref(the DB row id, not the OAuthclient_idstring),name,redirect_uris,scopes, and (update)changed_fields- never the OAuthclient_idstring orclient_secret. - Operator admin roles -
admin_role:created/_updated/_deleted/_assigned/_unassigned(AuditScope::System, gateadmin-roles:{create,edit,delete}). Theadmin_rolestable is separate fromuser_roles(whosesystem:user_role_*events already emit). Metadata:role_id,role_name,permissions(the grantedresource:actionlist — auditing the grant is the point),changed_fields(update); assign/unassign addtarget_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, gatebot-connections:{create,delete}). The nil-UUID global bot token, written byexchange_global_bot_oauthand the unifiedGlobalBotexchange. 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(gateusers:delete) andsystem:user_login_connection_deleted(AuditScope::System). The login-identity removal has full REST↔GraphQL parity —admin_delete_user_login_connection(GraphQL) andDELETE /v1/admin/users/{id}/login-connections/{provider}(routes::admin::admin_delete_login_connection, gateusers:edit) emit the same event. Both emit only on a real deletion. Metadata:target_user_id, plusproviderfor the login-connection case - never email/handle/tokens. - Login-identity routing -
account:login_connection_assigned(AuditScope::Account,assign_login_connectionon both protocols, gatelogin-assignments:createon the cross-user path; metadataprovider+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, gatelogin-assignments:delete; metadataprovider+target_user_id) anduser:login_connection_removed(AuditScope::User, self-servicedelete_login_connection/disconnectLoginConnection, first-party-gated ZAF-469; metadataprovider+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 GraphQLcreateUserApiKeyand RESTPOST /v1/api-keys(protocol parity).user:api_key_updated- a key is renamed (label only). Emitted by GraphQLupdateUserApiKeyand RESTPATCH /v1/api-keys/{id}(protocol parity).user:api_key_revoked- a key is deleted. Emitted by GraphQLdeleteUserApiKeyand RESTDELETE /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 GraphQLrotateUserApiKeyand RESTPOST /v1/api-keys/{id}/rotate(protocol parity); the rotate paths pre-read the key so the row can carry the old prefix. Metadata addsold_key_prefix+new_key_prefixalongsideapi_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, andresubmission(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/verificationare 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). Metadatareleased_balance_id,amount,currency,release_case, built byservices::developer_funds_release::audit_metadatafor 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 writessystem: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 setsclaim_started_at; a repeat writes nothing. Metadatareleased_balance_id,amount,currency, built byservices::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). Metadatareleased_balance_id,developer_id(the new profile),amount,currency, built byservices::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). Metadataverification_id,developer_id,has_tax_number,has_vat_id,small_business_declared- never the Steuernummer or USt-IdNr. themselves. Both protocols shareroutes::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, gatebilling:edit). Metadatacredit_note_id,cancellation_id,cancellation_number,reason,developer_id,reissued_id,reissued_number, built byroutes::developer_credit_notes::cancel_audit_metadatafor both protocols. The automatic cancellation of a failed payout is bookkeeping inside the payout failure (recorded bysystem: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(sourcerequestorclosing) and the closing payout ofscheduleProfileDeletion(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,sourceoperator) or the PayPal payouts webhook (no actor,sourcewebhook).system:developer_payout_failed(AuditScope::System): the provider refused the payout (sourcecycle, addsprovider_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 viaadmin_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); metadatatarget_user_id+ ahad_previous_emailflag - never the old/new address.system:user_account_creation_override_set- metadatatarget_user_id+override(allow|deny|default).system:user_max_accounts_override_set- GraphQL-only (admin_update_user_max_accounts_override);patch_userREST does not acceptmax_accounts, so a GraphQL-only emitter is correct parity. Metadatatarget_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}). Metadatatarget_account_id+platform; emitted only on a real deletion.system:account_login_connection_deleted- GraphQL-only (admin_delete_account_login_connection, gateaccounts:edit); no REST twin exists for the account-level login-connection delete (the RESTadmin_delete_login_connectiontargets a single user via the already-auditedsystem:user_login_connection_deleted). Metadatatarget_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}, gateaccounts:edit). Metadatatarget_account_id,target_user_id(+ the effectiverole_idon 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}]). Metadatacoupon_idplusdiscount_type(create) orchanged_fields(update; field names only, built by the shareddb::audit::coupon_changed_fields) - never the redeemablecode. 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}, gatese-tokens:delete). Metadatatarget_account_id+token_id- never the JWT, ciphertext ortoken_hint. The tenant self-service twin staysaccount:se_token_deleted.system:developer_verification_approved/system:developer_verification_rejected- operator KYC review (graphql/developer_verifications.rs+routes/developer_verifications.rs, gatedeveloper-verification:edit). Metadataverification_id(+target_account_idon approve) - never the free-text rejectionreason.system:extension_version_approved/system:extension_version_rejected- operator extension review (graphql/extension_reviews.rs+routes/extension_reviews.rs, gateextension-review:edit). Metadataversion_id,extension_id(+new_statuson approve) - never the reviewnotesorreason.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, gatedeveloper-limits:edit). Metadatatarget_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 gatedaccounts:edit. Metadataaccount_id, apathdiscriminator (upsert|resolved), and the numeric limit fields that were submitted (max_setting_presets,max_overlays, …) - never the free-textnotes.system:user_setting_preset_deleted- operator deleted one of a user's setting presets (adminDeleteUserSettingPreset/DELETE /v1/admin/setting-presets/{id}, gateusers:edit); emitted only on a real deletion. Metadatatarget_user_id,preset_id, andkind(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.
Copyright Manager moderation
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, targetaccount_id, targetuser_id(personal only;nullfor service), and the resultingpermissions.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 addsold_key_prefix+new_key_prefixto the samekind/api_key_id/account_id/user_idids.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.
| Event | Writers | Metadata |
|---|---|---|
account:bot_list_updated | addAccountBot / removeAccountBot; POST / DELETE /v1/account-bots | action (added/removed), platform, platform_user_id, display_name, source |
account:history_keywords_updated | addHistoryKeyword / updateHistoryKeyword / removeHistoryKeyword; POST / PUT / DELETE /v1/history/keywords | action (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 oneWHERE id = ANY($1)query per kind - never N+1.audit_eventslives in TimescaleDB, but the named entities live in primary Postgres, so the resolver takes the primary pool (state.db/ctx.data::<PgPool>()), notstate.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 keystarget_user_id/member_id(user),from_account_id/to_account_id/target_account_id(account), androle_id(account role). Other metadata ids are not resolved.role_idalso appears on thesystem: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_nameslist of{ id, kind, display_name }(resolvedNamesin GraphQL) for exactly the entities that event references, de-duplicated. Present and identical on GraphQLAuditEvent+AdminAuditEventand RESTAuditEventResponse. The raw UUIDs are never removed -resolved_namesis 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
- Add the
event_types::*constant with adomain:actionname matching its scope. - Emit it via
db::audit::emitat every protocol that can trigger the action. - Set
scopeexplicitly; put the target inmetadata; keep secrets out. - Add an emitter test and, for a new scope or reader, an isolation test.
- Document the event in Audit Log (and Admin · Audit Log if operators see it).
- Add the type to the admin catalog mirror in
apps/admin/src/app/(admin)/audit-log/event-types.tsand itsauditLog.eventTypeslabels inapps/admin/messages/en.jsonandapps/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.