Audit

Mutation audit log — sinks (console/memory/custom), include/exclude filters, the audit() mixin, testing with the memory buffer.

@voltro/plugin-audit has two independent surfaces, picked à la carte:

  1. The auditPlugin({ sink }) mutation interceptor — records every mutation invocation (tag, subject, input, outcome, duration) to a configurable sink.
  2. The audit() schema mixin — adds createdAt/updatedAt/createdBy/updatedBy columns, auto-stamped by the runtime from the request subject.

Status: ✓ shipped.

Installing

// app.config.ts
import { auditPlugin } from '@voltro/plugin-audit'

export default {
  type: 'api' as const,
  plugins: [
    auditPlugin({ sink: 'console' }),
  ],
}

What's recorded

The interceptor records mutation invocations only — not queries, not actions, not AI/agent calls. (For AI token counts and cost, read the usage returned on every @voltro/ai call — see Cost tracking.) One AuditEvent per outcome:

interface AuditEvent {
  readonly ts: number           // start time (epoch ms)
  readonly tag: string          // rpc tag, e.g. 'orders.create'
  readonly subject: Subject     // resolved caller (carries tenantId)
  readonly traceId: string
  readonly input: unknown       // the mutation's validated input
  readonly outcome:
    | { readonly kind: 'ok';    readonly value: unknown; readonly durationMs: number }
    | { readonly kind: 'error'; readonly error: unknown; readonly durationMs: number }
}

A failure records the underlying Effect.fail value / thrown error (not the whole Cause tree); the original mutation outcome always flows through untouched.

Sinks

sink is one of these shapes:

auditPlugin({ sink: 'console' })
// default — one structured line per mutation via @voltro/logger
// (log.info on success, log.warn on error)

auditPlugin({ sink: 'memory' })
// last 1000 events in an in-process ring buffer — for tests

auditPlugin({ sink: 'datastore' })
// DURABLE + queryable — contributes the _voltro_audit_log table
// (extendSchema, under store:write) and appends one row per mutation

auditPlugin({
  sink: async (event) => { /* ship to wherever */ },
})
// custom function: (event: AuditEvent) => void | Promise<void> | Effect.Effect<void>

'datastore' is the production sink: it survives restarts, is shared across replicas, and is queryable via ctx.store.select('_voltro_audit_log'). Each row carries the flattened tag / at / subjectId / tenantId / traceId / status / durationMs (indexed by tag + traceId) plus the full subject / input / outcome as portable json() columns.

The custom function is the escape hatch for persisting events anywhere the built-in table's schema doesn't fit — e.g. an Effect.Effect<void> sink that writes rows into your own audit table on top of @effect/sql. All return shapes are normalised by the interceptor. A sink that throws / rejects / dies is caught and swallowed, so a broken sink can never mask the mutation's real outcome.

Not included (yet): there is no built-in OpenTelemetry sink, no redaction option, no truncation options, and no retention machinery on the _voltro_audit_log table. Pair sink: 'datastore' with @voltro/plugin-governance's retention sweep to GC old rows, and redact PII inside a custom function sink if you need it.

Scoping which mutations are recorded

include / exclude are RegExps tested against the rpc tag:

auditPlugin({
  sink:    'console',
  include: /^orders\./,      // record ONLY these tags (default: all)
  exclude: /^orders\.debug/, // skip these — overrides `include`
})

Useful for keeping high-frequency mutations (presence pings, analytics events) out of the log.

The audit() schema mixin

Import from the browser-safe /mixin subpath in a *.entity.ts file and chain it with .with(...):

// database/notes.entity.ts
import { table, id, text } from '@voltro/database'
import { audit } from '@voltro/plugin-audit/mixin'

export const notes = table('notes', {
  id:    id(),
  title: text(),
  body:  text(),
})
  .with(audit())   // + createdAt / updatedAt / createdBy / updatedBy

Mixin adds:

Column Type Filled on
createdAt Date insert
updatedAt Date insert + update
createdBy string | null insert (→ actors)
updatedBy string | null insert + update (→ actors)

The *By columns are references to the app's actors table and are nullable — a system-seeded or pre-auth write (e.g. signup) leaves them unset. The runtime fills all four from the request subject automatically; an explicit value the caller passes is respected, not overwritten. softDelete(), tenant(), and deactivation() all transitively require audit().

Asserting on emitted events in a test

import { auditPlugin, readAuditBuffer, clearAuditBuffer } from '@voltro/plugin-audit'

beforeEach(() => clearAuditBuffer())

const plugin = auditPlugin({ sink: 'memory' })
// … drive a mutation through the plugin …
const events = readAuditBuffer()   // ReadonlyArray<AuditEvent>, oldest → newest

readAuditBuffer() returns a detached snapshot copy; the buffer caps at 1000 events (oldest evicted first).

Anti-patterns

  • Relying on the 'memory' sink in production. It is a per-process test buffer capped at 1000 events — not a durable store. Use sink: 'datastore' (or a custom function sink) instead.
  • A throwing sink as a validation gate. Sink failures are deliberately swallowed; the sink can never veto or alter the mutation.
  • PII in mutation inputs. The interceptor records the validated input verbatim — there is no built-in redactor. Keep secrets out of mutation inputs, or redact inside your custom sink before persisting.
  • Audit log as a queryable view of business state. It's a write log. The actual current state lives in your normal tables.