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 columndefaults 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 constantThe constant can be:
| Form | Example | Meaning |
|---|---|---|
| integer | @ 1751328000000000 | microseconds 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_int — round(),
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 timeConversely, @ (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:
| Selector | Valid time | Transaction time |
|---|---|---|
| (none) | every record (raw scan) | current belief |
@ V or @ (vt: V) | as of V | current belief |
@ (tt: T) | every record | as of T |
@ (vt: V, tt: T) | as of V | as 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:
| Intent | Write |
|---|---|
| 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):
| Op | Behavior on a TxTime relation |
|---|---|
:put | appends a new belief at commit tt |
:rm | system-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' |
:insert | asserts 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 |
:update | merges 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_not | assertions about the current belief at commit; binding vt or tt is rejected |
:replace | rejected 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_ttdrops 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 relationmnestic_evict_auditin the same transaction; addunredactedto 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
:returningonTxTimerelations — all loud errors. Statement-time index maintenance is structurally incompatible with buffered commit-time stamping. - Export/import does not round-trip
TxTimerelations. Bulkimport_relationsstamps one fresh tt per batch (a bulk import is one belief event) and rejects tt columns in payloads;import_from_backupis rejected for them outright. Use backup/restore, which preserves tt bytes verbatim and re-seeds the commit clock. - Temporary (
_-prefixed) relations cannot declareTxTime— they live inside one transaction and have no commit clock. - Pre-fork and upstream CozoDB builds cannot open a database containing
TxTimerelations.
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.