For the guided path, read getting started first. This page is for looking things up.
It has two halves. Part one is what an
application recording and reading changes touches — the data model, the kit,
Recorder, Service, verification, errors, the shipped adapters, and the HTTP
layer. Part two is the contract a
storage backend must satisfy: the Log port, the optional capabilities, and the
conformance suite that decides whether you got it right. If you are using one of
the shipped adapters, part two is not for you.
Conventions. Must and never describe guarantees the library upholds and
a caller may rely on; may describes something optional. “The suite” means
core/conformance, which is the executable form of the Log contract in part
two — a backend that passes it satisfies this page.
Stability. v0, experimental. Modules are versioned independently
(core/vX.Y.Z, adapters/sql/vX.Y.Z, …) and the API may change before v1. The
Log port plus the conformance suite is the most stable surface; capability
interfaces and adapters are the least.
Naming. The core module’s package is changelog. Kit packages are
chroniclekit, chronicleschema, chroniclediff, chronicleview,
chronicleexplain; adapters are changelogmemory, changelogsql,
changelogclickhouse.
Part one — what a caller uses
Everything in this half is what an application recording and reading changes touches. If you are picking a backend off the shelf, you never need part two.
Data model
Change
One recorded edit — a line in a commit’s diff.
type Change struct {
At time.Time `json:"at"`
Actor string `json:"actor"`
Path string `json:"path"`
Kind string `json:"kind"`
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
}
| Field | Contract |
|---|---|
At |
When the edit happened. Always overwritten by Recorder.Append (and therefore by Service.Seal and every kit record call) from the recorder’s clock. A value you set is discarded. Hashed, as part of Changes. |
Actor |
The author. The distinct, sorted set of actors in a commit becomes its Authors. Not validated; "" is accepted. |
Path |
What was edited. core treats this as an opaque string and imposes no grammar. The dotted grammar (items.0.qty) is chronicleschema’s, and only the kit interprets it. |
Kind |
The operation label. core fixes no vocabulary. The kit produces and replays exactly create, put, delete; any other kind is an error to chronicleview.Reconstruct. |
From / To |
Before/after values as strings — canonical JSON when produced by chroniclediff.Diff, otherwise whatever the producer chose. "" means “no value on that side”: From on a create, To on a delete. |
Commit
An immutable, content-addressed bundle of changes chained to its parent.
type Commit struct {
ID string `json:"id"`
Parent string `json:"parent"`
At time.Time `json:"at"`
Authors []string `json:"authors"`
Message string `json:"message,omitempty"`
Changes []Change `json:"changes"`
}
| Field | Hashed | Contract |
|---|---|---|
ID |
— | SHA-256 over (Parent, Message, JSON-encoded Changes), each field length-framed with an 8-byte big-endian prefix so adjacent fields cannot be re-split into a colliding preimage. The encoding is encoding/json over the Change structs, in declaration order — adapters re-marshal from Go structs, never from stored column text, which is what makes the recomputation sound. |
Parent |
yes | The previous commit’s ID; "" marks the document’s root commit. |
At |
no | When the commit was sealed. Unauthenticated convenience metadata — the authenticated timeline is each Change.At. |
Authors |
no | Distinct Change.Actors, sorted. Derived, so it is protected by recomputation in VerifyChain, not by the hash (ErrAuthorsMismatch). |
Message |
yes | Optional annotation. Editing it after sealing breaks the chain. |
Changes |
yes | The edits sealed, in staged order. |
What content-addressing does and does not give you. Equal
(Parent, Message, Changes) always yields an equal ID. It does not give you
exactly-once: each Change carries its own At, stamped at staging time and
covered by the hash, so re-staging the same logical edit later produces a
different id. Use idempotency keys for delivery deduplication.
DocCommit and Snapshot
type DocCommit struct { DocID string; Commit Commit } // cross-document results self-identify
type Snapshot struct { DocID, CommitID string; State []byte } // opaque to core
core never interprets Snapshot.State; the layer that wrote it does (the kit
writes JSON document state).
The kit
chroniclekit is the facade; chronicleschema owns the shared document model;
chroniclediff, chronicleview, and chronicleexplain are the three sides of
the flow. Each imports chronicleschema and nothing else of the kit’s, so any
one can be used without the others. The whole flow is walked through on
the kit, end to end.
Kit
func New(log changelog.Log) *Kit
func NewWithService(svc changelog.Service) *Kit
func (k *Kit) RecordUpdate(ctx, docID string, before, after any, opts ...RecordOption) (changelog.Commit, error)
func (k *Kit) RecordChanges(ctx, docID string, changes []changelog.Change, opts ...RecordOption) (changelog.Commit, error)
func (k *Kit) RecordPatch(ctx, docID string, ops []Operation, opts ...RecordOption) (changelog.Commit, error)
func (k *Kit) State(ctx, docID string) (map[string]any, error)
func (k *Kit) StateAt(ctx, docID, commitID string) (map[string]any, error)
func (k *Kit) CommitSnapshot(ctx, docID, commitID string) (any, error)
func (k *Kit) Service() changelog.Service
func WithMessage(m string) RecordOption
func WithActor(actor string) RecordOption
func WithIdempotencyKey(key string) RecordOption
func WithDiffOptions(opts ...chronicleschema.Option) RecordOption
| Call | Contract |
|---|---|
New(log) |
Builds the Service itself — a caller picks a backend and nothing else. |
NewWithService(svc) |
Takes one already built, for a caller holding or wrapping a Service. |
RecordUpdate |
Diffs before → after and seals the result as one commit. Nothing changed ⇒ ErrEmptyChanges. |
RecordChanges |
Seals pre-built changes as given. No diff, so WithDiffOptions is inert. |
RecordPatch |
Applies an RFC 6902 patch to the document’s state at HEAD, then diffs and seals — see sealing patches. |
State / StateAt |
Current state; StateAt reconstructs as of and including commitID. |
CommitSnapshot |
The commit’s before-state for the smallest subtree containing every change in it. |
Service() |
The underlying Service, for reads the Kit does not wrap. |
| Option | Contract |
|---|---|
WithMessage(m) |
Annotates the sealed commit. Hashed. |
WithActor(a) |
Fills a blank Change.Actor on every change being sealed — this is what makes Commit.Authors non-empty. An Actor already set wins, so a RecordChanges call may mix attributions. Applied in RecordChanges, so it reaches hand-built changes too. |
WithIdempotencyKey(k) |
Forwarded to Service.Seal; a retry with the same key returns the original commit. |
WithDiffOptions(…) |
Forwards chronicleschema options to the diff inside RecordUpdate / RecordPatch. |
Either constructor detects the backend’s Snapshotter and TailReader once,
through the Service’s Unwrap() chain. A Service exposing no Unwrap gets
full-replay reads.
chroniclediff.Diff
func Diff(before, after any, opts ...chronicleschema.Option) ([]changelog.Change, error)
| Contract | |
|---|---|
| Input | Both sides JSON-normalized (marshal then unmarshal), so structs honouring json tags and maps diff uniformly. |
| Output | From/To hold canonical-JSON leaf values; "" means absent on that side. Diff(x, x) returns no changes. |
| Root | Documents must be object- or array-rooted. A scalar root produces one change with an empty Path, which Reconstruct does not apply. |
Array pairing walks a chain, per array, stopping at the first hop that holds:
| Hop | Holds when | Result |
|---|---|---|
1. WithArrayKeys[path] |
every element of both sides is an object with a unique scalar at the key path | keyed |
2. each WithIdentityFields entry, in order |
same test, on that field | keyed |
| 3. positional | always | by index |
Scalar and mixed arrays always reach hop 3, so a mid-array insertion reads as a run of puts plus a tail create. Two consequences are contract, not incidental:
| Consequence | |
|---|---|
| Keyed pairing trades order for identity | Reordering alone records nothing, so replay reproduces the element set (survivors in before order, additions appended), not the after ordering. Use positional arrays where order is data. |
| Identity cannot be applied retroactively | It shapes what is recorded; a later declaration cannot re-key sealed commits. Declare it before the first write and keep it stable. WithStrictIdentity makes hop 3 an ErrNoIdentity for arrays of objects. |
chronicleview
func Reconstruct(commits []changelog.Commit) (map[string]any, error)
func New(svc changelog.Service) *Reader
| Contract | |
|---|---|
| Order | Reconstruct takes commits oldest first. |
create / put |
Set the value at their path; intermediate containers are vivified as needed (a numeric segment makes an array). |
delete |
Removes the path. A mid-array index deletion shifts later elements left. |
Any other Kind |
An error — the replay never guesses. |
| Non-object root | An error. |
Reader |
Adds accelerated paths: with a Snapshotter + TailReader backend, serves from the stored snapshot plus a tail replay. Every fast path falls back to a full rebuild rather than failing. |
chronicleexplain.Explain
func Explain(commits []changelog.Commit, opts ...chronicleschema.Option) ([][]Explained, error)
| Contract | |
|---|---|
| Order | Takes commits oldest first — slices.Reverse what Commits gave you. |
| Alignment | rows[i][j] decorates commits[i].Changes[j], one to one. Each Explained embeds the stored Change untouched, which is what keeps path on every row. |
| Derivation | Everything is derived at read time by replaying the chain. Nothing display-related is ever stored, so pre-schema records render like new ones and changing an option re-renders all history without touching a stored byte. |
| JSON | Explained, Element, Display, ValueNode carry snake_case tags with omitempty; the embedded Change inlines. |
| Boundary | The kit emits structure, never formatting — slices, not joined strings; names, not sentences; canonical scalars, not prettified text; flags, not decisions. Separators, truncation, pluralization, verbs, colours, and folding belong to the consumer. |
chronicleschema options
One vocabulary, two consumers. Which side an option affects is the thing to get right:
| Option | Side | Effect |
|---|---|---|
WithArrayKeys(map[string]string) |
write + read | Element identity per array, keyed by the array’s index-free dotted path ("" for a root array), mapping to a dot-path into each element. |
WithIdentityFields(fields ...string) |
write + read | Generic identity fields tried in order for arrays WithArrayKeys does not name. No default — the kit guesses no field names. Replaces any earlier call. |
WithStrictIdentity() |
write | Makes an array of objects with no usable identity an ErrNoIdentity instead of a silent fall back to positional pairing. Off by default; scalar, empty, and keyed arrays pass. |
WithValueTypes(vts ...ValueType) |
write + read | Object shapes compared and recorded as one canonical scalar instead of field-by-field. Replay yields the scalar, not the object; Explain keeps the shape a scalar leaf inside container values. Canon must return a JSON scalar — anything else is an ErrBadCanon. |
WithLabels(func([]string) (string, bool)) |
read | Resolves a field’s label, used verbatim — the kit never translates. ok == false falls back to Title Case of the field name. |
WithNameFields(names ...string) |
read | Fields tried in order for an element’s display name. Defaults to {"name"}; calling this replaces the default. |
WithNames(map[string]string) |
read | Seeds id→name pairs for entities stored outside the document. Names found in the replayed document win. Never recorded; Diff ignores it. Appends to earlier calls. |
WithIgnoredFields(entries ...string) |
read | Flags bookkeeping fields. A bare name matches at any depth; a dotted path matches that field and its subtree. Recording is unaffected — the changelog stays a full data record; Explain only marks the change Bookkeeping so a display can fold it. Appends to earlier calls. |
Write-side options are load-bearing at record time and cannot be changed retroactively. Read-side options are free to change at any time and re-render all history.
JSON Patch interop
func FromChanges(cs []changelog.Change) []Operation // changes → RFC 6902 patch
func ToChanges(ops []Operation) []changelog.Change // RFC 6902 patch → changes
Kind |
JSON Patch op |
|---|---|
create |
add |
put |
replace |
delete |
remove |
| Contract | |
|---|---|
| Ops | Only add/replace/remove are produced or consumed. move/copy/test are silently skipped by ToChanges. |
From |
A patch is forward-only: FromChanges drops it, ToChanges leaves it "". |
Path |
Converts between the dotted grammar and RFC 6901 pointers (items.0.qty ↔ /items/0/qty). A root-level empty-string key does not round-trip. |
To seal a patch use RecordPatch, not RecordChanges(ToChanges(ops)) — it
recovers From, re-pairs arrays by declared identity, and rejects the ops
ToChanges skips. Both are pure functions of their argument, which is why
neither can fill From; sealing patches is the full path.
Recorder
The staging area plus git commit, bound to one document. Safe for concurrent
use.
func NewRecorder(docID string, log Log) *Recorder
func (r *Recorder) WithClock(now func() time.Time) *Recorder
func (r *Recorder) Append(c Change)
func (r *Recorder) Pending() []Change
func (r *Recorder) Commit(ctx context.Context, opts ...CommitOption) (Commit, error)
func WithMessage(s string) CommitOption
| Call | Contract |
|---|---|
NewRecorder |
Defaults to time.Now().UTC for timestamps. |
WithClock |
Replaces the clock and returns the recorder, so it chains. A nil clock resets to the default. |
Append |
Stages one change and stamps c.At from the recorder’s clock, overwriting any value supplied. |
Pending |
A copy of the staged, uncommitted changes. |
Commit |
Seals everything staged into one commit chained onto the document’s current Head, and appends it to the Log. |
Commit behaviour |
|
|---|---|
| Nothing staged | ErrNothingToCommit |
| Any error | The staged changes are restored — a failed commit loses nothing and the call can be retried |
WithParent set |
Used as Parent instead of reading Head — an assertion, not a guard; a stale parent still commits, as a fork |
When to assert: pass WithParent (or Seal’s WithSealParent, which
forwards to it) whenever the writer knows which snapshot it built against —
typically the head a state read returned; the kit’s RecordPatch does this
automatically via StateWithHead. Omitting it chains onto whatever Head is
current at commit time, which is only honest for blind appends that never read
state at all.
Service
The in-process facade. No transport, no net/http dependency.
func NewService(log Log) Service
type Service interface {
Seal(ctx context.Context, docID string, changes []Change, message string, opts ...SealOption) (Commit, error)
Commits(ctx context.Context, docID string, limit int) ([]Commit, error)
AllCommits(ctx context.Context, limit int) ([]DocCommit, error)
Get(ctx context.Context, commitID string) (dc DocCommit, ok bool, err error)
}
func WithIdempotencyKey(key string) SealOption
NewService detects the backend’s Indexer and Deduper once, at
construction, following any Unwrap() Log chain. Construct it once and reuse it.
Seal runs these steps in order:
| # | Step |
|---|---|
| 1 | Empty changes ⇒ ErrEmptyChanges, immediately. |
| 2 | With WithIdempotencyKey and a Deduper backend: a key already sealed for this document returns that commit and stops. No new commit. |
| 3 | Stages every change through a fresh Recorder — so every At is restamped — and commits, passing message only when non-empty and forwarding WithSealParent’s id (if given) as the Recorder’s WithParent; otherwise the parent defaults to Head at commit time. No conflict check: a stale parent still commits, as a fork. |
| 4 | Records the idempotency key best-effort after the commit is durable. A failed MarkSeen is not reported and degrades that delivery to at-least-once. |
| Method | Contract |
|---|---|
Commits |
Delegates straight to the Log. |
AllCommits / Get |
Delegate to the Indexer, and return empty results with a nil error when the backend has none — check ok, not err. |
Two asymmetries with Recorder: Seal takes message as a positional argument
rather than an option, and reports an empty batch as ErrEmptyChanges where
Recorder.Commit reports ErrNothingToCommit.
Verification
func Verify(ctx context.Context, log Log, docID string) error
func VerifyAfter(ctx context.Context, log Log, docID, anchorID string) (head string, err error)
Verify |
VerifyAfter |
|
|---|---|---|
| Fetches | the whole document | only commits after the anchor, via TailReader |
| Cost | O(all commits) | O(commits since the anchor) |
| Returns | error |
the new verified head — anchorID itself when the tail is empty |
| Requires | any Log |
a TailReader backend; errors without one |
The anchor trust contract. Because Parent is inside every hash, a verified
anchor transitively attests everything behind it. The flip side: tampering at
or before the anchor is invisible to VerifyAfter. Run the full Verify when
the anchor’s own provenance is in doubt.
Errors wrap the offending commit’s position and id — match with errors.Is.
What “tamper-free” means precisely, and the two []Commit forms these are built
on, are in part two.
Errors
| Sentinel | Returned by | When |
|---|---|---|
ErrNothingToCommit |
Recorder.Commit |
nothing staged |
ErrEmptyChanges |
Service.Seal, and so Kit.RecordChanges / Kit.RecordUpdate |
no changes supplied, or the diff produced none |
ErrNoSuchCommit |
TailReader.CommitsAfter |
afterID is not on the document |
chroniclediff.ErrNoIdentity |
Diff, under WithStrictIdentity |
an array of objects has no usable element identity and would pair positionally |
chroniclediff.ErrBadCanon |
Diff, when a ValueType matches |
Canon returned something that is not a JSON scalar — sealing it would make the history unparseable on replay |
chroniclekit.ErrUnsupportedOp |
Kit.RecordPatch |
the patch carries an op outside add/replace/remove |
ErrHashMismatch |
the verify family | a commit’s content no longer hashes to its id |
ErrMissingParent |
the verify family | a commit’s parent names a commit that is not in the history — forks and multiple roots are legal, an absent parent is not |
ErrAuthorsMismatch |
the verify family | Authors is not the recomputed actor set of the changes |
Adapters
changelogmemory.New() *Log
changelogsql.Open(ctx, dsn string, opts ...Option) (*Log, error)
changelogsql.New(db *sql.DB, opts ...Option) *Log
changelogclickhouse.Open(ctx, dsn string, opts ...Option) (*Log, error)
changelogclickhouse.New(db *sql.DB) *Log
memory |
sql (MySQL) |
clickhouse |
|
|---|---|---|---|
| Durable | no | yes | yes |
| Concurrent same-document appends | may fork | may fork | may fork |
RunLogConformance (ForkAppend included) |
passes | passes | passes |
| Options | — | WithMigrate, WithDialect |
WithMigrate |
| Extra methods | — | Close, Migrate, PruneSeen (returns rows deleted) |
Close, Migrate, PruneSeen (async delete, no count) |
| Caveat | Applies to |
|---|---|
| Reference and test only. In-memory — state does not survive a restart. | memory |
Requires parseTime=true in the DSN so DATETIME scans into time.Time. WithDialect exists but MySQL is the only dialect today. |
sql |
WithMigrate(true) runs CREATE TABLE IF NOT EXISTS during Open and is idempotent. There is no schema-version table — breaking schema changes need a hand-written migration out of band (operations). |
sql, clickhouse |
Idempotency records have no automatic TTL. Cron PruneSeen with a retention longer than your producer’s maximum redelivery window. |
sql, clickhouse |
kit/httpapi
Optional. The core ships no HTTP; this is one worked transport over a Service,
and you can equally write your own.
func Handler(svc changelog.Service) http.Handler
| Route | Request | Success |
|---|---|---|
POST /commits |
{doc_id, changes[]?, patch[]?, before?, after?, schema?, message?, actor?, idempotency_key?} |
201 + the Commit |
GET /commits?doc=&limit=&after= |
— | 200 + that document’s commits, or [{doc_id, commit}] across all documents when doc is omitted; after=<commit id> (requires doc) returns the tail strictly after it, oldest-first |
GET /commits/{id} |
— | 200 + {doc_id, commit} |
GET /changes?doc=&limit= |
— | 200 + the flattened feed: {commit_id, doc_id, …change fields} |
GET /state?doc=&at= |
— | 200 + the reconstructed document state; at=<commit id> reconstructs as of that commit instead of HEAD |
GET /verify?doc= |
— | 200 + {ok, commits, head?} on a clean chain, {ok:false, commits, error} on a broken one — a failed verification is a result, not a transport error |
POST /explain |
{doc, limit?, options?} |
200 + commits newest-first, each {id, parent, at, authors, message?, changes} where every change is display-decorated |
POST /commits takes one of three write shapes, tried in this order — the
first one present wins, so a body naming two is not an error:
| Field | Method | Meaning |
|---|---|---|
changes |
RecordChanges |
seal these changes as given |
patch |
RecordPatch |
RFC 6902 ops, applied to stored state and diffed |
before/after |
RecordUpdate |
diff these two documents |
| Body rule | |
|---|---|
before omitted |
“create from empty” |
before without after |
rejected — it would silently delete the document |
patch |
needs no before; the handler reads state at HEAD itself, which is what lets the sealed changes carry From |
Schema over the wire. Each side carries the half of the vocabulary that
survives JSON. Both skip empty fields, since name_fields and
identity_fields replace rather than merge and an empty list would wipe the
default.
POST /commits → schema |
POST /explain → options |
|
|---|---|---|
| Fields | array_keys, identity_fields, strict_identity |
array_keys, identity_fields, name_fields, ignored_fields, names |
| Reaches | the diff in patch and before/after writes; ignored by changes |
Explain |
| Getting it wrong | permanent — identity shapes what is recorded, and no later declaration re-keys it. strict_identity turns the omission into a 400 |
free — re-request with different options |
| No wire form | value_types (ValueType.Canon is a Go func) |
labels (a Go func; falls back to Title Case) |
Two properties of POST /explain are contract:
limit trims the response, never the replay |
Decoration is derived by replaying from the root, so the handler always fetches the whole chain and slices afterwards. A limit that truncated the replay would return confidently wrong labels, names, and element identity. |
| Rows embed, never duplicate | The raw changes are not repeated alongside the decorated ones; each decorated row embeds the change it decorates, untouched. path is therefore on every row — the stable key a client translates from for its own i18n. |
| Status | When |
|---|---|
400 |
invalid JSON, missing doc_id (or doc on /explain, /state, /verify), after given without doc, none of changes/patch/after, before without after, an empty diff, a patch op outside add/replace/remove or targeting the document root, or strict_identity with an array the declared identity does not cover |
404 |
GET /commits/{id} found nothing, after naming an unknown commit, or at naming an unknown commit |
500 |
any backend error — logged server-side, and the response body is a generic {"error":"internal error"} so adapter internals are never disclosed |
| Limit | |
|---|---|
?limit= |
Parsed with strconv.Atoi; absent or unparseable means 0, which per the Service contract returns all rows. |
| Request body | Capped at 4 MiB. |
| Path segment | A single segment can vivify an array index up to 65 536; aggregate request size is bounded only by the body limit. |
The handler ships no authentication, authorization, rate limiting, or per-caller quotas. Mount it behind your own middleware, and validate changes from untrusted producers yourself.
Part two — what a backend implements
This half is the contract a storage backend must satisfy. Skip it unless you are
writing a Log — the three shipped adapters already satisfy all of it, and
getting started never mentions any of it.
The Log port
The mandatory storage contract. Three methods, no more.
type Log interface {
AppendCommit(ctx context.Context, docID string, c Commit) error
Commits(ctx context.Context, docID string, limit int) ([]Commit, error)
Head(ctx context.Context, docID string) (string, error)
}
| Method | Guarantees |
|---|---|
AppendCommit |
Stores one commit for docID, extending its history. c.Parent is the writer’s assertion of the snapshot it built against — the Log stores it verbatim; two commits sharing a parent record a fork, not an error. Concurrency control, if a deployment wants one linear chain, is the persistence layer’s or the producer’s job, never the Log’s. |
Commits |
Returns the document’s commits newest first. limit <= 0 means all; limit > 0 means the limit most recent. A document with no commits returns an empty slice, never an error. |
Head |
Returns the document’s tip commit id, or "" when the document has no commits. "" is not an error. |
Beyond the per-method table, every implementation must:
| Requirement | Asserted by |
|---|---|
| Isolate documents — no method ever returns another document’s data | RunLogConformance |
| Honour context cancellation | RunLogConformance |
The port is deliberately per-document. Cross-document reads, dedup, cursors, and snapshots are all optional capabilities below — none is required for a backend to be correct.
Optional capabilities
A backend may implement any of these. The Service and the kit’s reader
detect them with a type assertion, walking any Unwrap() chain. A backend
that implements none is still correct — it simply loses the corresponding
feature, and the layers above keep no fallback of their own.
type Indexer interface {
AllCommits(ctx context.Context, limit int) ([]DocCommit, error)
FindByID(ctx context.Context, commitID string) (dc DocCommit, ok bool, err error)
}
type Deduper interface {
Seen(ctx context.Context, docID, key string) (c Commit, ok bool, err error)
MarkSeen(ctx context.Context, docID, key string, c Commit) error
}
type TailReader interface {
CommitsAfter(ctx context.Context, docID, afterID string, limit int) ([]Commit, error)
}
type Snapshotter interface {
SaveSnapshot(ctx context.Context, s Snapshot) error
LoadSnapshot(ctx context.Context, docID string) (s Snapshot, ok bool, err error)
}
| Capability | Contract | Absent ⇒ |
|---|---|---|
Indexer |
Cross-document reads, newest first, limit <= 0 = all. FindByID searches every document. |
Service.AllCommits returns an empty slice and Service.Get returns ok == false — both with a nil error |
Deduper |
Seen/MarkSeen are scoped per document: the same key on a different docID is a distinct delivery. MarkSeen is first-writer-wins — a no-op when (docID, key) exists. |
WithIdempotencyKey is silently inert; Seal stays at-least-once |
TailReader |
Commits strictly after afterID, oldest first (replay order — the opposite of Commits). afterID == "" means from the root. limit <= 0 = all. An afterID not on the document returns ErrNoSuchCommit. |
VerifyAfter errors; the kit’s reader falls back to full replay |
Snapshotter |
One snapshot per document, latest write wins. A pure cache — deleting stored snapshots is always safe. | the kit’s reader falls back to full replay |
All three shipped adapters implement all four: adapters/sql and
adapters/clickhouse durably across a restart, adapters/memory in process
only.
Chain verification on fetched commits
The two forms that take commits you already hold, rather than a Log. An
adapter’s tests use them; Verify and VerifyAfter are built on them.
func VerifyChain(commits []Commit) error
func VerifyCommits(commits []Commit) error
VerifyChain |
VerifyCommits |
|
|---|---|---|
| Input order | newest first, as Log.Commits returns |
any batch — e.g. the oldest first tail TailReader.CommitsAfter returns |
| Checks | full ancestry: content hash, authors, and every Parent |
content only: content hash and authors, per commit in isolation |
| Empty input | valid | valid |
VerifyCommits never checks that a parent exists — a fork’s parent legitimately
lies outside the batch it was given, so that is an ancestry question only
VerifyChain (over the full history) can answer.
“Tamper-free” means all three hold — forks and multiple roots are legal ancestry, not violations:
| Check | Violation |
|---|---|
Every Parent is "" or the ID of a commit in the batch |
ErrMissingParent |
Every ID equals the recomputed content hash |
ErrHashMismatch |
Every Authors equals the recomputed actor set |
ErrAuthorsMismatch |
Conformance
The executable half of this page. A backend is correct when it passes the mandatory suite.
type NewLog func(t *testing.T) (changelog.Log, func())
func RunLogConformance(t *testing.T, newLog NewLog) // mandatory
func RunDeduperConformance(t *testing.T, newLog NewLog) // if you implement Deduper
func RunTailReaderConformance(t *testing.T, newLog NewLog) // if you implement TailReader
func RunSnapshotterConformance(t *testing.T, newLog NewLog) // if you implement Snapshotter
| Suite | Asserts |
|---|---|
RunLogConformance |
empty head and commits, append then head, parent chaining, the stored chain verifies, newest-first order, limit, per-document isolation, two commits sharing a parent both landing with Head as the latest arrival (ForkAppend), context cancellation |
RunDeduperConformance |
idempotency keys are scoped per document: a key marked on one document never resolves on another, and resolves to that other document’s own commit once marked there |
RunTailReaderConformance |
oldest-first cursor order, "" from the root, mid-cursor reads, limit, empty tail after head, ErrNoSuchCommit for an unknown or foreign cursor, context cancellation |
RunSnapshotterConformance |
absent snapshot reports ok == false, round-trip, latest write wins, per-document isolation, context cancellation |
The package imports only changelog and the standard library, so depending on it
from your _test.go adds nothing to your build.
What this library does not do
Stated plainly, because the git framing invites the assumption. Why each is absent is on design decisions.
| Absent | Instead |
|---|---|
| Merge, rebase, cherry-pick | A concurrent append is a recorded fork, not a conflict — both commits land, folded last-write-wins at replay |
| Branches | “Branch” in this model means “document” |
| Transport, client SDK | core imports nothing outside the stdlib; kit/httpapi is optional, stdlib-only, and has no client library in any language |
| Authentication, authorization | Yours — not in the Service, not in the handler |
| Retention policy | Commits are never pruned automatically. Only the seen (idempotency) table has a prune helper, and you must schedule it |
| Cross-document transactions | A Seal covers one document |
Provided as-is, without warranty. Review it against your own threat model before exposing it to untrusted input.