Audit Logs

Audit Schema

The AuditFields type, action naming conventions, actor types, idempotency, and how the schema sits inside a regular wide event.

event.audit is a typed field on every wide event. Downstream queries filter on audit IS NOT NULL to materialise an audit dataset out of regular logs.

AuditFields type

interface AuditFields {
  action: string                          // 'invoice.refund'
  actor: {
    type: 'user' | 'system' | 'api' | 'agent'
    id: string
    displayName?: string
    email?: string
    // For type === 'agent', mirrors evlog/ai fields:
    model?: string
    tools?: string[]
    reason?: string
    promptId?: string
  }
  target?: { type: string, id: string, [k: string]: unknown }
  outcome: 'success' | 'failure' | 'denied'
  reason?: string
  changes?: { before?: unknown, after?: unknown }
  causationId?: string                    // ID of the action that caused this one
  correlationId?: string                  // Shared by every action in one operation
  version?: number                        // Defaults to 1
  idempotencyKey?: string                 // Auto-derived; safe retries across drains
  context?: {                             // Filled by auditEnricher
    requestId?: string
    traceId?: string
    ip?: string
    userAgent?: string
    tenantId?: string
  }
  signature?: string                      // Set by signed({ strategy: 'hmac' })
  prevHash?: string                       // Set by signed({ strategy: 'hash-chain' })
  hash?: string
}

Action naming

Naming convention for action. Use noun.verb (invoice.refund, user.invite, apiKey.revoke). Past tense if the action already happened (invoice.refunded), present tense if withAudit() will resolve the outcome. Keep a small fixed dictionary in one file — auditors and SIEM rules query on action, so a typo is a missing alert.

A single dictionary file makes alerting straightforward:

src/audit/actions.ts
export const AUDIT_ACTIONS = {
  USER_INVITE: 'user.invite',
  USER_REMOVE: 'user.remove',
  USER_ROLE_CHANGE: 'user.role-change',
  INVOICE_REFUND: 'invoice.refund',
  API_KEY_REVOKE: 'apiKey.revoke',
} as const

Actor types

Don't fake the actor. Use actor.type: 'system' for cron jobs, queue workers, and background tasks; actor.type: 'api' for machine-to-machine calls authenticated by a token; actor.type: 'agent' for AI tool calls. Logging a synthetic 'user' for system actions is the single fastest way to fail an audit review.
actor.typeWhen to use
'user'A human authenticated through your normal auth flow.
'system'Cron jobs, queue workers, scheduled tasks, internal background processes.
'api'Machine-to-machine calls from another service authenticated by a token.
'agent'AI tool calls (combine with evlog/ai fields like model, tools, promptId).

Outcomes

outcomeMeaning
'success'The action completed as requested.
'failure'The action was attempted but failed (downstream error, race condition, etc.).
'denied'The action was rejected by an authorisation check.

'failure' and 'denied' are different things — auditors care a lot about denied actions because they signal probing or misconfigured access controls. Always log denials (see Recording Events).

Idempotency

idempotencyKey is auto-derived from a hash of action, actor.id, target, and a coarse timestamp. The result: even if your drain retries an audit insert across a network blip, the duplicate row collapses on ON CONFLICT DO NOTHING. You don't have to think about it — it's filled in for you.

Use the field as the primary key in Postgres / Bigtable / DynamoDB so retries stay safe by construction.

Causation and correlation

FieldUse case
correlationIdShared by every audit event that belongs to the same operation (e.g. one HTTP request that triggers a refund + an email + a webhook).
causationIdThe id of the previous audit event that caused this one. Useful for reconstructing chains of cascading actions.

Most teams set correlationId to requestId. causationId is opt-in and only worth filling when a single user action triggers many internal audit events.