Autumn's version history answers "who changed this row, when, and to what?" — but a column-level diff log cannot be queried as state. You cannot ask what a record looked like last Tuesday, diff it across two instants, or prove the stored history was not rewritten.

A ledgered entity closes that gap. One marker makes an entity bitemporal by construction: every write appends an immutable, hash-chained revision carrying a full row snapshot in your own Postgres or SQLite, so you can query any record as of any past instant, diff it across time, and verify the history was never tampered with — with no separate event store.

When to use this vs. version history vs. audit logging

ConcernTool
"What did invoice 42 look like on the day we approved it?"Ledger (this guide)
"Prove nobody edited that history afterwards."Ledger (this guide)
"Who changed row 42's plan_tier, and what was the previous value?"Version history
"Which admin exported user data at 14:32?"autumn::audit

The ledger is version history promoted to queryable, provable state. It does not replace version history — ledgered = true implies versioned = true, so a ledgered entity keeps version_history() and everything built on it.

Opting in

Rust
#[repository(Invoice, soft_delete, ledgered = true)]
pub trait InvoiceRepository {}

That marker is the only per-model change required. Every write path version history already covers — hand-written handlers, #[repository(api = "…")] endpoints, #[job] and #[mailer] paths, bulk saves, upserts, dependent cascades — appends a revision automatically.

soft_delete is required, not optional: see What is refused, and why.

Migration

Run autumn migrate after opting a model in. Autumn applies the framework migration that creates _autumn_ledger_revisions:

Sql
CREATE TABLE _autumn_ledger_revisions (
    id          BIGSERIAL   PRIMARY KEY,
    table_name  TEXT        NOT NULL,
    tenant_id   TEXT,
    record_id   BIGINT      NOT NULL,
    seq         BIGINT      NOT NULL,   -- 1-based position in this record's chain
    op          TEXT        NOT NULL CHECK (op IN ('insert', 'update', 'delete')),
    actor       TEXT        NOT NULL DEFAULT 'system',
    request_id  TEXT,
    snapshot    TEXT        NOT NULL DEFAULT '{}',   -- FULL row state after the write,
                                                     -- the exact canonical bytes hashed
    valid_from  TIMESTAMPTZ NOT NULL,   -- valid time
    recorded_at TIMESTAMPTZ NOT NULL,   -- transaction time
    prev_hash   TEXT,                   -- NULL at seq = 1
    hash        TEXT        NOT NULL
);

CREATE UNIQUE INDEX idx_autumn_ledger_revisions_chain
    ON _autumn_ledger_revisions (table_name, COALESCE(tenant_id, ''), record_id, seq);

snapshot is TEXT, not JSONB, on purpose. JSONB parses each number into numeric and re-renders it on output, so a float serde writes as 1e16 comes back as 10000000000000000. The revision hash covers the exact bytes that were written, so a re-rendering store would make ledger_verify report tampering on an untouched chain.

SQLite gets an equivalent fork (INTEGER PRIMARY KEY, JSON as TEXT) under the same migration version, exactly as version history does.

Ledgering a model after launch is non-destructive but not retroactive: the chain starts at the first write after you opt in. Existing rows are not backfilled, because their past is unknowable.

Querying the past

Rust
// Exact state at a past transaction instant.
let then: Option<Invoice> = repo.ledger_as_of(id, last_tuesday).await?;

// Field-level delta between two instants.
let delta = repo.ledger_diff(id, last_tuesday, now).await?;
for change in &delta.changes {
    println!("{}: {:?} -> {:?}", change.column, change.before, change.after);
}

// Prove the stored history was never rewritten.
let report = repo.ledger_verify(id).await?;
if let Some(broken) = &report.broken {
    tracing::error!(seq = broken.seq, kind = %broken.kind, "{}", broken.detail);
}

// The raw chain, oldest first.
let revisions = repo.ledger_revisions(id).await?;

// The head hash, for pinning outside the database.
let head = repo.ledger_head(id).await?;

ledger_as_of returns None when the record did not exist yet. Because a ledgered entity is soft_delete, a deleted record still resolves: the reconstructed model carries the deleted_at a live query would have shown, so live-only callers check it exactly as they would against the table.

Fidelity

Reconstruction is byte-for-byte identical to what a plain query would have returned at that instant — the snapshot is a full row image, not a replayed diff. Autumn's test suite pins this against an oracle recorded live at each intermediate instant, on both storage tiers.

Snapshots go through the model's durable per-field codec rather than serde_json::to_value, so they carry every column — including #[private] and #[encrypted] fields, which serde omits from a model's public JSON. Encrypted columns are stored as recoverable ciphertext (the ledger table never holds plaintext the model chose to protect) and come back decrypted.

Three consequences worth knowing:

  • Declaring #[version_history(sensitive = [...])] columns on a ledgered repository is a compile error — see below.
  • ledger_diff compares the reconstructed models, not the stored bytes: an encrypted column carries a fresh nonce per write, so raw snapshots would report it as changed on every revision. A column the model hides from serialization therefore does not appear in a diff, though it is fully preserved in an as-of reconstruction and fully covered by the hash.
  • The live-row cross-check in ledger_verify (below) compares the durable codec projection of both sides, decrypted. Raw ciphertext is never comparable between a stored snapshot and a freshly encoded live row (a fresh nonce per write in randomized mode; a re-encryption under the new key after a rotation), but the plaintext underneath is — so #[private] and #[encrypted] columns are covered there too. Key rotation is handled by the envelope's key_id, so a retired key still decrypts; only a column whose key is gone entirely drops out of the comparison, and the revision hash still covers it.

Bitemporality

Every revision carries two instants:

  • recorded_attransaction time: when the database learned the fact. Always set by the framework from the write's own clock read.
  • valid_fromvalid time: when the fact became true in the business domain. Defaults to recorded_at.

Read valid time from your own column when the domain has one:

Rust
#[repository(Invoice, soft_delete, ledgered(valid_time = "effective_at"))]
pub trait InvoiceRepository {}

The column may be DateTime<Utc>, NaiveDateTime, or an Option of either.

Both axes are queryable:

Rust
use autumn_web::ledger::LedgerAsOf;

// What the database held at this instant.
repo.ledger_as_of_at(id, LedgerAsOf::transaction(t)).await?;

// What was true at this instant, per everything the database knows now.
repo.ledger_as_of_at(id, LedgerAsOf::valid(t)).await?;

// What the database believed *then* about *then* — the auditor's question.
repo.ledger_as_of_at(id, LedgerAsOf::bitemporal(known_at, true_at)).await?;

Both bounds filter; the answer is always the newest revision that survives. A revision is a full snapshot, so a later one replaces an earlier one outright rather than sitting beside it on a timeline — valid time says when a revision's statement starts being true. A future-dated revision is invisible until its instant arrives; a back-dated correction is visible from the instant it claims, and supersedes what it corrects from then on. Nothing is ever written back to an existing revision, so the chain stays append-only.

Tamper evidence

Each revision embeds the hash of its predecessor, forming a per-record chain. ledger_verify walks the chain and reports the first broken link:

What was done to the stored historyLedgerBreak reported
A row was edited in placeHashMismatch at that revision
A row was edited and re-hashedPrevHashMismatch at the next revision
A revision was deletedMissingRevision at the absent sequence number
A revision was insertedDuplicateSeq, or HashMismatch on an appended forgery
The chain no longer starts at seq 1BrokenChainStart / MissingRevision
A sequence number is at i64::MAX and cannot be followedUnusableSeq
The newest revision does not describe the live rowLiveStateMismatch
A live row with no chain at allNot a break — see below

An intact report carries head_hash; a broken one carries none.

Why the live row is read too

A hash chain proves the revisions that are present were not edited. It cannot prove that none is missing from the end: lopping the last two revisions off leaves something internally perfect. Nor can it see a write that reached the table without appending a revision at all.

So ledger_verify also reads the live row and compares it against the head revision. That closes both gaps, and with them every remaining write path that can move a ledgered row without the repository's knowledge — a raw UPDATE, a counter-cache bump on a ledgered parent, a dependent(..., on_delete = delete_all) cascade declared on some other repository. None of those can be refused at compile time (they are declared elsewhere, or maintained by the framework), but none of them can hide either.

The reads are taken twice: a write landing between them would look exactly like a divergence, and this routine exists to produce trustworthy accusations. If the chain head moves under it, the live comparison is skipped rather than reported — a concurrent write is not tampering.

Threat model — read this

The chain is tamper-evident, not tamper-proof. It detects any mutation, insertion, deletion or reordering that does not also re-derive every subsequent hash, plus — via the live-row cross-check — a truncated tail and any write that bypassed the ledger.

What it cannot see is a consistent rewrite. The hashing rule is open source, so an adversary with write access to the ledger table can re-derive a whole chain and adjust the row to match. Nothing stored inside the same database can prevent that.

One state is deliberately not reported: a live row with no revisions at all. That is what every existing row looks like on the day you adopt ledgered — ledgering is not retroactive — so accusing it would put a false positive in front of every adopting deployment. The cost is that a wholly erased chain looks identical from inside the database; revisions_checked == 0 on the report is what makes the empty case visible, and a pinned head (below) is what tells the two apart.

There is also a narrower window worth knowing about. Deleting the newest revision and then letting a normal write land re-uses the deleted sequence number: the new revision chains cleanly onto its predecessor and matches the live row, so both the chain and the cross-check report intact and the deleted state leaves no trace. ledger_verify catches the truncation in the window before that next write — but only if it runs there.

Both gaps close the same way: pin the head hash somewhere the database cannot reach. Treat that as required for an audit posture, not optional.

Rust
if let Some(head) = repo.ledger_head(id).await? {
    notary.pin(id, head.seq, &head.hash).await?;   // append-only store, notary, …
}

A wholesale rewrite then produces a head hash that disagrees with the pin.

The database provides one hard guarantee on top of detection: the (table_name, COALESCE(tenant_id, ''), record_id, seq) unique index makes a duplicated or forked revision a write error rather than silent corruption.

What is refused, and why

A ledgered entity's history is the record, so every way of erasing or redacting it is refused at the repository seam — at compile time, not at runtime:

ConfigurationDiagnostic
ledgered = true without soft_deleteRejected: a hard DELETE erases the row the ledger reconstructs, so an as-of query would return state whose record no longer exists and verify could not tell erasure from tampering.
Calling purge(id)Not generated. purge is soft-delete's hard-delete escape hatch — a raw DELETE FROM that writes no history at all. delete_by_id and restore — both of which record a revision — are the whole delete surface.
A dependent(..., on_delete = destroy) cascade from a soft-deleting parentThe ledgered child is soft-deleted and records a revision, like any other delete.
The same cascade from a hard-deleting parentRefused at runtime with a typed LedgerError::HardDeleteCascade. Neither outcome is available: erasing the child destroys the record its ledger reconstructs, and soft-deleting it leaves a live foreign key pointing at a parent row about to disappear, which the database rejects. The parent's macro cannot see that the child is ledgered — they are separate #[repository] invocations — so this is the one guard the first slice cannot make a compile error. Make the parent soft_delete, or use on_delete = nullify.
#[version_history(sensitive = [...])]Rejected: a redacted column cannot be reconstructed, so byte-for-byte as-of fidelity would be unprovable.
no_versioned_record_implRejected: the ledger snapshots through the generated VersionedRecord impl, and a hand-written one is not guaranteed to serialize every column.
retention(...) / position(...)Already rejected for versioned = true: both mutate rows outside the history-writing paths.

Multi-tenancy and sharding

A tenant_scoped ledgered repository stamps tenant_id on every revision and scopes every ledger read to the active tenant — a read as tenant B never sees tenant A's revisions, and ledger_as_of fails closed to None.

across_tenants() ledger reads are rejected, not widened. A chain is per (tenant, record), and two tenants' rows may share a record id, so an unscoped read would interleave their chains into one sequence — 1, 1, 2, 2 — which verify would correctly call DuplicateSeq on history nobody touched. Read the ledger inside a tenant scope instead.

Cross-shard ledger reads are rejected for the same class of reason: per-shard record ids are ambiguous, so a naive merge would be wrong. Query a specific shard instead.

Cost

Each write adds one indexed SELECT … ORDER BY seq DESC LIMIT 1 and one INSERT, inside the transaction the write already opened; a ledgered delete adds a third statement, reading back the deleted_at the update wrote so the revision snapshots the post-delete row exactly. Bulk paths (save_many, upsert_many, delete_many) pay this per row, inside the transaction that already holds their row locks — a delete_many over 1000 ids goes from a couple of statements to a few thousand. Chunk accordingly, or keep bulk writes off ledgered entities where throughput matters.

ledger_head is a single indexed row, so pinning it on a schedule is cheap. ledger_revisions, ledger_as_of, ledger_diff and ledger_verify read a record's whole chain (there is no pagination in this slice), and ledger_verify additionally reads the live row and re-reads the head.

Snapshots store the full row, so a ledgered table's history grows with row width, not just with the size of each change — the price of O(1) as-of reconstruction and provable fidelity. Retention and compaction of old revisions are not part of this slice.

Limits of this slice

  • Single-entity only. Cross-entity "as of" queries that join several ledgered entities at one consistent past instant are not supported yet.
  • API-level only — no time-slider or history-viewer UI.
  • No retention, compaction, or archival of old revisions.
  • No distributed or multi-node ledger consensus.
  • Postgres and SQLite only.
  • No pagination on ledger_revisions — a record's whole chain is read at once.
  • Writes that reach a ledgered table from outside its own repository — a counter-cache column maintained on a ledgered parent, a dependent(..., on_delete = delete_all | nullify) cascade declared on another repository, a hand-written UPDATE — do not append a revision. They cannot be refused at compile time (they are declared elsewhere), but ledger_verify reports each as a LiveStateMismatch.

See also