@octabits-io/agent-ledger
The append-only, hash-chained record of what agents did, under whose grant, and how to undo it. One entry per applied action: who acted, on whose behalf, under which authorization; the operations exactly as written, with what each replaced; the ids the host assigned to creates; and the reversibility class of the whole application. Revert reads this entry and nothing else — and is an entry of its own, so nothing in the ledger is ever rewritten. Every entry carries the hash of the one before it; the chain verifies in your code, against a head you keep outside the database.
npm i @octabits-io/agent-ledger @octabits-io/proposal # + drizzle-orm if you use ./drizzle
import { createPostgresAgentLedgerStore } from '@octabits-io/agent-ledger/postgres';
const ledger = createPostgresAgentLedgerStore({ db: pool, scope: { column: 'org_id', value: orgId } });
// after applying a reviewed proposal
await ledger.record({
principal: { kind: 'agent', id: 'ai:record-fields', onBehalfOf: userId, authorizationId: grantId },
mode: 'reviewed',
scope: 'record:88',
workflowId,
decision,
operations: resolved, // as written — what each replaced stays inside
created: { 'new-photo': '4711' },
reversibility: 'reversible',
});
// undo, later
const standing = await ledger.findByWorkflow(workflowId); // the apply that stands
// … apply the inverse of standing.value.operations …
await ledger.recordRevert({ reverts: standing.value.id, principal: { kind: 'user', id: userId } });
// on a schedule: anchor the head outside the database, and verify against every anchor
await anchorLedger({ store: ledger, sink, scopeKey: orgId });
const check = await checkLedger({ store: ledger, sink, scopeKey: orgId });
This is the audit and undo log, not the system of record: your rows stay the truth; the ledger says who changed them and how to put them back. It is deliberately not event sourcing.
| Subpath | Deps | Contents |
|---|---|---|
. |
@octabits-io/proposal (vocabulary), @octabits-io/result |
the AgentLedgerStore contract, the hashing (hashLedgerEntry, canonicalJson, GENESIS_HASH), the verification (verifyLedgerChain, verifyLedgerStore), the anchors (anchorLedger, checkLedger, the LedgerAnchorSink contract, createInMemoryLedgerAnchorSink, createObjectStorageAnchorSink), the export (exportLedger, verifyLedgerExport), the in-memory store, the shared semantics |
agent-ledger-verify |
Node | the command (bin) that verifies an export file, with no database |
./postgres |
none | plain SQL over a structural SqlExecutor — pg Pool/Client, PGlite, a wrapper over postgres.js — plus agentLedgerDdl() and the three-step migration for a ledger written before the chain |
./drizzle |
drizzle-orm (optional peer), @octabits-io/postgres (peer) |
agentLedgerColumns to spread into your table (appliedAt reads back as ISO-8601 in UTC), createDrizzleAgentLedgerStore |
./fs |
Node (node:fs) |
createFileLedgerAnchorSink: anchors as JSON lines in a local file — the one subpath outside the portability gate |
A boundary lint keeps the root on proposal and result alone and
drizzle-orm confined to ./drizzle; the whole package type-checks with no
Node typings — the hash is Web Crypto, which Node, Bun, Deno, workers and
browsers all carry without an import.
When not to use it
You want to replay state from events (use an event store), you need a cross-service audit bus (use your events package’s outbox), or your “undo” is a compensating business process rather than the inverse of recorded operations (model the compensation; record its application here).
The contract
interface AgentLedgerStore {
record(entry): Promise<Result<AgentLedgerEntry, AgentLedgerStoreError>>; // append an apply
recordRevert({ reverts, principal, decision?, at? }): Promise<Result<AgentLedgerEntry, …>>; // append a revert
markReverted(id, { at?, by }): Promise<Result<void, …>>; // recordRevert for a host that knows only who
get(id): Promise<Result<AgentLedgerEntry | null, …>>;
findByWorkflow(workflowId): Promise<Result<AgentLedgerEntry | null, …>>; // the apply that stands
findByWorkflows(workflowIds): Promise<Result<Map<string, AgentLedgerEntry>, …>>;
listByActor(actorId, { limit? }): Promise<Result<AgentLedgerEntry[], …>>; // the timeline: applies and reverts
listChain({ afterId?, limit? }): Promise<Result<AgentLedgerEntry[], …>>; // oldest first, every kind
head(): Promise<Result<{ id, hash } | null, …>>; // anchor this outside the database
}
Nothing is rewritten. An entry is kind: 'apply' or kind: 'revert'; a
revert carries reverts, the id of the apply it undoes, the same operations,
and the principal who undid it. An apply’s revertedAt / revertedBy are
read off the revert that points at it — there is no column for them, and a
second revert of the same apply is refused.
Latest apply per workflow. A run may be applied, reverted, and applied
again; findByWorkflow returns the newest apply, so a re-application
supersedes its reverted predecessor. The rule lives in one exported function,
latestPerWorkflow, and every store calls it rather than re-deriving it.
Mode says when the apply happened relative to a human: reviewed (a
person decided first), autopilot (the agent applied under a grant that
allowed it to skip review), manual (a person acted directly — recorded so
the timeline is one list). Principal and reversibility are
@octabits-io/proposal’s types, by import: a proposal host stores its
ResolvedOperation[] as operations, but the ledger does not interpret them,
so an action that never went through a proposal fits just as well.
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 of the entry’s content (kind, reverts, the principal,
mode, scope, workflow, decision, operations, created, reversibility,
appliedAt) with prevHash and a format version. Never the id. The store
computes it before writing, in the application; a verifier recomputes it from
what it reads back, in the application — the database is not trusted with
its own evidence.
One chain per scope value when the store stamps a scope column, one for the
table otherwise. No forks: the SQL adapters keep a unique index on
prev_hash (with the scope column), so two writers that read the same head
cannot both insert; the loser re-reads the head and retries, with a short
jittered pause. Writes are ordered by that index, not by a lock, so a pool of
connections is fine.
Anchor the head. head() is the last entry’s id and hash. Write it
somewhere the database cannot reach on a schedule — a log line, a file in
object storage, a row in another system — and verify against it:
const verdict = await verifyLedgerStore(ledger, { anchor: { id: 1024, hash: '…' } });
// { ok: true, count, head }
// { ok: false, reason: 'content_changed', at: 512 } — a row was edited after it was written
// { ok: false, reason: 'link_broken', at: 513 } — a row was removed, inserted or reordered before 513
// { ok: false, reason: 'anchor_mismatch', at } — the chain was rewritten before the anchor was taken
verifyLedgerChain(entries, options) is the same over any iterable of entries
— an export, a backup — and verifyLedgerStore pages through listChain.
Values about a person — sealed per person
The chain hashes every value an entry states, so nothing in it can be removed later: a ledger that
records what an action wrote about a person would keep that person’s data as long as the entry.
sealLedgerStore(store, sealer) keeps the audit and lets the person go — crypto-shredding per
person:
import { hasErasedValues, sealLedgerStore, type LedgerSealer } from '@octabits-io/agent-ledger';
const sealer: LedgerSealer = {
subjectOf: ({ type, id }) => (type === 'contact' ? `contact:${id}` : null), // who a value is about
seal: (subject, value) => keysFor(subject).encrypt(JSON.stringify(value)), // your key per person
open: async (subject, data) => {
const keys = await existingKeysFor(subject);
return keys ? { value: JSON.parse(await keys.decrypt(data)) } : null; // null: the key is gone
},
};
const ledger = sealLedgerStore(createPostgresAgentLedgerStore({ db, scope }), sealer);
- On write, before the entry is hashed, the values of every operation about a person are
replaced by
{ $sealed: 1, subject, data }: anupdate’s ordelete’scurrentandproposedwhen its entitytargethas a subject, acreate’svalueunder the idcreated[ref]gave it, and a reviewer’s edit of either indecision.edits. The hash covers the ciphertext. Everything else — principal, mode, scope, anchors, paths,edited, reversibility, a decision’s choices — stays readable: it is the audit, and it names ids. - On read,
get,findByWorkflow(s)andlistByActoropen the values again.listChainandheadreturn entries as stored, because that is what the chain verifies over; open one withopenLedgerEntry(entry, sealer)— itshashstays the stored one. - Erasing a person is destroying their key. Their values then read as
{ $erased: 1, subject }, the chain still verifies, and every other person’s values still open.hasErasedValues(entry)tells a host not to revert an entry whose values are gone.
The package holds no key and does no crypto. @octabits-io/pii’s per-scope keys fit, with a scope
per person. Entries written before a store was sealed stay as they were written: rewriting them
would break the chain, so they leave with your retention purge. Whether destroying a key counts as
erasure is a legal judgement — common for append-only logs, but have your counsel word what the
person is told.
Anchors on WORM storage
The chain shows that nothing was changed inside it. It cannot show 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 is what shows that, and the sink is that place:
import { anchorLedger, checkLedger, createObjectStorageAnchorSink } from '@octabits-io/agent-ledger';
import { createAWSObjectStorageService } from '@octabits-io/storage/s3';
const worm = createAWSObjectStorageService({ ...s3, bucket: 'ledger-anchors', objectLock: { mode: 'COMPLIANCE', retainDays: 3650 } });
const sink = createObjectStorageAnchorSink({ storage: worm });
// A scheduled job, per scope, with the store bound to that scope:
await anchorLedger({ store: ledger, sink, scopeKey: orgId }); // writes the head when it moved
const check = await checkLedger({ store: ledger, sink, scopeKey: orgId });
if (check.ok && !check.value.verdict.ok) report(check.value.verdict); // a break is a verdict, not an error
- Every anchor counts.
checkLedgerwalks the chain against all anchors of the scope (verifyLedgerChaintakesanchorsnow): a chain rewritten before an anchor fails on that anchor’s entry, and a chain cut back below an anchor fails on the anchor it no longer reaches. - One object per anchor, never overwritten:
ledger-anchors/<scope>/<anchoredAt>-<head id>.json. On a bucket under Object Lock, neither can be changed or deleted before its date. The sink takes any store with@octabits-io/storage’s shape, structurally; when the store can list versions (the S3 adapter), it reads every version, so a delete marker on the newest anchor hides nothing. - Fail closed. An object under the prefix that is not an anchor of this chain fails the listing; it is not skipped.
LedgerAnchorSinkiswriteandlist: a second system, a log store with retention, a timestamping service are sinks too.
Anchors in a file. Before there is a bucket — in development, on one host
with a volume — ./fs keeps anchors in a local file, one JSON line each,
appended and never rewritten:
import { createFileLedgerAnchorSink } from '@octabits-io/agent-ledger/fs';
const sink = createFileLedgerAnchorSink({ path: '.data/ledger-anchors.jsonl' });
// after each committed write — never inside its transaction, or a rolled-back head is anchored
await anchorLedger({ store: ledger, sink, scopeKey: orgId });
It is outside the database, not WORM: whoever can write both the database and the file can rewrite both. Every scope shares the file; a line that is not an anchor fails the listing. The sink is the same contract as the bucket’s, so moving to Object Lock changes the line that builds it and nothing else.
The export
const exported = await exportLedger({ store: ledger, sink, scopeKey: orgId }); // every entry, oldest first, and every anchor
await archive.uploadObject({ key: `ledger/${orgId}/${date}.json`, body: encode(exported.value) });
const verdict = await verifyLedgerExport(JSON.parse(text), { anchors: fromTheWormBucket });
The export is one JSON document: the entries, the head, the anchors and the hash format. It verifies with no database and no store — every link, every hash, every anchor it carries and every anchor you pass — and from a shell:
npx agent-ledger-verify export.json --anchors anchors.json
# prints the verdict; exit 0 when the chain holds, 1 when it does not
An auditor who trusts neither this package nor its command can recompute it:
each hash is SHA-256 over the canonical JSON (keys sorted at every depth,
undefined dropped) of v, prevHash, kind, reverts, principal,
mode, scope, workflowId, decision, operations, created,
reversibility and appliedAt, and each prevHash is the hash before it.
What this is, and is not. A verified chain against an anchor on WORM storage makes the ledger tamper-evident. Keeping the records themselves for a statutory period is the job of the archive you put the export in.
Scoping
The scope column is yours: name, type, foreign key, nullability, or none at all in a single-scope deployment. The store stamps and filters by it:
scope |
writes | reads | chain |
|---|---|---|---|
| omitted | nothing stamped | unfiltered | one, the table’s |
{ column, value } |
every row stamped with value |
every read filtered by it | one per value |
{ column } |
stamped from entry.scopeKey; an entry without one is refused |
unfiltered — a process-wide writer serving many scopes | one per value; a revert joins its apply’s, and a scopeKey naming another scope is refused |
A { column } store serves writes across scopes; it cannot verify or anchor,
because listChain and head read across them and the chains interleave.
Verify with a { column, value } store per scope value.
The SQL adapter
import { agentLedgerDdl, createPostgresAgentLedgerStore } from '@octabits-io/agent-ledger/postgres';
await pool.query(agentLedgerDdl({ scope: { column: 'org_id' } })); // idempotent; or paste it into a migration
const ledger = createPostgresAgentLedgerStore({ db: pool, scope: { column: 'org_id', value: orgId } });
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 })) }.
The dialect is Postgres ($1, jsonb, timestamptz, = ANY($1)). Table and
column names are validated as plain identifiers before they reach SQL text;
everything else is a bound parameter. The DDL emits the two chain indexes:
one successor per hash, one revert per apply.
A ledger written before the chain migrates in three steps, with nothing
else writing in between — a @octabits-io/backfill definition is the natural
home for the middle one. The stores hold that line: while a scope’s newest row
has no hash, record and recordRevert are refused with a message naming the
two steps left, because an entry written then could neither link to that row
nor be chained by the backfill without a rewrite.
await pool.query(agentLedgerChainMigrationDdl({ table })); // 1. the columns, nullable
await chainExistingLedger({ db: pool, table, scopeColumn: 'org_id' }); // 2. hash every row in id order per scope; old reverts become entries
await pool.query(agentLedgerChainFinishDdl({ table, scope: { column: 'org_id' } })); // 3. NOT NULL, the unique indexes, the old columns dropped
The Drizzle adapter
import { agentLedgerColumns, createDrizzleAgentLedgerStore } from '@octabits-io/agent-ledger/drizzle';
export const agentLedger = pgTable('agent_ledger', { ...agentLedgerColumns, orgId: text('org_id').notNull() }, (t) => [
index('agent_ledger_workflow_idx').on(t.orgId, t.workflowId),
uniqueIndex('agent_ledger_chain_idx').on(t.orgId, t.prevHash),
uniqueIndex('agent_ledger_reverts_idx').on(t.reverts).where(sql`reverts IS NOT NULL`),
]);
const ledger = createDrizzleAgentLedgerStore({ db, table: agentLedger, scope: { column: 'orgId', value: orgId } });
The store takes a structural view of a Drizzle Postgres db (select,
insert — it never updates), so a transaction context works too — with one
limit both SQL adapters share: the retry after a fork refusal re-reads the
head, and inside a transaction the refused insert has already aborted that
transaction (Postgres 25P02), so the store returns
agent_ledger_store_failure saying so instead of retrying, and the host’s
transaction is done for. Two writers racing for one chain therefore need the
pooled db, or a lock of the host’s own that serialises them (a
SELECT … FOR UPDATE on the record they change, say). drizzle-orm is an
optional peer loaded by this subpath alone. The unique index on prevHash is
what refuses a fork: declare it.
appliedAt (and a revert’s at) is normalised to the ISO form every read
hands back — millisecond precision, UTC — before it is hashed, so an offset
or a second-precision timestamp from the host hashes the same as it reads.
Why this shape
- One entry, everything needed to undo. Revert never re-reads the entity to guess what “before” was; the operations carry it.
- The delegation chain is data.
actor,onBehalfOf,authorizationIdare columns, so “what did this agent do under that grant” is a query. - A revert is evidence, not an edit. Who undid what, when, and why is an entry with a hash of its own; the apply it undoes stays exactly as written.
- Tamper-evident in the application. A database that can be edited can edit its own audit table. The hashes are computed and checked outside it, and the head is anchored outside it, so an edit is a verdict, not a secret.
- Store-agnostic by contract, not by adapter count. The semantics live at
the root; adapters are thin. A third store is nine methods against
AGENT_LEDGER_COLUMNSwithhashLedgerEntry, and the conformance suite is there to hold it.
Grew up inside @octabits-io/framework (./drizzle/agent-ledger, retired on
2026-09-12).
License
MIT.
From @octabits-io/agent-ledger@0.8.1 in the platform repository.