Nothing is ever overwritten
A client moves from net-30 to net-45. Most systems update a row and the old terms cease to have existed. That is convenient right up to the moment somebody asks what the terms were in March — or why an agent gave an answer that was correct at the time. Facts here carry two time ranges, and a correction supersedes rather than erases.
- 2 time ranges per row
- Corrections supersede, never overwrite
- include_history on demand
- Stale-truth suppression
Two clocks, because there are genuinely two
Valid time is when something was true out in the world. System time is when this database came to believe it. They are not the same clock, and collapsing them is how audit trails get lost.
What a fact actually is
A row in facts. Subject, predicate, object — plus the two
ranges that make the row answerable in the past tense.
subject · predicate · objectvaliditysystem_timeconfidenceevidencesource_memory_idfacts_text · search_vector
Current rows are the ones whose system_time has no upper
bound, and there is a partial index for exactly that predicate, so
“what do we believe right now” stays a cheap question no matter how
much history accumulates behind it. A unique index over tenant, user,
source memory, subject and predicate stops the same extraction landing
twice.
A correction closes a range; it does not delete a row
When a new fact arrives for a subject and predicate that already has a
current row, and the two validity ranges overlap, the existing row is
closed on both clocks: its system_time gets an upper
bound of now, and its validity is trimmed to end where the
new fact begins. Then the new row is inserted with an open
system_time.
Nothing is deleted. The old row stops being current and starts being history, which is a different thing entirely — you can still read it, still cite it, and still explain an answer that was right in March.
The write returns counters rather than a vague success: how many rows were inserted, how many were superseded, and how many candidates were skipped for falling under the confidence floor.
subject ardal_finch
predicate payment_terms
object "net-30"
validity [2025-11-02, ∞)
system [2025-11-02, ∞)
// after the correction on 2026-04-11
object "net-30" superseded
validity [2025-11-02, 2026-04-11)
system [2025-11-02, 2026-04-11)
object "net-45" current
validity [2026-04-11, ∞)
system [2026-04-11, ∞)
Two endpoints, and one of them is guarded
GET /v1/facts/searchts_rank_cd against a
websearch_to_tsquery — so quoted phrases and
OR work the way a person expects. Returns up to 100
rows, eight by default, filtered by a
min_confidence floor that defaults to
0.55. Set include_history=true and the
superseded rows come back alongside the current ones, each carrying
the moment it stopped being believed.
POST /v1/facts/overridefacts:override permission, and it refuses to write
against a source memory that does not exist. The confidence floor is
dropped to zero for this call, because an operator stating a fact
outranks an extractor's opinion of it. Supports
Idempotency-Key, so a retried correction corrects once.
Knowing when the past is the answer
Bitemporal storage is only half the problem. The other half is working out whether a question wants the current row, the old one, or both — and this happens on every retrieval call, not just the ones that touch this page.
| Detected intent | Sounds like | What retrieval does |
|---|---|---|
current | “What are their terms now?” | Current rows only. Memories a current fact has superseded are demoted. |
historical | “What did they used to be on?” | Superseded rows are searched too, and suppression is switched off entirely. |
both | “What changed since March?” | Both states are packed, and at least one superseded record is forced in. |
unknown | Ambiguous phrasing | Behaves like current, but the guidance line tells the model not to ignore history if asked. |
The detector scores historical markers, present markers and
change-over-time markers separately, weights an explicit year heavily,
and returns the winning kind with a confidence and the list of signals
that fired — year, hist:used to,
cur:currently, both:compare. Those signals
appear in the retrieval trace, so when an answer reaches for the wrong
era you can see which word sent it there.
Stale-truth suppression
Facts are extracted from memories, and the link back is kept. So when a fact supersedes another, the system also knows which memories were asserting the version that lost.
On a present-tense question, those memories are demoted out of the context pack — not deleted, not hidden from you, just not handed to the model as though they were still true. If suppression would empty the result, the stale records come back rather than returning nothing.
Ask about the past and suppression switches off completely. Ask for
a comparison and it deliberately keeps a superseded record in, so
the model has both halves of the change it has been asked to
describe. Anything that does survive suppression while still
conflicting is tagged stale=true in the pack.
?q=payment+terms&include_history=true
// →
current
ardal_finch.payment_terms = "net-45"
system_end = null conf = 0.91
superseded
ardal_finch.payment_terms = "net-30"
system_end = 2026-04-11T09:14:02Z
evidence = "moving you to 45 from April"
Where facts come from
Extraction is a language-model call, and language-model calls do not
belong on a write path. So a memory write enqueues a
FACT_EXTRACT event in a Postgres transactional outbox, in
the same transaction as the memory itself, and a worker drains it
later. If the extractor is down, the memory is still stored and the
event is still queued; nothing is silently lost.
When you cannot wait for the night run,
POST /v1/memory/{id}/force-fact-extract queues that one
record immediately and returns the event id. It requires Postgres — the
outbox is the mechanism, and it does not exist on the file backend.
Facts can also arrive by hand through the override endpoint, which is how a person corrects an agent that learned something wrong. That path is deliberately privileged, and it is the highest-authority source in the trust table.
Give your agents a memory you can audit
Run the whole system on your own hardware under the MIT licence, or ask us about hosted access. Both start from the same place.