chronicle

package module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 20, 2026 License: MIT Imports: 22 Imported by: 0

README

chronicle

Bitemporal entity change history for Go — an ORM-agnostic, queryable record of what your entities were, and of what you believed they were at any point in the past.

Every enterprise product reimplements entity history, and most get the same thing wrong: they keep one time axis. That answers "what is Alice's salary" and "what was it in March", and then answers "what did we believe in April that her March salary had been" with today's belief about March — confidently, and wrongly, because a retroactive correction rewrote what the system appears to have known. That third question is the one that settles audits and disputes, and it needs a second axis.

  • Two independent time axes. Valid time is when a fact was true in the world and you supply it. Transaction time is when chronicle learned it; the system assigns it, you cannot set it, and it is never rewritten. There is no exported way to write the transaction axis, which is the whole basis for trusting the log.
  • Non-destructive by construction. Nothing is ever updated in place or deleted. An overlapping write closes the superseded records' transaction interval and writes replacements — which is exactly what 21 CFR 11.10(e) means by "record changes shall not obscure previously recorded information", with no cryptography involved.
  • A real query surface. Filter by kind, entity, actor, intent and time range on either axis, with deterministic keyset pagination behind an opaque cursor. The nearest Go alternative's entire storage interface is Store/Get/Has/Clear on one opaque string, with no time parameter anywhere.
  • Structural field-level diffs. Nested objects and arrays to any depth, RFC 6901 JSON Pointer paths, exact number comparison. A codec failure is an error, never a silently empty diff.
  • Required actor attribution. No ambient default, no silent "system".
  • A compliance layer that says what it is. Retention schedules behind an explicit sweeper with a dry run, legal holds with backdatable effective instants that always beat retention, opt-in hash chaining with its threat model stated rather than implied, and per-subject crypto-shredding with no GDPR claim attached. Every design choice here traces to primary regulatory text or is labelled as not required by any — see docs/COMPLIANCE.md.
  • Zero dependencies. Standard library only. Go 1.23+.
import "github.com/zkrebbekx/chronicle"

Status: phase 3. The core model, the in-memory store, the Postgres adapter and the compliance layer — retention, legal hold, optional tamper evidence, crypto-shredding — are in. The REST service is a later phase. See docs/DESIGN.md.

The question that justifies the library

log := chronicle.NewLog(chronicle.NewMemStore())
hr := chronicle.Actor{ID: "u-42", Name: "Dana"}

// In March, we record that Alice earns 50000 effective 1 March.
first, _ := log.Put(ctx, "employee", "alice",
    []byte(`{"salary":50000}`), march, time.Time{}, hr)

// In April, we discover the figure was wrong — it was always 60000.
log.Correct(ctx, "employee", "alice",
    []byte(`{"salary":60000}`), march, time.Time{}, hr)

now, _  := log.Get(ctx, "employee", "alice", chronicle.ValidAt(march))
then, _ := log.Get(ctx, "employee", "alice",
    chronicle.As{ValidAt: march, TxAt: first.TxAt})

now.Data  // {"salary":60000}  — what we believe today about March
then.Data // {"salary":50000}  — what we believed in March about March

Both answers are correct, and they are different. A uni-temporal log can only give you the first.

Half-open intervals, and the zero time

Both axes are half-open [from, to). An unbounded end is the zero time.Time, never a sentinel maximum — zero is unambiguous in Go, survives marshalling, cannot collide with a real instant, and maps to SQL NULL.

A zero ValidTo means the fact still holds. A zero ValidFrom means it always did. A zero TxTo means the record is current belief.

Reading a zero time correctly depends on which end of an interval it sits at — a zero lower bound is −∞ and a zero upper bound is +∞. Getting that backwards is the bitemporal bug, so all of it lives in one type:

chronicle.Between(march, june)  // [march, june)
chronicle.Since(march)          // [march, ∞)
chronicle.Until(june)           // [-∞, june)
chronicle.Always()              // all of time; the zero Interval

chronicle.Between(march, june).Overlaps(chronicle.Since(june)) // false — adjacent

Use Interval's methods rather than comparing record timestamps yourself.

Zero values are meaningful throughout, and they do not all mean the same thing — the two worth keeping apart are As (zero means now) and Query (zero means no restriction):

Type Its zero value means
Interval all of time (Always())
As now, on both axes, resolved when the read is made
Query match everything: no filters, no limit, ascending
GetQuery a real point — the zero instants are year-1 coordinates, not wildcards; Log.Get resolves "now" before the store ever sees the query
Cursor passed in: start at the beginning; returned: no next page

Writes split intervals

Put and Correct run the same algorithm and are identical in storage; they differ only in the recorded Intent, which is what makes a retroactive fix distinguishable from an ordinary late-arriving fact.

log.Put(ctx, "employee", "alice", []byte(`{"grade":"L3"}`), march, time.Time{}, hr)
log.Put(ctx, "employee", "alice", []byte(`{"grade":"L4"}`), april, june, hr)

// [2026-03-01, 2026-04-01)  {"grade":"L3"}
// [2026-04-01, 2026-06-01)  {"grade":"L4"}
// [2026-06-01, ∞)           {"grade":"L3"}

PutInterval and CorrectInterval are the same two operations taking an Interval value rather than a pair of instants — handy at call sites that already hold one.

To record that a fact stopped being true, assert it with a bounded ValidTo — a Put of the same state over [from, end) closes the open-ended version and leaves the timeline uncovered after end, which is the honest shape for "we do not assert anything from then on". Where "ended" is itself a fact worth recording, write a successor state that says so in its data (a status field, or JSON null), so readers find an assertion rather than an absence.

The uncovered parts of the superseded record are rewritten as remainders. A remainder carries the superseded record's actor, reason and metadata, and is marked IntentRemainder — it re-asserts a fact its original author asserted, and stamping the splitting writer on it would have the log claim they said something they never said. Nothing is lost: a remainder shares its TxFrom with the write that produced it, so the IntentAssert or IntentCorrection record at that same instant identifies who caused the split.

The invariant, which is the point of the library: at any transaction instant, an entity's current valid intervals do not overlap, and no write introduces a gap where the timeline was previously covered. This is asserted after every step of a seeded property test over long randomised write sequences, and under go test -fuzz.

Transaction time is ratcheted

Two writes must never share a transaction instant — a record superseded by the write immediately following it would be left with an empty transaction interval that no as-of query could ever observe.

So chronicle ratchets: a write whose clock reading fails to advance on the previous one is assigned the previous instant plus one nanosecond. Transaction timestamps within a Log are strictly increasing whatever the clock does, including a frozen test clock or a clock that jumps backwards.

The alternative — letting timestamps tie and ordering on a separate sequence number — pushes the tiebreak into every reader and every downstream query. Ratcheting keeps it in one place. The cost is that transaction time can lead the wall clock by a nanosecond per write under sustained load, which self-corrects as soon as the write rate drops.

The ratchet is per-Log, and a Log's clock is only authoritative when it is the only writer. Store.Apply therefore owns the transaction axis: it picks the instant, stamps it on everything the write inserts and closes, and returns it, and the log adopts whatever comes back. MemStore accepts the log's proposal because a MemStore has exactly one Log writing to it. pgstore takes the instant from the database, so any number of processes and any number of Log values over one store still produce a single ordered history.

Reads

log.Get(ctx, kind, id, as)                 // one record, both axes
log.History(ctx, kind, id, opts...)        // every version ever, superseded included
log.Timeline(ctx, kind, id, as)            // valid-time sequence at one belief instant
log.Diff(ctx, kind, id, from, to)          // field-level changes between two points
log.FieldHistory(ctx, kind, id, path, as)  // one field's changes over transaction time
log.Query(ctx, q)                          // cross-entity, filtered, paginated

As{ValidAt, TxAt} locates a point in bitemporal space; a zero field means "now". chronicle.Now(), ValidAt(t), Believed(tx) and AsOf(t) cover the common cases.

Pagination is keyset, ordered by transaction start, then valid start, then the unique record ID. Because the ID is the final tiebreaker the order is total, so no row can be skipped or repeated at a page boundary however many records share a timestamp:

var cursor chronicle.Cursor
for {
    page, next, err := log.Query(ctx, chronicle.Query{
        Kind: "employee", ActorID: "u-42", Limit: 100, After: cursor,
    })
    if err != nil {
        return err
    }
    // ... use page ...
    if next.IsZero() {
        break
    }
    cursor = next
}

The cursor is opaque, URL-safe and checksummed; a mangled one is ErrInvalidCursor rather than a silently wrong page.

Diffing

Diff decodes record data through a Codec (JSON by default) and compares the decoded structures, reporting each change with an RFC 6901 JSON Pointer path.

// modified /address/city    Leeds -> York
// modified /salary          50000 -> 60000
// added    /tenured         <nil> -> true

It descends into nested objects and arrays to any depth. A change of shape at a node — an object becoming a scalar — is reported once at that node rather than as a burst of unrelated additions and removals. Objects compare by key, so reordering keys is not a change. Numbers compare by value, exactly — no float64 round trip, so integers beyond its range compare correctly, and a change of notation (1 to 1.0, 100 to 1e2) is not a change of value.

Documented limitation: arrays are compared by position. Inserting an element at the head of an array reports every later element as modified plus one addition at the end, rather than a single insertion. Reporting it as an insertion needs an alignment heuristic — an LCS over values, or a per-array identity field — and a heuristic that misfires on the cases it does not fit would be worse than a rule that is simple and stated.

A codec failure is ErrCodec, never an empty diff. A change log that reports "nothing changed" when it means "I could not tell" is worse than one that fails.

Field history

FieldHistory is the single-field audit trail — the read this whole design promised, in its most focused form. Fix a point in valid time and it walks how the recorded value of one field changed over transaction time: who changed it, when we learned it, and whether it was an assertion or a correction.

revs, _ := log.FieldHistory(ctx, "employee", "alice", "/salary", chronicle.ValidAt(march))
// revs[0]: absent -> 50000   txAt=T1  intent=assert      actor=alice
// revs[1]: 50000  -> 55000   txAt=T2  intent=correction  actor=bob   ← when we found we were wrong

It is a walk along the transaction axis at a fixed valid point, not along valid time — the part uni-temporal systems cannot express at all. The path is an RFC 6901 JSON Pointer in the exact grammar Diff emits, and equality is Diff's, so a value re-recorded in different notation (100 then 100.0) is not a change and a field that never appears is an empty result, not an error. A malformed pointer is ErrInvalidPath; an undecodable record is ErrCodec, never a silent gap.

Each side of a change carries a present flag, so a field absent from the object stays distinct from one explicitly set to JSON null — the classic subtle bug in a field-level trail, and two genuinely different facts here.

It is a read-side composition over Query and the diff comparison: it adds no Store method and no schema, so it works identically on MemStore and Postgres and is exercised on both by the conformance suite. Cost is linear in the number of beliefs ever recorded about the fixed valid point — one query, paged internally, one decode each — and independent of the rest of the log. A present→absent change comes from a later belief that still covers the point but drops the field (a correction that omits it, or a null-body tombstone); an ordinary Put/Correct cannot instead make the coverage lapse, because the superseded record's tail is preserved as a remainder carrying the old value across the point.

Storage

type Store interface {
    Apply(ctx context.Context, req ApplyRequest) (time.Time, error)
    Get(ctx context.Context, q GetQuery) (*Record, error)
    Query(ctx context.Context, q Query) ([]Record, Cursor, error)
}

type ApplyRequest struct {
    Entity EntityRef // what to lock
    Valid  Interval  // which current records the plan needs
    TxAt   time.Time // the log's proposed instant
    Plan   Planner
}

// Planner computes the write from the entity's current overlapping records,
// read inside the store's transaction, and the instant the store assigned.
type Planner func(current []Record, txAt time.Time) (Write, error)

type Write struct {
    Supersede []RecordID
    Insert    []Record
}

A write supersedes some records and inserts others, and the two must land together or a reader sees a gap or an overlap in valid time. Apply carries both halves, and there is deliberately no way to express them separately.

Apply takes a plan, not a finished write, and that shape is the whole isolation story. A write to one entity is a read-modify-write: read the overlapping records, compute the split, apply it. All three have to be one indivisible step. So the store does the read itself, inside its own transaction and under its own lock, hands the records to the plan, and applies what comes back without releasing either. chronicle's temporal reasoning stays above the store — a store never learns what a remainder is — but it runs where the store can protect it.

The earlier shape, where the log read through Query and passed a finished Write to Apply, was correct and starved. With the read outside the lock, the writer that waits for the lock always finds its plan stale by the time it gets in, and the writer that never waits never conflicts — so one writer lands everything and the other lands nothing. That is a stable equilibrium, not a probabilistic tail, and it was measured at 100% starvation of one of two writers over eighty writes. No isolation level fixes it, because no isolation level spans two separate calls.

Apply returns the transaction instant it actually assigned, and stamps it on everything the write inserts and closes. ApplyRequest.TxAt is only a proposal: a store with more than one writing process substitutes an instant of its own — no single process's clock is authoritative — and the log adopts what comes back.

StaticWrite wraps an already-decided Write as a Planner, for seeding and migrations. It opts out of the protection above, so a store may reject it with ErrConflict — and since the Log never issues a StaticWrite, retrying one is its caller's job. The Log's own retry loop (DefaultWriteRetries attempts, tuned by WithWriteRetries) covers the conflicts its planned writes can still meet: something other than chronicle writing an overlapping record into the same table.

Ordinary callers never build an ApplyRequest — the Log constructs one for every write; only store implementers and seeding or migration code touch the type directly.

MemStore is the reference implementation and is safe for concurrent use. pgstore is the Postgres adapter — see below.

Conformance

chroniclefest is the store contract as an executable specification. Point it at a factory and it exercises the whole surface — half-open boundaries, cursor ties, page boundaries, supersession idempotence, the non-overlap invariant:

func TestMyStore(t *testing.T) {
    chroniclefest.Run(t, func(t chroniclefest.T) chronicle.Store {
        return mystore.New(...)
    })
}

It runs against MemStore and pgstore on every build, which is what keeps the two answering identically rather than merely plausibly. It needs no driver, so it lives in the dependency-free root module.

The compliance capabilities have their own entry points, because they are optional extensions a store claims rather than part of the core contract: chroniclefest.RunCompliance holds a store to the Deleter and HoldStore contracts (all-or-nothing refusal of current records, retry-stable tombstones, backdatable EffectiveFrom, store-assigned PlacedAt, releases that keep the row), and chroniclefest.RunKeyring holds a Keyring to stable per-subject keys and terminal destruction.

The suite reports through chroniclefest.T, a narrow testing interface. *testing.T does not satisfy it directly — Run takes a func(T) rather than a func(*testing.T) — so chroniclefest.Wrap adapts one, and chroniclefest.Run does the wrapping for you; the interface exists so the suite can also be driven by a harness that records failures instead of aborting. That is how the suite itself is tested: chroniclefest's own tests run it against forty-six implementations each broken in exactly one nameable way — thirty-six stores (non-atomic apply, ignored supersession, a caller-chosen transaction time, uni-temporal Get, a keyset cursor that drops or repeats a row, a lossy round trip) and ten compliance and keyring variants (deletes that take current belief with them, holds that vanish, keys that re-mint after destruction) — and assert that the suite fails, and fails on the check that names the fault. A conformance suite whose failure branches have never executed is not evidence that it checks anything.

Postgres

The adapter lives in a nested module so that the root stays dependency-free:

go get github.com/zkrebbekx/chronicle/pgstore

It imports only database/sql and the standard library, so you bring your own driver. The tests use github.com/jackc/pgx/v5/stdlib.

db, err := sql.Open("pgx", os.Getenv("DATABASE_URL"))
store, err := pgstore.New(db, pgstore.WithSchema("audit"))
if err := store.Migrate(ctx); err != nil { ... }

log := chronicle.NewLog(store)

Migrate applies the embedded schema and is safe to run repeatedly. pgstore.SchemaSQL returns the same DDL as a string if you would rather feed it to your own migration tool. Both need btree_gist; Migrate creates it, which requires a role permitted to do so.

Schema

One table per store, with both axes as tstzrange and NULL for an unbounded bound, matching chronicle's zero-time.Time convention:

column type notes
id text COLLATE "C" primary key; C collation so SQL ordering matches Go's byte-wise compare
kind, entity_id text
data bytea opaque, because the codec is pluggable — see below
valid_from, valid_to timestamptz NULL = unbounded
tx_from, tx_to timestamptz assigned by the database; tx_to NULL = current belief
valid, tx tstzrange generated, stored, [)
actor_id, actor_type, actor_name, reason text
intent smallint
meta jsonb always a string map, so jsonb is safe and indexable

data is bytea, not jsonb, because Record.Data is opaque bytes under a pluggable Codec. A jsonb column would silently reject every non-JSON codec and turn a storage adapter into a codec mandate. The "query by changed field" path in DESIGN.md's open questions therefore needs a JSON projection — a generated column or a side table — rather than a change of primary storage. meta is jsonb because chronicle controls its shape entirely.

The same Migrate also creates two derived tables: <table>_holds (legal holds, one row per hold forever, placement and release halves both attributed and both timestamped by the database) and <table>_tombstones (retained chain values of retention-deleted records, keyed on record ID so a retried deletion writes the same tombstone once). pgstore.NewKeyring manages its own chronicle_keys table separately — keys are a different trust decision from records, and its doc comment says so bluntly.

Constraints and isolation callers inherit
  • Non-overlap is structural. An EXCLUDE USING gist (kind =, entity_id =, valid &&) WHERE (tx_to IS NULL) constraint makes two current records covering the same valid instant for one entity impossible, rather than merely checked in Go. It is DEFERRABLE INITIALLY DEFERRED, which keeps the constraint correct under any statement order: the shipped Apply closes superseded records before inserting their replacements and never passes through an overlapping state itself, but the deferral costs nothing and does not depend on that ordering staying true.
  • Writes to one entity serialize. Apply takes a pg_advisory_xact_lock per (kind, entity_id) before it reads anything, and holds it to commit. The overlapping records are then read FOR UPDATE inside the same transaction and handed to the plan, so a plan cannot go stale between the read and the write. Readers are never blocked, and at the store level writes to different entities do not contend. That last property does not survive end-to-end through a single Log, though: a Log serializes its own writes, whatever entity they name, so one Log has at most one write in flight process-wide. Where cross-entity write throughput matters, run one Log per worker over the same pgstore — safe, because the store, not any one log, assigns transaction time.
  • Stale plans are rejected, not applied. A StaticWrite was not planned from that read, so Apply also re-checks that every record it means to supersede is still current, and the deferred exclusion constraint catches anything the check missed. Either way the result is ErrConflict.
  • Transaction time comes from the database. Apply stamps GREATEST(clock_timestamp(), <newest tx_from among the current records read for the plan> + 1µs) — a floor extended, before anything is closed, over the supersede targets an unplanned write named itself — so a superseded record can never be left with an empty transaction interval that no as-of query could see. ApplyRequest.TxAt from the log is discarded.
  • Microsecond resolution. timestamptz stores microseconds, so a time.Time with nanosecond precision is truncated on the way in. chronicle's own transaction timestamps are assigned by the database and so are already microsecond-aligned; caller-supplied valid times are not, and round-trip equality holds only to the microsecond.

The isolation level is READ COMMITTED, deliberately. SERIALIZABLE is the usual advice and it is the wrong tool here twice over. Under the old two-call shape it did nothing at all — the read that needed protecting was in an already-committed transaction, so SSI had no dependency to track — and under the current shape there is nothing left for it to protect, because the read and the write are already in one transaction behind one lock. It would add mandatory 40001 retry handling and buy nothing either way.

Index usage is asserted rather than assumed: TestQueryPlans runs EXPLAIN over a seeded table and fails if any of the six selective query shapes — entity lookups, actor scans, point-in-time gets, and keyset resumption among them — plans as a sequential scan. It asserts the absence of Seq Scan, not the presence of Index Cond, so it proves the planner never falls back to reading the table, which is the regression that matters at scale.

Not done here

Partitioning on transaction time, which DESIGN.md lists alongside the exclusion constraint, cannot coexist with it. Postgres requires every unique or exclusion constraint on a partitioned table to include the partition key with equality, and tx_from WITH = is meaningless for a non-overlap constraint:

ERROR:  unique constraint on partitioned table must include all partitioning columns
DETAIL: EXCLUDE constraint on table "p" lacks column "tx_from" which is part of
        the partition key.

Requirement 1 wins. Phase 3's retention story therefore does not partition: the sweeper destroys eligible rows in place and the archive hook hands each batch to caller-owned storage first — which may itself be a partitioned archive table, since nothing constrains a table chronicle does not write through the exclusion constraint.

Retention

Everything above preserves history; retain destroys it, on schedule, and the contradiction is deliberate. Data kept past its period is a liability its owner did not choose — but which kinds to sweep and after how long is a regulatory decision chronicle cannot make, so it refuses to: no default retention period ships, and a sweep with no explicit policy is an error. The commonly cited periods do not transplant the way vendors imply (HIPAA's six years attaches to written policies and procedures; the SOX-lineage seven years binds the external audit firm's workpapers — see docs/COMPLIANCE.md). Set the period your counsel advises.

import "github.com/zkrebbekx/chronicle/retain"

policies := []retain.Policy{{Kind: "employee", KeepFor: 7 * 365 * 24 * time.Hour}}

plan, err := retain.Plan(ctx, store, policies, time.Now())    // dry run: reads only
rep, err  := retain.Execute(ctx, store, policies, time.Now(),
    retain.WithArchive(archiveBatch))                          // the real thing

What a sweep can destroy is narrow by construction:

  • Only superseded records. A record whose TxTo is open is current belief and is never eligible, at any age. This is enforced twice — the sweeper never names one, and the store's Delete refuses whole batches containing one (ErrCurrentRecord).
  • Aged from supersession. KeepFor runs from TxTo — how long the record has been dead — not from TxFrom. Aging from the write instant would destroy a record that stopped being current belief yesterday.
  • Never a held record. Records matched by an active legal hold are withheld, always, with no override, and the Report lists each withheld record and the hold that saved it.

WithArchive runs your archival — an archive table, an object store — on each batch before it is destroyed, and an error aborts the batch untouched. The hook must be idempotent: no transaction can span your archive and the store's deletion, so a sweep that fails between the two will re-archive the same records on retry. Key the archive on record ID and upsert.

Deletion itself is a store capability — the optional Deleter extension, implemented by MemStore and pgstore — so the core Store contract stays destruction-free and existing third-party stores keep compiling.

A hold suspends retention for everything in its scope. It restrains destruction only — writes proceed as ever, because a log that stopped recording under litigation would be destroying evidence of the present.

hs := store.(chronicle.HoldStore)
hs.PlaceHold(ctx, chronicle.Hold{
    ID:            "matter-2026-014",
    Kind:          "employee",
    EffectiveFrom: lastMarch, // backdated, deliberately
    Reason:        "anticipated litigation",
    PlacedBy:      counsel,
})
// ... later ...
hs.ReleaseHold(ctx, "matter-2026-014", counsel, "matter settled")

EffectiveFrom may be backdated, and that is a requirement rather than a loophole: FRCP 37(e) attaches the preservation duty on "anticipation or conduct of litigation", judged after the fact by a court — not on complaint filing — so an operator has to be able to assert, honestly and late, "our duty attached last month". Two things backdating is not: it cannot resurrect records destroyed before the hold was placed, and it never filters which records the hold protects — an active hold withholds everything in scope whatever the records' own timestamps, because the duty covers relevant information however old it is.

The hold is itself a record: PlacedAt and ReleasedAt are store-assigned (a control whose timeline its operator could write would prove nothing), release fills in the releasing actor and keeps the row forever, and releasing twice is an error rather than a rewrite of the first release's attribution.

Tamper evidence

Off by default. chronicle.WithChaining() links every record of an entity into a SHA-256 hash chain — each record's chain value covers a canonical serialization of its immutable fields plus its predecessor's value, carried in reserved record metadata so no store needs schema for it.

log := chronicle.NewLog(store, chronicle.WithChaining())

rep, err := log.Verify(ctx, "employee", "alice") // recompute; first divergence, if any
head, err := log.ChainHead(ctx, "employee", "alice") // the value to anchor externally

The honest threat model, which is the reason this is opt-in and last: a hash chain detects retrospective edits by someone who does not control the chain head. It does nothing against an administrator who owns the database and can recompute every hash. Only anchoring heads outside the database changes that — ChainHead exists so you can — and chronicle ships no anchoring, so it claims nothing anchoring would be needed for. No regulation surveyed in docs/COMPLIANCE.md requires any of this.

Retention composes with chaining through tombstones: deleting a chained record retains its chain value, Verify passes over the gap and reports how many tombstones it crossed. Be precise about what that proves. A verified chain across a gap establishes that the survivors are what the head commits to and that a record with the recorded chain value stood in the gap — it cannot establish why the record was destroyed, because a store writes tombstones for whatever it is asked to delete, and within a run of consecutive tombstones only the last is constrained by a surviving successor. A tombstone is evidence of destruction, not of authorisation; telling those apart needs anchored heads and sweep reports kept where the database administrator cannot edit them.

Two documented edges: canonical timestamps are microsecond-truncated (the storage contract's floor, set by Postgres timestamptz), so sub-microsecond edits to valid times are outside the chain; and TxTo cannot be hash-covered (it is written later, at supersession), so Verify instead pins every superseded record's TxTo to the transaction starts of later chained writes — which detects a shifted TxTo but not one moved to a different chained write's instant. The canonical form is versioned (v1:), so a future format meets an explicit unknown-version divergence rather than a silent mismatch.

Crypto-shredding

chronicle supports per-subject encryption keys. Destroying a key renders that subject's historical values unrecoverable while preserving the record structure. Whether key destruction constitutes erasure under GDPR Art.17 depends on your supervisory authority's position; chronicle makes no compliance claim. No DPA decision, EDPB guidance or court ruling accepting it was verified by the research behind docs/COMPLIANCE.md — if you need a settled answer, get it from counsel, not from a library README.

log := chronicle.NewLog(store, chronicle.WithKeyring(keyring))

log.Put(ctx, "employee", "alice", data, march, time.Time{}, hr,
    chronicle.WithSubject("subj-42"))     // stored encrypted under subj-42's key

keyring.DestroyKey(ctx, "subj-42")        // terminal: no re-minting, ever
log.Get(ctx, "employee", "alice", now)    // ErrShredded — never garbage plaintext

Data is AES-256-GCM under a per-subject key from a pluggable Keyring, with the record's kind and entity as authenticated data so ciphertext cannot be replayed onto another entity. Get and Diff decrypt transparently and fail with ErrShredded once the key is gone; History, Timeline and Query return the log as stored — structure intact, ciphertext and markers visible — with Log.Decrypt as the explicit step, so shredding a subject never breaks the audit trail around them. Under chaining the hash covers the ciphertext, so destroying a key changes no hash and breaks no chain.

The keyring is the whole strength of the scheme: shredding assumes destroying a key destroys every copy. MemKeyring is for tests; pgstore.NewKeyring stores keys in a Postgres table, which means one backup cycle and one administrator for keys and ciphertext alike — read its doc comment before relying on it, and back the Keyring interface with a KMS or HSM where shredding has to withstand your own infrastructure. Subject identifiers are stored in clear and survive shredding; make them pseudonymous references, not the personal data they protect.

Running the service

Everything above is a library you embed. For polyglot shops that cannot import a Go package, the chronicled/ module wraps the same engine in a standalone REST service — same semantics, same error taxonomy, JSON over HTTP. Its dependency list is part of the story: stdlib net/http (Go 1.22 pattern routing), chronicle, pgstore, and jackc/pgx/v5 as the database driver. No framework, no config library, no logging library.

cd chronicled && docker compose up --build

That starts Postgres and chronicled (migrated, chaining on) with two demo tokens. Then the flagship bitemporal scenario, over the wire:

AUTH='Authorization: Bearer demo-writer-token'

# Assert: salary effective 1 March.
curl -s -X POST localhost:8080/v1/employee/alice/records -H "$AUTH" \
  -d '{"data":{"salary":50000},"validFrom":"2026-03-01T00:00:00Z"}'
# → note "txAt" in the response; call it $T1

# Correct, retroactively: it was actually 55000.
curl -s -X POST localhost:8080/v1/employee/alice/corrections -H "$AUTH" \
  -d '{"data":{"salary":55000},"validFrom":"2026-03-01T00:00:00Z"}'

# What do we now believe March was?           → 55000
curl -s "localhost:8080/v1/employee/alice?validAt=2026-03-15T00:00:00Z" -H "$AUTH"

# What did we believe about March at $T1?     → 50000, unrewritten
curl -s "localhost:8080/v1/employee/alice?validAt=2026-03-15T00:00:00Z&txAt=$T1" -H "$AUTH"

# How did our belief about the March salary evolve, and who changed it?
#   → two changes: absent→50000 (assert), 50000→55000 (correction)
curl -s "localhost:8080/v1/employee/alice/field-history?path=%2Fsalary&validAt=2026-03-15T00:00:00Z" -H "$AUTH"

The full surface — history, timeline, diff, single-field history, cross-entity query with opaque cursors, legal holds, retention sweeps with dry run, chain verification, key destruction — is documented in the embedded spec at /v1/openapi.yaml (authenticated) and mirrors the library exactly.

GET /v1/{kind}/{entity}/field-history?path=&validAt=&descending= is the single-field audit trail: for the entity's state valid at a fixed point, it walks how the value at one RFC 6901 JSON Pointer changed over transaction time — who changed it, when we learned it, and whether it was an assertion or a correction. It composes over the query surface and the diff comparison, so it adds no store method and behaves identically on every store.

Actor attribution is the service's one load-bearing decision. Each configured token maps to an actor, and the service stamps that actor on every write. No request body carries an actor — sending one is a 400 with an explanation, not a silent ignore — because an audit service that accepted caller-supplied actor claims would record fiction. Transaction time is likewise never accepted over the wire. Two roles exist: writer (write + read) and admin (also holds, retention, shredding, verification).

Honest scope: this is API-key authentication for a single trust zone — anyone holding a token is that actor. Put mTLS or an OIDC-aware proxy in front for anything bigger; chronicled deliberately does not grow an identity provider. Configuration is environment-only (CHRONICLED_DSN, CHRONICLED_ADDR, CHRONICLED_TOKENS or CHRONICLED_TOKENS_FILE, CHRONICLED_CHAINING, CHRONICLED_MIGRATE, CHRONICLED_LOG_LEVEL, timeouts) and the service fails fast, with an actionable message, on anything malformed. Migration is opt-in and off by default — production schema changes should be explicit. Horizontal replicas are safe because pgstore assigns transaction time from the database clock; within one replica writes serialize through the process's single Log (see the phase 4 notes in docs/DESIGN.md). Errors are always JSON — {"error", "code", "detail?"} with codes mirroring the library's sentinels (not_found, invalid_interval, conflict, shredded, hold_released, no_policy, …). Timestamps are RFC 3339 UTC, microsecond-precise end to end, inherited from Postgres timestamptz.

The Dockerfile is multi-stage, CGO_ENABLED=0, to a distroless/static-debian12:nonroot final image — the whole runtime is the one static binary.

Compliance, honestly

See docs/COMPLIANCE.md, which is sourced to primary regulatory text rather than vendor marketing. The short version:

No regulation surveyed there textually requires cryptographic tamper-evidence, immutability, WORM storage, hash chaining or Merkle trees. 21 CFR 11.10 contains no occurrence of tamper-evident, hash, immutable or WORM. The genuine bar is non-destructive — 11.10(e)'s "record changes shall not obscure previously recorded information" — which full-row versioning satisfies with no cryptography at all, and which FDA confirmed explicitly in the 1997 final-rule preamble (62 FR 13430, Comment 111).

Consequences visible in this API:

  • Actor is required, with no ambient default. 11.10(e) records "operator entries and actions"; 11.50(a)(1) requires the signer's printed name; PCAOB AS 1215 .16 requires "the name of the person who prepared".
  • Reason is optional. The "who/what/when/why" formulation vendors attribute to Part 11 is not in the regulation. The one clear reason-for-change mandate in the researched corpus binds audit firms' workpapers, not your database.
  • No default retention period. The commonly cited periods bind other parties or other objects — HIPAA's six years attaches to written policies (45 CFR 164.316(b)(1)), and the SOX-lineage seven years binds the external audit firm (PCAOB AS 1215 .14, SEC Rule 2-06). 21 CFR 11.10(e) is relative.
  • Legal holds accept a backdated effective instant. FRCP 37(e)'s trigger is anticipation of litigation, judged after the fact — not complaint filing.
  • Hash chaining is opt-in and claims only its real threat model. No regulation surveyed requires cryptographic tamper evidence; the genuine bar is non-destructive, which full-row versioning meets with no hashing at all.
  • Crypto-shredding is described as a mechanism, not as GDPR erasure. The legal characterization is unverified and stays hedged.

chronicle is a library; compliance is a property of your whole system. This is not legal advice.

Non-goals

  • Not a database. Postgres does the hard storage work; chronicle does not reimplement MVCC, indexing or replication.
  • Not a CDC/WAL tailer. Those lose actor identity and business intent — the two things a change record exists to capture.
  • Not event sourcing. chronicle records what an entity was, not a stream of domain commands to fold.
  • No ORM, ever. That is the specific unoccupied axis, and the reason every incumbent is unusable outside its own framework.
  • No tamper-proof claims. chronicle ships hash chaining, opt-in, and states exactly what it detects: retrospective edits by someone who does not control the chain head. It does nothing against an administrator who owns the database and can recompute it. Only external anchoring changes that, and chronicle does not ship anchoring, so it will not imply the stronger guarantee.

License

MIT

Documentation

Overview

Package chronicle is a bitemporal entity change history for Go: an ORM-agnostic, queryable record of what your entities were, and of what you believed they were at any point in the past.

A Record is one entity's state over a half-open valid-time interval, as believed over a half-open transaction-time interval. Writes never destroy anything: an overlapping write closes the transaction interval of the records it supersedes and writes replacements, so the log answers "what did we believe then" as readily as "what is true now".

log := chronicle.NewLog(chronicle.NewMemStore())
log.Put(ctx, "employee", "alice", []byte(`{"salary":50000}`), march, time.Time{}, actor)
rec, _ := log.Get(ctx, "employee", "alice", chronicle.Now())

The two time axes

Valid time is when a fact was true in the world, and the caller supplies it. Transaction time is when chronicle learned the fact; the system assigns it, the caller cannot set it, and it is never rewritten. There is no exported field, option or argument anywhere in this package that writes TxFrom or TxTo, and that absence is the whole basis for trusting the log — a transaction axis that its users could write would record what someone wanted to have believed rather than what was believed.

The pair makes three genuinely different questions separable:

  • What is Alice's salary? — Now
  • What was it in March? — ValidAt
  • What did we believe in April that it had been in March? — As with both fields set

A uni-temporal log answers the first two and gives a confidently wrong answer to the third, because a retroactive correction rewrites what it appears to have known.

Half-open intervals and the zero time

Both axes are half-open, [from, to): the lower bound is included and the upper bound is not. Closed intervals make adjacency ambiguous and coalescing wrong, and SQL:2011 follows the same convention.

An unbounded end is the zero time.Time, never a sentinel maximum timestamp. Zero is unambiguous in Go, survives marshalling, cannot be confused with a real instant, and maps to SQL NULL. A zero ValidTo means the fact still holds; a zero ValidFrom means it always did; a zero TxTo means the record is current belief.

Reading a zero time correctly depends on which end of an interval it sits at — a zero lower bound is negative infinity and a zero upper bound is positive infinity — and getting that backwards is the classic bitemporal bug. All of it is therefore confined to Interval. Use its methods rather than comparing record timestamps by hand.

Writes

Log.Put asserts that an entity had a state over a valid interval. Log.Correct does the same and marks the write as a correction of a prior belief. They are identical in storage and differ only in Intent, which is what makes a retroactive fix distinguishable from an ordinary late-arriving fact.

Both run the same algorithm. Every current record whose valid interval overlaps the new one is superseded, and wherever such a record extended beyond the new interval, the uncovered part is rewritten as a remainder record carrying the superseded record's data. The invariant this maintains is the point of the library: at any transaction instant, an entity's current valid intervals do not overlap, and no write introduces a gap where the timeline was previously covered.

Actor is required on every write. There is no ambient default and no silent "system" attribution; a write with an empty actor ID is ErrMissingActor. Empty and inverted valid intervals are rejected with ErrInvalidInterval rather than stored.

Transaction time is ratcheted

Two writes must never share a transaction instant, or a record superseded by the write that immediately follows it would be left with an empty transaction interval that no as-of query could observe. chronicle therefore ratchets: a write whose clock reading does not advance on the previous write is assigned the previous instant plus one nanosecond. Transaction timestamps within a Log are strictly increasing, whatever the clock does, so every superseded record has TxTo strictly after TxFrom.

The alternative — letting timestamps tie and ordering on a separate sequence number — pushes the tiebreak into every reader and every downstream query. Ratcheting keeps it in one place. The cost is that transaction time can lead the wall clock by a nanosecond per write under sustained load, which self-corrects as soon as the write rate drops. The ratchet is per-Log, and what that requires depends on the store: a MemStore adopts the log's instants and so must have exactly one Log writing to it, while a store that assigns transaction time itself — pgstore does — supports any number of Logs over one store.

Record IDs embed the transaction instant and a sequence number, so they sort in write order and give queries a total order even across the records of a single multi-record write.

Reads

Log.Get returns the single record in force at a point on both axes. Log.History returns every version an entity has ever had, superseded ones included. Log.Timeline returns the valid-time sequence as believed at one transaction instant. Log.Diff reports field-level changes between two points. Log.FieldHistory walks how belief about one field evolved over transaction time at a fixed valid point. Log.Query filters across entities by kind, entity, actor, intent and time on either axis, and pages with an opaque keyset Cursor whose final tiebreaker is the unique record ID, so no row can be skipped or repeated at a page boundary however many records share a timestamp.

Field history

Log.FieldHistory is the single-field audit trail: for one entity's state as valid at a fixed point in the world, it returns each time the recorded value of one field changed, along the transaction axis — who changed it, when we learned it, and whether it was an assertion or a correction. It is a composition over the query surface and the diff comparison, so it needs nothing a store does not already provide and behaves identically on every store.

The field is named by an RFC 6901 JSON Pointer, the same grammar Log.Diff emits. Each returned FieldRevision carries the field's previous and new value as a FieldValue, which keeps an absent field distinct from one explicitly set to JSON null — the classic subtle bug in a field-level trail, and the reason the two are separate facts here. Value equality is Diff's, so a value re-recorded in different notation (100 then 100.0) is not reported as a change.

Diffing

Log.Diff decodes record data through a CodecJSONCodec by default — and compares the decoded structures, reporting each change as a FieldChange with an RFC 6901 JSON Pointer path.

The comparison is fully structural: it descends into nested objects and arrays to any depth, and a change of shape at a node (an object becoming a scalar, say) is reported once at that node rather than as a burst of unrelated additions and removals. Objects are compared by key, so reordering keys is not a change. Numbers are decoded as encoding/json.Number, so integers beyond float64's exact range compare correctly.

Arrays are compared by position. This is the one documented limitation: inserting an element at the head of an array reports every later element as modified plus one addition at the end, rather than a single insertion. Reporting it as an insertion needs an alignment heuristic — a longest-common-subsequence over values, or a per-array identity field — and a heuristic that misfires on the cases it does not fit would be worse than a rule that is simple and stated.

A codec failure is ErrCodec, never an empty diff. A change log that reports "nothing changed" when it means "I could not tell" is worse than one that fails.

Storage

Store is the persistence boundary and MemStore is the reference implementation. The interface is shaped so that a database/sql implementation is straightforward — values in, values out, no callbacks, no lock-holding iterators, and a limit and cursor that push down into LIMIT and a keyset predicate.

Supersession must be atomic with the writes accompanying it, so Store.Apply carries both halves and there is no way to express them separately. A SQL implementation runs Apply in one transaction.

Apply takes a plan rather than a finished write, because a write is a read-modify-write and all of it has to be indivisible. The store reads the entity's current overlapping records itself, under its own lock and inside its own transaction, hands them to the Planner, and applies the result without releasing either. chronicle's temporal reasoning stays above the store — a store never learns what a remainder is — but it runs where the store can protect it. StaticWrite opts out, for seeding and migrations.

Apply also returns the transaction instant it assigned, which a store with more than one writing process must take from somewhere central rather than from any one process's clock. The log adopts whatever comes back. A store's valid-time resolution is its own — pgstore holds microseconds, so caller-supplied valid times round-trip only to the microsecond there.

A cancelled context means nothing lands: every store method honours cancellation, and pgstore rolls the whole transaction back.

ErrConflict reports a write planned against state that has since moved. The Log's own writes are planned inside the store's lock and cannot go stale that way; the log's retry loop exists for the residue — something other than chronicle writing an overlapping record into the same table — and retries up to DefaultWriteRetries times (see WithWriteRetries). A StaticWrite opts out of planning entirely, so a store may reject it with ErrConflict too; the Log never issues one, and callers driving a store directly retry for themselves.

Compliance

Three optional layers sit on the core, each stating exactly what it does and claiming nothing more — see docs/COMPLIANCE.md for the primary-text research behind every choice.

Legal holds (Hold, HoldStore) restrain retention destruction for scoped records. A hold's EffectiveFrom may be backdated, deliberately: FRCP 37(e)'s preservation duty attaches on anticipation of litigation, judged after the fact. Retention itself lives in the retain subpackage, behind an explicit sweeper with a first-class dry run; nothing in this package deletes implicitly, and the Deleter capability refuses to destroy current belief at all.

Tamper evidence (WithChaining, Log.Verify, Log.ChainHead) is an opt-in per-entity hash chain. Its threat model is stated on the option rather than implied: it detects retrospective edits by someone who does not control the chain head, and nothing more. Retention under a chain leaves Tombstone values so verification crosses the gap.

Crypto-shredding (WithKeyring, WithSubject, Keyring) encrypts a record's data under a per-subject key; destroying the key renders those values unrecoverable while the records' structure survives. Whether that constitutes erasure under GDPR Art.17 depends on your supervisory authority's position; chronicle makes no compliance claim.

Errors

Match errors with errors.Is against ErrNotFound, ErrInvalidInterval, ErrMissingActor, ErrUnknownKind, ErrUnknownIntent, ErrCodec, ErrInvalidCursor, ErrMissingEntityID, ErrClosed, ErrConflict, ErrInvalidMeta, ErrZeroTxTime, and the compliance layer's ErrMissingHoldID, ErrHoldExists, ErrHoldReleased, ErrCurrentRecord, ErrNoChain, ErrReservedMeta, ErrShredded, ErrKeyDestroyed and ErrNoKeyring. Where extra context helps, the concrete error is also available via errors.As: *IntervalError, *KindError, *IntentError, *CodecError, *NotFoundError, *CursorError, *ConflictError, *HoldError, *DeleteError, *ChainError, *KeyError and *ShredError.

Concurrency

A Log and a MemStore are safe for concurrent use. Records are deep-copied crossing the store boundary in both directions, so a caller can neither reach into the log through data it wrote nor corrupt it by mutating a record it read. Note that a single Log serializes its writes — see the concurrency note on Log before sizing a deployment.

Scope

This is the core temporal engine plus the compliance layer above: retention (the retain subpackage), legal hold, opt-in tamper evidence and crypto-shredding all ship. What does not ship is any service around the library — no REST API and no management console; chronicle is a package you embed. It is also not a database — Postgres does the hard storage work — nor a CDC/WAL tailer, which loses the actor identity and business intent a change record exists to capture, nor an event-sourcing framework, since chronicle records what an entity was rather than a stream of commands to fold.

The Postgres adapter lives in the pgstore submodule and the retention sweeper in the retain subpackage, keeping this package dependency-free and destruction-free respectively. See docs/DESIGN.md for the design record and docs/COMPLIANCE.md for what regulation actually requires, which is considerably less than most audit-log vendors claim.

Example

A log records what an entity was, and answers questions about it at any point on either time axis.

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/zkrebbekx/chronicle"
)

var march = time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)

func main() {
	ctx := context.Background()
	log := chronicle.NewLog(chronicle.NewMemStore())
	hr := chronicle.Actor{ID: "u-42", Type: "user", Name: "Dana"}

	// Alice's salary is 50000 from 1 March, with no known end.
	_, err := log.Put(ctx, "employee", "alice",
		[]byte(`{"salary":50000}`),
		march, time.Time{}, hr)
	if err != nil {
		panic(err)
	}

	rec, err := log.Get(ctx, "employee", "alice", chronicle.Now())
	if err != nil {
		panic(err)
	}
	fmt.Printf("current: %s by %s\n", rec.Data, rec.Actor.Name)

}
Output:
current: {"salary":50000} by Dana

Index

Examples

Constants

View Source
const (
	// MetaReservedPrefix is the namespace of metadata keys chronicle writes
	// itself. Caller metadata must not use it.
	MetaReservedPrefix = "chronicle:"

	// MetaChain carries a record's hash-chain value, written when the log was
	// built [WithChaining]. The value is a chain format version prefix and a
	// hex digest, currently "v1:" followed by 64 hex characters. Treat it as
	// opaque; [Log.Verify] is how it is checked.
	MetaChain = MetaReservedPrefix + "chain"
)

Reserved metadata keys. chronicle keeps its own bookkeeping in Record.Meta under the MetaReservedPrefix namespace, and every write path rejects caller-supplied metadata using that prefix with ErrReservedMeta — a caller who could write these keys could forge exactly what they exist to attest.

View Source
const (
	// MetaSubject names the subject whose key a record's data is encrypted
	// under. Its presence is what marks a record as encrypted.
	MetaSubject = MetaReservedPrefix + "subject"
	// MetaCipher names the encryption scheme, currently always
	// [CipherAESGCM1].
	MetaCipher = MetaReservedPrefix + "enc"
)

Reserved metadata keys the shredding machinery writes. See MetaReservedPrefix.

View Source
const CipherAESGCM1 = "aesgcm1"

CipherAESGCM1 is the record encryption scheme: AES-256-GCM with a random 96-bit nonce prepended to the ciphertext, and the record's kind and entity ID as additional authenticated data so a ciphertext cannot be replayed onto another entity.

View Source
const DefaultWriteRetries = 8

DefaultWriteRetries is the number of times a write is recomputed after a store reports ErrConflict.

View Source
const KeySize = 32

KeySize is the size of a subject data key in bytes: 32, for AES-256.

Variables

View Source
var (
	// ErrNotFound is returned when no record satisfies a lookup — no state was
	// believed for that entity at that point on both axes. It is distinct from
	// an empty result: [Log.Get] reports it, while [Log.History] and
	// [Log.Query] simply return nothing.
	ErrNotFound = errors.New("chronicle: not found")

	// ErrInvalidInterval is returned for an interval that is empty or
	// inverted — a bounded upper end that does not strictly follow the lower
	// end. Such intervals are rejected at the API boundary rather than stored,
	// because a zero-width valid interval asserts nothing and an inverted one
	// asserts a contradiction. Errors wrapping this are always an
	// [*IntervalError].
	ErrInvalidInterval = errors.New("chronicle: invalid interval")

	// ErrMissingActor is returned by every write path when the actor has no
	// ID. chronicle has no ambient default actor; see [Actor] for why.
	ErrMissingActor = errors.New("chronicle: actor required")

	// ErrUnknownKind is returned for an empty kind, and for any kind outside
	// the allow-list when the log was constructed [WithKinds].
	ErrUnknownKind = errors.New("chronicle: unknown kind")

	// ErrUnknownIntent is returned for an intent value chronicle does not
	// define — a [Query] whose intent filter is enabled but names no defined
	// [Intent]. Errors wrapping it are always an [*IntentError].
	ErrUnknownIntent = errors.New("chronicle: unknown intent")

	// ErrInvalidMeta is returned by every write path for caller-supplied
	// metadata no store can hold: a key or value containing a NUL byte.
	// PostgreSQL jsonb cannot represent NUL inside a string, so accepting one
	// would make the same write succeed on [MemStore] and fail on pgstore —
	// the write is rejected at the API boundary instead, identically
	// everywhere. Distinct from [ErrReservedMeta], which rejects keys chronicle
	// reserves for itself rather than values no backend can store.
	ErrInvalidMeta = errors.New("chronicle: invalid metadata")

	// ErrInvalidField is returned by every write path for a caller-supplied
	// string field no store can hold: a kind, entity ID, reason, actor field
	// or hold field containing a NUL byte. PostgreSQL text columns cannot
	// represent NUL, so accepting one would make the same write succeed on
	// [MemStore] and fail on pgstore with a driver error — the write is
	// rejected at the API boundary instead, identically everywhere. The
	// metadata map has its own sentinel, [ErrInvalidMeta].
	ErrInvalidField = errors.New("chronicle: invalid field")

	// ErrZeroTxTime is returned by a store handed a zero transaction instant
	// it would otherwise have stamped. A zero instant is not a timestamp:
	// written as TxFrom it reads as "always believed" and as TxTo it reads as
	// "still current", so a store that adopts proposed instants ([MemStore])
	// refuses a zero one rather than corrupting the transaction axis silently.
	ErrZeroTxTime = errors.New("chronicle: zero transaction instant")

	// ErrCodec is returned when a record's data cannot be decoded for
	// structural comparison. [Log.Diff] reports it rather than silently
	// reporting no changes: under-reporting a change is the one failure mode a
	// change log must not have.
	ErrCodec = errors.New("chronicle: codec")

	// ErrInvalidCursor is returned when a pagination cursor is malformed,
	// truncated, or fails its checksum.
	ErrInvalidCursor = errors.New("chronicle: invalid cursor")

	// ErrInvalidPath is returned by [Log.FieldHistory] for a path that is not a
	// well-formed RFC 6901 JSON Pointer — one that does not begin with '/', or
	// whose '~' escapes are malformed. It is deliberately distinct from a path
	// that is well formed but matches nothing: a syntactically broken pointer is
	// a caller bug, while a pointer that no record happens to contain is an
	// ordinary empty result, not an error. Errors wrapping it are always a
	// [*PathError].
	ErrInvalidPath = errors.New("chronicle: invalid path")

	// ErrMissingEntityID is returned when a write or lookup names no entity.
	// An empty entity ID is always a caller bug rather than a wildcard;
	// treating it as one would let a typo write into a shared phantom history.
	ErrMissingEntityID = errors.New("chronicle: entity ID required")

	// ErrClosed is returned by a store that has been closed.
	ErrClosed = errors.New("chronicle: store closed")

	// ErrConflict is returned by [Store.Apply] when the write was computed
	// against a pre-state that no longer holds, because another writer changed
	// the entity in between. Nothing is applied.
	//
	// It is a retryable condition rather than a failure: [Log] re-reads the
	// entity and recomputes the split, up to a bounded number of attempts, and
	// only surfaces the error if it keeps losing. Callers who drive a store
	// directly should do the same.
	ErrConflict = errors.New("chronicle: write conflict")

	// ErrMissingHoldID is returned when a legal hold is placed without an ID.
	// Errors wrapping it are always a [*HoldError].
	ErrMissingHoldID = errors.New("chronicle: hold ID required")

	// ErrHoldExists is returned when a hold is placed under an ID that already
	// exists. Holds are not upsertable; see [HoldStore.PlaceHold].
	ErrHoldExists = errors.New("chronicle: hold already exists")

	// ErrHoldReleased is returned when a hold that has already been released
	// is released again. A second release would rewrite or silently discard
	// the first release's attribution; see [HoldStore.ReleaseHold].
	ErrHoldReleased = errors.New("chronicle: hold already released")

	// ErrCurrentRecord is returned by [Deleter.Delete] when a deletion names a
	// record that is still current belief. Nothing is deleted. Retention trims
	// history; it must never change the present. Errors wrapping it are always
	// a [*DeleteError] naming the record.
	ErrCurrentRecord = errors.New("chronicle: record is current belief")

	// ErrNoChain is returned by [Log.Verify] and [Log.ChainHead] when the
	// entity has no chained records and no tombstones — there is nothing to
	// verify, which must not be mistaken for a verification that passed.
	ErrNoChain = errors.New("chronicle: no hash chain")

	// ErrReservedMeta is returned by every write path when caller-supplied
	// metadata uses a key in chronicle's reserved namespace — any key starting
	// with [MetaReservedPrefix]. chronicle stores its own bookkeeping (chain
	// hashes, encryption markers) in Meta under that prefix, and a caller who
	// could write those keys could forge exactly what they exist to attest.
	ErrReservedMeta = errors.New("chronicle: reserved metadata key")

	// ErrShredded is returned when a record's data was encrypted under a
	// per-subject key that is no longer available — usually because
	// [Keyring.DestroyKey] destroyed it. The record's structure survives; its
	// value does not, and chronicle fails rather than hand back ciphertext
	// where plaintext was asked for. Errors wrapping it are always a
	// [*ShredError].
	ErrShredded = errors.New("chronicle: data shredded")

	// ErrKeyDestroyed is returned by a [Keyring] for a subject whose key has
	// been destroyed. Destruction is terminal: the keyring will not mint a
	// replacement under the same subject, since a quietly re-minted key would
	// make new writes readable under an identifier the caller believes erased.
	ErrKeyDestroyed = errors.New("chronicle: subject key destroyed")

	// ErrNoKeyring is returned when an operation needs a subject key and the
	// log has no [Keyring] configured — writing with [WithSubject], or reading
	// a record whose data is encrypted.
	ErrNoKeyring = errors.New("chronicle: no keyring configured")
)

Sentinel errors returned, usually wrapped, by this package. Match them with errors.Is rather than by comparing error strings.

Functions

func CompareRecords

func CompareRecords(a, b Record) int

CompareRecords imposes chronicle's total order: transaction start, then valid start, then record ID. Because IDs are unique this never returns zero for two distinct records, which is what makes keyset pagination exact.

Types

type Actor

type Actor struct {
	// ID identifies the actor within your system. It is the only required
	// field, and it is the field [Query.ActorID] matches on.
	ID string
	// Type describes what sort of actor this is — "user", "service", "job",
	// whatever your system distinguishes. Free-form and optional.
	Type string
	// Name is a human-readable display name, retained so that a rendering of
	// the log is legible without a join against your user table.
	Name string
}

Actor identifies who caused a change. It is required on every write.

chronicle has no ambient default actor and will never silently record "system" on your behalf: a write whose actor has an empty ID fails with ErrMissingActor. That restriction is deliberate and has a regulatory basis — 21 CFR 11.10(e) requires audit trails to record "operator entries and actions", 11.50(a)(1) requires a signer's printed name, and PCAOB AS 1215 .16 requires "the name of the person who prepared" added documentation. An attribution that defaults is not an attribution.

func (Actor) IsZero

func (a Actor) IsZero() bool

IsZero reports whether the actor carries no identity.

type ApplyRequest

type ApplyRequest struct {
	// Entity is the entity being written, and the granularity at which the
	// store locks. A zero Entity means the write is not planned from existing
	// state: Plan is called with no current records, and the store locks only
	// what the resulting write turns out to touch. Use it for seeding and
	// migration, never for an ordinary write, since it gives up exactly the
	// protection the planning form exists to provide.
	Entity EntityRef

	// Valid narrows the current records handed to Plan to those whose valid
	// interval overlaps it. The zero interval covers all of time.
	Valid Interval

	// TxAt is the transaction instant the log proposes. It is a proposal: a
	// single-writer store may adopt it, and a store with more than one writing
	// process must not, since no one process's clock is authoritative. The
	// instant the store settles on is what Plan is given and what Apply
	// returns.
	TxAt time.Time

	// Plan computes the write. It must not block, must not touch the store,
	// and must confine itself to Entity — the store locked that and nothing
	// else, so records written for another entity are unprotected.
	Plan Planner
}

ApplyRequest describes one indivisible change as a plan for the store to compute, rather than as a finished write for it to carry out.

type As

type As struct {
	// ValidAt is the instant on the valid axis — when in the world.
	ValidAt time.Time
	// TxAt is the instant on the transaction axis — as known when.
	TxAt time.Time
}

As is a point in bitemporal space: an instant on each axis.

A zero field means "now", resolved when the read is made. The zero As is therefore the ordinary current-state question — what is true, according to what we currently believe — and the two axes are set independently, because they answer genuinely different questions:

As{}                          // what is Alice's salary?
As{ValidAt: march}            // what was it in March?
As{ValidAt: march, TxAt: april} // what did we believe in April it had
                                // been in March?

The third is the one uni-temporal systems get wrong. They answer it with today's belief about March, because a retroactive correction rewrote what they appear to have known, and the answer looks entirely reasonable.

Note the deliberate contrast with Query: in an As a zero field means "now", while in a Query a zero time filter means "no restriction". A point lookup with an unset axis is a question about the present; a scan with an unset filter is a scan that does not filter, and defaulting it to the present would silently hide everything superseded.

func AsOf

func AsOf(tx time.Time) As

AsOf returns an As at the given transaction instant, asking about the state valid at that same instant — "what did the world look like to us then".

func Believed

func Believed(tx time.Time) As

Believed returns an As at the given transaction instant, with the valid instant left at "now" — what do we appear, as of that past belief, to have known about the present moment. It is ValidAt's mirror image: one pins the world and lets belief float, the other pins belief and lets the world float.

func Now

func Now() As

Now is the zero As: current state, current belief.

func ValidAt

func ValidAt(t time.Time) As

ValidAt returns an As at the given valid instant, at current belief.

func (As) IsZero

func (a As) IsZero() bool

IsZero reports whether both axes are unset.

type ChainError

type ChainError struct {
	// Kind and EntityID identify the entity.
	Kind, EntityID string
	// Err is the wrapped error.
	Err error
}

ChainError reports a chain operation that could not run, carrying the entity involved. It wraps ErrNoChain, or a store failure.

func (*ChainError) Error

func (e *ChainError) Error() string

Error implements the error interface.

func (*ChainError) Unwrap

func (e *ChainError) Unwrap() error

Unwrap returns the wrapped error so errors.Is matches.

type ChangeOp

type ChangeOp uint8

ChangeOp describes what happened to a field between two points.

const (
	// ChangeModified means the field exists on both sides with different
	// values.
	ChangeModified ChangeOp = iota
	// ChangeAdded means the field exists only on the later side.
	ChangeAdded
	// ChangeRemoved means the field exists only on the earlier side.
	ChangeRemoved
)

func (ChangeOp) String

func (o ChangeOp) String() string

String implements fmt.Stringer.

type Clock

type Clock interface {
	// Now returns the current instant. chronicle converts the result to UTC
	// and applies the monotonic ratchet described on [NewLog], so a Clock is
	// not required to be monotonic or to return UTC itself.
	Now() time.Time
}

Clock supplies transaction time. It exists so that tests can drive the transaction axis deterministically; production code should leave it alone and get time.Now in UTC.

A Clock is only ever read by chronicle. There is no exported path by which a caller can write a transaction timestamp onto a record, and injecting a clock does not create one — see Log.Put.

var SystemClock Clock = ClockFunc(func() time.Time { return time.Now().UTC() })

SystemClock is the default clock: time.Now in UTC.

type ClockFunc

type ClockFunc func() time.Time

ClockFunc adapts a function to the Clock interface.

func (ClockFunc) Now

func (f ClockFunc) Now() time.Time

Now implements Clock.

type Codec

type Codec interface {
	// Name identifies the codec in error messages.
	Name() string
	// Decode turns record data into a map of field names to values. Values may
	// be scalars, []any, or map[string]any, nested arbitrarily; [Log.Diff]
	// walks whatever structure it is given.
	//
	// Decode must return an error for input it cannot interpret. Returning an
	// empty map for undecodable data would make Diff silently report no
	// changes, and a change log that under-reports changes is worse than no
	// change log.
	Decode(data []byte) (map[string]any, error)
}

Codec decodes a record's Data into a structure that Log.Diff can compare field by field.

chronicle stores Data as opaque bytes and never needs to interpret it on the write path — a log with no codec configured still stores, supersedes and queries perfectly well. The codec exists solely so that Diff can answer "which fields changed" rather than "the bytes differ".

Implementations must be safe for concurrent use.

type CodecError

type CodecError struct {
	// Codec is the name of the codec that failed.
	Codec string
	// RecordID identifies the record whose data could not be handled, when
	// known.
	RecordID RecordID
	// Err is the underlying failure.
	Err error
}

CodecError reports a failure to encode or decode record data, carrying the codec's name and the record involved. It wraps ErrCodec.

func (*CodecError) Error

func (e *CodecError) Error() string

Error implements the error interface.

func (*CodecError) Is

func (e *CodecError) Is(target error) bool

Is reports that a CodecError matches ErrCodec, in addition to whatever the wrapped error matches.

func (*CodecError) Unwrap

func (e *CodecError) Unwrap() error

Unwrap returns the underlying failure.

type ConflictError

type ConflictError struct {
	// Reason describes what changed underneath the write.
	Reason string
	// Attempts is the number of times the write was retried before giving up,
	// zero when the error comes straight from a store.
	Attempts int
	// Err is the underlying failure, when a store had one to report. Nil when
	// the conflict was detected rather than reported.
	Err error
}

ConflictError reports that a write was computed against a pre-state that no longer holds. It wraps ErrConflict.

func (*ConflictError) Error

func (e *ConflictError) Error() string

Error implements the error interface.

func (*ConflictError) Is

func (e *ConflictError) Is(target error) bool

Is reports that a ConflictError matches ErrConflict, in addition to whatever the wrapped error matches.

func (*ConflictError) Unwrap

func (e *ConflictError) Unwrap() error

Unwrap returns the underlying failure, so that a store's own error remains reachable with errors.As.

type Cursor

type Cursor string

Cursor is an opaque pagination token. Treat it as a string with no internal structure: it is safe to store, to pass through a URL, and to hand back to Log.Query, and nothing else about it is guaranteed.

The empty Cursor means two different things in the two directions it travels, and both are the natural reading. Passed in, it means "start at the beginning". Returned, it means "there is no next page".

const NoCursor Cursor = ""

NoCursor is the empty cursor: start from the beginning, or no more results.

func EncodeCursor

func EncodeCursor(r Record) Cursor

EncodeCursor renders a record's sort key as an opaque, checksummed token.

A Store returns one when it withholds records, and only then: a cursor that comes back empty is what lets callers terminate a paging loop without a trailing empty page.

Two edges of the encoding are worth knowing when seeding records with caller-chosen IDs. The payload is delimited with the unit separator \x1f, so an ID containing that byte encodes to a token DecodeCursor rejects; chronicle-minted IDs never contain it, and seeded IDs must not. And the timestamps travel as RFC 3339, which cannot represent a year of 10000 or beyond — a concern only for a deliberately absurd fixture, but the failure is a rejected cursor rather than a wrong page.

func (Cursor) IsZero

func (c Cursor) IsZero() bool

IsZero reports whether the cursor is empty.

func (Cursor) String

func (c Cursor) String() string

String implements fmt.Stringer. The value is opaque; this exists so that a cursor prints legibly in logs, not so that it can be parsed.

type CursorError

type CursorError struct {
	// Cursor is the offending token.
	Cursor Cursor
	// Reason describes what was wrong with it.
	Reason string
	// Err is the wrapped sentinel.
	Err error
}

CursorError reports a rejected pagination cursor. It wraps ErrInvalidCursor.

func (*CursorError) Error

func (e *CursorError) Error() string

Error implements the error interface.

func (*CursorError) Unwrap

func (e *CursorError) Unwrap() error

Unwrap returns the wrapped sentinel so errors.Is matches.

type CursorKey

type CursorKey struct {
	// TxFrom is the transaction start of the last record on the previous page.
	TxFrom time.Time
	// ValidFrom is its valid start. Zero means unbounded, and sorts first.
	ValidFrom time.Time
	// ID is its record ID, the final and unique tiebreaker.
	ID RecordID
}

CursorKey is a cursor's decoded position: the sort key of the last record on the previous page. It mirrors the ordering in CompareRecords exactly, so resuming is a matter of keeping records that sort strictly after it.

It is exported for the benefit of Store implementations, which have to translate a cursor into whatever their backing store's keyset predicate looks like. Callers of Log should treat a Cursor as opaque and never decode one.

func DecodeCursor

func DecodeCursor(c Cursor) (CursorKey, error)

DecodeCursor parses a token produced by EncodeCursor, for Store implementations that need to turn a cursor into a keyset predicate.

Every failure mode — bad base64, wrong field count, wrong version, unparseable time, bad checksum — reports ErrInvalidCursor, because none of them are distinguishable to a caller who is meant to treat the value as opaque.

func (CursorKey) After

func (k CursorKey) After(r Record, descending bool) bool

After reports whether a record sorts strictly after the cursor position in the given direction. This is the whole of keyset pagination: because the record ID is the final, unique tiebreaker, a record either sorts strictly after the last one returned or it does not, and no record can fall through the crack between two pages however many share a timestamp.

type DeleteError

type DeleteError struct {
	// RecordID names the record the deletion was refused over.
	RecordID RecordID
	// Err is the wrapped sentinel.
	Err error
}

DeleteError reports a refused deletion, naming the record that caused the refusal. It wraps ErrCurrentRecord.

func (*DeleteError) Error

func (e *DeleteError) Error() string

Error implements the error interface.

func (*DeleteError) Unwrap

func (e *DeleteError) Unwrap() error

Unwrap returns the wrapped sentinel so errors.Is matches.

type Deleter

type Deleter interface {
	// Delete destroys the named records and returns how many were actually
	// destroyed. The whole call is atomic: all named records are removed and
	// their tombstones recorded, or — if any named record is current — nothing
	// is, and the error wraps [ErrCurrentRecord]. IDs that do not exist are
	// skipped.
	Delete(ctx context.Context, ids []RecordID) (int, error)

	// Tombstones returns the tombstones recorded for one entity, in chain
	// order: transaction start, then valid start, then record ID. Both kind
	// and entityID are required.
	Tombstones(ctx context.Context, kind, entityID string) ([]Tombstone, error)
}

Deleter is an optional Store extension for stores that can destroy records. It exists for exactly one caller: the retention sweeper in package retain.

Deletion is real destruction, and it contradicts the posture of everything else in this package — chronicle's core promise is that nothing is ever destroyed, and this interface destroys. The contradiction is deliberate and belongs to the caller: retention schedules exist precisely to destroy on a schedule, keeping data past its period is a liability its owner did not choose, and whether a given kind should be swept at all is a regulatory decision chronicle cannot make. What chronicle can do is refuse to blur the line — deletion is a separate capability, invoked by an explicit sweep against an explicit policy, and never a side effect of anything.

What a store must guarantee

Delete must refuse to destroy current belief. A record whose TxTo is zero is what the log currently asserts, and destroying it does not trim history — it changes the present. If any named record is current, the store must delete nothing and fail with an error wrapping ErrCurrentRecord naming the record. The sweeper never names a current record; the store enforcing it anyway is what makes a buggy or malicious caller a loud failure instead of a quiet loss.

Delete must leave tombstones for chained records. A deleted record whose metadata carries MetaChain takes its place in a tamper-evidence chain, and destroying it would otherwise break the chain for every record after it. The store records a Tombstone — the record's coordinates and its chain hash, nothing else — atomically with the deletion, and Log.Verify passes over tombstones using the retained hash. The store computes tombstones itself, from the records it is destroying, so that no caller can destroy a chained record and skip the tombstone.

Delete must be idempotent. IDs that no longer exist are skipped, not errors, so a sweep interrupted after a partial batch can be retried whole.

type Delta

type Delta struct {
	// Kind and EntityID identify the entity.
	Kind, EntityID string
	// From and To are the points that were compared, as resolved — zero
	// fields in the caller's [As] have been replaced with the instants
	// actually used.
	From, To As
	// FromRecord is the record in force at From, or nil if the entity had no
	// state there.
	FromRecord *Record
	// ToRecord is the record in force at To, or nil if the entity had no state
	// there.
	ToRecord *Record
	// Changes are the differences, ordered by path. It is empty when the two
	// states are structurally identical, which is not the same as the two
	// records being the same record.
	//
	// Numbers compare by value rather than by notation: 1, 1.0 and 1e0 are the
	// same number and produce no change. Where two numbers genuinely differ,
	// Old and New carry each side in its original notation.
	Changes []FieldChange
}

Delta is the set of field-level changes between two points in an entity's history, together with the records those points resolved to.

func (Delta) Change

func (d Delta) Change(path string) (FieldChange, bool)

Change returns the change at the given path, and whether there was one.

func (Delta) IsEmpty

func (d Delta) IsEmpty() bool

IsEmpty reports whether the two states were structurally identical.

func (Delta) Paths

func (d Delta) Paths() []string

Paths returns the paths that changed, in order.

type Divergence

type Divergence struct {
	// RecordID is the record — or, for a missing tombstone hash, the
	// tombstone — at which the chain stopped adding up.
	RecordID RecordID
	// Position is the entry's index in chain order, counting records and
	// tombstones together from zero.
	Position int
	// Reason says what failed.
	Reason string
}

Divergence names the first chain entry that failed verification.

type EntityRef

type EntityRef struct {
	// Kind discriminates the type of entity.
	Kind string
	// EntityID is the caller's opaque identifier, scoped by Kind.
	EntityID string
}

EntityRef names one entity: a kind and an ID within that kind.

type FieldChange

type FieldChange struct {
	// Path locates the field as an RFC 6901 JSON Pointer: "/salary",
	// "/address/city", "/tags/0". The empty path refers to the whole document.
	// Keys containing "~" or "/" are escaped as "~0" and "~1".
	Path string
	// Op says whether the field was added, removed or modified.
	Op ChangeOp
	// Old is the value on the earlier side, nil for an addition. For a change
	// at a structural node it is the whole subtree, not a scalar.
	Old any
	// New is the value on the later side, nil for a removal.
	New any
}

FieldChange is one field-level difference between two states.

type FieldHistoryOption added in v0.2.0

type FieldHistoryOption func(*fieldHistoryOpts)

FieldHistoryOption configures a Log.FieldHistory call.

func FieldHistoryDescending added in v0.2.0

func FieldHistoryDescending() FieldHistoryOption

FieldHistoryDescending reverses the result of Log.FieldHistory so the most recently recorded revision comes first. The default is transaction-time ascending. Only the order of the slice changes; each revision still reads From then To in forward transaction time, so "100 corrected to 120" is From=100, To=120 whichever way the list runs.

type FieldRevision added in v0.2.0

type FieldRevision struct {
	// Path is the RFC 6901 JSON Pointer the history was taken for, echoed on
	// every revision.
	Path string
	// From is the field's value in the belief immediately before this one, on
	// the transaction axis, at the fixed valid point. For the first revision it
	// is the absent value (Present false): before anything was recorded the
	// field did not exist.
	From FieldValue
	// To is the field's value in the belief this revision records.
	To FieldValue
	// TxAt is the transaction instant this belief was recorded — the TxFrom of
	// the record that introduced the new value. It answers "when did we come to
	// believe this", which for a correction is "when did we discover we were
	// wrong".
	TxAt time.Time
	// ValidFrom and ValidTo are the valid interval of the record that introduced
	// this belief. The fixed valid point the history was taken at always falls
	// within [ValidFrom, ValidTo); the bounds show how much of the world around
	// it the same belief covered.
	ValidFrom time.Time
	ValidTo   time.Time
	// Actor is who recorded the introducing belief.
	Actor Actor
	// Reason is the free-text justification on the introducing record, if any.
	Reason string
	// Intent distinguishes an original assertion ([IntentAssert]) from a
	// correction of a prior belief ([IntentCorrection]) — the distinction that
	// makes "when did we discover the salary was wrong" answerable. A revision
	// can also be introduced by a remainder ([IntentRemainder]) chronicle wrote
	// when splitting an interval, though a remainder re-asserts existing data
	// and so rarely changes a field's value at the split point.
	Intent Intent
}

FieldRevision is one recorded change to a single field, on the transaction axis, at a fixed point in valid time. It is the element of a Log.FieldHistory result — the answer to "how did our belief about this field evolve, and who changed it, and was it an assertion or a correction".

The name is FieldRevision rather than FieldChange because FieldChange is already the element of a Delta — a spatial diff between two states, carrying an add/remove/modify Op. A field's history is a different shape: it carries no Op (every element is a change, by construction), it distinguishes absent from null on both sides, and it carries the attribution and the two time coordinates of the belief that introduced the change. Overloading one name with both meanings would have been the more confusing choice.

type FieldValue added in v0.2.0

type FieldValue struct {
	// Value is the decoded value at the path, meaningful only when Present.
	// A present nil is an explicit JSON null.
	Value any
	// Present reports whether the path existed in the object at all.
	Present bool
}

FieldValue is the value at one JSON Pointer path in a decoded record, with the one distinction that makes a single field's history correct: a field that is not in the object is not the same as a field explicitly set to JSON null.

Present reports whether the path resolved to anything at all. When it is false the field was absent — the object did not contain the path — and Value is nil and meaningless. When it is true the field was there, and Value holds the decoded value, which may itself be nil for an explicit JSON null. So an absent field is FieldValue{Present: false} and a null field is FieldValue{Present: true, Value: nil}, and Log.FieldHistory treats a move between them as a real change, because it is one.

Value follows the same representation Log.Diff uses: scalars decoded by the Codec (numbers as encoding/json.Number so notation does not read as a change), []any for arrays and map[string]any for objects, nested to any depth. A path that lands on a whole subtree carries that subtree here.

func (FieldValue) IsNull added in v0.2.0

func (v FieldValue) IsNull() bool

IsNull reports whether the field was present and explicitly JSON null, as opposed to absent (Present false) or present with a value.

type FixedClock

type FixedClock struct {
	// T is the instant the clock reports.
	T time.Time
}

FixedClock is a Clock that returns a fixed instant until advanced. It is intended for tests and is not safe for concurrent modification while a log is writing; set it up before use, or advance it from the same goroutine that drives the writes.

A fixed clock does not defeat chronicle's ordering guarantees. Because the log ratchets transaction time forward by a nanosecond whenever the clock fails to advance, a sequence of writes against a frozen clock still produces strictly increasing transaction timestamps — which is what keeps every superseded record's TxTo strictly after its TxFrom.

func NewFixedClock

func NewFixedClock(t time.Time) *FixedClock

NewFixedClock returns a FixedClock reporting t.

func (*FixedClock) Advance

func (c *FixedClock) Advance(d time.Duration) time.Time

Advance moves the clock forward by d and returns the new instant.

func (*FixedClock) Now

func (c *FixedClock) Now() time.Time

Now implements Clock.

func (*FixedClock) Set

func (c *FixedClock) Set(t time.Time)

Set moves the clock to t.

type GetQuery

type GetQuery struct {
	// Kind and EntityID identify the entity.
	Kind, EntityID string
	// ValidAt is the instant on the valid axis. A zero value is a real
	// instant here, not a wildcard: [Log.Get] resolves "now" before calling
	// the store, so that stores need no notion of the current time.
	ValidAt time.Time
	// TxAt is the instant on the transaction axis, resolved the same way.
	TxAt time.Time
}

GetQuery locates a single record by its coordinates on both time axes.

type HistoryOption

type HistoryOption func(*Query)

HistoryOption filters Log.History.

func ByActor

func ByActor(actorID string) HistoryOption

ByActor restricts history to writes attributed to one actor.

func CurrentOnly

func CurrentOnly() HistoryOption

CurrentOnly restricts history to records that are still current belief.

func Descending

func Descending() HistoryOption

Descending reverses the result order, newest belief first.

func InTxRange

func InTxRange(iv Interval) HistoryOption

InTxRange restricts history to records whose transaction interval overlaps iv — that is, to beliefs held at some point during that window.

func InValidRange

func InValidRange(iv Interval) HistoryOption

InValidRange restricts history to records whose valid interval overlaps iv.

func Limit

func Limit(n int) HistoryOption

Limit caps the number of records returned. Use Log.Query when you need to page rather than truncate.

func WithIntent

func WithIntent(i Intent) HistoryOption

WithIntent restricts history to one intent — IntentCorrection to see only the retroactive fixes, for instance.

type Hold

type Hold struct {
	// ID identifies the hold. Caller-supplied — typically a matter or case
	// reference — required, and unique within the store.
	ID string

	// Kind scopes the hold to one entity kind. Empty matches every kind.
	Kind string
	// EntityID scopes the hold to one entity ID. Empty matches every entity.
	// With Kind empty too, the hold matches an entity ID across all kinds,
	// which is the natural scope for litigation about one subject whose
	// records span kinds. Both fields empty holds everything in the store.
	EntityID string

	// EffectiveFrom is when the preservation duty attached, as asserted by the
	// operator placing the hold. It may be backdated — see above — and it may
	// sit in the future, in which case the hold does not bite until then. The
	// zero time means the duty has no asserted start: the hold is effective
	// over all of time until released.
	EffectiveFrom time.Time

	// Reason is a free-text description of why the hold exists. Optional, like
	// [Record.Reason], and for the same researched reason: no regulation in
	// the corpus behind docs/COMPLIANCE.md mandates a reason field here.
	// Record one because your counsel wants one.
	Reason string

	// PlacedBy is who placed the hold. Required, with the same rule as every
	// chronicle write: no ambient default, no silent "system".
	PlacedBy Actor
	// PlacedAt is when the store recorded the hold. Store-assigned; whatever a
	// caller supplies is overwritten. The distinction between PlacedAt and
	// EffectiveFrom is the whole design: the operator asserts when the duty
	// attached, the system records when they asserted it, and neither can
	// masquerade as the other.
	PlacedAt time.Time

	// ReleasedAt is when the hold was released, exclusive, store-assigned.
	// Zero means the hold has not been released. A hold is active over
	// [EffectiveFrom, ReleasedAt), half-open like every interval in chronicle.
	ReleasedAt time.Time
	// ReleasedBy is who released the hold. Required at release.
	ReleasedBy Actor
	// ReleaseReason is a free-text justification for the release. Optional.
	ReleaseReason string
}

Hold is a legal hold: an instruction that records within its scope must not be destroyed while the hold is in effect. The retention sweeper skips every record an active hold matches, and reports what it withheld and under which hold, so that a sweep's output is evidence of the control working rather than a bare count.

A hold restrains destruction only. It does not freeze writes — new records, supersessions and corrections proceed as ever, and must, because a log that stopped recording under litigation would be destroying evidence of the present to preserve evidence of the past. Nothing chronicle does destroys history anyway; the only operation a hold restrains is the retention sweeper's, which is the only destructive operation chronicle has.

Backdating is deliberate

EffectiveFrom may sit in the past, and that is a requirement rather than a loophole. FRCP 37(e) applies to information "that should have been preserved in the anticipation or conduct of litigation", and per the 2015 Advisory Committee Note the duty attaches on *anticipation* — an event judged after the fact by a court, not the filing of a complaint. An operator therefore has to be able to assert, honestly and after the fact, "our duty attached last month". A hold that can only take effect "now" is the wrong shape for the obligation it exists to satisfy. See docs/COMPLIANCE.md.

Two things backdating is not. It is not retroactive protection: a record destroyed before the hold was placed is gone, and the backdated timestamp cannot resurrect it — what it does is make the log of controls state when the duty attached, so that destruction between EffectiveFrom and PlacedAt is identifiable as the violation it may have been. And it is not a filter on which records the hold protects: an active hold withholds every record in its scope regardless of the record's own timestamps, because the preservation duty covers relevant information however old it is. A hold that excluded records older than its effective date would destroy exactly the evidence it was placed to keep.

The hold is itself a record

PlacedAt is assigned by the store, never by the caller — an audit control whose own timeline could be written by its operator would prove nothing. Releasing a hold sets the release fields and keeps the row: the fact that a hold existed, who placed it, who released it and when, survives the hold itself.

func (Hold) ActiveAt

func (h Hold) ActiveAt(t time.Time) bool

ActiveAt reports whether the hold is in effect at the instant t: at or after EffectiveFrom and before ReleasedAt, with the usual half-open reading of zero bounds — a zero EffectiveFrom is "always was", a zero ReleasedAt is "still is".

func (Hold) Matches

func (h Hold) Matches(r Record) bool

Matches reports whether the hold's scope covers the record. Scope is deliberately independent of the hold's effective interval — see the type comment for why a hold never filters records by time.

func (Hold) Validate

func (h Hold) Validate() error

Validate checks the fields a caller supplies at placement: the ID and the placing actor are required. It is exported so that every HoldStore implementation rejects the same malformed holds with the same errors.

type HoldError

type HoldError struct {
	// ID is the hold involved, empty when the caller supplied none.
	ID string
	// Err is the wrapped sentinel: [ErrMissingHoldID], [ErrMissingActor],
	// [ErrHoldExists], [ErrHoldReleased] or [ErrNotFound].
	Err error
}

HoldError reports a failed legal-hold operation, carrying the hold's ID.

func (*HoldError) Error

func (e *HoldError) Error() string

Error implements the error interface.

func (*HoldError) Unwrap

func (e *HoldError) Unwrap() error

Unwrap returns the wrapped sentinel so errors.Is matches.

type HoldStore

type HoldStore interface {
	// PlaceHold records a hold and returns it as stored, with PlacedAt
	// assigned by the store. The caller's PlacedAt, ReleasedAt, ReleasedBy and
	// ReleaseReason are ignored: placement writes the placement half of the
	// row and nothing else. Placing a hold whose ID already exists fails with
	// an error wrapping [ErrHoldExists] — a hold is not upsertable, because
	// silently replacing one hold's scope with another's is exactly the edit
	// an audit control must not permit.
	PlaceHold(ctx context.Context, h Hold) (Hold, error)

	// ReleaseHold marks the hold released, attributed to the given actor with
	// an optional free-text reason, and returns it as stored. ReleasedAt is
	// store-assigned. The hold row survives; nothing deletes it. Releasing a
	// hold that does not exist wraps [ErrNotFound]; releasing one already
	// released wraps [ErrHoldReleased], because a second release would either
	// rewrite the first release's attribution or silently do nothing, and an
	// audit control should do neither quietly.
	ReleaseHold(ctx context.Context, id string, by Actor, reason string) (Hold, error)

	// Holds returns every hold the store has ever recorded, released ones
	// included, ordered by placement.
	Holds(ctx context.Context) ([]Hold, error)
}

HoldStore is an optional Store extension for stores that can hold legal holds. Both shipped stores implement it; the retention sweeper consults it when present, and a store without it simply cannot contain holds for the sweeper to honour — placing one is the only way in.

Implementations must retain released holds. The audit value of a hold is precisely that its whole lifecycle — placed by whom, effective from when, released by whom — outlives it.

type Intent

type Intent uint8

Intent records why a record was written. It distinguishes a routine assertion from an explicit correction of a prior belief, and marks the records chronicle writes itself when splitting an existing interval.

const (
	// IntentAssert is a normal [Log.Put]: a statement about what was true,
	// carrying no claim about whether it revises anything.
	IntentAssert Intent = iota
	// IntentCorrection is a [Log.Correct]: an explicit statement that a prior
	// belief was wrong. Storage-identical to an assertion; the difference is
	// that it is auditable as a correction, which is what makes "what did we
	// believe then, and when did we stop believing it" answerable.
	IntentCorrection
	// IntentRemainder marks a record chronicle wrote itself to preserve the
	// part of an existing record's valid interval that a new write did not
	// cover. A remainder carries the superseded record's data, actor, reason
	// and metadata unchanged — it re-asserts an existing fact rather than
	// making a new one — and it carries the transaction time of the write that
	// caused the split. See [Log.Put] for why the actor is attributed that way.
	IntentRemainder
)

func (Intent) String

func (i Intent) String() string

String implements fmt.Stringer.

type IntentError

type IntentError struct {
	// Intent is the offending value.
	Intent Intent
	// Err is the wrapped sentinel.
	Err error
}

IntentError reports an intent value chronicle does not define, carrying the offending value. It wraps ErrUnknownIntent.

func (*IntentError) Error

func (e *IntentError) Error() string

Error implements the error interface.

func (*IntentError) Unwrap

func (e *IntentError) Unwrap() error

Unwrap returns the wrapped sentinel so errors.Is matches.

type Interval

type Interval struct {
	// From is the inclusive lower bound. Zero means unbounded below.
	From time.Time
	// To is the exclusive upper bound. Zero means unbounded above.
	To time.Time
}

Interval is a half-open time interval [From, To).

Both endpoints may be unbounded, and an unbounded endpoint is always the zero time.Time — never a sentinel maximum timestamp. A zero From means "since the beginning of time"; a zero To means "and it still holds". The zero Interval is therefore the whole of time, which is why it is a sensible default for a filter that means "no restriction".

Every temporal comparison in chronicle is expressed through this type. That is deliberate: the zero-value-means-unbounded convention is the single most bug-prone part of a bitemporal engine, and scattering IsZero checks through query and write paths is how those bugs get in. If you need to reason about two chronicle intervals, use these methods rather than comparing the endpoints yourself.

Example

An unbounded end is the zero time, on either axis.

package main

import (
	"fmt"
	"time"

	"github.com/zkrebbekx/chronicle"
)

var (
	march = time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)

	june = time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
)

func main() {
	bounded := chronicle.Between(march, june)
	openEnded := chronicle.Since(march)

	fmt.Println(bounded)
	fmt.Println(openEnded)
	fmt.Println(openEnded.Contains(time.Date(2126, 1, 1, 0, 0, 0, 0, time.UTC)))
	fmt.Println(bounded.Overlaps(chronicle.Since(june))) // adjacent, not overlapping

}
Output:
[2026-03-01T00:00:00Z, 2026-06-01T00:00:00Z)
[2026-03-01T00:00:00Z, ∞)
true
false

func Always

func Always() Interval

Always returns the interval covering all of time. It is the zero Interval.

func Between

func Between(from, to time.Time) Interval

Between returns the half-open interval [from, to). Either endpoint may be the zero time to indicate an unbounded end.

func Since

func Since(from time.Time) Interval

Since returns the interval [from, ∞) — true from the given instant onwards, with no known end.

func Until

func Until(to time.Time) Interval

Until returns the interval [-∞, to) — true up to but not including the given instant, with no known beginning.

func (Interval) Contains

func (iv Interval) Contains(t time.Time) bool

Contains reports whether the instant t falls within the interval, honouring the half-open convention: the lower bound is included, the upper bound is not.

func (Interval) Duration

func (iv Interval) Duration() (time.Duration, bool)

Duration returns the length of the interval and whether it is finite. It returns false for any interval with an unbounded end.

func (Interval) EndsUnbounded

func (iv Interval) EndsUnbounded() bool

EndsUnbounded reports whether the interval has no upper bound. On the transaction axis this is what "current belief" means; on the valid axis it is what "and it still holds" means.

func (Interval) ExtendsBeyond

func (iv Interval) ExtendsBeyond(o Interval) bool

ExtendsBeyond reports whether this interval ends strictly after the other. An unbounded end extends beyond every bounded end, and nothing extends beyond an unbounded end.

func (Interval) IsAlways

func (iv Interval) IsAlways() bool

IsAlways reports whether the interval covers all of time.

func (Interval) Overlaps

func (iv Interval) Overlaps(o Interval) bool

Overlaps reports whether the two intervals share at least one instant. Adjacent intervals — where one ends exactly where the other begins — do not overlap, which is the point of the half-open convention.

func (Interval) StartsBefore

func (iv Interval) StartsBefore(o Interval) bool

StartsBefore reports whether this interval begins strictly before the other. An unbounded start precedes every bounded start.

func (Interval) StartsUnbounded

func (iv Interval) StartsUnbounded() bool

StartsUnbounded reports whether the interval has no lower bound.

func (Interval) String

func (iv Interval) String() string

String renders the interval in half-open notation, using -∞ and ∞ for unbounded ends.

func (Interval) UTC

func (iv Interval) UTC() Interval

UTC returns the interval with both bounds converted to UTC. Zero bounds are left as the zero time so that they keep meaning "unbounded".

func (Interval) Validate

func (iv Interval) Validate() error

Validate reports whether the interval is well formed. An interval is well formed unless it is bounded at both ends and its upper bound does not strictly follow its lower bound: empty and inverted intervals are both rejected, wrapping ErrInvalidInterval.

An unbounded end is never invalid, because there is nothing for it to be inverted against.

type IntervalError

type IntervalError struct {
	// Field names the interval that was rejected, when the operation involved
	// more than one. Empty when unambiguous.
	Field string
	// Interval is the offending interval.
	Interval Interval
	// Err is the wrapped sentinel.
	Err error
}

IntervalError reports a malformed interval, carrying the offending bounds so that the caller can see which write was rejected. It wraps ErrInvalidInterval.

func (*IntervalError) Error

func (e *IntervalError) Error() string

Error implements the error interface.

func (*IntervalError) Unwrap

func (e *IntervalError) Unwrap() error

Unwrap returns the wrapped sentinel so errors.Is matches.

type JSONCodec

type JSONCodec struct{}

JSONCodec decodes JSON objects. It is the default codec.

Numbers are decoded as json.Number rather than float64. That keeps comparison exact — 9007199254740993 and 9007199254740992 are different values here, where float64 decoding would silently collapse them — and it keeps a diff's reported values in the notation they were written in.

A JSON null decodes to an empty map rather than an error, so that a null record body is a usable tombstone: diffing from a populated record to a null one reports every field removed. Empty data is an error, because an empty byte slice is much more likely to be a bug than an intention.

func (JSONCodec) Decode

func (JSONCodec) Decode(data []byte) (map[string]any, error)

Decode implements Codec.

func (JSONCodec) Name

func (JSONCodec) Name() string

Name implements Codec.

type KeyError

type KeyError struct {
	// Subject is the subject whose key was involved.
	Subject string
	// Err is the wrapped error: [ErrKeyDestroyed], [ErrNoKeyring], or a
	// keyring's own failure.
	Err error
}

KeyError reports a keyring failure, carrying the subject involved.

func (*KeyError) Error

func (e *KeyError) Error() string

Error implements the error interface.

func (*KeyError) Unwrap

func (e *KeyError) Unwrap() error

Unwrap returns the wrapped error so errors.Is matches.

type Keyring

type Keyring interface {
	// Key returns the subject's data key, minting one — [KeySize] random
	// bytes — on first use. After [Keyring.DestroyKey] it fails with an error
	// wrapping [ErrKeyDestroyed]: destruction is terminal for the subject,
	// because quietly re-minting under the same identifier would make new
	// writes readable under a name the caller believes erased.
	Key(ctx context.Context, subject string) ([]byte, error)

	// DestroyKey irrevocably destroys the subject's key, rendering every
	// value encrypted under it unrecoverable. It is idempotent, and it
	// succeeds for a subject that never had a key — recording the destruction
	// so no key can be minted for that subject later — because "make this
	// subject unreadable" should not fail on the subject it cannot read.
	DestroyKey(ctx context.Context, subject string) error
}

Keyring stores per-subject data keys. It is the pluggable half of crypto-shredding: chronicle encrypts and decrypts, the keyring decides where key material lives and what destroying it means.

The keyring's storage is the whole strength of the scheme. Shredding assumes destroying a key destroys every copy — a keyring whose backups retain destroyed keys, or which lives in the same database and the same backup cycle as the records it protects, undoes the destruction it reports. MemKeyring and the Postgres keyring in pgstore are provided for development and for deployments that accept those properties; a deployment that needs shredding to mean something should back this interface with a KMS or HSM whose key-destruction semantics it trusts.

Implementations must be safe for concurrent use.

type KindError

type KindError struct {
	// Kind is the offending kind, empty if the caller supplied none.
	Kind string
	// Err is the wrapped sentinel.
	Err error
}

KindError reports a rejected entity kind. It wraps ErrUnknownKind.

func (*KindError) Error

func (e *KindError) Error() string

Error implements the error interface.

func (*KindError) Unwrap

func (e *KindError) Unwrap() error

Unwrap returns the wrapped sentinel so errors.Is matches.

type Log

type Log struct {
	// contains filtered or unexported fields
}

Log is the bitemporal engine: it turns caller assertions about what was true into a non-destructive record of what was believed, and when.

A Log is safe for concurrent use, and serializes its writes: the write path holds the log's lock across the store call, so one write is in flight per Log at a time, whatever entity it names. Reads never take the write lock. Where write throughput across entities matters, run one Log per worker over a store that assigns transaction time itself — pgstore does — which is safe because the store, not any one log, orders the transaction axis. A MemStore adopts the log's instants instead, so it must have exactly one Log writing to it.

Transaction time

Transaction time is assigned here and nowhere else. There is no exported field, option, or argument by which a caller can set TxFrom or TxTo, and that absence is load-bearing: a log whose transaction axis can be written by its users records what someone wanted to have believed, not what was believed, and answers the audit question wrongly while looking correct.

The monotonic ratchet

Each write takes the clock's instant, but the log will not accept one that fails to advance: if the clock returns an instant at or before the previous write's, the log uses the previous instant plus one nanosecond instead. Transaction timestamps within a log are therefore strictly increasing.

This settles the same-instant question the cheap way round. Two writes in the same nanosecond — from a coarse system clock, a frozen test clock, or simple speed — get distinct, ordered transaction times, so a superseded record always has TxTo strictly after TxFrom and is never left with an empty transaction interval that no as-of query could see. The alternative, letting timestamps tie and ordering on a sequence number, makes every reader carry the tiebreak; ratcheting puts it in one place.

The cost is that transaction time can run ahead of the wall clock under sustained writes, by one nanosecond per write beyond the clock's resolution. At a million writes per second that is a millisecond of drift per second of writing, and it is self-correcting the moment the write rate drops.

The ratchet is per-Log, and a Log's clock is only authoritative when it is the only writer. Store.Apply therefore returns the transaction instant it actually assigned, and the log adopts it: a store shared between processes stamps its own instant — from the database's clock, not any one process's — and the ratchet follows along behind it. Two Log values over one Postgres store consequently produce a single, correctly ordered transaction history, which two independent in-process ratchets could not.

func NewLog

func NewLog(store Store, opts ...Option) *Log

NewLog returns a log over the given store. It panics if store is nil, since a log without storage has no meaningful degraded behaviour.

Writes are applied through Store.Apply, indivisibly. There is no non-atomic path: a supersession and the insertion it accompanies always land together or not at all.

func (*Log) ChainHead

func (l *Log) ChainHead(ctx context.Context, kind, entityID string) ([]byte, error)

ChainHead returns the entity's current chain head: the chain value of the last entry — record or tombstone — in chain order. It reads the stored value without verifying the chain behind it; anchoring and verification are separate acts, and an anchor of what the database currently claims is exactly what makes a later recomputed chain provable.

Anchoring is the caller's, and chronicle is explicit about the division: a head lodged somewhere the database administrator cannot reach — a ledger, a timestamping service, a different trust domain's storage — upgrades the chain from "detects editors who do not control the head" to "detects editors who do not control the anchor". chronicle ships no anchoring.

It reports ErrNoChain when the entity has no chained entries.

func (*Log) Codec

func (l *Log) Codec() Codec

Codec returns the log's codec.

func (*Log) Correct

func (l *Log) Correct(ctx context.Context, kind, entityID string, data []byte, validFrom, validTo time.Time, actor Actor, opts ...WriteOption) (Result, error)

Correct records that a previously held belief was wrong.

Its effect on storage is identical to Log.Put — same supersession, same remainders, same non-destructive guarantee — and it differs only in marking the record IntentCorrection. That flag is the point: without it, a retroactive fix is indistinguishable from an ordinary late-arriving fact, and "when did we discover we were wrong" has no answer even though every byte needed to answer it is present.

Example

A retroactive correction changes what we believe now without changing what we are recorded as having believed then.

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/zkrebbekx/chronicle"
)

// must unwraps a result in example code, where an error would mean the example
// itself is broken.
func must[T any](v T, err error) T {
	if err != nil {
		panic(err)
	}
	return v
}

var (
	march = time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
	april = time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
)

func main() {
	ctx := context.Background()
	clock := chronicle.NewFixedClock(march)
	log := chronicle.NewLog(chronicle.NewMemStore(), chronicle.WithClock(clock))
	hr := chronicle.Actor{ID: "u-42", Name: "Dana"}

	// In March we record 50000, effective 1 March.
	first := must(log.Put(ctx, "employee", "alice", []byte(`{"salary":50000}`), march, time.Time{}, hr))

	// In April we discover the figure was wrong: it was always 60000.
	clock.Set(april)
	must(log.Correct(ctx, "employee", "alice", []byte(`{"salary":60000}`), march, time.Time{}, hr))

	now := must(log.Get(ctx, "employee", "alice", chronicle.ValidAt(march)))
	then := must(log.Get(ctx, "employee", "alice", chronicle.As{ValidAt: march, TxAt: first.TxAt}))

	fmt.Printf("we now believe March was:      %s\n", now.Data)
	fmt.Printf("in March we believed it was:   %s\n", then.Data)

}
Output:
we now believe March was:      {"salary":60000}
in March we believed it was:   {"salary":50000}

func (*Log) CorrectInterval

func (l *Log) CorrectInterval(ctx context.Context, kind, entityID string, data []byte, valid Interval, actor Actor, opts ...WriteOption) (Result, error)

CorrectInterval is Log.Correct taking an Interval.

func (*Log) Decrypt

func (l *Log) Decrypt(ctx context.Context, r Record) (Record, error)

Decrypt returns the record with its data decrypted, when the record is encrypted and the log's keyring still holds the subject's key. A record that is not encrypted comes back unchanged.

Log.Get and Log.Diff decrypt implicitly; Log.History, Log.Timeline and Log.Query deliberately do not — they are views of the log as stored, they must keep working after a subject is shredded, and a page that fails because one record on it is unreadable would make shredding break the audit trail it is meant to leave intact. Decrypt is the explicit step for callers walking those views who want values back.

Failure is loud by design: a destroyed key is a *ShredError wrapping ErrShredded, a missing keyring is ErrNoKeyring, and a ciphertext that fails authentication — tampered, or its key destroyed and re-minted by a keyring that does not honour terminal destruction — is a *ShredError too. No path returns ciphertext or garbage where plaintext was asked for.

func (*Log) Diff

func (l *Log) Diff(ctx context.Context, kind, entityID string, from, to As) (Delta, error)

Diff reports the field-level changes in an entity's state between two points in bitemporal space.

Both points are resolved independently, so this compares across either axis or both at once. Holding ValidAt fixed and moving TxAt shows what a correction changed about a single moment in the world; holding TxAt fixed and moving ValidAt shows what actually happened to the entity over time, according to one consistent belief.

If the entity has no state at one of the points, every field is reported as added or removed accordingly. If it has none at either, the result is an error wrapping ErrNotFound. If the codec cannot decode a record that does exist, that is a *CodecError wrapping ErrCodec — never an empty diff, because a change log that reports "nothing changed" when it means "I could not tell" is worse than one that fails.

Numbers are compared by value, not by notation: a field that moves from 1 to 1.0, or from 100 to 1e2, has not changed, because JSON does not distinguish those forms. The comparison is exact — no float64 round trip — so integers beyond float64's range still compare correctly. Where two numbers genuinely differ, the change reports each side as written.

Example

Diff reports field-level changes between two points, descending into nested objects.

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/zkrebbekx/chronicle"
)

// must unwraps a result in example code, where an error would mean the example
// itself is broken.
func must[T any](v T, err error) T {
	if err != nil {
		panic(err)
	}
	return v
}

var (
	march = time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
	april = time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
)

func main() {
	ctx := context.Background()
	clock := chronicle.NewFixedClock(march)
	log := chronicle.NewLog(chronicle.NewMemStore(), chronicle.WithClock(clock))
	hr := chronicle.Actor{ID: "u-42"}

	before := must(log.Put(ctx, "employee", "alice",
		[]byte(`{"salary":50000,"address":{"city":"Leeds"}}`), march, time.Time{}, hr))
	clock.Set(april)
	after := must(log.Correct(ctx, "employee", "alice",
		[]byte(`{"salary":60000,"address":{"city":"York"},"tenured":true}`), march, time.Time{}, hr))

	delta, err := log.Diff(ctx, "employee", "alice",
		chronicle.As{ValidAt: march, TxAt: before.TxAt},
		chronicle.As{ValidAt: march, TxAt: after.TxAt})
	if err != nil {
		panic(err)
	}
	for _, c := range delta.Changes {
		fmt.Printf("%-8s %-16s %v -> %v\n", c.Op, c.Path, c.Old, c.New)
	}

}
Output:
modified /address/city    Leeds -> York
modified /salary          50000 -> 60000
added    /tenured         <nil> -> true

func (*Log) FieldHistory added in v0.2.0

func (l *Log) FieldHistory(ctx context.Context, kind, entityID, path string, as As, opts ...FieldHistoryOption) ([]FieldRevision, error)

FieldHistory walks how our belief about a single field of one entity evolved over transaction time, holding the valid-time point fixed.

This is the bitemporal read the whole library exists to make possible, in its most focused form. as.ValidAt pins a point in the world — a day, an instant — and the result answers: for the entity's state as it was (or is) valid at that point, how did the recorded value of path change as we learned more, and who changed it. It is a walk along the *transaction* axis at a fixed valid point, not along valid time. Only as.ValidAt is used; as.TxAt is ignored, the mirror image of Log.Timeline, which uses only as.TxAt. A zero as.ValidAt means now.

path is an RFC 6901 JSON Pointer in the same grammar Log.Diff emits: "/salary", "/address/city", "/tags/0", with "~0" for a literal "~" and "~1" for a literal "/". The empty path is the whole document. A path that is not a well-formed pointer is ErrInvalidPath; a well-formed path that no record happens to contain is not an error, it is an empty result.

A FieldRevision is emitted every time the value at path differs from the value in the previous belief, including the first appearance (absent to present) and a later belief that still covers the valid point but no longer contains the field (present to absent). Equality is Log.Diff's: numbers compare by value, not notation, so re-recording 100 as 100.0 is not a change. Absent and null are distinguished — see FieldValue.

A record that exists but cannot be decoded is a *CodecError wrapping ErrCodec, never a silently skipped step; a record encrypted for a subject whose key has been destroyed is a *ShredError. Under-reporting a change is the one failure mode a change log must not have, so both fail loudly, exactly as Log.Diff does.

Cost

FieldHistory reads every record that ever covered as.ValidAt — one store query, paged internally — and decodes each once, so it is linear in the number of beliefs recorded about that valid point and independent of the rest of the log. It holds one page of records and the previous field value at a time, not the whole history.

What a present-to-absent revision means, and does not

A field goes absent when a later belief still covers the valid point but its object no longer contains the path — a correction that drops the field, or a tombstoning write whose body is JSON null. Ordinary Log.Put and Log.Correct cannot instead make the *coverage* lapse: a write whose valid interval stops before as.ValidAt still leaves a remainder carrying the old belief across the point, so the point stays covered. Only destruction (retention, erasure) can remove a belief outright, and FieldHistory reflects the surviving history — it does not resurrect a belief that was deleted.

func (*Log) Get

func (l *Log) Get(ctx context.Context, kind, entityID string, as As) (*Record, error)

Get returns the single record in force for the entity at the given point on both axes, or an error wrapping ErrNotFound if the entity had no state there.

Because current records never overlap in valid time, at most one record can satisfy any (ValidAt, TxAt) pair, so this is a lookup rather than a search that happens to return one row.

func (*Log) History

func (l *Log) History(ctx context.Context, kind, entityID string, opts ...HistoryOption) ([]Record, error)

History returns every version of an entity ever recorded, superseded ones included, ordered by transaction start then valid start.

This is the raw log for one entity. Unlike Log.Get it reports no error when the entity is unknown — an entity with no history has an empty history, which is a fact rather than a failure.

func (*Log) Put

func (l *Log) Put(ctx context.Context, kind, entityID string, data []byte, validFrom, validTo time.Time, actor Actor, opts ...WriteOption) (Result, error)

Put asserts that the entity had the given state over the given valid interval, as of now in transaction time.

validFrom is inclusive and validTo is exclusive; a zero validTo means the state still holds, and a zero validFrom means it always did. An interval that is empty or inverted is rejected with ErrInvalidInterval rather than stored. The actor is required: a zero actor ID is ErrMissingActor.

Nothing is destroyed. Every current record whose valid interval overlaps the new one has its transaction interval closed, and where such a record extends beyond the new interval on either side, the uncovered part is rewritten as a remainder record carrying the superseded record's data. The result is that at the new transaction instant the entity's current records tile its valid timeline exactly: no overlaps, and no gaps that were not already there.

Attribution of remainders

A remainder carries the *superseded record's* actor, reason and metadata, not the actor of the write that caused the split, and is marked IntentRemainder. The reasoning is that a remainder re-asserts a fact its original author asserted; stamping the new actor on it would have the log claim they said something they never said, which is the specific failure an attribution trail exists to prevent. Nothing is lost by this: a remainder shares its TxFrom with the write that produced it, so the record carrying IntentAssert or IntentCorrection at that same instant identifies who caused the split.

Example (ActorRequired)

An actor is required on every write; there is no ambient default.

package main

import (
	"context"
	"errors"
	"fmt"
	"time"

	"github.com/zkrebbekx/chronicle"
)

var (
	march = time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
	april = time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
)

func main() {
	ctx := context.Background()
	log := chronicle.NewLog(chronicle.NewMemStore())

	_, err := log.Put(ctx, "employee", "alice", []byte(`{}`), march, april, chronicle.Actor{})
	fmt.Println(errors.Is(err, chronicle.ErrMissingActor))

}
Output:
true
Example (Overlap)

Writing over part of an existing interval splits it, preserving the parts the new record does not cover.

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/zkrebbekx/chronicle"
)

// must unwraps a result in example code, where an error would mean the example
// itself is broken.
func must[T any](v T, err error) T {
	if err != nil {
		panic(err)
	}
	return v
}

var (
	march = time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
	april = time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
	june  = time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
)

func main() {
	ctx := context.Background()
	log := chronicle.NewLog(chronicle.NewMemStore())
	hr := chronicle.Actor{ID: "u-42"}

	// A grade held from March, indefinitely.
	must(log.Put(ctx, "employee", "alice", []byte(`{"grade":"L3"}`), march, time.Time{}, hr))
	// A temporary promotion covering only April to June.
	must(log.Put(ctx, "employee", "alice", []byte(`{"grade":"L4"}`), april, june, hr))

	timeline := must(log.Timeline(ctx, "employee", "alice", chronicle.Now()))
	for _, r := range timeline {
		fmt.Printf("%s %s\n", r.Valid(), r.Data)
	}

}
Output:
[2026-03-01T00:00:00Z, 2026-04-01T00:00:00Z) {"grade":"L3"}
[2026-04-01T00:00:00Z, 2026-06-01T00:00:00Z) {"grade":"L4"}
[2026-06-01T00:00:00Z, ∞) {"grade":"L3"}

func (*Log) PutInterval

func (l *Log) PutInterval(ctx context.Context, kind, entityID string, data []byte, valid Interval, actor Actor, opts ...WriteOption) (Result, error)

PutInterval is Log.Put taking an Interval rather than two instants. It is the same operation; the argument list is just shorter to read at a call site that already has an interval in hand.

func (*Log) Query

func (l *Log) Query(ctx context.Context, q Query) ([]Record, Cursor, error)

Query returns records across entities, filtered on either axis and paged with an opaque cursor.

A zero instant in q means "no restriction", not "now" — a cross-entity query is a scan over the log rather than a question about the present, and defaulting its time filters to the current instant would silently hide everything superseded. Use Query.CurrentOnly, or set TxAt explicitly, to ask about a particular belief instant.

The returned cursor is empty when the result set is exhausted, so the idiomatic loop terminates without a trailing empty page:

var cursor chronicle.Cursor
for {
    page, next, err := log.Query(ctx, chronicle.Query{Kind: "employee", Limit: 100, After: cursor})
    if err != nil {
        return err
    }
    // ... use page ...
    if next.IsZero() {
        break
    }
    cursor = next
}
Example

Query walks the whole log with keyset pagination.

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/zkrebbekx/chronicle"
)

// must unwraps a result in example code, where an error would mean the example
// itself is broken.
func must[T any](v T, err error) T {
	if err != nil {
		panic(err)
	}
	return v
}

var (
	march = time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
	april = time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
)

func main() {
	ctx := context.Background()
	log := chronicle.NewLog(chronicle.NewMemStore())
	hr := chronicle.Actor{ID: "u-42"}

	for i := 0; i < 5; i++ {
		id := fmt.Sprintf("e-%d", i)
		must(log.Put(ctx, "employee", id, []byte(`{"v":1}`), march, april, hr))
	}

	var cursor chronicle.Cursor
	pages := 0
	total := 0
	for {
		recs, next, err := log.Query(ctx, chronicle.Query{
			Kind:  "employee",
			Limit: 2,
			After: cursor,
		})
		if err != nil {
			panic(err)
		}
		pages++
		total += len(recs)
		if next.IsZero() {
			break
		}
		cursor = next
	}
	fmt.Printf("%d records over %d pages\n", total, pages)

}
Output:
5 records over 3 pages

func (*Log) Timeline

func (l *Log) Timeline(ctx context.Context, kind, entityID string, as As) ([]Record, error)

Timeline returns the entity's valid-time sequence as believed at a single transaction instant: the run of records that tiled its timeline at that moment, ordered by valid start.

Only the TxAt axis of as is used. A timeline is a slice through valid time at one belief instant, so pinning a valid instant as well would reduce it to Log.Get; ValidAt is ignored rather than quietly narrowing the result.

Taken at two different TxAt values, this is the clearest view of what a correction actually did — the same stretch of valid time, tiled differently.

func (*Log) Verify

func (l *Log) Verify(ctx context.Context, kind, entityID string) (VerifyReport, error)

Verify recomputes an entity's hash chain and reports the first divergence, if any. A divergence is reported in the VerifyReport, not as an error; the error return is for the entity being unreadable, or having no chain at all — ErrNoChain — which must never be mistaken for a chain that verified.

Verify detects a chained record whose content differs from what was hashed, a chained record moved relative to its neighbours, a record removed without a tombstone, and a record inserted after the chain began without being part of it. It cannot check the content standing behind a tombstone — that content is destroyed — and within a run of consecutive tombstones only the last one's hash is constrained by a surviving successor; see Tombstone for exactly how little a tombstone proves. It also cannot detect anything done by an actor who recomputed the entire chain, per the threat model on WithChaining.

Verify does not require the log to have been built WithChaining: an auditor process that only reads can verify what the writers chained.

type MemKeyring

type MemKeyring struct {
	// contains filtered or unexported fields
}

MemKeyring is an in-memory Keyring, for tests and for callers who want the mechanism without persistent key storage. Keys vanish with the process: restarting is indistinguishable from having destroyed every key, which makes MemKeyring the wrong choice anywhere the data outlives the process.

func NewMemKeyring

func NewMemKeyring() *MemKeyring

NewMemKeyring returns an empty in-memory keyring.

func (*MemKeyring) DestroyKey

func (k *MemKeyring) DestroyKey(ctx context.Context, subject string) error

DestroyKey implements Keyring. The key material is zeroed before the reference is dropped; the destruction marker persists so the subject can never be re-keyed.

func (*MemKeyring) Key

func (k *MemKeyring) Key(ctx context.Context, subject string) ([]byte, error)

Key implements Keyring.

type MemStore

type MemStore struct {
	// contains filtered or unexported fields
}

MemStore is an in-memory Store, and the reference implementation of the storage contract. It is the store to test against and a reasonable choice for callers who want bitemporal semantics without a database, but it holds the entire log in memory and never evicts, so it is not a durability story.

It is safe for concurrent use, and writes are indivisible: a reader either sees the whole of a write — every supersession and every insertion — or none of it.

Records are deep-copied on the way in and on the way out. A caller cannot reach into the log by holding on to the Data slice or Meta map it passed to Log.Put, and cannot corrupt it by mutating a record it read back.

func NewMemStore

func NewMemStore() *MemStore

NewMemStore returns an empty in-memory store.

func (*MemStore) Apply

func (s *MemStore) Apply(ctx context.Context, req ApplyRequest) (time.Time, error)

Apply implements Store. Reading the current records, planning against them, and applying the plan all happen under one lock, so no reader can observe the moment between closing the old records and inserting the new ones — which is the moment at which an entity's valid-time coverage would appear to have a hole in it — and no second writer can plan against a pre-state this write is in the middle of replacing.

MemStore accepts the log's proposed transaction instant and returns it unchanged. It can: a MemStore has exactly one Log writing to it, so that log's ratchet is authoritative. A store shared between processes must assign transaction time itself.

Because the proposal is adopted verbatim, a zero one is refused with ErrZeroTxTime rather than stamped: a zero TxFrom would read as "always believed" and a zero TxTo as "still current", either of which corrupts the transaction axis silently.

func (*MemStore) Close

func (s *MemStore) Close() error

Close releases the store's contents. Subsequent operations report ErrClosed. Closing twice is not an error.

func (*MemStore) Delete

func (s *MemStore) Delete(ctx context.Context, ids []RecordID) (int, error)

Delete implements Deleter. It destroys the named records, records a Tombstone for each one whose metadata carries MetaChain, and refuses the whole batch if any named record is still current belief.

func (*MemStore) Get

func (s *MemStore) Get(ctx context.Context, q GetQuery) (*Record, error)

Get implements Store. Where the log's non-overlap invariant holds at most one record can match; if several somehow do, the earliest in chronicle's total order is returned, so the result is deterministic either way.

func (*MemStore) Holds

func (s *MemStore) Holds(ctx context.Context) ([]Hold, error)

Holds implements HoldStore, returning every hold ever placed — released ones included — in placement order.

func (*MemStore) Len

func (s *MemStore) Len() int

Len returns the number of records held, including superseded ones. Intended for tests and diagnostics.

func (*MemStore) PlaceHold

func (s *MemStore) PlaceHold(ctx context.Context, h Hold) (Hold, error)

PlaceHold implements HoldStore. PlacedAt is assigned here, and the release fields are cleared whatever the caller put in them: placement writes the placement half of the hold and nothing else.

func (*MemStore) Query

func (s *MemStore) Query(ctx context.Context, q Query) ([]Record, Cursor, error)

Query implements Store.

The scan is linear over the candidate set, narrowed by the entity index when the query names one. That is the right shape for a reference implementation and the wrong shape for a large log; a SQL store pushes the same predicates, the same ordering and the same keyset resumption into the database.

func (*MemStore) ReleaseHold

func (s *MemStore) ReleaseHold(ctx context.Context, id string, by Actor, reason string) (Hold, error)

ReleaseHold implements HoldStore. The hold's row survives with its release fields set; releasing twice is an error rather than a quiet no-op.

func (*MemStore) Tombstones

func (s *MemStore) Tombstones(ctx context.Context, kind, entityID string) ([]Tombstone, error)

Tombstones implements Deleter, returning one entity's tombstones in chain order.

type NotFoundError

type NotFoundError struct {
	// Kind and EntityID identify the entity that was looked up.
	Kind, EntityID string
	// As is the point on both axes at which the lookup was made.
	As As
}

NotFoundError reports that no record satisfied a lookup, carrying the coordinates that were searched. It wraps ErrNotFound.

func (*NotFoundError) Error

func (e *NotFoundError) Error() string

Error implements the error interface.

func (*NotFoundError) Unwrap

func (e *NotFoundError) Unwrap() error

Unwrap returns ErrNotFound.

type Option

type Option func(*Log)

Option configures a Log.

func WithChaining

func WithChaining() Option

WithChaining makes every write extend a per-entity hash chain, giving the log tamper evidence. Off by default.

Each record chronicle inserts — the caller's record and every remainder — gets a MetaChain metadata value: a SHA-256 over the previous chain value and a canonical serialization of the record's own immutable fields. The chain runs per (kind, entity) in chronicle's total order, and Log.Verify recomputes it to detect records that were modified, reordered, removed without a tombstone, or inserted from outside the chain.

The honest threat model

A hash chain detects retrospective edits by someone who does not control the chain head. It does nothing against an administrator who owns the database: they can alter a record and recompute every hash after it, heads included, and Verify will pass. Only anchoring chain heads outside the database changes that — read a head with Log.ChainHead and lodge it somewhere the database administrator cannot reach — and chronicle ships no anchoring, so it claims nothing anchoring would be needed for. No regulation in the corpus behind docs/COMPLIANCE.md requires any of this; chaining is offered because it is useful against the threat it actually addresses, not because compliance demands it.

What the hash does and does not cover

The hash covers the record's immutable fields: ID, kind, entity, data, valid interval, transaction start, actor, reason, intent, and metadata (minus the chain value itself). It cannot cover TxTo, because TxTo is written later, when the record is superseded — the one mutation chronicle's model permits. Verify compensates by checking every superseded record's TxTo against the transaction starts of later chained writes, which pins it to the set of instants the chain vouches for, though not to one of them.

Timestamps are canonicalized at microsecond resolution — the storage contract's floor, set by Postgres timestamptz — so a change below one microsecond in a caller-supplied valid time is invisible to the chain, just as it is invisible to a round trip through the Postgres adapter.

Data encrypted for a subject (see WithSubject) is hashed as stored — the ciphertext — so destroying a subject's key afterwards changes no hash and breaks no chain.

Rules of use

Chaining is per-writer, and a chain is only as complete as its writers are consistent: enable it on every Log that writes an entity, or on none. Records written without chaining before the chain began are tolerated as an unchained prefix; an unchained record after the chain began reads as an insertion and fails Verify — which is the correct reading, since out-of-band writes are indistinguishable from the tampering the chain exists to catch.

func WithClock

func WithClock(c Clock) Option

WithClock sets the clock supplying transaction time. The default is SystemClock. A clock cannot be used to backdate a write: whatever it returns is still forced strictly forward of the previous write, so an injected clock can slow the transaction axis down but never rewind it.

func WithCodec

func WithCodec(c Codec) Option

WithCodec sets the codec used by Log.Diff. The default is JSONCodec.

func WithKeyring

func WithKeyring(k Keyring) Option

WithKeyring gives the log a Keyring, enabling WithSubject on writes and transparent decryption on Log.Get and Log.Diff. Without one, writes naming a subject and reads of encrypted records fail with ErrNoKeyring.

func WithKinds

func WithKinds(kinds ...string) Option

WithKinds restricts the log to a fixed set of entity kinds. Writes and reads naming a kind outside the set fail with ErrUnknownKind.

Without it any non-empty kind is accepted. The allow-list is worth setting where kinds come from anywhere near user input, since a typo'd kind otherwise creates a silently separate history that reads as an empty one.

func WithWriteRetries

func WithWriteRetries(n int) Option

WithWriteRetries sets how many times a write is recomputed after the store reports ErrConflict. The default is DefaultWriteRetries; a negative value is treated as zero, which makes the first conflict fatal.

A conflict means another writer changed the entity between this log's overlap scan and its Apply. Retrying re-reads and re-splits, so the second attempt plans against the state the winner left behind. Raise this where contention on a single entity is expected and lower it where a caller would rather fail fast than sit in a queue.

type PathError added in v0.2.0

type PathError struct {
	// Path is the offending pointer, as the caller supplied it.
	Path string
	// Reason says what is wrong with it.
	Reason string
}

PathError reports a malformed JSON Pointer, carrying the offending path and why it was rejected. It wraps ErrInvalidPath.

func (*PathError) Error added in v0.2.0

func (e *PathError) Error() string

Error implements the error interface.

func (*PathError) Unwrap added in v0.2.0

func (e *PathError) Unwrap() error

Unwrap returns ErrInvalidPath so errors.Is matches.

type Planner

type Planner func(current []Record, txAt time.Time) (Write, error)

Planner computes a write from an entity's current records and the transaction instant the store has assigned.

current holds the entity's records that are still current belief and whose valid interval overlaps the request's, read inside the store's transaction under its lock. It is ordered by chronicle's total order and the planner may retain it; stores hand over copies.

txAt is the instant the store has settled on. A planner that mints record IDs from the transaction time should use this one rather than any it chose beforehand.

func StaticWrite

func StaticWrite(w Write) Planner

StaticWrite returns a Planner that ignores the current state and applies w exactly as given.

It is for seeding fixtures, for migrations, and for tests — anywhere the write is already decided. It is not for ordinary writes: planning from state the store read under its own lock is the entire mechanism by which two writers to one entity cannot both split the same pre-state, and a static write opts out of it.

type Query

type Query struct {
	// Kind restricts results to one entity kind. Empty matches all kinds.
	Kind string
	// EntityID restricts results to one entity. Empty matches all entities.
	// It is only meaningful alongside Kind, since entity IDs are scoped by
	// kind.
	EntityID string
	// ActorID restricts results to writes attributed to one actor.
	ActorID string
	// Intent restricts results to one intent. Use HasIntent to enable it,
	// since the zero Intent is a meaningful value ([IntentAssert]).
	Intent Intent
	// HasIntent enables the Intent filter.
	HasIntent bool

	// Valid selects records whose valid interval overlaps this one. The zero
	// interval covers all of time and so filters nothing.
	Valid Interval
	// Tx selects records whose transaction interval overlaps this one.
	Tx Interval
	// ValidAt selects records whose valid interval contains this instant.
	// Zero disables the filter.
	ValidAt time.Time
	// TxAt selects records whose transaction interval contains this instant.
	// Zero disables the filter.
	TxAt time.Time
	// CurrentOnly restricts results to records that are still current belief,
	// that is, whose transaction interval is open.
	CurrentOnly bool

	// Limit caps the number of records returned. Zero or negative means no
	// limit, and a store may then return the whole matching set.
	Limit int
	// After resumes from a cursor returned by a previous call. It must have
	// been produced by a query with the same Descending setting; resuming a
	// descending scan from an ascending cursor walks the other way from that
	// point rather than failing.
	After Cursor
	// Descending reverses the result order.
	Descending bool
}

Query selects records across entities, filtered on either time axis and paginated with an opaque cursor.

Every field is optional and the zero Query matches every record in the log. Time filters follow chronicle's usual convention: a zero instant means "no restriction", and a zero Interval covers all of time. Unlike Log.Get, a store never substitutes the current time for a zero one — the log resolves "now" before it calls down, so that a store is a pure function of its contents and the query.

Order

Results are ordered by transaction start, then valid start, then record ID, ascending unless Descending is set. The record ID breaks every remaining tie, and record IDs are unique, so the order is total: no two records ever compare equal, and pagination cannot skip or repeat a row when many records share a transaction instant.

func (Query) Matches

func (q Query) Matches(r Record) bool

Matches reports whether a record satisfies the query's filters, ignoring ordering, cursor and limit.

A Store backed by a database translates the same predicates into its WHERE clause instead. This is the definition they must agree with, and the reason it is exported: the conformance suite checks the agreement, and a store author needs something to check against.

func (Query) Validate

func (q Query) Validate() error

Validate checks the query's filters for coherence: neither time range may be empty or inverted, and the intent filter must name an intent chronicle defines.

A Store should call it before touching its backing store, so that a malformed query is the same error from every implementation rather than whatever the database happened to say about it.

type Record

type Record struct {
	// ID is the record's unique, chronicle-assigned identifier.
	ID RecordID
	// EntityID is the caller's opaque identifier for the entity.
	EntityID string
	// Kind discriminates the type of entity.
	Kind string
	// Data is the serialized state, in whatever encoding the log's [Codec]
	// understands. Records carry their own shape, so a schema change does not
	// orphan history written before it.
	Data []byte

	// ValidFrom is when the fact became true in the world, inclusive. Zero
	// means it was always true.
	ValidFrom time.Time
	// ValidTo is when the fact stopped being true, exclusive. Zero means it
	// still holds.
	ValidTo time.Time

	// TxFrom is when chronicle learned the fact, inclusive. System-assigned.
	TxFrom time.Time
	// TxTo is when this belief was superseded, exclusive. Zero means it is the
	// current belief. System-assigned.
	TxTo time.Time

	// Actor is who caused the write. Always populated.
	Actor Actor
	// Reason is free-text business justification. Optional by design: see
	// docs/COMPLIANCE.md — a reason-for-change mandate has essentially one
	// textual home in the researched regulatory corpus, and it binds audit
	// firms rather than the systems chronicle records.
	Reason string
	// Intent records whether this was an assertion, a correction, or a
	// remainder chronicle wrote when splitting an interval.
	Intent Intent
	// Meta is caller-supplied metadata, copied on the way in and out.
	Meta map[string]string
}

Record is one entity's state over a half-open valid-time interval, as believed over a half-open transaction-time interval.

The two axes are independent and answer different questions. Valid time is when the fact was true in the world and is supplied by the caller. Transaction time is when chronicle learned it; it is assigned by the system, is never supplied by the caller, and is never rewritten. There is no exported way to set TxFrom or TxTo on a write, and that restriction is the only reason the log is worth trusting.

Both axes are half-open, [from, to). An unbounded end is the zero time.Time. Use Record.Valid and Record.Tx to reason about the intervals rather than comparing the fields directly.

func (Record) Clone

func (r Record) Clone() Record

Clone returns a deep copy. Data and Meta are copied, so the result shares no mutable state with the receiver.

chronicle clones every record crossing the boundary into or out of a store, which is what lets callers hold on to returned records without being able to corrupt the log, and what keeps the in-memory store race-free.

func (Record) IsCurrent

func (r Record) IsCurrent() bool

IsCurrent reports whether this record is part of the current belief — that is, whether its transaction interval is still open.

func (Record) Tx

func (r Record) Tx() Interval

Tx returns the record's transaction-time interval.

func (Record) Valid

func (r Record) Valid() Interval

Valid returns the record's valid-time interval.

type RecordID

type RecordID string

RecordID uniquely identifies a record within a log.

IDs are assigned by chronicle, never by the caller. The format is an implementation detail and callers must treat an ID as opaque, but two properties are guaranteed and relied upon internally.

The first is uniqueness, which holds across processes as well as within one: two logs writing to the same store never mint the same ID, so a store's primary key cannot silently swallow one of two concurrent writes.

The second is that IDs minted by one log sort lexicographically in write order, which is what breaks the remaining tie in chronicle's total order. The tie only ever arises between records sharing both a transaction instant and a valid start, and the records that do share a transaction instant are the ones written together — a write and its remainders — so a within-log ordering is enough. Across logs the ordering is arbitrary but stable, which is all pagination needs.

Note in particular that ID order does not track transaction time across processes. A store that assigns transaction time itself, as any store with more than one writing process must, stamps an instant the minting log had not seen. Compare records with their transaction times, never by ID.

type Result

type Result struct {
	// TxAt is the transaction instant assigned to the write. Every record in
	// Written has this as its TxFrom, and every record named in Superseded has
	// it as its TxTo.
	TxAt time.Time
	// Written holds the records inserted: the caller's record first, then any
	// remainders.
	Written []Record
	// Superseded names the records whose transaction interval this write
	// closed.
	Superseded []RecordID
	// Record is the caller's own record, as stored.
	Record Record
}

Result describes what a write did. It is worth keeping: the superseded IDs and the transaction instant together are enough to reconstruct the write later, and the transaction instant is the coordinate an as-of query needs to see the state as it stood immediately after.

type ShredError

type ShredError struct {
	// Subject is the subject whose key the data was encrypted under.
	Subject string
	// RecordID identifies the unrecoverable record, when known.
	RecordID RecordID
	// Reason says why recovery failed, when the cause is more specific than
	// the wrapped error.
	Reason string
	// Err is the underlying failure, when there was one.
	Err error
}

ShredError reports that a record's encrypted data could not be recovered. It wraps ErrShredded — always — and additionally whatever underlying error the keyring or cipher reported.

func (*ShredError) Error

func (e *ShredError) Error() string

Error implements the error interface.

func (*ShredError) Is

func (e *ShredError) Is(target error) bool

Is reports that a ShredError matches ErrShredded, in addition to whatever the wrapped error matches.

func (*ShredError) Unwrap

func (e *ShredError) Unwrap() error

Unwrap returns the underlying failure, when there was one.

type Store

type Store interface {
	// Apply computes and applies one indivisible change.
	//
	// It reads the current records for req.Entity whose valid intervals
	// overlap req.Valid, passes them to req.Plan along with the transaction
	// instant it has assigned, and applies the returned [Write]: every record
	// named in Supersede has its transaction interval closed, and every record
	// in Insert is added. Either all of it is visible to a subsequent read, or
	// none of it is. The read and the write share one transaction and one
	// lock, so the plan cannot go stale between them.
	//
	// Apply owns the transaction axis. It picks the instant, stamps it on
	// every inserted record's TxFrom and every superseded record's TxTo, and
	// returns it. Whatever TxFrom an inserted record arrives carrying is
	// overwritten, so there is no path by which a caller can choose when the
	// log appears to have learned something — which is the only reason the
	// transaction axis is worth trusting.
	//
	// An error from the plan is returned unchanged and nothing is applied.
	Apply(ctx context.Context, req ApplyRequest) (time.Time, error)

	// Get returns the single record covering the given point on both axes, or
	// an error wrapping [ErrNotFound]. Where the log's invariant holds, at
	// most one record can match.
	Get(ctx context.Context, q GetQuery) (*Record, error)

	// Query returns records matching q in the order described by [Query],
	// along with a cursor for the next page. The cursor is empty when the
	// result set is exhausted.
	Query(ctx context.Context, q Query) ([]Record, Cursor, error)
}

Store is the persistence boundary. chronicle's temporal reasoning lives above it; a store only has to filter, sort and hand back rows.

The interface is deliberately shaped so that a database/sql implementation is straightforward: every method takes a value and returns values, nothing takes a callback, nothing returns an iterator that holds a lock, and nothing requires that the whole log fit in memory. Query carries a limit and a cursor precisely so that a SQL implementation can push both down into a LIMIT and a keyset predicate rather than materialising a result set.

Atomicity

A write supersedes some records and inserts others, and the two must land together — a Put that closes three records and writes four must never be observable half-applied, or a reader will see either a gap or an overlap in valid time, which is exactly the invariant the library exists to hold.

Store.Apply therefore carries both halves of a write, and there is no way to express the halves separately. An earlier design had Put and Supersede as distinct methods with Apply as an optional extension; that shape was removed because the fallback path — supersede, then insert, with no shared transaction — is only correct when nobody else is looking, which is a property no library can check and every caller assumes.

Isolation

A write to one entity is a read-modify-write: chronicle reads the records whose valid intervals overlap the new one, computes how to split them, and applies the result. All three parts have to be one indivisible step, or two writers can observe the same pre-state and each split it.

Apply therefore takes a *plan* rather than a finished write. The store reads the current overlapping records itself, inside its own transaction and under whatever lock it uses, hands them to the plan, and applies what comes back without ever releasing either. chronicle's temporal reasoning stays above the store — the store never learns what a remainder is — but the reasoning runs where the store can protect it.

An earlier shape had the log read through Query and pass a finished Write to Apply. It was correct and it starved: with the read outside the lock, the writer that waits for the lock always finds its plan stale, so under sustained contention one writer wins every race and the other never lands a write at all. That is not a tuning problem, and no isolation level fixes it, because no isolation level spans two separate calls.

Implementing a Store

The contract, in the order implementations most often get it wrong:

  • Honour context cancellation on every method: a cancelled Apply must land nothing. This is required, not advisory — the conformance suite injects cancelled contexts and fails a store that writes anyway. pgstore rolls the transaction back; MemStore checks before mutating.
  • Copy records in both directions. A caller must not be able to reach into the store through a slice or map it passed in, nor corrupt it by mutating a record it read back.
  • Assign transaction time centrally when more than one process writes. ApplyRequest.TxAt is only a proposal; a shared store takes the instant from somewhere all writers agree on — the database's clock — and returns what it actually stamped.
  • Call Query.Validate before touching the backing store, so a malformed query is the same typed error from every implementation.
  • Agree with Query.Matches on filtering and with CompareRecords on order. These exported definitions are the arbiters; a WHERE clause and ORDER BY are translations of them, and the suite checks the translation.
  • Build cursors with EncodeCursor and read them with DecodeCursor, so pagination survives a caller switching stores.

Then certify with chroniclefest.Run, which is the executable form of this list and of everything the wording above underspecifies.

type Tombstone

type Tombstone struct {
	// Kind and EntityID name the entity whose chain the destroyed record
	// belonged to.
	Kind, EntityID string
	// RecordID is the destroyed record's ID.
	RecordID RecordID
	// ValidFrom and TxFrom are the destroyed record's positions on the two
	// axes — the parts of its sort key, retained so the tombstone can be
	// ordered into the chain where the record used to be.
	ValidFrom time.Time
	TxFrom    time.Time
	// ChainHash is the chain value the destroyed record carried, verbatim from
	// its [MetaChain] metadata, format version prefix included.
	ChainHash string
	// DeletedAt is when the store destroyed the record. Store-assigned.
	DeletedAt time.Time
}

Tombstone is what remains of a chained record after retention destroyed it: its coordinates in the chain, and the chain hash it carried. The content is gone — that was the point of deleting it — but the hash keeps the chain verifiable across the gap.

Be precise about what a tombstone proves, because it is less than it looks. Log.Verify passing over a tombstone establishes that the surviving records around the gap are the ones the chain head commits to, and that a record with this chain value stood in the gap. It establishes nothing about why the record was destroyed: a store's Delete writes tombstones for whatever it is asked to delete, so an administrator with database access can destroy a chained record through the same protocol and Verify will pass just as it does after a legitimate sweep. A tombstone is evidence of destruction, not of authorisation. If you need the two distinguished, anchor chain heads externally with Log.ChainHead and keep sweep Reports where the database administrator cannot edit them; chronicle ships neither, and says so rather than implying otherwise.

type VerifyReport

type VerifyReport struct {
	// Kind and EntityID identify the verified entity.
	Kind, EntityID string
	// ChainedRecords is how many surviving records the chain vouches for.
	ChainedRecords int
	// Tombstones is how many destroyed records the chain passed over on their
	// retained hashes. A tombstone attests that a record with that chain value
	// stood there — not what it said, and not whether destroying it was
	// authorised. See [Tombstone].
	Tombstones int
	// UnchainedPrefix is how many records predate the chain — written before
	// chaining was enabled for this entity. They are outside the chain's
	// protection and counted so that a report never silently understates what
	// it did not check.
	UnchainedPrefix int
	// Head is the chain's final value, set only when the chain is intact. It
	// is the value to anchor externally; see [Log.ChainHead].
	Head []byte
	// Divergence is the first point at which the chain failed to verify, nil
	// when it is intact.
	Divergence *Divergence
}

VerifyReport is the result of Log.Verify: what the chain covered, and the first point at which it stopped adding up, if any.

func (VerifyReport) Intact

func (r VerifyReport) Intact() bool

Intact reports whether the chain verified end to end.

type Write

type Write struct {
	// Supersede names the records whose transaction interval is to be closed.
	// A record already closed keeps the timestamp it was closed with:
	// transaction time, once assigned, is never rewritten, and naming one that
	// no longer exists is not an error.
	//
	// A planner given the store's own reading of the current records has no
	// way to name a record that has since moved, which is the point. A
	// [StaticWrite] does, and a store may then report [ErrConflict] rather
	// than apply half of a split.
	Supersede []RecordID
	// Insert holds the records to add: the caller's new record, plus any
	// remainders preserving the parts of superseded intervals the new record
	// did not cover.
	Insert []Record
}

Write is one indivisible unit of change, as returned by a Planner.

func (Write) Entities

func (w Write) Entities() []EntityRef

Entities returns the distinct (kind, entity) pairs a write touches, as implied by the records it inserts, in a deterministic order.

Store implementations use it to decide what to lock. It cannot see entities named only by Supersede, since a record ID does not carry its entity; a store that needs those must look them up.

type WriteOption

type WriteOption func(*writeOpts)

WriteOption carries the optional parts of a write.

func WithMeta

func WithMeta(meta map[string]string) WriteOption

WithMeta attaches metadata. Later calls merge into earlier ones, and the map is copied, so the caller may reuse or mutate it afterwards.

func WithMetaValue

func WithMetaValue(key, value string) WriteOption

WithMetaValue attaches a single metadata key.

func WithReason

func WithReason(reason string) WriteOption

WithReason attaches a free-text business justification.

Optional by design. Vendors routinely present "who, what, when and why" as a 21 CFR Part 11 requirement; the regulation's text does not contain it, and the one clear reason-for-change mandate in the researched corpus (PCAOB AS 1215 .16) binds audit firms' workpapers rather than the systems chronicle records. See docs/COMPLIANCE.md. Record a reason because your process wants one, not because a library told you a regulation demands it.

func WithSubject

func WithSubject(subject string) WriteOption

WithSubject encrypts the write's data under the subject's key from the log's Keyring, so that Keyring.DestroyKey later renders the value — this record and every remainder that inherits its data — unrecoverable.

The stored record's Data is ciphertext, its metadata carries MetaSubject and MetaCipher, and everything else about it is unchanged: intervals, actor, intent and caller metadata stay plaintext, which is what "preserving the record structure" means and is the entire trade the mechanism offers. Choose subject identifiers accordingly — the subject string itself is stored in clear and survives shredding, so it should be a pseudonymous reference, not the personal data it protects.

Under WithChaining, the hash covers the ciphertext as stored, so destroying a key changes no hash and breaks no chain.

Directories

Path Synopsis
chronicled module
Package chroniclefest is an executable specification of the chronicle.Store contract.
Package chroniclefest is an executable specification of the chronicle.Store contract.
pgstore module
Package retain is chronicle's retention sweeper: scheduled, explicit, hold-aware destruction of superseded records.
Package retain is chronicle's retention sweeper: scheduled, explicit, hold-aware destruction of superseded records.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL