Temporal Validity: Let the Latest Fact Win Without Losing History
Facts about a contact change over time. Matrix gives every memory temporal validity so the latest fact wins recall while the old one is kept for history.
A contact moves from Pune to Bangalore. Six months later they change jobs. A year after that, their kid starts a new school. Each of these is a fact your agent learned — and each one quietly invalidated the fact before it.
Most agent-memory layers handle this in one of two bad ways. They either overwrite the old fact (so "lives in Pune" is gone, and you can never answer "where did they used to live?") or they keep both (so recall now surfaces two contradictory cities and the model picks one at random). Neither is right. The latest fact should win the conversation, but the history should never be thrown away.
This is the classic problem a temporal knowledge graph solves: every fact has a window of validity, and a query resolves to whichever fact was valid at the time you care about. Matrix builds a focused, production version of this into its memory layer. Here's how it works, and — just as importantly — what isn't built yet.
The problem: facts have a shelf life
A semantic memory in Matrix is a durable fact about a contact — "current city is Bangalore", "works as a backend engineer", "prefers a callback after 6pm". These get written two ways:
- The post-call extractor distills a finished session and writes new facts (
MemoryExtractorService.newNote). - The memory tools an agent calls mid-conversation write directly (
MemoryToolset.addNote,set_contact_current_location, and friends).
The trouble is that facts aren't static. "Current city is Pune" was true when you wrote it and is false now. If recall keeps handing the model both the stale fact and the fresh one, the agent re-asks questions it already knows the answer to, or worse, asserts something that stopped being true a year ago. Memory that doesn't track when a fact stopped being true doesn't just bloat — it actively lies.
The model: validFrom, supersededAt, supersedes
Matrix attaches three temporal fields to every Memory row:
| Field | Meaning |
|---|---|
validFrom | When the fact became true — set to createdAt on write. |
supersededAt | When the fact stopped being the current truth — set the moment a replacement is written. null means "still current". |
supersedes | A back-link to the row this fact replaced — the chain of history. |
A live fact has supersededAt = null and (if it replaced something) a supersedes pointer to its predecessor. When a newer, conflicting fact arrives, the old row isn't deleted — it's stamped with a supersededAt timestamp, and the new row points back at it through supersedes.
That gives you a linked chain: Bangalore → supersedes → Pune, where Pune still exists in the graph with its validity window closed. You lose nothing. You just stop recalling the closed one.
// The current city for a contact: live facts only.
MATCH (m:Entity {entityType: 'Memory', userId: $userId, agentId: $agentId})
WHERE m.kind = 'SEMANTIC' AND m.supersededAt IS NULL
RETURN m.content, m.validFrom
ORDER BY m.validFrom DESC
// The full history, including what was superseded and when.
MATCH (m:Entity {entityType: 'Memory', userId: $userId})
RETURN m.content, m.validFrom, m.supersededAt
ORDER BY m.validFrom DESC
The first query is what an agent's recall runs. The second is what you'd run to answer "where did they live before?" — the data is right there, gated only by a WHERE clause.
How a fact gets superseded
Superseding is the UPDATE outcome of Matrix's reconcile-on-write path. Before any new semantic note is written, it's embedded and compared by cosine similarity to the nearest existing memory in the same scope:
- ≥ 0.95 (
matrix.memory.reconcile-update) → UPDATE: stamp the old row'ssupersededAt, write the new row, link it viasupersedes. - < 0.80 (
matrix.memory.reconcile-add) → ADD: this is a genuinely new, unrelated fact — no supersession. - in between → an optional LLM arbiter returns
ADD | UPDATE | SKIP, falling back to the midpoint rule when the arbiter bean isn't configured.
So "current city is Bangalore" lands close enough to "current city is Pune" to trigger UPDATE — supersession — while "prefers a callback after 6pm" is far enough away to be a plain ADD. (The dedup-and-arbiter side of this is the subject of its own deep-dive: Reconcile-on-Write. This post is about what happens to the old row once reconcile decides to replace it.)
One honest caveat: with the noop embedding backend, reconcile is skipped entirely and every note is an ADD — no supersession, because there's no similarity signal to act on. Temporal validity is a property of the vector-backed memory path.
Recall: the latest fact wins, both ways
The payoff is in recall, and it lands on both recall paths Matrix uses:
- The vector path — a real HNSW similarity search over the 768-dimension embeddings.
- The substring fallback — the keyword path used when embeddings are off or a vector hit is thin.
Both exclude rows where supersededAt is set. That's the whole trick. A superseded fact is invisible to recall but still present in the graph. The latest fact wins the conversation; the history is retained, never deleted. There's no second index to keep in sync, no tombstones to garbage-collect, no separate "archive" store — it's one IS NULL predicate on one native Neo4j property.
This matters because Matrix joins memory across channels on Session.userId: a phone call and a web chat with the same person draw from one pool (see Agents That Remember You Across Channels). If supersession only applied to one path or one channel, a contact who updated their city by phone could still get the stale city quoted back at them in chat. Excluding superseded rows at the source — on every recall path — keeps the truth consistent no matter how the contact reaches the agent.
Ranking: recency and importance, not just similarity
Excluding stale facts is necessary but not sufficient. Among the live facts, which ones lead?
Query-driven recall re-ranks the vector hits by a weighted blend:
score = α·similarity + β·recency + γ·importance
with defaults α=0.7, β=0.2, γ=0.1 (matrix.memory.rank-similarity, -recency, -importance). So a fact that's a slightly weaker semantic match but much fresher can outrank an older near-tie — exactly what you want when facts evolve. Similarity still dominates; recency and importance are tie-breakers that nudge the freshest, most-relevant truth to the top.
One deliberate asymmetry: the passive, no-query context render — the "Who you're talking to" block that gets injected into every prompt whether or not the model asked for anything — stays recency-ordered. The only thing temporal validity changes about that block is the superseded filter. We didn't want the always-on context to silently reshuffle based on a blended score; predictability there beats cleverness. The re-rank applies where there's an actual query to rank against.
What's deferred — and we mean it
House rule on this blog: we don't oversell. Two things are intentionally not built yet.
-
Full bitemporal "as-of date X" queries. Matrix filters superseded rows — it answers "what is true now" perfectly. It does not yet answer "what was true on March 3rd?" A true bitemporal store lets you query the graph as of any past instant; Matrix has the
validFrom/supersededAtwindows that would make that possible, but the query layer to walk them by date isn't there. Today, supersession filtering is the extent of the temporal reasoning. The history is retained (you can read the chain), it's just not yet queryable by point-in-time. -
Importance auto-scoring. The
importancefield and its place in the ranking blend (γ) are wired end to end — butimportancecurrently defaults to a uniform0.5for every fact. There's no model pass yet that scores "this contact's allergy" as more important than "this contact mentioned the weather". The knob is real and live; the automatic scoring behind it is future work.
We'd rather tell you that than let you discover it in production.
Why this shape
It would have been easier to bolt a temporal layer onto a dedicated memory service. Matrix instead expresses validity as native properties on the same Memory entity as everything else — no special-case storage, no parallel index. Supersession is a timestamp write plus a relationship; recall is a WHERE supersededAt IS NULL; history is the same rows with the filter dropped. The temporal knowledge graph isn't a separate subsystem you opt into — it's the default behaviour of the memory you already have, costing one predicate at read time.
Takeaway
Facts about people change, and an agent that can't represent when a fact stopped being true will either forget the past or quote a stale present. Matrix gives every semantic memory a validity window: validFrom when it's learned, supersededAt when it's replaced, and a supersedes back-link to the fact it displaced. Recall — vector and substring alike — excludes superseded rows, so the latest fact wins the conversation while the old one stays in the graph for history. Recency and importance re-rank what's left. The latest fact wins; nothing is ever lost.
Want agents whose memory tracks the truth over time, not just the most recent overwrite? Create a workspace and point an agent at a few real conversations — then watch it update a contact's city without forgetting where they used to live. Dig into the mechanics in docs/COGNITIVE_CORE.md, or read the companion piece on Reconcile-on-Write for how facts get deduplicated before they're ever stored.
Build your first agent on Matrix
Spin up a workspace, wire up tools and knowledge, give your agent a voice, and talk to it in real time — no agent code required.