Temporal validity (bi-temporal-lite)
2.0 item 4. The entity index carries an ingestion-time validity window, so "what did we know about X as of June" is answerable from the store — the one mainstream graph-camp mechanism (Zep/Graphiti) worth having at our scale.
2.2, issue #656. Page versions carry the same window at page grain
(pages.valid_from / valid_to, V62), and as_of fuses the entity
timeline with version-filtered full-text search. Vocabulary is pinned
in docs/design-page-ingestion-windows.md: this is ingestion time
("the version the store treated as current"), never world-time
validity — "valid-time" stays reserved for the deferred Phase B.
Honest scope
- Ingestion time only.
valid_from/superseded_at(link grain) andvalid_from/valid_to(page grain) record when ai-memory learned and replaced a fact, not when it was true in the world. A world-time split (Graphiti'svalid_at/invalid_atextracted by an LLM) is deliberately out of scope: it requires trusting an LLM to date facts, and our zero-LLM default path could never populate it. - Two grains, one semantic. Entity links inherit their page version's timeline (V56, repaired by V58); page versions now materialize it directly (V62). Typed relation edges (item 3) die with their page version, which is already a timeline — no extra columns needed there yet.
- Deletion is deletion. Purged pages cascade their entity links away; the timeline does not survive an explicit purge (that is what purge means here — see the purge docs).
Schema
entity_page_links carries two additive columns:
| column | meaning |
|---|---|
valid_from |
created_at of the page version the link belongs to |
superseded_at |
created_at of the version that superseded it; NULL while the version is latest |
Backfill is a one-shot data migration inside refinery: valid_from
from the linked version's created_at; superseded_at from the
superseding version's created_at (found via pages.supersedes),
NULL for latest versions. New writes populate valid_from at
insert, and the supersede path closes the outgoing version's windows
in the same transaction — a link's window can never be open-ended in
a superseded version.
pages carries the same window at page grain (V62, issue #656):
| column | meaning |
|---|---|
valid_from |
the version's own created_at |
valid_to |
created_at of the superseding version; the retirement instant for successor-less retirements; NULL while the version is latest |
For existing successor-less retirements, V62 uses the decay marker first,
then the earliest recorded entity-link close, then updated_at as an
approximation when no retirement timestamp survived. Reorg and
move-regenerate historically recorded the close only in entity links.
The end is named valid_to, not superseded_at, because
pages.superseded_at already exists with a different meaning: the V03
decay-tombstone eviction marker, written exactly when supersedes IS NULL. Every retire path closes both grains in the same transaction
(supersede, decay, reorg graveyard, move-regenerate); the V62 backfill
covers existing stores with the same rules. No FTS rebuild is needed:
the pages_fts_* triggers already index every version row, superseded
ones included — version-filtered search is a join-predicate change,
not an index change.
When to reach for it
Ordinary memory_query answers "what do we know NOW" and is the right
call 99% of the time. as_of is for audits and post-mortems: "what
did the wiki say when that decision was made", "did we already know
about this gotcha before the incident".
A worked example
A project recorded postgres as its database, then migrated:
// June: notes/db.md says "we use postgres" (entity: postgres)
// August: the page is superseded — "we migrated to sqlite" (entity: sqlite)
memory_query { "query": "postgres" }
// → no hits: current knowledge has moved on
memory_query { "query": "postgres", "as_of": "2026-07-01T00:00:00Z" }
// → the JUNE version of notes/db.md — the page that carried the
// `postgres` entity when July began, even though it has since been
// superseded
The entity leg needs the entity: it only answers when the query names
a tracked entity the version carried. The FTS leg covers the rest — a
version with no entities:/tags: frontmatter, or a question phrased
without the exact retired term ("which database were we on during the
outage?"), still resolves through the text that was live at T.
Query
memory_query accepts as_of (ISO-8601). When present the query is
a lookup over historical versions using two streams:
- the entity timeline: links whose window contains the instant
(
valid_from <= T AND (superseded_at IS NULL OR superseded_at > T)), returning the page versions that carried the matched entities then; - version-filtered FTS: page versions whose page-grain window
contains the instant
(
valid_from <= T AND (valid_to IS NULL OR valid_to > T)), answering questions through lexical matches even without entity metadata.
The two streams merge with the default path's RRF (k=60) plus the same
bounded authority adjustment, and explain=true reports both in
streams_active (["entity", "fts"]) with per-stream ranks and RRF
contributions. The time filter selects historical versions, but FTS5
BM25 uses statistics from the current index, including later versions
and other projects. Later writes can therefore change ordering and the
limited result set for the same T. This retrieves historical content;
it does not reproduce the ranking a search would have returned at T.
Still out of as_of: vector (embeddings are present-tense artifacts
of the current text; version-scoped vectors are a separate project),
graph neighbours (latest-scoped by construction), and the
raw-observation fallback. Audit reads take no access bump and no
rerank. Omit as_of and nothing changes.
One asymmetry to know: the FTS leg hides pages already TTL-expired at
T (the usual not_expired predicate evaluated at T); the entity leg
ignores TTL, on the grounds that a page valid at T was still what we
knew at T even if it expired since.
Where entities come from
as_of and the entity retrieval stream both read the entity index, so
they only answer once pages actually carry entities. Entities are the
salient nouns a page is about, and they are populated from each page's
frontmatter at write time:
- an explicit
entities:list, when an LLM consolidator emits one; and - the page's
tags:list, which nearly every page carries — tags are "what this page is about", so they seed the entity index deterministically with no LLM pass and no re-consolidation.
Broad tags do not distort ranking: the entity stream weights each match by inverse page-frequency, so a tag shared by many pages contributes proportionally little.
Existing stores self-heal. On the first start after upgrading, a
one-shot, idempotent backfill scans latest pages that have no entity
links yet, derives their entities from the same frontmatter, and opens
each link's validity window at the page version's own created_at — so
as_of works historically, not just from the upgrade forward. A store
whose pages were all written through the current path finds no
candidates and the pass is a no-op. No command is required; a manual
reindex is only needed if you deliberately cleared the index.