core deliberately knows nothing about documents, paths, or comparison. It
stores changes and hash-chains them. Everything that turns a document into
changes and back again lives under kit/, which imports only core plus the
standard library.
The kit is five packages, plus the optional kit/httpapi transport at the end
of this page. chroniclekit (in kit/) is the facade this page uses;
underneath it, chronicleschema holds the vocabulary you declare your
document’s shape with, and chroniclediff, chronicleview, and
chronicleexplain are the three sides of the flow below. Each of the three is
independent of the others, so you can take one without the rest — the path
grammar and JSON handling they share lives in an internal package, out of your
way.
Every snippet below is taken from Example_explain in
kit/example_test.go — a runnable example whose
output go test checks, so nothing on this page can drift from the code. One
adaptation: the example wires up memlog, the kit’s internal in-memory test
backend, since it has no adapter to import; this page swaps that line for
New(yourLog) against a real one.
The shape of the flow
%% alt: Write side — your document is diffed into []Change and sealed into hash-chained commits. Read side — Commits() replays the chain and Explain turns it into display-ready rows.
flowchart TB
subgraph WRITE["write"]
direction LR
DOC["your document"] -->|Diff| CH["[]Change"] -->|Seal| COM["hash-chained commits"]
end
subgraph READ["read"]
direction LR
REP["replay the chain"] -->|Explain| ROWS["display-ready rows"]
end
COM -->|"Commits()"| REP
Write and read are given the same []chronicleschema.Option. That is the
whole point of the vocabulary: declare your document’s schema once, hand it to
both sides. It lives in its own package for exactly that reason — neither side
owns it.
Where changes come from
The kit takes either two document states or a ready-made list of changes. It does not care what produced them, and two shapes are worth spelling out.
A CRUD handler is the obvious one: load the record, apply the request, hand both versions over. One person, one edit, one commit.
k.RecordUpdate(ctx, docID, before, after, chroniclekit.WithActor(userID))
A collaboratively edited document is the other — the Google Docs shape, where several people edit at once and the last write to a field wins.
chronicle implements none of that. Deciding the order is the CRDT layer’s job, and this library is not going to grow into that domain. What it does is keep the audit chain afterwards, and the reason it fits is a property of the data model rather than a feature:
Commit.Changesis an ordered list of operations, not a snapshot of the result.
Replay it and you get the converged document, because the last write is replayed last. Read it and every actor’s operation is still there — including the ones that lost, which the converged document no longer shows anywhere. That is what makes the record worth auditing: “the budget is 1800” is one fact, “bob set 1500 and dave overrode it” is the one an investigation needs.
ops := []changelog.Change{
{Path: "budget", Kind: KindPut, From: "1000", To: "1500", Actor: "bob"},
{Path: "title", Kind: KindPut, From: `"Q3 plan"`, To: `"Q3 plan (final)"`, Actor: "carol"},
{Path: "budget", Kind: KindPut, From: "1500", To: "1800", Actor: "dave"},
}
commit, _ := k.RecordChanges(ctx, "doc-9", ops, WithMessage("collaborative edit"))
state, _ := k.State(ctx, "doc-9")
converged: map[budget:1800 title:Q3 plan (final)]
authors: [bob carol dave]
bob budget 1000 -> 1500
carol title "Q3 plan" -> "Q3 plan (final)"
dave budget 1500 -> 1800
Two writes to budget in one commit; the replay settles on dave’s, and bob’s
is still on the record. This is why RecordChanges sits beside RecordUpdate,
and why Actor lives on each Change rather than on the commit — WithActor
stamps one actor for a whole call, and a collaborative edit has several. Output
above is Example_collaborativeEdit in
kit/example_crdt_test.go, checked by
go test like everything else on this page.
Note that diffing two converged states would not do this: a diff sees only 1000 → 1800 and bob disappears. Record the operations, not the net effect.
One thing to know either way: chronicle does not merge, rebase, or reject a
concurrent append. Two commits landing on the same parent are recorded as a
fork — both stay, Verify/VerifyChain treat it as legitimate ancestry
rather than corruption, and the tamper-evidence story survives it because
every commit’s Parent is still inside its own hash. Replay still resolves
the state last-write-wins, by arrival order. If collaborators edit arrays,
declare element identity below, or concurrent positional edits will record
against whatever happened to sit at that index.
Other producers work the same way: a message consumer, a reconciliation job, an importer. Anything that can name what changed.
1. Declare the schema
opts := []chronicleschema.Option{
// Write-side: how array elements are identified. "lines" is keyed by a
// dot-path into the embedded entity; other arrays fall back to "_id".
chronicleschema.WithArrayKeys(map[string]string{"lines": "product._id"}),
chronicleschema.WithIdentityFields("_id"),
// Read-side: how things are named, labelled, and folded.
chronicleschema.WithNameFields("name"),
chronicleschema.WithIgnoredFields("meta.rev"),
chronicleschema.WithNames(map[string]string{"t1": "Fragile"}), // entity lives elsewhere
}
The options split into two groups, and the split matters more than it looks:
Write side (Diff, RecordUpdate) |
Read side (Explain, State) |
|
|---|---|---|
| Options | WithArrayKeys, WithIdentityFields, WithStrictIdentity, WithValueTypes |
all but WithStrictIdentity, plus WithLabels, WithNameFields, WithNames, WithIgnoredFields |
| Output | []Change, hash-sealed into a commit |
rows and value trees, stored nowhere |
| Getting it wrong | permanent — the changes are the hash preimage, and no later option re-keys history | free — change the option, all existing history re-renders |
Identity is the one declaration you cannot take back. It decides whether an
array edit records as lines.0.qty: 1 → 3 against a stable element, or as a
positional rewrite of every field after an insert. The kit ships no default
— it knows no field names, so an array with neither a WithArrayKeys entry nor
a usable WithIdentityFields field pairs positionally. Declare it before the
first write and keep it stable.
Because that mistake is silent, add WithStrictIdentity() and an object array
with no usable identity fails the write with chroniclediff.ErrNoIdentity
instead of quietly pairing by index.
Everything else only decorates at read time. Rename a label, add a name field, fold a new bookkeeping field — all of history re-renders, and not a stored byte moves.
Value types straddle the split. A declared value-object shape acts on both
sides: Diff compares and records a matching object as one canonical scalar,
and Explain keeps the same shape a scalar leaf inside container value trees.
money := chronicleschema.ValueType{
Fields: []string{"amount", "currency"}, // key set must match exactly
Canon: func(obj map[string]any) (string, bool) {
amt, aok := obj["amount"].(string)
cur, cok := obj["currency"].(string)
if !aok || !cok {
return "", false // decline — normal field-wise handling applies
}
return fmt.Sprintf("%q", amt+" "+cur), true // a JSON scalar — see below
},
}
opts = append(opts, chronicleschema.WithValueTypes(money))
A whole-container change embedding a money object then renders its tree as
Line Line
Price = "10.50 USD" Price
Qty = 2 Amount = "10.50"
Currency = "USD"
Qty = 2
(with the declaration) (without it)
Canon’s return must be a valid JSON scalar — hence the quoting above. When the
money object is itself the compared node, that string is recorded verbatim as
From/To, and replay parses it like any other stored value; a bare
10.50 USD would seal an unparseable change into history, so Diff refuses
it with chroniclediff.ErrBadCanon (Explain just declines it). Declared late, old
records still hold the raw object — the write side of the bargain is gone —
but the read side is free: a nested match renders as its canonical leaf, and a
raw object stored whole as a change’s value keeps scalar semantics
(FromValue/ToValue nil).
2. Record
RecordUpdate diffs before → after and seals the result in one call.
A document that existed before recording began can be onboarded in the same
call: WithCaptureBaseline(message, actor) first seals the full before as a
baseline root commit (only when the document has no commits yet), then the
delta parented to it. Without it the chain roots at the first delta, and
State/replay can only ever reconstruct the fields touched since capture
began — every commit stays true and verifiable, but a field never edited
appears in no reconstruction, current state included.
// Safe to pass on every call: the baseline seals once, on the first write
// that finds an empty chain and a non-nil before. After that it is a no-op.
k.RecordUpdate(ctx, "order-7", existing, edited,
WithCaptureBaseline("import pre-existing document", "importer"),
WithActor("alice"))
A chain already recorded without a baseline heals forward, never by rewrite: diff the partial reconstruction against the real document and seal that as an ordinary commit at HEAD. Reconstruction is complete from this anchor on; history before it stays partial — that data was never captured, and no design can recover what was not recorded.
// Late capture: the missing fields land as creates, parented to the current
// head. Append-only; nothing detaches, nothing is rewritten.
partial, _ := k.State(ctx, "order-7")
k.RecordUpdate(ctx, "order-7", partial, fullDoc,
WithMessage("late capture"), WithActor("importer"))
v1 := map[string]any{
"status": "open",
"meta": map[string]any{"rev": 1},
"lines": []any{
map[string]any{"product": map[string]any{"_id": "P1", "name": "NP1"}, "qty": 1},
},
}
v2 := map[string]any{
"status": "open",
"meta": map[string]any{"rev": 2},
"lines": []any{
map[string]any{"product": map[string]any{"_id": "P1", "name": "NP1"}, "qty": 3},
map[string]any{
"product": map[string]any{"_id": "P2", "name": "NP2"},
"qty": 2,
"tagIds": []any{"t1", "t9"},
},
},
}
k := New(yourLog)
k.RecordUpdate(ctx, "order-7", nil, v1, WithActor("alice"), WithDiffOptions(opts...))
k.RecordUpdate(ctx, "order-7", v1, v2, WithActor("bob"), WithDiffOptions(opts...))
Note the document shape: each line embeds the entity it refers to rather
than carrying its fields inline, and tagIds holds ids of entities that live
outside this document entirely. Both are ordinary CRUD shapes, and both are
handled below without storing anything extra.
The stored record stays machine-shaped — dotted paths, canonical-JSON scalars:
put lines.0.qty 1 → 3
create lines.1 ∅ → {"product":{"_id":"P2",…},"qty":2,"tagIds":["t1","t9"]}
put meta.rev 1 → 2
Display metadata is never stored alongside it — design decisions has the reasoning. One stored change, two audiences:
The kit emits structure — slices, names, canonical scalars, flags — never formatting. Separators, truncation, verbs, colors, and folding belong to your renderer: a CLI, a web app, and an email digest can all draw the same rows their own way.
3. Read the chain
Commits is newest-first, like git log. Explain replays from the root, so
reverse before handing it over.
commits, err := k.Service().Commits(ctx, "order-7", 0)
slices.Reverse(commits)
rows, err := chronicleexplain.Explain(commits, opts...)
// rows[i][j] decorates commits[i].Changes[j], 1:1
Explain replays the chain and derives display metadata from the revisions
surrounding each commit. Because it is all derived, records written long before
any schema was declared decorate exactly like new ones.
4. Render
Each Explained embeds the stored Change untouched and adds:
| Field | What it is |
|---|---|
Field []string |
label trail of the changed field, element-relative when Element is set |
Element |
the keyed array element the change sits in (Trail, Name, ID); Element with an empty Field is a whole-element add/remove |
Display |
id-valued From/To resolved to names |
Bookkeeping |
the change touches a WithIgnoredFields entry — still recorded, foldable on display |
FromValue/ToValue |
a container value decomposed as a ValueNode tree |
for _, r := range rows[1] { // the update commit
where := strings.Join(r.Field, " > ")
if r.Element != nil {
where = strings.Join(append(r.Element.Trail, r.Element.Name), " > ") + " | " + where
}
fmt.Printf("%s: %s -> %s", where, blank(r.From), blank(r.To))
if r.Display != nil {
fmt.Printf(" (%s -> %s)", blank(r.Display.From), blank(r.Display.To))
}
if r.Bookkeeping {
fmt.Print(" [bookkeeping]")
}
fmt.Println()
printTree(r.ToValue, " ")
}
which prints:
Lines > NP1 | Qty: 1 -> 3
Lines > NP2 | : ∅ -> {"product":{"_id":"P2","name":"NP2"},"qty":2,"tagIds":["t1","t9"]}
NP2
Product
Id = "P2" (NP2)
Name = "NP2"
Qty = 2
Tag Ids
0 = "t1" (Fragile)
1 = "t9"
Meta > Rev: 1 -> 2 [bookkeeping]
Four things happened there, none of which required storing anything:
Lines > NP1— the element is keyed byproduct._id, a dot-path. The element root has no name of its own, so the display name comes from the object the path descends into:product.name. The same rule names the value tree’s root (NP2).Tag Ids › 0 = "t1" (Fragile)—t1names an entity that is not in this document at all.WithNamessupplied it.Valueis still the stored canonical scalar;Displayis additive, so a UI can show the name and keep the id for a tooltip.Id = "P2" (NP2)— ids found inside the document resolve too, with no dictionary entry needed. Pairs discovered in the replayed revisions always beat a caller-supplied name, so a stale dictionary can never override the record.[bookkeeping]—meta.revwas recorded in full and flagged, not dropped. Folding it is the display’s choice.
What the kit will not do
The boundary is deliberate: 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, colors, and folding belong to the consumer — which is why a CLI, a web app, and an email digest can render the same rows differently, and why the library never limits the styling.
Over a wire
The option vocabulary is data, not callbacks — WithNames in particular is a
map precisely so a schema declared in one process survives serialisation to
another. Running Explain server-side keeps browser clients free of schema
logic, and the server stays schema-blind too — the vocabulary travels in the
request.
kit/httpapi serves exactly that route:
POST /explain {"doc":"order-7","limit":10,"options":{
"array_keys":{"lines":"product._id"}, "identity_fields":["_id"],
"name_fields":["name"], "ignored_fields":["meta.rev"],
"names":{"t1":"Fragile"}}}
The write side travels the same way. POST /commits takes a schema object
carrying the write half of the vocabulary — array_keys, identity_fields,
strict_identity — for the same reason: the producer knows its document’s
shape, the server does not, and the declaration has to arrive with the write
that depends on it.
Two things about it are contract rather than convenience. limit trims the
response, never the replay — decoration is derived by replaying from the
root, so a truncated history would yield wrong labels, names, and element
identity. And WithLabels has no wire form: it takes a resolver function,
which never crosses a process boundary. That costs less than it sounds, because
every row still carries the stored Change untouched — including path, the
raw dotted path. A client that wants its own i18n keys off the wire translates
from path and ignores the Title Case field fallback.
POST /commits also accepts patch — an RFC 6902 operation list, applied to
the stored document and diffed rather than sealed directly. If your clients
speak JSON Patch, read sealing RFC 6902 patches before you
mount it; if they do not, ignore the field and nothing changes.
A non-empty actor field feeds WithActor, filling the blank Change.Actor
on every change the write produces — the same call above, over HTTP, one
field instead of a RecordOption. Two read-only routes round out the
surface: GET /state?doc=&at= reconstructs a document at HEAD, or as of a
given commit, and GET /verify?doc= runs VerifyChain over its history and
reports the result.
Where to go next
- Reference — the exact contract of every call above
- Design decisions — why the vocabulary splits the way it does, why display data is never stored, why there are three write methods
- Sealing RFC 6902 patches — only if a client sends you one