---
title: "@octabits-io/audit"
summary: "The append-only, hash-chained record of what people did through an admin or back-office surface: actor, role, action, target, scope, a required reason, the state before and after, the approver and the grant — written in the transaction of the change it records."
install: "pnpm add @octabits-io/audit"
availability: "Available from November 2026"
version: "0.1.0"
license: "MIT"
related:
  - name: "@octabits-io/result"
    url: "https://octabits.io/platform/result.md"
canonical_url: "https://octabits.io/platform/audit"
---

# `@octabits-io/audit`

The append-only, hash-chained record of what **people** did through an admin
or back-office surface: who acted, in which role, what they did, to what, in
which scope, why, with whose approval and under which grant — and the state
before and after, as you chose to state it. Every entry is written **in the
transaction of the change it records**: there is no other way to write, so an
entry and its change commit together or not at all. The application role can
append and read, and nothing else. Every entry carries the hash of the one
before it, and the chain verifies in your code against heads you keep outside
the database.

```bash
npm i @octabits-io/audit zod     # + drizzle-orm if you declare the table with ./drizzle
```

```ts
import { createPostgresAuditLog } from '@octabits-io/audit/postgres';

const audit = createPostgresAuditLog({ db: pool, scope: { column: 'org_id' } });

await inTransaction(pool, async (tx) => {
  await offboardUser(tx, userId);
  const entry = await audit.record(tx, {
    actor: staffId,
    actorRole: 'admin',
    action: 'users.offboard',
    target: { kind: 'user', id: userId },
    scopeKey: orgId,
    reason,                                   // required
    requestId,
    before: { status: 'active' },
    after: { status: 'offboarded' },
  });
  if (!entry.ok) throw entry.error;           // the offboarding rolls back with it
});

const page = await audit.list({ target: { kind: 'user', id: userId }, limit: 50 });
```

`agent-ledger` records what **agents** applied, with the operations to undo
it; `server/mcp`'s tool-call log records what an MCP client called. This is
the third record: a person, a reason, a change. It is not an undo log and not
the system of record — your rows stay the truth.

| Subpath | Deps | Contents |
|---|---|---|
| `.` | `zod` (peer), `@octabits-io/result` | the `AuditLog` contract and `createAuditLog` over a backend, the entry schema (`auditEntryInputSchema`), the hash (`hashAuditEntry`, `canonicalJson`, `GENESIS_HASH`), the verification (`verifyAuditChain`, `verifyAuditLog`), the anchors (`anchorAuditLog`, `checkAuditLog`), the sealing (`AuditSealer`, `openAuditEntry`, `hasErasedValues`), the in-memory log |
| `./postgres` | none | `createPostgresAuditLog` over a structural `SqlExecutor` — `pg` Pool/Client, PGlite, a wrapper over postgres.js — plus `auditLogDdl()` and `auditLogGrantsDdl()` |
| `./drizzle` | `drizzle-orm` (optional peer) | `auditLogColumns`, the table for drizzle-kit; no Drizzle store — the SQL store is the store |

A boundary lint keeps the root on `zod` and `result` and `drizzle-orm` in
`./drizzle`; the package type-checks with no Node typings — the hash is Web
Crypto.

## When not to use it

An agent applied a change you may want to revert (use `agent-ledger`); you
want every request logged (that is your access log or your traces); you need
a cross-service event bus (use `events`); or the action is the business's own
fact — an order, a payment — that belongs in its own table.

## The contract

```ts
interface AuditLog<Tx> {
  record(tx: Tx, entry: AuditEntryInput): Promise<Result<AuditEntry, AuditLogError>>;  // on the change's transaction
  get(id): Promise<Result<AuditEntry | null, …>>;
  list(query?): Promise<Result<{ entries, nextCursor }, …>>;                       // newest first, cursor-paginated
  listChain(scopeKey, { afterId?, limit? }?): Promise<Result<AuditEntry[], …>>;    // one chain, oldest first, as stored
  head(scopeKey): Promise<Result<{ id, hash } | null, …>>;                         // anchor this outside the database
}
```

**The entry.** `kind` is `write` (the default) or `read` — a read of personal
data in bulk, such as an export or the start of an impersonation. `actor`,
`approvedBy` and `onBehalfOf` are opaque ids, never names. `action` is a key
(`users.offboard`, `role.grant`). `target` is `{ kind, id }` in your
vocabulary. `reason` is required on every entry, trimmed, at least
`minReasonLength` characters (default 1 — set your policy). `before` and
`after` are any JSON, **redacted by you before you record them**: what is
recorded cannot be taken back, except by sealing (below). `approvedBy` equal
to `actor` is refused: an approval by the actor is no approval. The store sets
`at`, the id and the chain.

**Errors are values.** An invalid entry is `audit_entry_invalid` and writes
nothing; a database failure is `audit_log_failure`. On either, **roll the
transaction back** — return the error from your transaction callback, or
throw. A failed insert has already aborted a Postgres transaction, so its
change cannot commit; a refused entry has not touched the database, so the
rollback is yours to do.

**Query.** `list` filters by `actor`, `action`, `target` (a kind, or a kind
and an id), `scopeKey` (`null` for the entries across scopes), `kind`, and
`from` (inclusive) / `to` (exclusive); `limit` defaults to 50, at most 500;
`nextCursor` is the page's last id, opaque to you.

## The chain

Every entry carries `prevHash` — the `hash` of the entry before it in its
chain, or `GENESIS_HASH` (64 zeros) on the first — and `hash`: SHA-256 over
the canonical JSON (keys sorted at every depth, `undefined` dropped) of `v`,
`prevHash`, `kind`, `actor`, `actorRole`, `action`, `target`, `scopeKey`,
`reason`, `requestId`, `before`, `after`, `approvedBy`, `onBehalfOf` and `at`.
Never the id. The application computes it before writing and recomputes it
when verifying; the database is not trusted with its own evidence. The
encoding is `agent-ledger`'s, held equal by a test.

**One chain per scope value**, and the entries across scopes (`scopeKey:
null`) as a chain of their own — so a scope's chain is readable under that
scope's row-level security, and verifiable on its own.

**Written inside the transaction, never forked.** `record` takes a
transaction-scoped advisory lock on the chain (`pg_advisory_xact_lock`), reads
its head, hashes over it and inserts. A second writer of the same chain waits
at the lock until the first commits or rolls back, then links to whatever
stands; writers of other chains do not wait. A pool of connections is fine,
and no retry is ever needed inside a transaction that could no longer run
one. The unique index on the previous hash is the backstop: a `record` outside
a transaction (the lock ends with its statement) or under `REPEATABLE READ`
with a snapshot older than the head fails rather than forks.

```ts
const verdict = await verifyAuditLog(audit, orgId);
// { ok: true, value: { ok: true, count, head } }
// { ok: true, value: { ok: false, reason: 'content_changed', at: 512 } }   — a row was edited
// { ok: true, value: { ok: false, reason: 'link_broken', at: 513 } }       — a row was removed, inserted or reordered
```

## Append-only

```ts
await pool.query(auditLogDdl({ scope: { column: 'org_id', type: 'uuid' }, appRole: 'app' }));
```

With `appRole`, the DDL revokes everything from that role on the table and
grants back `SELECT`, `INSERT` and the id sequence: `UPDATE`, `DELETE` and
`TRUNCATE` are `permission denied`, whatever default privileges the role had.
A host on drizzle-kit adds `auditLogGrantsDdl({ appRole: 'app' })` in a custom
migration after the table's.

## Values about a person — sealed per person

The chain hashes `before` and `after`, so they cannot be edited out after an
erasure request. With a sealer, they are ciphertext under a key per person,
and erasing the person is destroying the key:

```ts
const audit = createPostgresAuditLog({
  db: pool,
  scope: { column: 'org_id' },
  sealer: {
    subjectOf: ({ type, id }) => (type === 'user' ? `user:${id}` : null),   // who a target's values are about
    seal: (subject, value) => keysFor(subject).encrypt(JSON.stringify(value)),
    open: async (subject, data) => {
      const keys = await existingKeysFor(subject);
      return keys ? { value: JSON.parse(await keys.decrypt(data)) } : null;   // null: the key is gone
    },
  },
});
```

- **On write**, before hashing, `before` and `after` of an entry whose target
  has a subject become `{ $sealed: 1, subject, data }`. The hash covers the
  ciphertext.
- **On read**, `get` and `list` open them; `listChain` returns entries as
  stored, because that is what the chain verifies over — open one with
  `openAuditEntry(entry, sealer)`.
- **After the key is destroyed**, the values read as `{ $erased: 1, subject }`,
  `hasErasedValues(entry)` says so, and the chain still verifies.

Everything else stays readable — it is the audit, and it names ids. Keep
people's data out of `reason`. The sealer has the shape of `agent-ledger`'s
`LedgerSealer`, so one sealer serves both; `@octabits-io/pii`'s per-scope
keys fit, with a scope per person. Whether destroying a key counts as erasure
is a legal judgement — have your counsel word what the person is told.

## Anchors

The chain shows that nothing was changed inside it, not that it was not
rewritten from scratch: whoever can write the table can re-hash every row. A
head written where the database cannot reach shows that:

```ts
import { createObjectStorageAnchorSink } from '@octabits-io/agent-ledger';
import { anchorAuditLog, checkAuditLog } from '@octabits-io/audit';

const sink = createObjectStorageAnchorSink({ storage: worm, prefix: 'audit-anchors/' });   // its own prefix
await anchorAuditLog({ log: audit, sink, scopeKey: orgId });          // after a commit, or on a schedule
const check = await checkAuditLog({ log: audit, sink, scopeKey: orgId });
if (check.ok && !check.value.verdict.ok) report(check.value.verdict);    // a break is a verdict, not an error
```

An anchor here has `agent-ledger`'s shape, so its sinks — object storage under
Object Lock, a file through `agent-ledger/fs` — serve as they are. Give the
audit log a sink of its own: two chains under one scope key in one sink fail
on each other's anchors. Anchor after the commit, never inside the
transaction, or a rolled-back head is anchored.

## Scoping

The scope column is yours — name, type, foreign key — and nullable: an entry's
`scopeKey` goes there, `NULL` for an action across scopes. Without
`scope`, the log keeps one chain and refuses an entry that names a scope. The
store filters only when `list` is given a `scopeKey`; row-level security on
the table is yours to add, and a writer must be able to see the chain it
writes to (a policy that hides the `NULL` chain from a scoped role makes its
writes to that chain fail at the unique index).

## The SQL adapter

```ts
import { auditLogDdl, createPostgresAuditLog } from '@octabits-io/audit/postgres';

await pool.query(auditLogDdl({ scope: { column: 'org_id' }, appRole: 'app' }));   // idempotent; or paste it into a migration
const audit = createPostgresAuditLog({ db: pool, scope: { column: 'org_id' } });
```

`SqlExecutor` is `query(text, params) → { rows }`. `pg` and PGlite satisfy it
as-is; for postgres.js, `{ query: (t, p) => sql.unsafe(t, p).then(rows => ({ rows })) }`.
Reads run on `db`; writes run on the `tx` handed to `record`. The dialect is
Postgres 15+ (`NULLS NOT DISTINCT` on the chain index). Table, column, type
and role names are validated as plain identifiers before they reach SQL text;
everything else is a bound parameter.

## The Drizzle table

```ts
import { index, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
import { auditLogColumns } from '@octabits-io/audit/drizzle';

export const auditLog = pgTable('audit_log', { ...auditLogColumns, orgId: text('org_id') }, (t) => [
  uniqueIndex('audit_log_chain_idx').on(t.orgId, t.prevHash).nullsNotDistinct(),
  index('audit_log_scope_idx').on(t.orgId, t.id),
  index('audit_log_actor_idx').on(t.actor, t.id),
  index('audit_log_target_idx').on(t.targetKind, t.targetId, t.id),
]);
```

`record` takes a plain-SQL executor on the connection that holds your
transaction. Drizzle's transaction object has no `query(text, params)`, so open
the transaction on a `pg` client and hand the same client to `drizzle(client)`
and to `record`. The unique index on `prevHash` is what refuses a fork: declare it.

## What it does not prove

- **A superuser, or the table's owner, can still rewrite rows.** The grants bind
  the application role; the chain makes a rewrite detectable; only an anchor
  outside the database makes a rewrite from scratch detectable.
- **That every action was recorded.** The log holds what `record` was called
  with. A route that forgets to call it leaves no trace — review and tests are
  where that is caught.
- **That `before` and `after` are true.** They are what the host stated.
- **Retention.** Deleting old rows is the owner role's job, and a purge cuts
  the chain's start: keep an export of what you purge, with its anchors.

## Why this shape, and not `agent-ledger`

The ledger and this log share the chain, the anchors and the sealing, and
not much else. A ledger entry is an applied proposal — operations, a
reversibility class, a workflow, a revert that is its own entry; an audit
entry is a person, a reason and a change, and is never reverted. The ledger
recovers from a fork by re-reading the head and retrying, which cannot run
inside a transaction a refused insert has aborted; an audit entry must be
written inside the change's transaction, so it serialises writers with a
lock instead. A mode of the ledger would have carried both sets of fields and
both write paths through one table and one contract. What carries over,
carries over by shape: the hash encoding (held equal by a test), the anchor
and its sinks, the sealer.

## License

MIT.