# Audit

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



---

<!-- source: en/plugins/audit.md -->
## 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

```ts
// 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](/docs/ai/cost-tracking).) One `AuditEvent` per outcome:

```ts
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:

```ts
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](#tamper-evidence--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:

```ts
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`](/docs/plugins/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.

```ts
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](/docs/database/sensitivity), 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.

```ts
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:

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

```ts
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`](/docs/plugins/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.
>
> ```sql
> 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:

```ts
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`](/docs/plugins/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.

```ts
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:

```ts
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 `RegExp`s tested against the rpc tag:

```ts
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(...)`:

```ts
// 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 `reference`s 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

```ts
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

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

```ts
auditPlugin({ redactOutcome: 'shape', redactInput: 'shape' })
```

```json
{ "__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 |
| 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.** 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. If that matters in your threat
model, `'all'` is the default and stays available.

`redactSubject` spells its variant `'metadata-shape'`, because its default
(`'metadata'`) already names the field it acts on.

## Scoping the trail — `scope`

```ts
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`](#redactinput--what-of-the-payload-is-kept)
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.
