Bitemporal memory
A fact has two independent timelines: when it was true in the world, and when your system came to believe it. Collapse them into one column and you permanently lose the ability to explain a decision you already made.
- 2 time ranges per fact
- 4 temporal intents
- Supersession, not update
Two clocks, not one
Most systems store one timestamp per row and call it
updated_at. Bitemporality says that a single timestamp is
conflating two genuinely different things, and that keeping them apart
is the whole trick.
Valid time is when the statement was true out in the world. The client preferred Tuesday calls from January to June. That range is a property of reality; it has nothing to do with your database and would have been the same if you had never written it down.
System time — sometimes called transaction time — is the window during which your system believed it. You learned about the Tuesday preference on 4 January and you learned it had changed on 12 June. That range is a property of your knowledge, not of the world.
Usually the two run roughly in parallel, which is exactly why it is tempting to keep only one. They come apart precisely when it matters: when you learn something late, when you learn something was wrong, or when someone asks you to justify a decision made under the information you had at the time.
subject client_9
predicate prefers_call_day
object "Tuesday"
validity tstzrange · when it was true
system_time tstzrange · when we believed it
confidence 0.82
source m_4a17 // the memory it came from
// a fact is current when the upper bound
// of system_time is still open
What overwriting destroys
Consider the ordinary implementation. A new value arrives, so you update the row. The write is small, the read is fast, and the schema stays tidy. What you have just done is delete the only evidence that you ever believed anything else.
The cost lands later, and it always lands as the same question: “what did we believe on the day we made that decision?” An agent booked a call on a Tuesday in April. Was that a bug, or was Tuesday genuinely the best information available in April? With one timestamp you cannot tell. The row now says Thursday, and it says so as though it had always said so.
Appending a second row without linking it is not better; it is worse in a different way. Now two contradictory statements sit in the store with nothing to say which supersedes which, and retrieval — which ranks by similarity — will happily hand a model both. The agent picks one. Which one it picks depends on chunk boundaries and embedding noise.
Bitemporality is not an audit feature bolted on for compliance. It is the thing that makes “current” a computable property rather than a guess.
Supersession instead of update-in-place
A new fact never modifies an old one’s content. It closes the old one’s belief window and opens its own.
When a fact arrives for a subject and predicate that already has a current value, the write does two things in one transaction. First it closes the existing row by setting the upper bound of its system time to now — the row keeps every field it had, including its original validity, and simply stops being current. Then it inserts the new row with an open system time.
Nothing is deleted and nothing is edited. A fact’s content is immutable once written; only the window in which it was believed ever changes, and it changes exactly once, from open to closed.
Reading current truth is then a filter, not a heuristic: the rows whose system time is still open. Reading history is the complement: the rows whose system time has been closed, ordered by when they stopped being believed. A partial index over the open rows keeps the common case fast, and a uniqueness constraint over the source memory, subject and predicate stops the same extraction being ingested twice.
Each ingest returns three counters — inserted, superseded and skipped — so a pipeline can tell the difference between learning something new, learning that something changed, and re-reading a document it had already processed.
UPDATE facts
SET system_time = tstzrange(
lower(system_time), now(), '[)')
WHERE subject = :subject
AND predicate = :predicate
AND upper(system_time) IS NULL;
// 2 · open a new one for what we now hold
INSERT INTO facts (…, validity, system_time)
VALUES (…, tstzrange(:valid_from, NULL),
tstzrange(now(), NULL));
// current → upper(system_time) IS NULL
// history → upper(system_time) IS NOT NULL
Stale-truth suppression, at retrieval time
Storing history correctly creates a new problem: the history is now in the store, and a similarity search will find it. “The client prefers Tuesday calls” remains an excellent match for a question about scheduling long after it stopped being true. Without a countermeasure, careful bitemporal storage makes retrieval worse, because there is simply more contradictory material to surface.
Stale-truth suppression is that countermeasure. Before the context pack is assembled, the retriever asks the facts layer which of the candidate memories are the source of facts that have since been superseded. Those candidates are marked stale and demoted — held back from the pack while non-stale material is available, and allowed back in only if suppressing them would leave the pack too thin to answer at all.
The important part is that suppression is conditional on what you asked. Applying it to every query would make historical questions unanswerable — you would have built the memory of the past and then hidden it. So the decision is delegated to a temporal-intent classifier, and the retrieval trace records which memory identifiers were suppressed and why, so the behaviour is inspectable rather than mysterious.
Classifying the question: current, historical, both, unknown
The classifier is deliberately rule-based — no model call on the hot path, no key required, no per-query cost, and behaviour you can read in a file. It scores signals rather than matching a single keyword list.
| Intent | What it means | Typical phrasing | Effect on retrieval |
|---|---|---|---|
| current | The question is about the state of the world now. | "What day do they prefer?" · "Who owns this account?" · "As of today, what is the rate?" | Suppression applied. Superseded facts are held back unless there is nothing else to return. |
| historical | The question explicitly points at the past. | "What did we think in March?" · "In 2024, who was the contact?" · "What was it before?" | Suppression not applied. Superseded facts are the answer, not noise. |
| both | The question is about change — it needs the old value and the new one together. | "What changed?" · "Compare before and after" · "How has this moved since January?" | Suppression not applied, and at least one superseded record is deliberately kept in the pack. |
| unknown | No temporal signal at all. Most questions land here. | "Tell me about this client." · "Summarise the account." | Treated like current, because a silent question almost always means now. |
The signals it weighs include an explicit year, which pulls hard towards historical; comparison phrasing such as “before and after” or “what changed”, which pulls towards both; and present markers such as “currently” or “as of today”, which pull towards current. Each classification carries a confidence and the list of signals that produced it, so when the answer surprises you, the reason is on the record rather than in a model’s head. Being a heuristic, it is occasionally wrong; the failure mode is a superseded fact appearing next to the current one, which is recoverable, rather than history being hidden, which is not.
Worked example: a preference changes
The ordinary case, where valid time and system time move together.
On 4 January the client says they prefer Tuesday calls. A fact is written: validity opens on 4 January, system time opens on 4 January. For five months it is the only current answer.
On 12 June they say Thursday from now on. The write closes the old row’s system time at 12 June and inserts a new row with validity and system time both opening on 12 June. The Tuesday row still exists, still says Tuesday, still records that it was believed from January to June.
Now the three questions have three different, correct answers — and none of them requires anyone to remember anything. The store knows the difference between what is true, what was true, and what changed.
This is also what makes an agent’s April booking defensible. It did not make a mistake; it acted on the best information the system held in April, and the system can still prove what that was.
intent: current
→ Thursday (since 12 Jun)
> what did we think in March?
intent: historical — year/month signal
→ Tuesday (believed 4 Jan – 12 Jun)
> what changed about their calls?
intent: both
→ Tuesday → Thursday
on 12 Jun, source m_9f02
Worked example: a correction arrives late
The case that single-timestamp designs cannot represent at all, and the reason the second clock exists.
fact account_owner = "R. Iyer"
valid from 1 Feb
belief from 1 Feb → 3 Aug
// on 3 August we learn it was wrong all along:
// the handover actually happened on 15 January
fact account_owner = "M. Costa"
valid from 15 Jan (in the past)
belief from 3 Aug (only now)
> who owns the account? → M. Costa
> who did we think owned it in March? → R. Iyer
> was the March email misaddressed? → yes,
and defensibly so
In February the system records that R. Iyer owns the account, and acts on it: emails are addressed to them, a renewal is routed to them, an agent summarises the relationship in their name.
In August someone discovers the handover to M. Costa actually happened on 15 January — the February record was wrong from the moment it was written. The correction is valid from 15 January, because that is when the world changed, but it is believed only from 3 August, because that is when we found out.
With one timestamp you must choose. Date the correction January and the record now claims you knew in February, which makes every February action look like negligence. Date it August and you have lost the fact that the handover happened in January, which makes the timeline wrong. Neither is true, and both are unfixable afterwards.
With two, nothing is lost: the account changed hands in January, you learned about it in August, and the emails sent in between were reasonable given what you held. That is not a nicety. It is the difference between an incident review that concludes “the process worked on bad input” and one that concludes “nobody can tell what happened”.
What it costs, and what it does not solve
The cost is real and worth naming. Facts accumulate rather than being replaced, so the table grows with every change instead of staying the same size. Writes do two statements rather than one. Every query has to be explicit about which timeline it wants, and a query that forgets will read history as though it were current — which is why the intent classifier exists and why the current-row index is partial.
Nor does it make anything true. Bitemporality records what you believed and when; it has no opinion about whether you were right. A confidently wrong fact is stored just as faithfully as a correct one, which is why authority, verification state and the evidence chain are separate concerns handled elsewhere in the model.
What it does give you is the ability to answer, precisely, a question that is otherwise unanswerable: not only what is true, but what you thought was true, when you thought it, and what changed your mind.
Give your agents a memory you can audit
Run the whole system on your own hardware under the MIT licence, or ask us about hosted access. Both start from the same place.