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, plus the chainId / seq / prevHash / hash tamper-evidence columns (see the hash chain).
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.
record — which outcomes reach the sink
include / exclude filter by TAG, before the call runs. record filters by what happened, after:
auditPlugin({ sink: 'datastore', record: 'errors' })'all'(default) — every invocation.'errors'— refusals only: a denied guard, a revoked key, a rejected validation. This is the forensic core, and it pairs with@voltro/plugin-row-history, which records the successful writes — so the two together still cover everything while this table stays small enough that retention is a footnote.- a predicate —
(event) => boolean, for anything else.
'all' is the default even though 'errors' is often the right choice, because defaulting to errors would silently stop recording successes for every app that upgrades — and "what did this compromised account touch" is answered by successes. Shrinking the trail is a decision you make with your eyes open.
redactInput — what of the payload is kept
The default is 'all': the payload is replaced by { __redacted: 'all' }. The row still proves a payload existed; it just does not carry it.
auditPlugin({ sink: 'datastore', redactInput: 'none' }) // the raw input, verbatim
auditPlugin({ sink: 'datastore', redactInput: (e) => pick(e) }) // field-level controlinput is the raw mutation input, so an unredacted audit table is where a password change, an API key at issuance and a PAT land — the one place nobody thinks to look for a credential. Losing payload detail is visible the first time you read a row; leaking a credential is not visible at all.
Why it is not driven by .serverOnly() / .sensitive(), which is the obvious design: those markers live on TABLE COLUMNS, and this is a mutation's INPUT. A changePassword({ oldPassword, newPassword }) has no column to consult, so a marker-driven default would cover exactly 0% of the case it exists for — while reading, to whoever configured it, like protection. (.sensitive() is also the export axis, not "unsafe to log"; treating one as the other is the category error that page warns about.) Pass a function once you know your own inputs.
redactOutcome — what the call RETURNED
The default is 'all': outcome.value on success and outcome.error on failure are replaced by { __redacted: 'all' }. kind, durationMs and the error's _tag survive.
auditPlugin({ sink: 'datastore', redactOutcome: 'none' }) // the outcome verbatim
auditPlugin({ sink: 'datastore', redactOutcome: (e) => summarise(e) }) // field-level controlThis is redactInput's reasoning applied to the field it structurally cannot cover. For a credential-ISSUING call the secret is never in the input:
apiKeys.createPersonalApiKey({ name, scopes }) // input: nothing sensitive
→ { keyValue: '<the plaintext key>' } // outcome: the whole point
webhooks.create({ url, subscribedEvents }) // input: nothing sensitive
→ { signingSecret: '<live secret>' } // outcome: returned once, by designThe option that existed covers the field those calls leave empty. Found by a team running voltro db scan-credentials on the first release where it read the json blobs: nine rows of _voltro_audit_log.outcome matched, four of them webhooks.create carrying a live 64-character signing secret in full — with redactInput: 'all' and redactSubject: 'metadata' both already on.
The error's _tag survives on purpose. A trail that records "something failed" without saying what is not a trail, and a tag is a schema-declared discriminant that structurally cannot be a secret. A record predicate still sees the live outcome, so a filter keyed on what a call returned keeps working — redaction applies to what is stored.
A FUNCTION sink gets neither the table nor the retention sweep. Both are gated on
sinkbeing the literal'datastore', so a function that redacts and then delegates todataStoreAuditSinkstill writes rows on a database where_voltro_audit_logalready exists — while creating the table nowhere and arming the TTL nowhere. It works where you tested it and fails on the next fresh database. If you want the durable trail with different redaction, usesink: 'datastore'plus the threeredact*options: those compose, the sink does not.
redactSubject — what of the CALLER is kept
The default is 'metadata': subject.metadata is replaced by { __redacted: 'all' }. type, id, tenantId and scopes survive, which is everything the trail is actually read for.
auditPlugin({ sink: 'datastore', redactSubject: 'none' }) // the subject verbatim
auditPlugin({ sink: 'datastore', redactSubject: (s) => pickSafe(s) }) // field-level controlThis exists because the durable sink wrote a live credential. A reporter found a working Jira Personal Access Token in plaintext in 12 of 23 rows of their _voltro_audit_log, and neither plugin involved was wrong on its own:
plugin-atlassian'scredentialsResolvertook aSubjectand nothing else, so an app doing per-user Atlassian auth had nowhere to put the caller's PAT exceptsubject.metadata;- this plugin serialised the Subject verbatim into a json column.
Two correct contracts disagreeing about what a Subject is — an identity, or a credential envelope — with nothing reconciling them.
The reasoning is redactInput's, word for word, applied to the field it did not cover: metadata is not a table column either, so no schema marker protects it; it is app-controlled, so its contents cannot be reasoned about here; and the framework's own per-user-credential mechanism puts a credential in it.
resolveScope still sees the live subject, so a scope derived from metadata keeps working — redaction applies to what is stored, not to what the plugin can compute.
One thing redactSubject deliberately cannot reach: the impersonation mark. @voltro/plugin-auth mints it into subject.metadata, so the default above erased it — and an impersonated action then recorded indistinguishably from the user's own, which is the one distinction an audit trail exists to make. It is now lifted out before the redaction chain runs and stored as AuditEvent.impersonation, in its own nullable impersonation column. No setting takes it away, including a custom function that erases the subject wholesale.
null on an ordinary row, the mark on an impersonated one — so WHERE impersonation IS NOT NULL is the query, on every dialect. The actor is inside the mark (impersonationOf() from @voltro/plugin-auth types it); there is no flattened impersonatedBy column beside it, because that filter already narrows to a handful of rows and a second column would mean this plugin knowing the mark's shape.
If you are upgrading, check the rows you already have. A safer default does not un-leak a past row.
SELECT count(*) FROM _voltro_audit_log WHERE subject::text ILIKE '%token%';Purge what you find and rotate the credentials — assume anything written to a log table has been read.
Reading it back — the correlation join
The trail is only useful if you can enter it by the questions an incident asks. Two entry points, matching the two indices:
import { auditByTrace, auditBySubject } from '@voltro/plugin-audit'
import { historyByTrace } from '@voltro/plugin-row-history'
// What happened during ONE call — and what it changed.
const calls = await auditByTrace(ctx.store, traceId)
const changed = await historyByTrace(ctx.store, traceId, ctx.request.subject.tenantId)
// Every refusal by one actor, newest first.
const denied = await auditBySubject(ctx.store, actorId, { status: 'error', limit: 50 })traceId is the join key. plugin-row-history records what changed; this records who called and whether they were refused. Neither is complete alone, and before the join key existed they could not be read together at all.
auditBySubject takes status as a real argument rather than leaving you to filter in JS: the index is (subjectId, status, at), so a filter applied after fetching would not use it.
Tamper-evidence — the hash chain
Append-only is a convention, not a guarantee. An actor with UPDATE on the database could rewrite what a call did, or DELETE the row that recorded a refusal, and no read of the table would notice — an audit trail whose integrity rests on "nobody has database access" is exactly as trustworthy as the thing it exists to check.
Every row written by sink: 'datastore' therefore carries its position in a hash chain: chainId, seq, prevHash and hash, where hash covers the row's own content and the previous row's hash. Altering any row invalidates every row after it; removing one leaves a hole.
import { verifyAuditChain } from '@voltro/plugin-audit'
const verdict = await verifyAuditChain(ctx.store)
// { ok, rowsChecked, unchainedRows, keyed, chains: [{ chainId, from, to, tip, prunedPrefix }], issues: [...] }issues names what does not add up, and the kinds are not interchangeable:
| Kind | Meaning |
|---|---|
tampered |
The row's content does not hash to its stored hash. A column was altered. |
broken-link |
The row's prevHash is not the previous row's hash. Reordering, or a row substituted for another. |
gap |
seq jumped. A row was deleted, or its write failed. |
A gap at the start of a chain is not an issue — the retention sweep prunes oldest-first, so a pruned prefix is reported as prunedPrefix: true. Rows written before chaining shipped carry no hash at all and are counted as unchainedRows rather than passed over silently.
The chain is per WRITER, and that is the concurrency answer
One global chain would need every insert to know the current tip — a serialization point across every process writing audit rows. Two replicas racing on one chain fork, and a fork is indistinguishable from tampering. A chain that breaks under normal operation is worse than no chain at all, because the first false positive is what teaches everyone to ignore the verifier. A per-tenant chain has the identical problem one level down.
So each process mints its own chainId at boot and allocates seq/prevHash/hash in a synchronous, await-free step — atomic against any number of concurrent events. What you get in exchange is stated plainly: N replicas produce N chains, so verification attests "every chain is intact", not "the log is complete".
Read the guarantee before you quote it
Unkeyed (the default), the chain detects any change that does not recompute it: a hand-run UPDATE, a botched migration, storage corruption, a script that scrubs one row. It does not stop an adversary who knows the scheme and rewrites the chain forward — SHA-256 is public, so with write access they can. Two things close that, both available:
VOLTRO_AUDIT_CHAIN_SECRET— set it and the chain is HMAC-SHA256. An actor with the database but not the key cannot forge a link. There is no default value and nothing is minted for you; keep the key where the database is not.- Anchor the tips.
verifyAuditChainreturns each chain'stip. Publish it on a schedule to somewhere append-only you do not control (an object-lock bucket, a log shipper, a compliance mailbox). This is also the only defence against tail truncation — deleting the newest N rows of a chain is undetectable from the table alone, for any hash chain.
Retention, and the GDPR interaction
sink: 'datastore' registers its own retention: 365 days by default, tunable with VOLTRO_AUDIT_LOG_TTL_HOURS, drained by the boot sweep. An append-only trail with no ceiling is the one that eventually takes the database down.
Erasure is deliberately NOT registered for you. Erasing a subject must not delete the record that they were refused four hundred times — that record is the evidence. The defensible treatment is to anonymise rather than delete, and it is a compliance decision your app makes explicitly:
governancePlugin({
subjectScopes: [{ table: '_voltro_audit_log', subjectField: 'subjectId' }],
erasure: { mode: 'anonymize', anonymizeFields: ['subjectId', 'subject', 'input'] },
})Not included (yet): no built-in OpenTelemetry sink and no truncation options.
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).
Who acted — a snapshot, not a reference
Both _voltro_audit_log and _voltro_row_history carry
actor json { id, type, displayName, email }, resolved from the actors row at
WRITE time.
The reason is visible inside a single row: data on a history row is a full-row
snapshot — deliberately, so it survives what happens to its source — while
changedBy beside it is a foreign key that does not. One record, two
philosophies.
That matters because the right to be forgotten is one this framework grants:
@voltro/plugin-governance's governance.erase (delete | anonymize) exists
for it. Without a snapshot, installing audit + row-history + governance together
makes the first two unreadable for exactly the subjects an investigation is
about. Anonymisation is the worse half: the join succeeds and returns
"Anonymised" for every entry that actor ever produced, retroactively rewriting
history that was correct when it was written.
email is read opportunistically — the framework's own actors carries
id / kind / displayName, and apps commonly extend it. Resolution is
best-effort and never fails the mutation it records, and absent stays absent: a
fabricated name is what this column exists to prevent.
_voltro_audit_log.metadata is yours to write — the noun a diff cannot contain.
"Anna removed Bernd from the Frontend sub-team" is one row-delete plus a
membership row, and no column-level detail reconstructs the sentence.
What is recorded
auditPlugin({
// Actions record by default. Queries are opt-in — a read-heavy app would
// write one row per read and drown the trail it needs searchable.
recordQueries: true,
include: /^export\./,
})Mutations and actions record by default; both write. Queries are opt-in, for the
surfaces where the READ is the sensitive act — a GDPR export, a salary view —
usually together with include so it stays targeted.
Reading a redacted row — 'shape'
redactInput, redactOutcome and redactSubject default to removing the
payload entirely ({ "__redacted": "all" }). That is the right default and it
is unreadable on purpose.
'shape' is the middle option: the payload's STRUCTURE, no value from it.
auditPlugin({ redactOutcome: 'shape', redactInput: 'shape' }){ "__redacted": { "jiraToken": "string(113)", "attempts": "number" } }It exists because a team spent a day on a bug their own trail could have ended
in seconds: a value arrived as 113 characters where 44 were due, and the row
that would have said so read {"__redacted":"all"}. They had asked for that
redaction one round earlier, and both requests were right.
What it discloses, and what it never does:
| a string | its LENGTH — string(113). Never a prefix, never a hash. Below a floor, a bucket: string(<16) |
| a number, a boolean, a date | its TYPE only. A number can BE the secret |
| a declared field name | survives — jiraToken |
| a key that is not an identifier | described, not reproduced — <key:string(36)> |
| depth / breadth | capped, and the shape says where it stopped |
Two of those are worth a sentence each.
A key can be the value. An object keyed by user data ({ "user@example.com": … }) puts a datum where a schema name belongs, so a key is reproduced only when
it looks like a declared field: a plain short identifier. A legitimate key that
is not one (content-type) loses its name and keeps its shape.
A string's length is a real disclosure, and a small one — above a floor. For a fixed-format credential it carries nothing — every token of a given kind is the same length. For a human-chosen password it is a weak hint.
Below a floor it stops being small: a six-digit TOTP reported as string(6), or
a four-digit PIN as string(4), tells a reader with log access exactly what
shape to try. Nothing this mode is FOR lives under 16 characters — the case that
motivated it is 113 against an expected 44 — so a string shorter than that
reports string(<16): shorter than the floor, not how much shorter.
auditPlugin({ redactOutcome: 'shape', redactionLengthFloor: 0 })0 turns the floor off, for a deployment whose audited payloads are ids and
tokens and every character of length is worth having; raise it for one holding
short human-entered secrets. An EMPTY string stays exact (string(0)) — "the
field arrived empty" is a real diagnosis and an empty string is not a secret. The
floor applies to described keys too, for the reason above them: a key that is not
a declared field is a key carrying data.
redactSubject spells its variant 'metadata-shape', because its default
('metadata') already names the field it acts on.
Scoping the trail — scope
auditPlugin({
// The app's own dimension — the framework does not know what a team is.
// From the subject when your session is team-shaped …
resolveScope: (ctx) => ({ teamId: ctx.subject.metadata?.teamId }),
})
auditPlugin({
// … or from the call's INPUT, which is where it usually lives.
resolveScope: (ctx) =>
typeof ctx.input?.teamId === 'string' ? { teamId: ctx.input.teamId } : undefined,
})
rowHistoryPlugin({
// From the ROW here — that is what this plugin has.
resolveScope: (row) => ({ teamId: row.teamId }),
})input is there because the subject-only version covered the wrong half. A
reporter's users belong to MANY teams, so their session carries no "current
team" and cannot without inventing a concept their product does not have — a
mutation's team comes from its input or from the row it loads. Their API-key
subjects do carry a teamId, which made subject-only worse than nothing for
them: it would have populated for key-authenticated calls and been null for every
human one, so a filtered view would have looked like it worked.
The input here is raw — not what redactInput
will store. That is required (a scope derived from a redacted payload is not
derivable at all) and it is a hazard worth naming: whatever you return lands in
scope, which is not redacted. Return the dimension, never the payload.
scope is opaque json on both _voltro_audit_log and _voltro_row_history,
stored and returned verbatim and filtered on equality — the same column
_voltro_webhook_targets carries, for the same reason: .with(tenant()) is one
level too coarse when a trail is per-team and a tenant has many teams.
It is not metadata, and the distinction is load-bearing. metadata is
documented as the app's free-form note — the noun a diff cannot contain. Filtering
on it means building a read path against a column whose contract says it is not
one. Two columns, two jobs.
Configure it on both plugins or half of every view is unfiltered. Neither
resolver can fail your write: an underivable scope is null, the same answer as
not configuring one.
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.