@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 }: an update’s or delete’s current and proposed when its entity target has a subject, a create’s value under the id created[ref] gave it, and a reviewer’s edit of either in decision.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) and listByActor open the values again. listChain and head return entries as stored, because that is what the chain verifies over; open one with openLedgerEntry(entry, sealer) — its hash stays 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. checkLedger walks the chain against all anchors of the scope (verifyLedgerChain takes anchors now): 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.
  • LedgerAnchorSink is write and list: 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, authorizationId are 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_COLUMNS with hashLedgerEntry, 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.