mnestic
GitHub

Bitemporality

A memory that can be corrected needs two clocks. Valid time records when a fact was true in the modeled world; transaction time records when the database came to believe it. Upstream CozoDB ships only the first. mnestic adds the second as an engine-assigned axis: a crash-safe monotone commit clock stamps every write at commit, a plain read returns the current belief in one seek per key, and one selector answers what the database believed at time T about any period.

mnestic

This entire page is specific to mnestic: bitemporality landed in 0.10.0. It covers why the axis exists and what shipped with it; the complete temporal reference — selector grammar, resolution semantics, per-op write behavior — lives in Time travel. Relations declaring TxTime cannot be opened by upstream CozoDB builds.

The problem

Cozo's valid-time axis is user-settable by design: a Validity key column you can backdate, future-date, and import, with a one-token @ as-of read (Time travel covers it in full). That control is exactly why it cannot answer questions about the database's own record. A :put at the same key and valid time physically replaces the old row, so three things are impossible with valid time alone:

  • Reproducing a past result. Yesterday's query cannot be re-run as it would have answered yesterday; corrections applied since are baked in.
  • Auditing belief. "When did the agent learn this, and what did it think before?" has no answer — the question behind memory-poisoning forensics, provenance obligations, and "what did the agent know when it acted".
  • Telling change from correction. "The owner changed in March" and "we were wrong about March" are different facts; one axis conflates them.

Transaction time closes the gap, and the engine has to own it: the stamp is only audit-grade if it is assigned at commit, monotonically, and cannot be forged by any caller.

A correction, on the record

A relation whose last key column has type TxTime is system-versioned: every write appends a record stamped at commit, and a plain read returns the current belief. Here is an agent's store of extracted facts:

:create belief { subject: String, prop: String, tt: TxTime => value: String }

Writes never mention tt; the engine assigns it, and supplying a value on any write path is an error. From a first pass over a stale runbook, the agent records who owns the Postgres connector and what its timeout is:

?[subject, prop, value] <- [
  ['postgres-connector', 'owner',   'Sam'],
  ['postgres-connector', 'timeout', '60 seconds'],
]
:put belief { subject, prop => value }

Later it reads the live config: the timeout is 30 seconds and the runbook was out of date. The correction is the same plain :put:

?[subject, prop, value] <- [['postgres-connector', 'timeout', '30 seconds']]
:put belief { subject, prop => value }

A read with no selector is the current belief, resolved in one seek per key:

?[prop, value] := *belief{ subject: 'postgres-connector', prop, value }
["owner","Sam"]
["timeout","30 seconds"]

Nothing was overwritten. ::history returns the raw belief timeline, ordered key-ascending, then newest first:

::history belief [['postgres-connector', 'owner'], ['postgres-connector', 'timeout']]
["postgres-connector","owner","assert",1783880426761363,"Sam"]
["postgres-connector","timeout","assert",1783880426763169,"30 seconds"]
["postgres-connector","timeout","assert",1783880426761363,"60 seconds"]

The owner record and the 60 seconds record share the tt …761363: they were written in one transaction, and a transaction is one belief event. The correction sits at its own, later tt.

@ (tt: T) reads the relation exactly as it stood at commit time T. Using the first belief event's tt from the history (transaction times are minted at commit, so yours will differ):

?[value] := *belief{ subject: 'postgres-connector', prop: 'timeout', value,
                     @ (tt: 1783880426761363) }
["60 seconds"]

:as_of pins a whole query block instead, so a multi-atom report cannot silently mix one atom of current belief into a historical answer. This re-runs the report exactly as it would have answered before the correction, which is the original motivating use case:

?[prop, value] := *belief{ subject: 'postgres-connector', prop, value }
:as_of 1783880426761363
["owner","Sam"]
["timeout","60 seconds"]

The commit clock

Transaction times come from a wall-clock-floored, strictly monotonic counter: tt = max(now_µs, last_tt + 1). Two commits inside one microsecond get distinct tts, a backward step of the system clock cannot reorder them, and the clock's high-water mark is persisted inside each committing transaction, so a crash cannot lose it; restarts and backup restores re-seed it monotonically. All writes of one transaction share a single tt, and tt order equals commit order equals visibility order: a reader can never observe records appearing below a point it has already read.

The other half of the guarantee is refusal. Valid time stays falsifiable on purpose. Transaction time is trustworthy because nobody can write it: a TxTime value in a :put spec, a header, or an import payload is rejected on every path. Details, including the single-instance caveat on the SQLite backend, are under the commit clock.

Both axes: belief about a period

Declaring both axes — temporal axes are the trailing key columns, in the fixed order Validity then TxTime — makes a relation fully bitemporal:

:create oncall { service: String, at: Validity, tt: TxTime => person: String }

The agent recorded from June's rota that Maya covers search-service; it later learns the rota had been redrawn and Sam covered June. Same valid time, later transaction time:

?[service, at, person] <- [['search-service', '2026-06-01T00:00:00Z', 'Maya']]
:put oncall { service, at => person }
?[service, at, person] <- [['search-service', '2026-06-01T00:00:00Z', 'Sam']]
:put oncall { service, at => person }

Bare @ keeps its shipped meaning — valid time, with transaction time defaulting to the current belief — so the ordinary as-of read answers with the correction:

?[person] := *oncall{ service: 'search-service', person, @ '2026-06-15' }
["Sam"]

Both beliefs remain in ::history, which on bitemporal relations grows a vt_ts column. The two records sit at the same valid time; the correction is the newer tt:

::history oncall [['search-service']]
["search-service",1780272000000000,"assert",1783880426792790,"Sam"]
["search-service",1780272000000000,"assert",1783880426790522,"Maya"]

The fully bitemporal question — what did we believe about June, before we learned better? — is one order-free labeled selector, taking the earlier tt from that history:

?[person] := *oncall{ service: 'search-service', person,
                      @ (vt: '2026-06-15', tt: 1783880426790522) }
["Maya"]

Each axis defaults independently to what the bare forms already meant, which yields a migration invariant: adding a tt: TxTime column to an existing relation changes no existing query's results, up to corrections actually recorded since. Selection is per atom, so one query can join current state against historical belief. The full selector table, the resolution semantics, and the three correction shapes are in Bitemporal relations.

What ships with it

A lifecycle for history. Append-only belief needs management, and three ops provide it. ::history is the raw timeline, as above. ::history_gc drops superseded records below a cutoff while preserving every as-of answer at or above it, and persists a per-relation floor: a read below the floor is an error, never a reconstruction silently presented as historical belief. ::evict hard-deletes every record of a key — the one deliberate break of append-only, for data-erasure obligations such as GDPR — writing a salted-hash audit row in the same transaction. See Forgetting and the op reference in System ops.

Belief revision for derived relations. :reconcile declares a query's output to be a TxTime relation's new complete current belief: the engine diffs it against the resolved current belief and records the assertions and retractions as one belief event. Unchanged keys record nothing, so an identical re-run is a true no-op. Retract a base fact, re-derive, reconcile: derived beliefs stay consistent with the revised base, including annotated ones such as min_cost_k proof packs, and ::history answers what was believed, and why, as of any T. See Belief revision.

Writes that check the current belief. :insert, :update, :delete, and :ensure/:ensure_not evaluate their existence checks against the resolved current belief: :delete fails on a missing key and on a believed-deleted one, naming which it hit, because it reads the belief rather than the raw records; :update lands its correction in the current belief's own valid-time version; and :replace is rejected outright, because destroy-and-recreate would silently drop history. See Writing against the current belief.

Overhead, measured

Bitemporality is opt-in per relation. Relations that do not declare TxTime keep their exact single-axis encoding and zero overhead; the read dispatch is untouched.

On relations that do opt in, the 0.10.0 release gate measured bitemporal current-belief point reads within ~4–12% of the identical workload on a valid-time-only relation, on both the SQLite and RocksDB backends; tt-only relations, which carry no Validity axis, measured at or below the baseline. Full scans of single-version relations came out about 2× faster than the baseline (the sequential walk outruns the skip scan's per-key seeks), while deep-history scans, at 10–100 versions per key, run past the ~10% budget — up to about 2× on the deepest corrected cells. That is a recorded trade-off: the two-level walk probes both the assert and retract runs of every version, where a single-axis scan needs one probe.

The restrictions are loud errors, not silent degradation: no secondary, FTS, HNSW, or LSH indexes, no triggers, callbacks, or :returning on TxTime relations; export/import does not round-trip them (backup/restore does, and re-seeds the clock); and writes are invisible to later reads in the same transaction, because a transaction is one belief event and does not query its own half-formed belief. The full list: Restrictions and overhead.

See also