Tiramemsu

Time travel and bitemporality, explained

Tiramemsu articles · 30 September 2026

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.

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 nowBelieved then
True nowWhat do we think is true today?What did we think was true today, back then?
True thenWhat 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.

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:

  1. Transaction 1. We learn alice worksAt acme, valid from 2020-01-01, no end.
  2. Transaction 2. We learn she actually left Acme at the end of 2023. We supersede the fact with v_to = 2024-01-01.
  3. Transaction 3. We learn she joined Globex on 2024-03-01.

The table after all three, with nothing deleted:

RowStatementt_addt_retValid
e1alice worksAt acme12 (supersede)2020-01-01 → open
e2alice worksAt acme2live2020-01-01 → 2024-01-01
e3alice worksAt globex3live2024-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.

e1 alice worksAt acme (believed at t=1 only) e2 alice worksAt acme valid until 2024-01-01 e3 alice worksAt globex valid at 2026-01-01 t=1t=2t=3now 2020202220242026open valid time (when it was true) → transaction time (when we knew) → as of t=1
Each row is a rectangle: valid time across, transaction time up. A query is a horizontal cut (as of some 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.

ViewRustSPARQLCypher
Now (default)db.now()no clauseno clause
As of a transactiondb.as_of(TimeRef::Tx(1))FROM <urn:tiramemsu:tm:asOf/1>USE AS OF 1
As of a wall-clock timedb.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 everdb.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.

ViewAnswerWhy
nowacme, globexe2 and e3 are live. Without a valid-time filter, both episodes show.
now, valid at 2026-01-01globexe2 ended in 2024. e3 covers 2026.
now, valid at 2024-02-01nobodyAcme ended 2024-01-01, Globex began 2024-03-01. Two months between jobs, and the database says so.
as of t=1acmeOnly e1 existed.
as of t=1, valid at 2026-01-01acmeWe believed she was still there. We were wrong, and we remember being wrong.
historySPARQL: 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:

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

Costs. Keeping everything means reads on one key slow down as that key collects many updates, because an as-of lookup has to find the right version among them. High-churn state that does not deserve history (a heartbeat, a cursor) belongs in the separate volatile table, which is not part of the graph.

When to use which clock

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.

Next: layered graphs, explained →