Time travel and bitemporality, explained
An agent that remembers is useful. An agent that can say what did I believe last Tuesday? and what was actually true back then? is much harder to fool, including by its own past mistakes. Those are two different questions, and tiramemsu keeps a separate clock for each. This article shows how the clocks work, how to query them, and why you want both.
Two clocks, two questions
Take the sentence Alice works at Acme. There are two times attached to it, and people mix them up constantly.
- Transaction time is when the database learned the fact, and when it stopped believing it. It belongs to the database. Nobody can change it after the fact, because it is the record of what happened.
- Valid time is when the fact held in the world. Alice started at Acme in 2020 and left at the end of 2023. That belongs to reality, and we may learn it late, or get it wrong and fix it.
A store that has only transaction time can replay what it believed, but cannot say when things were true. A store that has only valid time can say when things were true, but forgets that it once believed something else. A bitemporal store has both, so it can answer all four combinations:
| Believed now | Believed then | |
|---|---|---|
| True now | What do we think is true today? | What did we think was true today, back then? |
| True then | What do we now think was true in 2024? | What did we think about 2024, back then? |
The bottom right cell is the one that audits, incident reviews and “why did the agent say that?” need.
How it is stored
Every statement is one row. It has its own id (see layered graphs) and four time columns. Nothing else about time is stored.
t_add: the transaction that asserted it.t_ret: the transaction that retracted it, empty while it is live.v_fromandv_to: the valid interval, half-open, so[v_from, v_to). An empty end means unbounded, and both empty means “valid for all time”.
A row is believed during [t_add, t_ret). That is all “time travel” is: a filter on those columns. No snapshots, no copies, no replay at read time.
Every committed transaction gets a number t that starts at 1 and never skips or repeats, plus a wall-clock instant that only moves forward (if the system clock jumps backwards, the instant is bumped so the mapping from time to t stays monotonic). Asking for the state at a wall-clock time resolves to the largest t whose instant is not later. Before the first transaction, the answer is the empty database, which is correct and a little sad.
A worked example
Three things happen to what we know about Alice:
- Transaction 1. We learn
alice worksAt acme, valid from 2020-01-01, no end. - Transaction 2. We learn she actually left Acme at the end of 2023. We
supersedethe fact withv_to = 2024-01-01. - Transaction 3. We learn she joined Globex on 2024-03-01.
The table after all three, with nothing deleted:
| Row | Statement | t_add | t_ret | Valid |
|---|---|---|---|---|
| e1 | alice worksAt acme | 1 | 2 (supersede) | 2020-01-01 → open |
| e2 | alice worksAt acme | 2 | live | 2020-01-01 → 2024-01-01 |
| e3 | alice worksAt globex | 3 | live | 2024-03-01 → open |
Correcting the end date did not edit e1. It retracted e1 and inserted e2, and it replayed any confidence or source layers hanging on e1 onto e2, in the same transaction. That is why valid time never changes in place: the fact that we once believed she was still at Acme is itself part of the history.
t), a vertical cut (valid at some date), or both. The dashed lines show as of t=1, valid at 2026-01-01: they meet only inside e1.Querying the past
There are four views. A view is a way of looking at the same rows, and every query, path search and pattern reads through one.
| View | Rust | SPARQL | Cypher |
|---|---|---|---|
| Now (default) | db.now() | no clause | no clause |
| As of a transaction | db.as_of(TimeRef::Tx(1)) | FROM <urn:tiramemsu:tm:asOf/1> | USE AS OF 1 |
| As of a wall-clock time | db.as_of(TimeRef::Instant(ms)) | FROM <urn:tiramemsu:tm:asOf/2026-09-01T12:00:00Z> | USE AS OF datetime('2026-09-01T12:00:00Z') |
| Valid at a date | .valid_at(ms) | FROM <urn:tiramemsu:tm:validAt/2025-03-01> | USE VALID AT date('2025-03-01') |
| Everything ever | db.history() | FROM <urn:tiramemsu:tm:history> | USE HISTORY |
Transaction time and valid time combine: db.as_of(TimeRef::Tx(1)).valid_at(ms), or two FROM IRIs, or USE AS OF 1 VALID AT date('2026-01-01').
Valid time is opt-in. With no clause the view is “believed now” and valid time is not filtered. An implicit “valid now” would quietly hide every past fact, which is the opposite of what a memory is for.
What the Alice example returns
The question is always who does Alice work for?, only the view changes.
| View | Answer | Why |
|---|---|---|
| now | acme, globex | e2 and e3 are live. Without a valid-time filter, both episodes show. |
| now, valid at 2026-01-01 | globex | e2 ended in 2024. e3 covers 2026. |
| now, valid at 2024-02-01 | nobody | Acme ended 2024-01-01, Globex began 2024-03-01. Two months between jobs, and the database says so. |
| as of t=1 | acme | Only e1 existed. |
| as of t=1, valid at 2026-01-01 | acme | We believed she was still there. We were wrong, and we remember being wrong. |
| history | SPARQL: acme, globex Cypher: acme, acme, globex | e1, e2 and e3 are all visible, including the retracted e1. SPARQL sees a set of (subject, predicate, object), so the two Acme episodes are one row. Cypher sees one row per statement. |
Rust
// what do we believe now, and what did we believe after the first transaction?
let q = "SELECT ?org WHERE { v:alice v:worksAt ?org }";
db.now().sparql(q)?;
db.as_of(TimeRef::Tx(1)).sparql(q)?;
// the same, restricted to what was true on a date
db.as_of(TimeRef::Tx(1)).valid_at(jan_2026_ms).sparql(q)?;
SPARQL
# whole query in the past
SELECT ?org
FROM <urn:tiramemsu:tm:asOf/1>
FROM <urn:tiramemsu:tm:validAt/2026-01-01>
WHERE { v:alice v:worksAt ?org }
Time can also be chosen for one pattern with SERVICE, which is what makes “what changed?” a single query:
# what changed about alice's employer between tx 1 and now
SELECT ?before ?after WHERE {
SERVICE <urn:tiramemsu:tm:asOf/1> { v:alice v:worksAt ?before }
v:alice v:worksAt ?after .
FILTER (?before != ?after)
}
Cypher
// same store, same past
USE AS OF 1 VALID AT date('2026-01-01')
MATCH (a)-[:worksAt]->(c) RETURN a, c
// one part of a query in another time
MATCH (a {`@id`: 'v:alice'})-[:worksAt]->(after)
CALL { WITH a USE AS OF 1 MATCH (a)-[:worksAt]->(before) RETURN before }
WITH before, after WHERE before <> after
RETURN before, after
Asking a statement about its own time
Each statement also exposes its bookkeeping as virtual properties, so time is data you can filter and return. In Cypher they are r.txAdded, r.txRetracted, r.validFrom and r.validTo. In SPARQL they are the tm:txAdded and tm:txRetracted predicates on the statement id, for example v:alice v:worksAt v:acme ~ ?r . ?r tm:txRetracted ?x under the history view returns when Acme was retracted.
Changing the past without lying about it
You never edit history. You add to it, and there are a few verbs for the common cases:
assertadds a fact, and is idempotent: asserting what is already live does nothing.retractends belief in a fact and in every layer about it, in the same transaction. Nothing is deleted, only marked witht_ret.supersedecorrects a fact, including its valid interval, and replays its layers onto the new row.confirmrecords that another source agrees, without changing anything else.- A
withblock, ordry_run, applies changes hypothetically, lets you query the result, and discards it. Speculation starts from now: you can ask what if about the present, but you cannot branch the past.
Who did it, and why, goes on the transaction itself as ordinary metadata (sys:author, sys:source, sys:reason), so an audit query is a join between statements and their transactions.
Why it can be trusted
- The file enforces it. SQLite triggers reject
DELETEand any second change to a row. The only mutation ever allowed is settingt_retonce, from empty. - Time is resolved in one place. A view-aware scan turns a view into conditions on the four columns, and SPARQL, Cypher and the path engine all read through it, so they never disagree about what “as of t” means.
- The log is a view, not a second copy. The event log (one row per assertion, one per retraction) is derived from the same table. For every
t, the state as oftequals the result of replaying the events up tot, and that is a tested property. - Erasure does not break it. Legal erasure is planned as crypto-shredding: destroy a key, keep the rows. That is designed but not yet built.
volatile table, which is not part of the graph.When to use which clock
- Use as of to reproduce what the agent knew when it acted: debugging, audits, “why did it say that?”.
- Use valid at to ask about the world: “where did Alice work in 2024?”, using everything we know today.
- Use both to compare belief with reality, and to find the facts we were late to learn.
- Use history to see every version, with the retracted ones, for review.
Two clocks sound like twice the work, but in practice you set valid time only when you know it, and the transaction clock ticks by itself. The reward is an agent that can say I used to think so, and here is when I found out I was wrong. That is more than most of us manage.