mnestic
GitHub

Time travel

Time travel in a database means tracking changes to data over time, so that a query can be logically executed at a point in time and see the data as it was. mnestic tracks time on two independent axes. Valid time, inherited from CozoDB, records when a fact is true in the modeled world: you set it, you may backdate or future-date it, and a one-token @ selector reads a relation as of any moment. Transaction time, added by mnestic, records when the database learned each fact: the engine stamps it at commit and it cannot be set from outside, which is what makes "what did we believe last Tuesday, before the correction?" answerable. The first half of this page covers valid time; the second half covers transaction time and fully bitemporal relations.

Valid time

A stored relation supports valid-time travel when the last column of its key has the explicit type Validity. Here is an agent's record of who owns search-service, over time:

:create service_owner { service: String, at: Validity => person: String? }
?[service, at, person] <- [
  ['search-service', [1748736000000000, true], 'Maya'],
  ['search-service', '2025-07-08T00:00:00Z',   'Sam'],
]
:put service_owner { service, at => person }

Two facts: Maya owned the service from June 1, 2025 (written as an explicit [timestamp, is_assert] pair, in microseconds), and Sam took over on July 8 (written as an RFC 3339 string — all the accepted write forms are listed below). A query attaches an @ selector inside the atom, after the columns, to read the relation as of any moment:

?[person] := *service_owner{ service: 'search-service', person, @ 'NOW' }
["Sam"]
?[person] := *service_owner{ service: 'search-service', person, @ '2025-06-15' }
["Maya"]

Logically, the query runs against a snapshot of the relation containing only the facts valid at the specified time. Nothing was updated in place to make this work: the history is ordinary rows, and in a sense the relation is immutable — new facts supersede old ones, but the old ones remain.

The Validity type

A validity has two parts: a timestamp, a signed integer, and an assertion flag, a boolean — [42, true] is a validity. Sorting compares the timestamp first and then the flag, each in descending order:

[1, true] < [1, false] < [-1, true]

A relation keyed by a validity returns rows newest-first, with the assertion before the retraction at an equal timestamp:

?[v] <- [[[1, true]], [[1, false]], [[-1, true]]]
:create vld_sort { v: Validity }
?[v] := *vld_sort{ v }
[[1,true]]
[[1,false]]
[[-1,true]]

The engine does not interpret the timestamp — that is up to you. If you use it to represent calendar time, treat it as microseconds since the Unix epoch: every convenience below ('ASSERT', RFC 3339 strings, 'NOW') assumes that interpretation. And it is an integer, strictly: a float is rejected wherever a validity is built, because a float timestamp almost always comes from now() or parse_timestamp(), which count in seconds — a million times coarser than the unit the column stores. The write forms and the selector below each say what that means in practice.

Writing temporal facts

A Validity column accepts several forms on write:

  • A two-element list [timestamp, bool] — the explicit form. The timestamp must be an integer.
  • The string 'ASSERT' or 'RETRACT' — an assertion or retraction at the current timestamp. Every write using these strings in one transaction is guaranteed to receive the same timestamp, and they work as column defaults too.
  • An RFC 3339 string such as '2025-07-08T00:00:00Z' for an assertion; prefix it with ~ for a retraction at that time.

A bare date such as '2025-07-08' is not accepted on the write path — it raises eval::invalid_validity. Bare dates are a read-side convenience only, accepted by the @ selector (see the selector notes below).

With a default, temporal bookkeeping disappears from the write entirely:

:create obs { id: String, at: Validity default 'ASSERT' => note: String }
?[id, note] <- [['o1', 'compaction ran clean tonight']]
:put obs { id => note }
?[id, note] := *obs{ id, note, @ 'NOW' }
["o1","compaction ran clean tonight"]

Caution

A default built on now() writes 1970. now(), parse_timestamp(), round(), floor() and ceil() all return float seconds; a validity stores integer microseconds. Every build through mnestic 0.12.1 — and every CozoDB release, since the coercion is upstream's — took the float without a word, so Validity default [floor(now()), true] — the obvious spelling, and the one in upstream's own HNSW test — stamped every row it wrote at a moment in January 1970. Such a row reads back perfectly on an ordinary query and is wrong only under a time-travel read. The schema still compiles; since 0.12.2 it is the write that fails:

:create obs_bad { id: String, at: Validity default [floor(now()), true] => note: String }
?[id, note] <- [['o1', 'compaction ran clean tonight']]
:put obs_bad { id => note }
eval::float_validity: a Validity column stores an integer timestamp, got the
float 1783952076
help: Validity timestamps are integer MICROSECONDS since the Unix epoch. A
float here is almost always float SECONDS from now()/parse_timestamp(), and
would have been stored about 1,000,000x too small — a permanently wrong valid
time (1970), invisible to an ordinary query and visible only under time travel.
Write [to_int(<expr> * 1000000), true], or use the string forms
'2024-06-01T12:00:00Z' / 'ASSERT' / 'RETRACT'.

That float in the message is now() itself — seconds, not microseconds. Multiply through and truncate with to_int, or use 'ASSERT', which never had the problem:

:create obs_at { id: String, at: Validity default [to_int(now() * 1000000), true] => note: String }

Rows an earlier build already stamped at 1970 cannot be repaired by upgrading. They have to be rewritten at their true timestamps.

How a history is read

All rows that share every key column except the trailing validity form the history of that logical key. A row with a true flag asserts its value from its timestamp (inclusive) until the timestamp of the next row of the same key, with time flowing forward. A bare access without @ is a raw scan that returns every version:

?[at, person] := *service_owner{ service: 'search-service', at, person }
[[1751932800000000,true],"Sam"]
[[1748736000000000,true],"Maya"]

Read as a history: from June 1 up to (but not including) July 8 the owner is Maya, from July 8 onwards it is Sam, and before June 1 the key does not exist — a query at @ '2025-01-01' returns no rows. Numeric selectors work the same as strings; this is July 1, 2025 in microseconds:

?[person] := *service_owner{ service: 'search-service', person, @ 1751328000000000 }
["Maya"]

Retractions

A row with a false flag does nothing except make the previous fact invalid from its timestamp on. Suppose Sam's July 8 assignment is only confirmed through September 1 — the agent records the expiry in advance, as a ~-prefixed retraction:

?[service, at, person] <- [['search-service', '~2025-09-01T00:00:00Z', null]]
:put service_owner { service, at => person }

From September 1 the key has no valid value, while earlier reads are untouched:

?[person] := *service_owner{ service: 'search-service', person, @ '2025-09-15' }
(no rows)
?[person] := *service_owner{ service: 'search-service', person, @ '2025-08-15' }
["Sam"]

Note

A retraction is an ordinary row, so its value columns still pass type checking. Declare value columns nullable (person: String? above) or supply the previous values when retracting.

Asserting at the same timestamp

Two rows may share a key and a validity timestamp and differ only in the flag. Queried at exactly that timestamp, the assertion wins — the retraction is invisible to every time-travel query. Continuing the story: September 1 arrives and Maya takes the service over, asserted at exactly the timestamp of the standing retraction:

?[service, at, person] <- [['search-service', [1756684800000000, true], 'Maya']]
:put service_owner { service, at => person }
?[person] := *service_owner{ service: 'search-service', person, @ '2025-09-01' }
["Maya"]

This behaviour exists for exactly this pattern: assert a fact only until a future time up to which it is sure to remain valid, and when that time comes, assert whatever is then true — if the old fact still holds, re-assert it, with no need to :rm the earlier retraction.

NOW, END, and future-dated facts

Validity timestamps may lie in the future, and @ 'NOW' and @ 'END' differ exactly there: 'NOW' is the current timestamp (guaranteed equal to what 'ASSERT' and 'RETRACT' write in the same transaction), while 'END' is a timestamp at the end of time — the final state of the relation, future-dated assertions included. Suppose it is already agreed that Sam takes the service back in 2027:

?[service, at, person] <- [['search-service', '2027-01-01T00:00:00Z', 'Sam']]
:put service_owner { service, at => person }
?[person] := *service_owner{ service: 'search-service', person, @ 'NOW' }
["Maya"]
?[person] := *service_owner{ service: 'search-service', person, @ 'END' }
["Sam"]

Note

Since a whole future history can be written ahead of time, an interface that always queries @ 'NOW' shows scheduled changes taking effect exactly on time, with no writes happening at all — a small manifestation of Laplace's demon.

What the @ selector accepts

The expression after @ must be a compile-time constant. Query parameters are substituted before compilation and work fine (@ $t); a variable is rejected:

?[person] := t = 1751328000000000,
             *service_owner{ service: 'search-service', person, @ t }
eval::not_constant: Expression contains unevaluated constant

The constant can be:

FormExampleMeaning
integer@ 1751328000000000microseconds since the Unix epoch
Validity value@ dt_to_validity(parse_timestamp('2025-06-15'))that instant, with the unit carried by the type (mnestic 0.13.0+)
RFC 3339 string@ '2025-06-15T12:30:00Z'that instant
bare date string@ '2025-06-15'midnight UTC of that date
'NOW'@ 'NOW'the current timestamp
'END'@ 'END'the end of time (final state)

A float is none of these, and since mnestic 0.12.2 it is an error rather than a coercion.

mnestic

Bare dates landed in mnestic 0.10.0, on both temporal axes; upstream required a full RFC 3339 timestamp. The same release fixed two inherited defects on the valid-time axis: a negated atom with a selector (not *rel{ ... @ 'NOW' }) panicked, and a join binding the validity column of an @-selected relation could return wrong answers. See What mnestic adds.

Caution

Quote your dates. @ 2025-06-15 (unquoted) is integer subtraction — it evaluates to 2004 microseconds after the epoch and is accepted silently, returning rows (usually none) from the dawn of 1970.

Caution

Don't hand the selector a float. @ now(), @ round(now()), @ parse_timestamp('2025-06-15T12:30:00Z') are all float seconds, a million times smaller than the microseconds the selector reads. Through mnestic 0.12.1 each of them was accepted, read the relation at a moment in 1970 — before any row was asserted — and returned zero rows and no error, which is indistinguishable from "no data yet". (@ 1e300 was accepted too, saturating to the end of time.) Since 0.12.2 a float is rejected outright:

?[person] := *service_owner{ service: 'search-service', person,
                             @ parse_timestamp('2025-06-15T12:30:00Z') }
parser::float_validity_spec: valid-time timestamp must be an integer, got the
float 1749990600
help: valid-time timestamps are integer MICROSECONDS since the Unix epoch.
`now()` and `parse_timestamp()` return float SECONDS, so a float here would be
read about 1,000,000x too small — i.e. 1970, before your data exists.
If the value is in seconds:      to_int(<expr> * 1000000)
If it is already microseconds:   to_int(<expr>)
Note that `round()` returns a float and will NOT convert it.
Non-numeric forms also work: '2024-06-01', '2024-06-01T12:00:00Z', 'NOW', 'END'.

Multiply through to microseconds and truncate with to_intround(), floor() and ceil() all return floats and will not do it:

?[person] := *service_owner{ service: 'search-service', person,
                             @ to_int(parse_timestamp('2025-06-15T12:30:00Z') * 1000000) }
["Maya"]

The string forms need no arithmetic at all: @ '2025-06-15' and @ '2025-06-15T12:30:00Z' mean the same instant, unambiguously.

Note

Integer seconds is the one unit mistake the engine cannot catch for you. @ 1750000000 is a perfectly legal validity timestamp — it just means twenty days into 1970, so on a relation stamped in microseconds it returns nothing, silently. Nothing can be done about that here: the timestamp is an abstract logical clock you control, and the tutorial reads @ 2019 on a relation keyed by calendar years, so no magnitude check could tell a wrong-unit timestamp from a small deliberate one without breaking legitimate queries. When a time-travel read comes back empty, check the unit before you conclude the data is missing. Or take the typed path, which landed in mnestic 0.13.0: dt_to_validity converts float seconds to a Validity where the unit is known, and @ accepts the Validity-typed result directly — no bare integer in sight.

Two scope rules. The selector attaches to stored-relation atoms only, in both named and positional form (*rel{ ..., @ t }, *rel[... @ t]), not to rules and not to ~index search atoms (FTS, HNSW, LSH). And it requires the Validity column to be the last key column: declaring one elsewhere is legal, but the column is then ordinary data and an @ query on the relation is rejected (eval::invalid_time_travel).

Extracting timestamps and flags

to_int extracts a validity's timestamp as an integer, to_bool extracts its flag, and format_timestamp formats the timestamp part directly as an RFC 3339 string. The full history of the example, made readable:

?[fmt, flag, person] := *service_owner{ service: 'search-service', at, person },
                        fmt = format_timestamp(at), flag = to_bool(at)
:order fmt
["2025-06-01T00:00:00+00:00",true,"Maya"]
["2025-07-08T00:00:00+00:00",true,"Sam"]
["2025-09-01T00:00:00+00:00",false,null]
["2025-09-01T00:00:00+00:00",true,"Maya"]
["2027-01-01T00:00:00+00:00",true,"Sam"]

Transaction time (mnestic)

mnestic

Everything from here on is specific to mnestic: bitemporality landed in 0.10.0 — engine-assigned transaction time alongside valid time, including the TxTime column type, the labeled @ (vt: …, tt: …) selectors, :as_of, :reconcile, and the ::history / ::history_gc / ::evict ops. Relations declaring TxTime cannot be opened by upstream CozoDB builds. See Bitemporality for the design and the measured overhead, What mnestic adds for the release map, and Types for the column type itself.

Valid time is under your control. That is a feature — you can backdate, future-date, and import histories — but it also means 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 yesterday's query result may be unreproducible today, and "when did we learn this?" has no answer. Transaction time (tt) closes that gap: a second temporal axis stamped by the engine at commit, never settable from outside. Where valid time says when a fact held in the world, transaction time says when the database believed it.

System-versioned relations

A relation whose last key column has type TxTime is system-versioned: every write appends a record stamped with the commit clock, nothing is ever overwritten, and a plain read returns the current belief — the latest record per key, with retracted keys absent. Here the agent records its user's preferences:

:create pref { topic: String, tt: TxTime => setting: String }

Writes omit the tt column entirely; supplying a value for it is an error on every write path (eval::txtime_user_supplied_col):

?[topic, setting] <- [
  ['pr_size_limit', '400 lines'],
  ['review_style',  'inline comments'],
]
:put pref { topic => setting }

Later the agent learns the limit was renegotiated. The correction is another plain :put:

?[topic, setting] <- [['pr_size_limit', '600 lines']]
:put pref { topic => setting }

Reads need no selector — the default is the current belief, resolved in one seek per key:

?[topic, setting] := *pref{ topic, setting }
["pr_size_limit","600 lines"]
["review_style","inline comments"]

The tt column can be bound and projected like any other; it renders as a [timestamp, flag] pair, microseconds since the epoch:

?[topic, tt, setting] := *pref{ topic, tt, setting }
["pr_size_limit",[1783806938343960,true],"600 lines"]
["review_style",[1783806938342038,true],"inline comments"]

Removal is also an append. :rm records a retraction at commit time (values snapshot the key's latest record; removing a missing key is a no-op), after which the key is absent from current-belief reads — but not from history:

?[topic] <- [['review_style']]
:rm pref { topic }
?[topic, setting] := *pref{ topic, setting }
["pr_size_limit","600 lines"]

The commit clock

Transaction times come from a wall-clock-floored, strictly monotonic commit counter: tt = max(now_µs, last_tt + 1). It never collides, even for commits within one microsecond or across a backward step of the system clock, and its 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 — a transaction is one belief event — and tt order equals commit order equals visibility order.

Caution

The clock's authority is in-process: exactly one live database instance may write a store containing TxTime relations. The RocksDB backend enforces this with its LOCK file; the SQLite backend does not, so keep such a database open through a single handle.

The belief timeline: ::history

::history returns every record of the given keys — the raw belief timeline rather than the current belief. Output columns are the key columns, vt_ts (only on bitemporal relations), op (assert or retract), tt (integer microseconds), and the value columns, ordered key-ascending, then newest first; optional trailing integers page with limit and offset. The full op reference lives in System ops.

::history pref [['pr_size_limit'], ['review_style']]
["pr_size_limit","assert",1783806938343960,"600 lines"]
["pr_size_limit","assert",1783806938342038,"400 lines"]
["review_style","retract",1783806938349818,"inline comments"]
["review_style","assert",1783806938342038,"inline comments"]

Note the two original assertions share the tt …342038: they were written in one transaction — one belief event.

Reading past beliefs

@ (tt: T) reads a relation exactly as it stood at commit time T. Taking the tt of the first belief event from the history above (yours will differ — transaction times are minted at commit):

?[setting] := *pref{ topic: 'pr_size_limit', setting, @ (tt: 1783806938342038) }
["400 lines"]

T accepts integer microseconds, RFC 3339 strings, and bare dates, exactly like the valid-time selector — and rejects a float exactly like it, with parser::float_validity_spec naming the transaction-time axis (mnestic 0.12.2; before that, @ (tt: now()) read a moment in 1970 and returned nothing). 'NOW' and 'END' both mean current belief — deliberately the end of transaction time rather than the wall clock, since the monotone clock can run ahead of a stepped-back system clock and "current belief" must always include every committed record. There is no "earliest belief" token: a read before the first record answers, correctly, that nothing was known —

?[topic, setting] := *pref{ topic, setting, @ (tt: '2026-07-01T00:00:00Z') }
(no rows)

— and "what did we believe first?" is a ::history question.

Bare @ E means valid time on every relation, always. On a system-versioned relation, which has no valid-time axis, the axis must be named:

?[topic, setting] := *pref{ topic, setting, @ 'NOW' }
eval::txtime_no_vt_axis: relation pref is system-versioned: it has no
valid-time axis
help: select transaction time with `@ (tt: …)`; bare `@ E` always means
valid time

Conversely, @ (tt: …) on a relation with no TxTime column is an error (eval::txtime_no_tt_axis), naming the trailing tt: TxTime declaration that would enable it.

Reproducible queries: :as_of

Per-atom selectors are precise but easy to forget on one atom of a multi-atom rule, which would silently mix current belief into a supposedly historical answer. The :as_of query option pins the default transaction time for every TxTime-stamped relation atom in the query block that lacks an explicit tt: selector — re-running a report exactly as it would have answered at T:

?[topic, setting] := *pref{ topic, setting }
:as_of 1783806938342038
["pr_size_limit","400 lines"]
["review_style","inline comments"]

Both the pre-correction limit and the since-retracted review_style row answer as they were believed then. Explicit per-atom tt: selectors win over :as_of; using it in a block that references no TxTime relation is an error (a typo guard). It takes the same forms as tt: — microseconds, ISO strings, 'NOW' — and rejects a float on the same terms, so :as_of parse_timestamp(…) is an error rather than a silent read of 1970.

Caution

:as_of pins only TxTime-stamped relations. Plain and valid-time-only relations in the same query still read their present contents, so a mixed query is only partially reproducible.

Bitemporal relations

Declaring both axes gives a fully bitemporal relation — valid time for when facts held in the world, transaction time for when the database believed them:

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

The temporal-axis rule: temporal axes are the trailing key columns, in the fixed order Validity then TxTime, at most one of each, any subset. Malformed declarations fail at :create with the corrected form in the error:

:create bad { tt: TxTime, service: String => person: String }
eval::invalid_temporal_axes: invalid temporal-axis declaration: TxTime must be
the last key column
help: temporal axes must be the trailing key columns, in the order
vt (Validity) then tt (TxTime), at most one of each; corrected declaration:
`:create bad {service: String, tt: TxTime => person: String}`

One @ clause selects on either or both axes, with order-free labels (duplicates are a parse error). Each axis defaults independently:

SelectorValid timeTransaction time
(none)every record (raw scan)current belief
@ V or @ (vt: V)as of Vcurrent belief
@ (tt: T)every recordas of T
@ (vt: V, tt: T)as of Vas of T

Because the defaults are "what the bare forms already meant", adding a tt: TxTime column to an existing relation changes no existing query's results (up to corrections actually recorded since) — a bare scan on a bitemporal relation still returns its valid-time records, each valid-time version resolved to the current belief about it.

Now the worked example. The agent believed Maya owned postgres-connector from June 2025; it later learns the June record was wrong — it was Sam all along. The correction is a :put at the same valid time, which lands at a later transaction time:

?[service, at, person] <- [['postgres-connector', '2025-06-01T00:00:00Z', 'Maya']]
:put owner { service, at => person }
?[service, at, person] <- [['postgres-connector', '2025-06-01T00:00:00Z', 'Sam']]
:put owner { service, at => person }

What do we believe now about June? The correction:

?[person] := *owner{ service: 'postgres-connector', person, @ '2025-06-15' }
["Sam"]

Both beliefs are in the timeline:

::history owner [['postgres-connector']]
["postgres-connector",1748736000000000,"assert",1783806955357668,"Sam"]
["postgres-connector",1748736000000000,"assert",1783806955355696,"Maya"]

And the fully bitemporal question — what did we believe, before the correction, about June? — is one selector, using the earlier tt from the history:

?[person] := *owner{ service: 'postgres-connector', person,
                     @ (vt: '2025-06-15', tt: 1783806955355696) }
["Maya"]

The wrong belief is preserved as what it was: the belief held then. Selection is per atom, so a single query can join current state against historical belief.

Resolution semantics

For each key, resolution walks valid-time versions downward from the selected vt; within one valid-time version it takes the record with the greatest tt ≤ T — the belief held about that version as of T:

  • an assertion answers for the key;
  • a retraction means the key was believed ceased at that valid time: nothing is emitted, and older valid-time versions do not shine through (matching the single-axis semantics of retractions);
  • a version with no record at tt ≤ T (all beliefs about it recorded later) falls through to the next older version;
  • at an exact (vt, tt) tie the assertion wins, mirroring the valid-time equal-timestamp rule — though the engine also prevents creating one: a transaction may not both assert and retract one (key, vt).

Corrections and cessations

The three shapes of belief change, all of them plain writes:

IntentWrite
Value correction — "it was Sam at that time, not Maya":put the same valid time with the corrected value (as above)
Cessation — "the fact stopped holding at valid time V":put with a retraction validity at V, or :rm with the key and V
Repudiation — "the assertion at V should never have existed":put at V re-asserting the predecessor's value (a copy, not a reference: if the predecessor is later corrected, re-copy)

A cessation through :rm names the valid time and copies the believed values into the retraction record. The connector is retired on March 1, 2026:

?[service, at] <- [['postgres-connector', '2026-03-01T00:00:00Z']]
:rm owner { service, at }
?[person] := *owner{ service: 'postgres-connector', person, @ '2026-04-01' }
(no rows)
?[person] := *owner{ service: 'postgres-connector', person, @ '2025-12-01' }
["Sam"]

Like every belief, a cessation can itself be superseded. The retirement turns out to be wrong — Maya took the connector over that day:

?[service, at, person] <- [['postgres-connector', '2026-03-01T00:00:00Z', 'Maya']]
:put owner { service, at => person }
?[person] := *owner{ service: 'postgres-connector', person, @ '2026-04-01' }
["Maya"]

The recorded cessation is not erased — an @ (tt: …) read between the two events still answers that the connector was believed retired. Meanwhile the bare scan shows the valid-time history at current belief, one row per valid-time version:

?[at, person] := *owner{ service: 'postgres-connector', at, person }
[[1772323200000000,true],"Maya"]
[[1748736000000000,true],"Sam"]

Writing against the current belief

On TxTime relations, the existence-checking write ops evaluate against the resolved current belief (at valid time 'NOW' on bitemporal relations):

OpBehavior on a TxTime relation
:putappends a new belief at commit tt
:rmsystem-versioned: appends a retraction. Bitemporal: requires the valid time and records a cessation there; removal without a valid time is a valid-time statement — use :put with 'RETRACT'
:insertasserts absence. System-versioned: re-inserting a believed-deleted key is legal. Bitemporal: requires no records at any valid time
:delete:rm plus an existence assertion — fails on missing or believed-deleted keys
:updatemerges the provided value columns over the current belief; on bitemporal relations the correction lands in the current belief's own valid-time version, and binding the validity column is rejected (use :put to correct a specific version)
:ensure / :ensure_notassertions about the current belief at commit; binding vt or tt is rejected
:replacerejected outright — destroy-and-recreate would silently drop history

A failed check names the belief it tested, e.g. :insert of an existing key fails with key exists in database (current belief) and :delete of a retracted key with key is believed-deleted.

Because a transaction is one belief event, three consequences follow, all enforced. Writes to a TxTime relation are not visible to later reads in the same script — they materialize at commit (a braced multi-statement script runs as one transaction):

{
  ?[topic, setting] <- [['tabs_vs_spaces', 'spaces']]
  :put pref { topic => setting }
}
{
  ?[setting] := *pref{ topic: 'tabs_vs_spaces', setting }
}
(no rows)
?[setting] := *pref{ topic: 'tabs_vs_spaces', setting }
["spaces"]

Double-writes of one key in one transaction collapse last-write-wins. And one key cannot mix operations in one transaction: writing and removing it would produce two records at the same tt, an unresolvable tie, and existence-checking a key the same transaction rewrites is an ambiguous target — the engine rejects both scripts outright.

Belief revision: :reconcile

:reconcile declares a query's output to be a TxTime relation's new complete current belief. The engine diffs the output against the resolved current belief and records, as one belief event: assertions for new and changed keys, retractions (or valid-time cessations, on bitemporal relations) for currently-believed keys absent from the output. Unchanged keys record nothing, so an identical re-run is a true no-op — no history bloat, no tt burned.

This is built for derived relations. The agent maintains a materialized set of its high-importance memories:

:create salient { id: String, tt: TxTime => importance: Float }
?[id, importance] := *memory{ id, importance }, importance >= 0.75
:reconcile salient { id => importance }
?[id, importance] := *salient{ id, importance }
["m2",0.9]
["m4",0.8]
["m5",0.85]
["m8",0.75]

The base changes — m8's importance decays to 0.6 — and the same derive-and-reconcile script runs again:

?[id, importance] := *memory{ id, importance }, importance >= 0.75
:reconcile salient { id => importance }
?[id, importance] := *salient{ id, importance }
["m2",0.9]
["m4",0.8]
["m5",0.85]

The history shows exactly what reconciliation recorded: nothing for the unchanged m2, an assert-then-retract lifecycle for m8:

::history salient [['m2'], ['m8']]
["m2","assert",1783806972457230,0.9]
["m8","retract",1783806972461713,0.75]
["m8","assert",1783806972457230,0.75]

Retract a base fact, re-derive, reconcile: derived beliefs stay consistent with the revised base, and ::history plus @ (tt: …) reads answer what did we believe, and why, as of T — including for annotated derivations such as min_cost_k proof packs materialized into a TxTime relation. Truth maintenance is user-driven: nothing propagates from base to derived relations automatically; you re-run the derivation and reconcile.

Contracts: :reconcile requires a TxTime relation (plain relations keep :replace); a reconciled relation admits no other write in the same transaction; duplicate keys with conflicting values in one output are an error; on bitemporal relations the output must carry assert-flagged, explicit valid-time timestamps ('NOW' would mint a fresh version per run and defeat idempotence — as do value columns with non-constant defaults, if omitted); cost is O(relation size) per call.

Forgetting: ::history_gc and ::evict

Append-only history needs a lifecycle. Two ops manage it, both documented in full in System ops:

  • ::history_gc rel cutoff_tt drops superseded records below the cutoff while preserving, per key and valid-time version, the record that resolution would pick at the cutoff — as-of reads at or above it are unchanged. It persists a per-relation gc floor; an @ (tt: …) read below the floor is an error rather than a reconstruction silently presented as historical belief. Cutoffs in the future of the commit clock are refused.
  • ::evict rel [[k], …] hard-deletes every record of the given keys — the one deliberate break of append-only, for data-erasure obligations such as GDPR. An audit row (relation, salted key hash, rows deleted, eviction tt) is written to the reserved relation mnestic_evict_audit in the same transaction; add unredacted to record the keys themselves instead of hashes.

The user asks the agent to forget the tabs-versus-spaces argument entirely:

::evict pref [['tabs_vs_spaces']]
["pref","7177af05-3c47-58e2-a460-f1fbf412d3e4",1,1783806972468471]
::history pref [['tabs_vs_spaces']]
(no rows)

Restrictions and overhead

  • No secondary, FTS, HNSW, or LSH indexes, no triggers, no callbacks, and no :returning on TxTime relations — all loud errors. Statement-time index maintenance is structurally incompatible with buffered commit-time stamping.
  • Export/import does not round-trip TxTime relations. Bulk import_relations stamps one fresh tt per batch (a bulk import is one belief event) and rejects tt columns in payloads; import_from_backup is rejected for them outright. Use backup/restore, which preserves tt bytes verbatim and re-seeds the commit clock.
  • Temporary (_-prefixed) relations cannot declare TxTime — they live inside one transaction and have no commit clock.
  • Pre-fork and upstream CozoDB builds cannot open a database containing TxTime relations.

The whole feature is opt-in per relation: non-temporal relations keep their exact encoding and zero overhead. On relations that do opt in, current-belief point reads measured within ~4–12% of the single-axis baseline at the 0.10.0 release gate.

Adapted from the CozoDB documentation by Ziyang Hu and the Cozo Project Authors, used under CC‑BY‑SA‑4.0. Adaptations for mnestic are released under the same license. mnestic is an independent fork and is not affiliated with or endorsed by the original authors.