---
title: "@octabits-io/agent-ledger"
summary: "The append-only record of what agents did, under whose grant, and how to undo it: one row per applied action with the delegation chain, the operations exactly as written, and the reversibility class."
install: "pnpm add @octabits-io/agent-ledger"
availability: "Available from November 2026"
version: "0.8.1"
license: "MIT"
related:
  - name: "@octabits-io/postgres"
    url: "https://octabits.io/platform/postgres.md"
  - name: "@octabits-io/proposal"
    url: "https://octabits.io/platform/proposal.md"
  - name: "@octabits-io/result"
    url: "https://octabits.io/platform/result.md"
canonical_url: "https://octabits.io/platform/agent-ledger"
---

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

```bash
npm i @octabits-io/agent-ledger @octabits-io/proposal   # + drizzle-orm if you use ./drizzle
```

```ts
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

```ts
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:

```ts
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**:

```ts
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:

```ts
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:

```ts
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

```ts
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:

```bash
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

```ts
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.

```ts
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

```ts
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.