@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,
beforeandafterof an entry whose target has a subject become{ $sealed: 1, subject, data }. The hash covers the ciphertext. - On read,
getandlistopen them;listChainreturns entries as stored, because that is what the chain verifies over — open one withopenAuditEntry(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
recordwas called with. A route that forgets to call it leaves no trace — review and tests are where that is caught. - That
beforeandafterare 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.