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

npm i @octabits-io/audit zod     # + drizzle-orm if you declare the table with ./drizzle
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

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.

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

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:

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:

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

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

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.

From @octabits-io/audit@0.1.0 in the platform repository.