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.

valid time — when it was true system time — when we learned it Next.js — superseded 2026‑04‑11 React + Vite — current a correction never overwrites — it supersedes, and both stay readable
Every fact carries a validity range and a system range. Asking a present-tense question returns only the current row, marked authoritative; asking what changed returns the superseded row too, labelled with the moment it stopped being true.

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 · object
The claim itself. The object is JSONB, so a fact can be a string, a number or a structure.
validity
A tstzrange: when this was true in the world. Open-ended until something ends it.
system_time
A tstzrange: when this database believed it. An open upper bound means “current”.
confidence
How much weight the extractor or the operator placed on it.
evidence
The supporting quotation or note, carried through to retrieval.
source_memory_id
The memory this came from, with a cascade. Delete the memory and the fact goes too.
facts_text · search_vector
Generated columns, so full-text search over facts never drifts from the fact.

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.

Two endpoints, and one of them is guarded

GET /v1/facts/search
Full-text search over current facts, ranked with ts_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/override
The human correction path. It requires an admin token as well as the facts: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 intentSounds likeWhat 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.

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.

How facts are weighted against everything else

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.