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:
- The
auditPlugin({ sink })mutation interceptor — records every mutation invocation (tag, subject, input, outcome, duration) to a configurable sink. - The
audit()schema mixin — addscreatedAt/updatedAt/createdBy/updatedBycolumns, 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 / updatedByMixin 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 → newestreadAuditBuffer() 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. Usesink: '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.