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.

Diagram — a superseding fact closes the predecessor's validity at the event boundary instead of deleting it, so an as-of query resolves to a different row than a query about now
Diagram source
%% caption: a superseding fact closes the predecessor's validity at the event boundary instead of deleting it, so an as-of query resolves to a different row than a query about now
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

The bitemporal mark asks only that validity time be tracked separately from record time; this pattern also asks that a read path query it, so a carrier of the mark is not necessarily an instance of the pattern.

Graphiti puts both axes on graph edges — each edge carries transaction time and real-world validity, and a fact is invalidated by closing an interval rather than erasing history.

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.

GENOME writes the interval arithmetic down in the docstring, which is the part that is easy to leave implicit and get wrong once. facts_valid_at(entity, T) returns facts where valid_from <= T < valid_until, and the docstring names the convention — SQL:2011 application-time periods, valid_from inclusive, valid_until exclusive — then states the consequence a caller would otherwise have to discover: "Querying at exactly valid_until returns the SUCCESSOR fact, not this one." Supersession closes the predecessor's interval rather than overwriting the row, so the period during which the wrong value was in force stays answerable. It also stores facts as ordinary memory records tagged by operator rather than in a dedicated table, and says what that buys: scope isolation, cascade delete, embedding and search, and parent-filtered retrieval, all inherited rather than re-implemented beside a second schema. A believed_by field on each fact makes two agents' contradictory beliefs about one entity representable instead of a last-writer-wins collision — the axis this page does not otherwise cover.

Atomic Agent is the near miss, and it is a useful one because the columns are all present. profile_facts carries valid_from, created_at and updated_at beside a supersedes/superseded_by chain under a partial unique index, and the legacy retrofit is documented (valid_from = legacy.updated_at). All three are written with the same now on every insert, and the code says so — updatedAt is "identical to validFrom because every write creates a fresh row", and created_at is "copied from valid_from on a fresh row", kept so a later phase can stamp vote timestamps without another migration. No read in src/ takes a time argument. That is version history on one clock: it answers what did this key hold before, and not what did this store believe on a given date, which is what makes the pair of columns a plan rather than a second axis.

MagiCore puts the instant form in a .NET library: reference_time in a memory's metadata beside CreatedAt and UpdatedAt, a range filter applied before ranking, a dedup key that includes the instant so one sentence at two dates is two rows, and a deterministic interpreter that turns an ISO date, a year or last month into a range with a confidence and gives up below a threshold — TemporalSearchFailsOpenWhenInterpretationConfidenceIsLow asserts a vague question returns both memories rather than the wrong one. Its record axis is queryable too: GetAllAtAsync replays the history to a timestamp. What it lacks is the end of the interval, so the period a fact held is recoverable only from the record-time history of its supersession — Gini's occurred_end and GENOME's valid_until are the missing column.

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.

Midas implements the smallest version that still answers the question. A retired belief keeps superseded_at in its metadata, created_at is caller-supplied so importers set it from the source turn, and recall(as_of=) walks the supersession chain to the version whose window contains the timestamp. tests/test_bitemporal.py pins it with three sequential launch dates and asserts that each as_of returns the belief that was current then. Two lines of state and a chain walk, with no graph database.

ClawMem carries the axis on its triple store rather than its documents: entity_triples holds valid_from, valid_to and created_at, a new assertion closes the old interval with UPDATE ... SET valid_to rather than overwriting, and the read path filters on the pair — surfaced as an MCP tool whose description is "what was true about X on date Y?". The uneven coverage is the sentence worth carrying: validity is tracked for the triples extracted from documents and not for the documents themselves.

memory-lancedb-pro separates two things that are easy to merge — invalidated_at, set when another fact supersedes this one, and valid_until, an expiry the fact carried from the start — with isMemoryExpired documented as "separate from isMemoryActiveAt (which checks invalidated_at from superseding)". It also refuses to store an incoherent interval: an invalidated_at earlier than valid_from is dropped rather than written.

MemBukkit is the counterexample worth keeping beside those, because it has everything this pattern needs except the second axis and reads as though it has both. A fact carries timestamp for when it was true and valid_to for when it stopped being true; is_active_as_of filters the evidence pool by them; ask(as_of=...) is a first-class argument on every surface. And link_supersessions sets valid_to to the replacement fact's own timestamp, so both ends of the interval sit on the validity axis and no fact row records when the store learned anything. The store can answer what was true in May and cannot answer what I believed in May — which is the question you have when an agent said something wrong and you are trying to find out whether the memory was wrong then or the retrieval was. The single-axis version is genuinely useful and much cheaper; the distinction is worth drawing explicitly, because a reader comparing feature lists will see as_of and valid_to and assume the pair.

Uteke ships valid_from, valid_until and recall --at, and all three are record time. valid_from equals created_at on every path, and outside import and restore valid_until is written only by a deprecation, to the moment it happened, or by its undo. Point-in-time recall draws candidates from a vector index that drops a row when it is deprecated, and list --at filters deprecated = 0 in SQL, so a memory retired after the requested time is missing from the answer about that time. The predicate that would keep it is correct and never receives such a row.

Janus-Graph is Graphiti seen from a caller, and both of its temporal defects live in the wrapper rather than the engine. It dates every episode by the sweep that processed it — the worker's reference_time falls back to now because it reads a created_at the queue's claim never sets — so relative dates in a delayed or dead-letter-replayed episode resolve against the wrong day, and no caller can supply the time. And its recall filter is invalid_at IS NULL, which keeps closed facts out but also hides a fact whose extracted end date lies in the future; comparing against the query instant is the correct predicate. It still earns the mark, because the engine's edges carry a model-extracted validity that can precede their row, and the wrapper reads that axis on every search_memory call.

Utopia puts its discipline on the read side. valid_from/valid_to carry a precision column per endpoint, replacing a single default that made a fact with no date at all look measured to the day, and the end precision admits unknown, so "former CEO" — an ending stated without a date — is distinguishable from still holding. Every read predicate for both axes is assembled in two modules, world_axis.rs and record_axis.rs, on the stated argument that a defence spread across fifty read sites fails silently when one is missed; both take an instant, and the record axis is reversed for chunks, documents and entity merges as well, so a replay at a past date sees the graph as it stood, merges undone and deleted documents back. The textbook indeterminate-instant trick is explicitly refused: filling valid_to with the document's date would put a confident-looking timestamp in a column every reader would have to check the precision of first.

Spector carries the mark and shows how the pattern fails at the unit boundary. Its fact rows hold validFrom, validTo and a millisecond txTime, and supersession appends a retraction row rather than overwriting. Every production writer documents or defaults to epoch seconds for valid time, while TemporalFact.validAtInstant compares milliseconds, so the lower bound never binds and any finite upper bound excludes the fact at every present-day instant; the display code guesses the unit and the filter does not. Name the unit in the field and test the read with the writer's own values.

mempalace-code is the same schema as MemPalace taken down the other branch. Both carry valid_from, valid_to and extracted_at on a triple; upstream later made the window half-open and added a supersede that closes and opens at one instant, while the fork, diverged on 7 April 2026, keeps a closed window with a date-only valid_to read as 23:59:59, so an invalidate-and-add on one date leaves both facts visible on that date. Its miner also closes every triple from a changed file and re-adds each relation with valid_from NULL, so an unchanged fact gains a closed copy and an open copy that both claim validity from the start.

engram (NickCirv) has the column and not the clock. Migration 8 adds valid_until and invalidated_by_commit to its node table, the Read-path provider and the pre-edit guard both drop a mistake whose valid_until has passed, and the changelog describes invalidation when the referenced code is refactored away — but no production path assigns the field, and the tests that exercise the filter set it by hand. Its revert miner's foundFalseAt is the same timestamp as lastVerified, so the one temporal fact it does record is stored twice rather than on two axes. A validity filter is only as good as its writer; grep for the assignment before trusting the read.

emem (Vortx AI) shows why a transaction-time bound has to read history rather than the current index. Its canonical index holds one fact per address, so the only candidate it offers is the newest, whose record time is later than any past bound, and an as-of query returned nothing at every bound until resolve_as_of_transaction_time began resolving each address over the multi-attester history the store already kept. The code comment dates the measurement to 11 August 2026; the fix routes three call sites through one helper so the rule cannot drift between copies.

Smriti shows both halves of the pattern split across a core and an add-on. The core row carries the world interval, and consolidate rebuilds the whole chain for a key by event time on every write, so a late report of an earlier fact becomes history instead of the current value (smriti/consolidation.py:119-146). The core never reads its own ingested_at, though, and its retrieval only sorts superseded facts below current ones; the knowledge axis and the as-of reader live in the enterprise package, whose fact_validity_history appends each relink with the time the store learned it, and facts_asof(world=, known=) answers "on K, what did we believe about W" in SQL (enterprise/smriti_enterprise/temporal.py:36-100).

local-memory-mcp carries the three ClawMem columns on its graph observations and answers a narrower question than it advertises. memory_entity_open({asOf}) filters on [valid_from, valid_to) and the server instructions call that "What did I know at this date?". But the moment memory_observation_supersede closed an interval is not recorded, so a retroactive validTo changes what an earlier as-of call returned, and valid_from has no interactive writer — only memory_import sets it, and the boundary tests set it by raw SQL. It passes Helm's diagnostic on the end of the interval and on restore, and the whitepaper lists expired_at as roadmap.

Remembra has both clocks on its graph edges and one clock on its memories. The entity prompt asks for the dates a relationship started and ended, _process_entities_for_memory stores them beside created_at, and the relationship-search route and the relationships_at MCP tool filter with is_valid_at(as_of) (src/remembra/services/memory.py:1782-1799, src/remembra/storage/database.py:3128-3203). A memory row's valid_from is set to the store's clock on both write paths, and valid_to to the moment a newer memory superseded it, so its as-of recall is a record-time query under a valid-time name. The helpers that would close a contradicted WORKS_AT edge have no caller, and the edge filter never reads created_at.

obsidian-wiki is the pattern on a Markdown vault with one read path short. valid_from and valid_until are frontmatter a person or agent writes beside the stamped created and updated, and graphrag.query keeps only pages current at as_of, returning a historical hit's superseded_by so the model can follow it (obsidian_wiki/graphrag.py:232-241, :626-631). The structural intents see the whole graph on purpose, since deleting a page breaks the historical pages linking to it. The context pack loads pages without the predicate (obsidian_wiki/context_pack.py:197-207), so the same superseded claim drops out of search and arrives in a packed prompt. No skill writes either field, while the ingest skill's own supersession move, lifecycle: archived, filters nothing.

aigent-OS has the reader and not the writer, and no record axis. search-vault.js parses a body line Valid from X to Y as whole UTC days and stable-sorts every chunk of an ended note below every current one, refusing to act on an unparseable or impossible date (daemons/semantic-search/search-vault.js:123, :146-176). The format is stated only in that file's comment, its tests and the eval fixtures. The README's link for the feature leads to a JSON facts ledger no script reads, and the /supersede skill corrects by strikethrough, which search ranks as before. The project's harness shows what the reader buys: its eight temporal cases all fail before the demotion landed and all pass after.

Apache Maka has the writer and not the reader. The extraction model supplies eventStartedAt and eventEndedAt with a temporal type, checked by a CHECK constraint and stored beside created_at and observed_at (packages/storage/src/sqlite-long-term-memory-schema.ts:38-87). No read path filters, ranks or returns them; the Recall fact projection carries content, kind and observedAt only (packages/runtime-host/src/server/execution-composition.ts:890-894).

A third clock

Two clocks answer what was true and what did we believe. The temporal database literature names a third, decision time: when an assertion was made in the world, apart from when it held and when it was recorded. In agent memory the useful form is when the source said it. A Friday conversation about Tuesday's deploy, ingested by Sunday's batch, has three answers to "when", and the middle one is what you need when two sources disagree and the question is which spoke later.

The third clocks in the atlas do not mean the same thing, which is the first thing to settle when a system claims one:

  • Said. Gini has all three columns — occurred_start/occurred_end, mentioned_at ("when it was said") and created_at — and its temporal recall channel filters on the occurred window.
  • Last seen. Veracium keeps observed_at beside a valid_from that is "first-known and immutable"; confirm_edge advances the one and never the other, and ingest starts both at the caller's event date. ELAI's last_observed_at starts equal to ingested_at and valid_from, is bumped by update_trust during recall, and recall sorts by it. That is recency, not assertion.
  • Three columns, two axes. memv carries valid_at, invalid_at and expired_at: an interval on one clock and an end on the other.
  • Claimed, not built. NornicDB's README advertises "tritemporal facts". Its temporal procedures take one interval, named by two property names the caller chooses, and an MVCC version: two axes per call. The third is an asserted_at property in a user guide's example schema, written by the user's own Cypher and read by no engine code, and the guide's examples set it to the same datetime() as valid_from in the same committing statement.
  • Said, but not queryable. Utopia carries attested_from and attested_to: the date of the document that attested the fact, set at insert, moved only earlier when the same assertion is observed again, with separate anchors at each end so a bare open fact closed by a later "no longer serving" takes its start from the first evidence and its end from the document that said so. Helm's diagnostic half-passes: a writer sets it independently of both other clocks, and a read path does query it — facts_holds_from reads a fact's lower bound as COALESCE(valid_from, attested_from). But it is consumed as a bound on the world axis, never as an axis of its own: there is no as-said parameter anywhere. A third column doing real work, and still two clocks.

The diagnostic is Helm's, applied once more: can a writer set the third time independently of the other two, and does any read path query it? A third clock always stamped with the insert time, or one nothing filters on, is metadata. That does not make it wrong to keep — a said-at column costs one field and makes a later disagreement tractable — but it does not make the store tritemporal.

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.