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:
- Keep the source event for both facts.
- Set the new fact's real-world
valid_at. - Close the old fact's
invalid_atat the appropriate event boundary. - Record when the system made that change through
created_at/expired_at. - Query against the clock appropriate to the question.
Do not silently hard-delete the older edge merely because it is no longer current.
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") andcreated_at— and its temporal recall channel filters on the occurred window. - Last seen. Veracium keeps
observed_atbeside avalid_fromthat is "first-known and immutable";confirm_edgeadvances the one and never the other, and ingest starts both at the caller's event date. ELAI'slast_observed_atstarts equal toingested_atandvalid_from, is bumped byupdate_trustduring recall, and recall sorts by it. That is recency, not assertion. - Three columns, two axes. memv carries
valid_at,invalid_atandexpired_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_atproperty 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 samedatetime()asvalid_fromin the same committing statement. - Said, but not queryable. Utopia carries
attested_fromandattested_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_fromreads a fact's lower bound asCOALESCE(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.