This page is the lookup table: what each pattern is called, which file implements it, and the prior art it came from. Why any of it was chosen is on design decisions; where the modules sit is on architecture.
Append-only log, immutable events
The storage model in two terms: the log is append-only — commits are only ever added, never updated or deleted; there is no edit or compaction API — and each commit’s changes are immutable events: facts about what happened, sealed at write time. A correction is a new commit that changes the value back, on the record, attributed — never a rewrite of the old one. Everything else on this page (hash chain, replay, snapshots, forks) leans on these two properties.
Hash chain
Every commit’s ID is SHA-256(parent ID, message, changes) — see computeID
in core/commit.go. Because the parent’s ID is inside the
hash, altering any historical commit changes every ID after it: the log is
tamper-evident, the same structure git and blockchains use. Fields are
length-framed before hashing so no two different inputs can collide by
concatenation. This is what makes immutability checkable rather than
promised.
The commit’s own time and author metadata are deliberately excluded from the hash, which is where chronicle diverges from git’s SHA — that divergence, and what it means for re-staging the same edit, is worked through on the git model.
Content-addressed storage
An object’s identifier is derived from its content rather than assigned: equal
(parent, message, changes) always name the same commit, and an ID can be
re-verified from content alone. Write-time dedup is NOT hash-based, though —
Seal restamps change timestamps into the payload, so a redelivered message
dedups to one commit through its idempotency key (Service.Seal + the
Deduper capability, core/service.go), not
through its hash. (The SQL adapter does treat re-appending a byte-identical
commit — same document, same content hash — as a no-op.)
Trusted anchor / checkpoint verification
Full verification (core/verify.go Verify) refetches and
rehashes the entire history — O(all commits), every run — and is the ancestry
authority: only the full history can decide that every commit’s claimed parent
exists. The incremental form (VerifyAfter) content-checks just the commits
after a persisted cursor — hash and authors, O(commits since the last run) —
and returns the next cursor. Each commit’s hash covers its parent, so content
tampering in the tail is caught; whether a parent exists is a question a
tail cannot answer (a legitimate fork’s parent lies before any cursor), so
run the full Verify when ancestry is in doubt.
The trust contract is explicit — tampering at or before the cursor is invisible to incremental verification, so the cursor is only as trustworthy as where it is stored. Anchor in the same database guards against corruption and casual tampering; against a hostile database owner the anchor (or a signature over it) must live elsewhere.
Why not a Merkle tree? Trees buy O(log N) membership proofs — proving one arbitrary record to a third party. “Is my linear history intact” doesn’t need that.
Event sourcing
The storage philosophy: persist the changes (events), derive current state
by replaying them. Reconstruct in kit/view is the
replay; State is a projection (materialized view) of the event stream.
Core deliberately stores only the log — every read model is derived.
Snapshotting
The classic event-sourcing companion pattern: cache the projection as of a
known commit, then replay only the events after it. The Snapshotter
capability (core/capability.go) stores one opaque
snapshot per document; State, StateAt, and CommitSnapshot in
kit/view all use it as a fast path, turning reads from
O(all commits) into O(commits since snapshot). The cost is amortized: an
occasional full replay primes the cache, subsequent reads are cheap. The
snapshot is a pure cache — deleting it is always safe, readers fall back to
full replay.
Historical reads (StateAt, CommitSnapshot) never refresh the snapshot: a
read of the past must not move the HEAD cache.
Dual-audience change records
One stored history, two audiences. The record stays machine-shaped — dotted
paths, canonical-JSON scalars — because change JSON is the commit-hash
preimage: stored display metadata could never be backfilled, and old history
would render display-blind forever. Instead, Explain in
kit/explain derives the human form at read time by
replaying the chain: label trails, keyed-element identity, id→name display
values, bookkeeping flags (a bare field name matched at any depth, or an
index-free schema path matching its field and subtree), and per-field
ValueNode breakdowns of container values. The caller’s schema arrives as DiffOptions — the same vocabulary
Diff uses on the write side — so changing labels, name fields, or ignored
fields re-renders all existing history without touching a stored byte.
The boundary the API holds: the kit emits structure (slices, names, canonical scalars, flags), never formatting — why that line is drawn there.
Cursor (keyset) pagination
TailReader.CommitsAfter(docID, afterID, limit) — “the commits after this
one” — rather than OFFSET-style paging. Keyset cursors stay O(page) at any
depth and are the primitive under both the snapshot fast path and incremental
verification. Adapters implement it with an indexed range scan
(adapters/sql/capability.go).
Capability pattern (graceful degradation)
Backends implement optional interfaces — Indexer, TailReader,
Snapshotter, Deduper (core/capability.go) —
discovered by Go type assertion. A backend without a capability, or a stale
or corrupt cache, degrades to the slower correct path (full replay), never to
a wrong answer. This is the Go-idiomatic strategy pattern: behavior selected
at runtime by what the value can do, not by configuration.
Fork-tolerant append (forked lineage)
Writers append; the Log never rejects on concurrency grounds. A commit’s
parent is the snapshot the writer built against — RecordPatch anchors it to
the head its state read reached (StateWithHead), and WithParent lets any
caller assert it (core/recorder.go). Two
writers racing the same document therefore record two commits sharing a
parent: a forked lineage — two honest facts, both kept, folded
last-write-wins in arrival (seq) order at read time. Learned from git, not
copied: the anchoring that keeps each commit truthful, without branches, refs,
or merge machinery.
The read side is a small eventual consistency model: a fork means two writers held different views for a moment, and the fold reconciles them — every reader replaying the same arrival order converges on the same state, deterministically. What is never eventual is the record itself: both sides of the fork are durable the instant they append.
Arrival order means the order the backend durably accepted the appends —
the in-memory adapter’s slice order, SQL’s per-document seq, ClickHouse’s
at timestamp, where producer clock skew can reorder it (the tradeoffs are on
operations). And the fold
never edits the record: a change’s From values were diffed against the state
its writer read, so on the side the fold overrides they can differ from the
folded result — honest provenance of that writer’s view, not a replay
precondition. Replay applies To values only.
The layering is deliberate. The atomic append primitive is the adapter’s job; declaring which snapshot a commit was built against can only ever be the writer’s. OCC and ACID — retries, conflict errors, expected-version checks — belong to the persistence layer or the producer (a store’s compare-and-swap, a single writer per document). The library records what happened; it does not referee it.
Idempotency keys (idempotent consumer)
The idempotent consumer pattern: at-least-once delivery (queues, webhooks,
retrying HTTP clients) is made exactly-once at the log. Seal with
WithIdempotencyKey consults the Deduper and returns the original commit
for a replayed key instead of sealing a duplicate — the producer retries
freely, the log absorbs the duplicates. Keys are scoped per document.
Lowest common ancestor (LCA)
CommitSnapshot scopes its output to the deepest path prefix shared by all
of a commit’s changed paths (lcaPath, kit/view) — the
smallest subtree that contains the whole diff. Segment-wise longest common
prefix, climbing to the nearest enclosing container when the LCA is a scalar.
Prior art
Systems and paradigms chronicle borrows from — useful when explaining what it is, or looking up how a problem was solved elsewhere. Ranked by closeness.
- git — the write model: stage, seal into a content-addressed commit, hash-chain to the parent. See the git model for the call-by-call side-by-side, the stored-diffs-not-snapshots inversion, and the one deliberate divergence (commit metadata stays out of the hash).
- Event sourcing / CQRS (Greg Young; Kafka’s log-as-truth) — the architecture: the append-only change log is the source of truth, every state is a derived projection.
- Temporal databases — the read model. Datomic’s
as-ofquery is exactlyStateAt; XTDB and SQL:2011 temporal tables (FOR SYSTEM_TIME AS OF) are the same idea. chronicle tracks commit time only (no separate valid-time axis, i.e. not fully bitemporal). - Verifiable logs — the integrity model. Certificate Transparency
(RFC 6962) runs public tamper-evident append-only logs with incremental
consistency proofs;
VerifyAfteris the linear-chain version of that idea. AWS QLDB, immudb, and Google Trillian are ledger databases in the same family. A blockchain minus consensus: one writer, no proof-of-anything. - Audit-trail libraries (Rails PaperTrail, Hibernate Envers, Django simple-history) — the product niche: per-record change history with who/when/what. chronicle is that niche made storage-agnostic, diff-native, and tamper-evident.
- Write-ahead log (WAL) — the database-internal ancestor: append-only log as truth, tables as projections.
- Change data capture (Debezium) — adjacent but inverted: CDC extracts changes from a database after the fact; chronicle records them first-class at write time.
- CRDTs / operational transformation — the deliberate contrast, and a
natural companion. Those resolve concurrent divergent histories; chronicle
only records them (a fork of sibling commits, folded last-write-wins at
read) and will not grow merge semantics. But because a commit stores an
ordered list of operations rather than a snapshot, a CRDT application can
hand its settled order straight to
RecordChanges: replay converges last-write-wins, and each actor’s operation survives the merge that collapsed it. See where changes come from.