Back to atlas

Pattern · Time

Bi-Temporal Fact Validity

Track when a fact was true separately from when the memory system learned, changed, or expired it.

Intent

Represent changing facts and backfilled history without overwriting the past or confusing event time with ingestion time.

The problem

A memory system usually has at least two clocks:

  • the time an event happened or a fact was true;
  • the time the system received, stored, corrected, or deleted that information.

Collapsing them into one timestamp breaks questions such as “Where did Alice work last year?” A backfilled event appears new, and a corrected fact may erase the state that was valid earlier.

The pattern

Give facts an application-time interval and a system-time lifecycle:

fact
  valid_at    ───────────── invalid_at
  created_at  ───────────── expired_at

When a new fact supersedes an old one:

  1. Keep the source event for both facts.
  2. Set the new fact's real-world valid_at.
  3. Close the old fact's invalid_at at the appropriate event boundary.
  4. Record when the system made that change through created_at/expired_at.
  5. Query against the clock appropriate to the question.

Do not silently hard-delete the older edge merely because it is no longer current.

flowchart TD
    Ev["source event"] --> F1["fact: lives<br/>in Berlin"]
    Ev2["later<br/>source<br/>event"] --> F2["fact: lives<br/>in Lisbon"]
    F2 --> C["close F1.invalid_at<br/>at the event boundary"]
    C --> F1
    Q1["as of<br/>last March"] --> F1
    Q2["as<br/>of<br/>now"] --> F2
    F1 -. "retained, not deleted" .- H["history"]

Two clocks, two questions. What was true then reads application time; what did we believe then reads system time.

Why it works

The graph can answer current-state and historical questions from the same records. Backfilled information stays ordered by event time even though it was ingested later. Corrections become inspectable state transitions rather than destructive rewrites.

Tradeoffs

  • Date extraction may be uncertain or absent.
  • Overlapping intervals need explicit policy.
  • Entity resolution mistakes can invalidate the wrong fact.
  • Queries and indexes become more complex.
  • “Unknown end date” must remain different from “still true”.
  • System-time retention may conflict with privacy deletion requirements.

Temporal precision is not truth. Store the evidence and confidence behind inferred dates.

Cost to adopt

Build: two time dimensions on the record, interval-closing instead of overwriting, and an as-of parameter on the read path.

Forces elsewhere: every query must now say when, and defaults become load-bearing. Extraction has to produce validity times, which usually means asking a model for a date and inheriting its mistakes.

Ongoing: interval arithmetic accumulates edge cases — open intervals, overlapping updates, corrections to the validity time itself.

Skip it if your facts do not change on a timeline anyone will ask about. Most personal preferences do not need as-of queries.

Seen in the atlas

Graphiti remains the fullest treatment — edges carry both transaction time and real-world validity, and a fact is invalidated by closing an interval rather than erasing history.

The useful new finding is that you do not need a graph database for this.

Gini gets most of the value from three columns on an ordinary SQLite row:

occurred_start TEXT, occurred_end TEXT,   -- when it was true
mentioned_at   TEXT NOT NULL             -- when it was said

Its temporal recall channel parses a query for an absolute or relative range and matches units whose occurred window overlaps it — and only participates when the query actually contains a temporal expression, so it does not dilute fusion elsewhere. A memory recorded on Friday about Tuesday's deploy answers a question about Tuesday.

Atomic Agent applies the same split to profile facts — valid_from for when a row started being authoritative, created_at as the audit timestamp, with supersedes/superseded_by chaining — and documents the retrofit explicitly (valid_from = legacy.updated_at), so the point at which bi-temporality was added stays recoverable rather than silently assumed.

Hindsight extracts temporal spans alongside facts. MetaClaw and Redis Agent Memory Server carry expires_at and TTL, which is validity's poorer relative: an expiry says when to stop believing something but not when it was true.

Helm is the near-miss worth checking your own schema against. It has valid_from and expired_at on the fact row, a history <key> verb that orders by valid_from DESC, and correction that stamps the old row rather than erasing it — every structural piece of this pattern. And it is not bi-temporal, because valid_from is only ever written as the insert timestamp or backfilled to created. No writer anywhere in the repository can set it to anything else, so validity time is record time under a second name, and the history the verb returns is a list of when the system changed its mind, never of when anything was true.

The diagnostic generalizes past this one repository, because the columns are the easy part and are usually the part that gets built: ask whether any caller can land a fact whose validity precedes its own row. If a backfill cannot express "this was true from March, and I am only learning it now", the second time dimension is decorative no matter what the column is called.

Tests to require

  • Backfilled old events ingested today.
  • A current fact replacing a formerly true fact.
  • Overlapping, missing, and timezone-naive intervals.
  • Contradictions whose source events arrive out of order.
  • Historical queries versus current-state queries.
  • Entity deduplication followed by temporal correction.
  • Exact deletion of source evidence and all unsupported derived facts.

Run these as a matrix rather than a checklist — see the contradiction test for the case shapes and the four outcomes worth scoring separately.