Skip to content

The bi-temporal model

Every fact carries four timestamps, in two independent pairs. This is the design decision that makes Memory Soda more than a fact list, and the one most worth understanding before you use asOf.


VALID TIME , when the fact is true in the world
validAt ──────────────────► validUntil
BELIEF TIME , when we believed it
createdAt ────────────────► invalidAt

They move independently. A fact can stop being true without us noticing, and we can stop believing something that was always false.

Column Answers
validAt From when is this true?
validUntil Until when is it true? null = open-ended.
createdAt When did we record it?
invalidAt When was it superseded or deleted? null = still believed.

invalidAt means superseded or deleted, only. It never means “stopped being true”.

An end of validity goes in validUntil. A change of belief goes in invalidAt. Conflating them is the mistake that makes most fact stores unable to answer historical questions.

// The user says: "I'm on a cut until December."
{ validAt: '2026-08-16', validUntil: '2026-12-01', invalidAt: null }
// Stops being live on 1 December, automatically. Nothing had to invalidate it.
// The user says: "I traded in my Honda Civic for a Tesla Model 3."
// old fact:
{ object: 'honda civic', validAt: '2026-03-01', validUntil: null, invalidAt: '2026-08-16' }
// new fact:
{ object: 'tesla model 3', validAt: '2026-08-16', validUntil: null, invalidAt: null }
// The Honda Civic fact was true and we believed it. Now we don't. `validUntil` stays null.

“Currently true” is one predicate, used by every read path:

invalid_at IS NULL
AND valid_at <= now()
AND (valid_until IS NULL OR valid_until > now())

Three ways a fact leaves the live set:

Cause
invalidAt set A contradicting fact won, or you deleted it
validUntil passed Its stated window ended, no write required
validAt in the future Not true yet

“I pick up my Model 3 in September” is extracted, stored with validAt = 2026-09-01, and cannot be recalled until September. This is correct bi-temporal behaviour and surprising the first time you hit it.

To see them:

await memory.listFacts('user_42', { includeInvalidated: true });

asOf answers “what did we believe, about that moment, at that moment?”

created_at <= $asOf -- the row existed by then
AND valid_at <= $asOf -- and was in its validity window
AND (valid_until IS NULL OR valid_until > $asOf)
AND (invalid_at IS NULL OR invalid_at > $asOf) -- and we still believed it
// What did we know in June?
const { context } = await memory.recall({
dataset: 'user_42',
asOf: '2026-06-01T00:00:00Z',
});
// → "- user drives honda civic" (the Tesla Model 3 fact didn't exist yet)

Uses:

  • Auditing, “why did the agent say that on 14 August?”
  • Debugging, separate a retrieval bug from a memory that legitimately changed
  • Compliance, reconstruct the state that drove a decision

asOf bypasses hybrid retrieval. It falls back to keyword and recency ranking, because the vector/entity path assumes the current live set. Results are correct but ranked less well. See Point-in-time recall.


Extraction sets validFrom / validUntil only when the user states them explicitly. Defaulting both to null is deliberate, most facts are open-ended, and invented bounds are worse than none.

The user says validAt validUntil
“I like Breaking Bad” now null
“I’ve used Arch since 2019” 2019-01-01 null
“I’m on a cut until December” now that December
“I’ll run daily for the next six months” now now + 6 months
“I used to work at Google” now past, inserted as history, never supersedes anything

validFrom is date-only, so “today” would resolve to midnight, hours before facts recorded earlier the same day. A brand-new statement would look older than the fact it supersedes, and the contradiction judge would keep the wrong one.

So: a validFrom equal to today is coerced to now.


Not every difference is a contradiction. The judge is asked to decide, and defaults to keeping both.

Verdict Meaning Example
old The new fact replaces the old employer, home city, current phone
new The new fact is wrong or adds nothing a less precise restatement
neither Both are true at once multiple skills, hobbies, devices

Exclusive states, one employer, one home city, supersede. Non-exclusive ones accumulate. On genuine uncertainty the verdict is neither, because destroying knowledge is worse than keeping a redundant row.

An expired-but-not-superseded fact (validUntil past, invalidAt still null) still occupies the live-unique index. Re-stating the same claim stamps invalidAt on the expired row so the new one can be inserted. “I’m cutting again until March” works even though last year’s cut is still on file.


2026-03-01 "I drive a Honda Civic."
→ honda civic validAt 03-01 validUntil null invalidAt null
2026-05-10 "I'm keeping the Civic until August, then a Model 3."
→ honda civic validAt 03-01 validUntil 08-01 invalidAt null (updated window)
→ tesla model 3 validAt 08-01 validUntil null invalidAt null (future-dated)
2026-08-16 recall()
→ tesla model 3 is live; honda civic expired on its own, nothing invalidated it
2026-08-16 recall({ asOf: '2026-06-01' })
→ honda civic, on 1 June that was both true and believed