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, 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 control

input 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 control

This 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 design

The 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 sink being the literal 'datastore', so a function that redacts and then delegates to dataStoreAuditSink still writes rows on a database where _voltro_audit_log already 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, use sink: 'datastore' plus the three redact* 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 control

This 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's credentialsResolver took a Subject and nothing else, so an app doing per-user Atlassian auth had nowhere to put the caller's PAT except subject.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. verifyAuditChain returns each chain's tip. 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 / 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).

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. 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.