Temporal facts & contradictions
A temporal fact is a (subject, predicate, object) triple
that Korely stores with a validity interval: valid_from marks
when the fact became true and invalid_at marks when it stopped
being true. When new information contradicts an old fact, Korely
invalidates the old one instead of overwriting it, so your
agent always reads current state by default while retaining the complete
history for audits, point-in-time queries, and user-facing transparency.
For an agent builder this means one thing: you never have to reconcile
conflicting beliefs yourself, and you never silently lose the context that
explains how a customer or a plan changed.
For the idea behind it, with a short video and the trade-offs, read Bi-temporal memory for AI agents: what was true, and when.
Facts change. The plan that cost 40 last month costs 50 today. The user who preferred email now wants Slack. The customer whose budget was approved had it cut a month later. There are two naive ways to handle this:
- Keep everything. Both facts sit in the store with similar embedding scores, and your agent quotes whichever one the retriever surfaces. Half the time that is the stale one.
- Overwrite. The new fact replaces the old one and the history is gone. The agent can never answer "when did this change?" or "what was it before?".
Korely does neither. When new information contradicts old information, the old fact is invalidated, never deleted. Default reads return only what is true now; the full history stays one parameter away. Every fact in Korely carries a validity interval, by design.
The bi-temporal model
Facts in Korely are (subject, predicate, object) triples extracted from what your agents write (conversations, notes, transcripts, sessions), typed against canonical predicates in 10 families. Each fact carries two timestamps:
| Field | Meaning |
|---|---|
valid_from | When the fact became true, as far as the corpus knows. |
invalid_at |
When the fact stopped, or will stop, being true. NULL while
the fact is open. Set by a contradiction, a correction, a forget, or a
past write; a future value is a known end date.
|
A fact is current while valid_from is not in
the future and invalid_at is null or still ahead:
the two timestamps are the liveness signal, there is no separate status
field to read. An invalidated fact keeps its full
row: subject, predicate, object, provenance, both timestamps. It simply
stops appearing in default reads. Nothing about the past is destroyed when
the present changes.
flowchart TD
A["Fact added, Jan<br/>Maria, subscribes_to, Pro plan<br/>valid_from: Jan · invalid_at: NULL"]
B["New memory arrives, June<br/>Maria switched to Free"]
C{"Two-stage<br/>contradiction check"}
D["Stage 1: candidate matching<br/>same subject and relation, related relations, the same entities, nearest facts by meaning<br/>→ candidates found"]
E["Stage 2: conflict confirmed<br/>→ old fact stamped invalid_at: June"]
F["Old fact, INVALIDATED, not deleted<br/>Maria, subscribes_to, Pro plan<br/>valid_from: Jan · invalid_at: June"]
G["New fact, ACTIVE<br/>Maria, subscribes_to, Free plan<br/>valid_from: June · invalid_at: NULL"]
H["Default read<br/>GET /v1/facts<br/>→ returns Free"]
I["Point-in-time read<br/>GET /v1/facts?as_of=March<br/>→ returns Pro"]
A --> B --> C --> D --> E
E --> F
E --> G
G --> H
F -. "history preserved" .-> I
Two-stage contradiction detection
Invalidation is automatic. When a new fact is extracted, it goes through two stages before the model judge can retire an existing one (a fact stated in the past tense also closes the open fact it restates, without the judge):
- Candidate matching. The new fact is checked against facts still open in the same end user's memory: those with the same subject and relation (costs 40 vs costs 50); those with the same subject, a matching object and another relation (likes X vs dislikes X, works_at Acme vs left Acme); those about the same two entities in reverse; and the nearest facts by meaning. This stage is cheap and deliberately over-matches.
- Conflict verification. A model reads each new fact with its candidates and decides which of them it actually contradicts and which can coexist. "Marco likes coffee" and "Marco likes tea" coexist. "The EU server costs 40" and "the EU server costs 50 euro per month" cannot both be true at once. Only verified conflicts trigger invalidation.
When a conflict is confirmed, the old fact gets invalid_at
set to the new fact's valid_from: the moment the change
happened, or the moment of the write when the new information gives no
date. The moment the store learned of the change is recorded separately,
in closed_known_at, and that is what known_as_of
reads. If the new fact is the older of the two (for example a batch
written newest first), the store keeps the order of the dates: the fact
that began later stays open, and the older one is closed at the moment the
later one began. The chain is intact: you can always trace what was
believed, and when the belief changed. The automatic check sets
invalid_at and closed_known_at;
invalidated_by is filled when a fact is replaced through
correct_fact, and stays null on a fact the
check closed from your memories.
All of this runs on the write side, in the background after
add() returns, in the pipeline that also embeds the memory
and extracts its facts. Once the memory's status is ready
(see GET /v1/events), reads see the result; no read ever
arbitrates a conflict.
A real example from our own memory store
It comes from the corpus we run Korely on ourselves; the responses below
shorten the ids and leave out some fields. In May we wrote a note saying our EU server costs 40. On June 7
a newer note recorded the price bump to 50 euro per month. The extraction
pipeline picked up the new triple, stage 1 matched it against the existing
(EU server, costs, 40) fact, stage 2 confirmed the conflict,
and the old fact was invalidated.
A default GET /v1/facts request now returns only the current state:
# default: the facts true now (valid_from not in the future, invalid_at null or still ahead)
$ curl "https://api.korely.ai/v1/facts?entity=EU+server" \
-H "Authorization: Bearer kor_live_..."
# 200 OK: flat JSON, the stale price is simply not in the set
{
"facts": [
{
"id": "fct_b91e",
"subject": "EU server",
"predicate": "costs",
"predicate_raw": "costs",
"object": "50 euro per month",
"predicate_family": "financial",
"valid_from": "2026-06-07T09:14:00+00:00",
"invalid_at": null,
"invalidated_by": null,
"source_memory_id": "mem_3d20",
...
}
],
"total": 1
}
No stale price in sight. Add include_invalidated=true and the full chain comes back, with
superseded facts struck through:
$ curl "https://api.korely.ai/v1/facts?entity=EU+server&include_invalidated=true" \
-H "Authorization: Bearer kor_live_..."
# 200 OK: both facts; the old one carries invalid_at (invalidated_by stays null on an automatic closure)
{
"facts": [
{
"id": "fct_b91e",
"subject": "EU server",
"predicate": "costs",
"object": "50 euro per month",
"predicate_family": "financial",
"valid_from": "2026-06-07T09:14:00+00:00",
"invalid_at": null,
"invalidated_by": null,
...
},
{
"id": "fct_a774",
"subject": "EU server",
"predicate": "costs",
"object": "40",
"predicate_family": "financial",
"valid_from": "2026-05-21T08:02:00+00:00",
"invalid_at": "2026-06-07T09:14:00+00:00",
"invalidated_by": null,
...
}
],
"total": 2
}
Output is a flat JSON list, {"facts": [...], "total": N}, one
object per fact, the most recently confirmed first (newest valid_from first when as_of or known_as_of is set). Each fact reports a normalized
predicate (the raw verb it was extracted from is kept in
predicate_raw), its predicate_family, and the
bi-temporal pair valid_from / invalid_at. Filters:
subject, entity (matches either side of the
triple), predicate, predicate_family,
as_of, known_as_of, and include_invalidated. The ten
predicate families are preferences, people, places, work, ownership,
health, financial, events, identity, and other. A relation the end
user's vocabulary has not seen yet is matched once by the model to a
known relation, or starts a new one, and the model gives it a family;
that mapping stays for that end user. other is what is left
when no family fits. Only the identity predicates
(renamed_to, formerly, codename,
also_known_as) have a fixed family. Each end user learns their own relation names: the
example and the one-value rule the judge reads come from that end user's
facts alone, and erasing the end user removes them. To get the same facts grouped by family with an
as_of echo, call get_profile instead.
Reads are retrieval, not generation. No generative model runs on the read path. Contradiction detection runs at write time, so a facts read is a deterministic SQL lookup, with no model call, however many facts are in the store. That is also why read quotas are at least a hundred times more generous than write quotas.
Point-in-time queries
Because every fact keeps both timestamps, "what did we believe on June 1?"
is a single query parameter. Pass as_of to
GET /v1/facts and the response is filtered on
valid_from <= date and invalid_at > date
(or NULL):
$ curl "https://api.korely.ai/v1/facts?entity=EU+server&as_of=2026-06-01" \
-H "Authorization: Bearer kor_live_..."
# 200 OK: what the corpus believed on June 1, with the moment that belief ended
{
"facts": [
{
"id": "fct_a774",
"subject": "EU server",
"predicate": "costs",
"object": "40",
"predicate_family": "financial",
"valid_from": "2026-05-21T08:02:00+00:00",
"invalid_at": "2026-06-07T09:14:00+00:00",
"invalidated_by": null,
...
}
],
"total": 1
}
The response is a history, not a snapshot: the old price comes back with
its full chain, including when it was superseded and by which fact.
Agents get current state by default and the full history via
include_invalidated; arbitrary time-travel slicing is done
with the as_of parameter. See the
API reference for the complete
/v1/facts contract.
Two clocks: what was true, and what the store knew
as_of answers what was true on a date, as the store knows it
now. known_as_of answers what the store knew on that date.
The two differ when a change is told late. On 5 October an agent learns
that customer-4812 moved from Milan to Turin on 1 September. The effective
date of a new fact is the timestamp of the memory that states it, so the
agent writes that memory with timestamp=2026-09-01; the date
inside the sentence is not parsed. created_at is always the
real time of the write, so known_as_of sees only what was
actually written by that date, and a backdated write is invisible before
the day it was made.
# what was true on 20 September, as the store knows it now: Turincurl -G https://api.korely.ai/v1/facts \ -H "Authorization: Bearer kor_live_..." \ --data-urlencode "user_id=customer-4812" \ --data-urlencode "as_of=2026-09-20"# (memory written with timestamp=2026-09-01)# {"facts":[{"object":"Turin","valid_from":"2026-09-01T00:00:00+00:00",# "created_at":"2026-10-05T09:00:00+00:00","closed_known_at":null,...}],"total":1}
# what the store knew on 20 September: Milan, the move was not told yetcurl -G https://api.korely.ai/v1/facts \ -H "Authorization: Bearer kor_live_..." \ --data-urlencode "user_id=customer-4812" \ --data-urlencode "known_as_of=2026-09-20"# {"facts":[{"object":"Milan","invalid_at":null,"invalidated_by":null,# "closed_known_at":null,...}],"total":1}| Clock | Fields | Read with |
|---|---|---|
| True in the world | valid_from, invalid_at | as_of |
| Known to the store | created_at, when the store learned the fact; closed_known_at, when it learned the fact had ended (null while the fact is open) | known_as_of |
With known_as_of set, a read returns the facts the store had
learned by that moment, each with the end it had learned by then, true at
as_of (by default, at known_as_of itself). An end
the store learned later is not shown: invalid_at,
invalidated_by and closed_known_at come back
null, because on that day the fact was open. With
include_invalidated=true and no as_of, every fact
learned by then comes back, ended or not.
known_as_of answers what an agent could have known when it
decided. It is on Get facts,
Get profile and
Get context (the block as
the store could have built it then), on the MCP tools
korely_get_facts and korely_get_context, in the
SDKs and in the CLI (--known-as-of).
The limits. The database writes
closed_known_at from version 0.1.26, live on
api.korely.ai since 11 October 2026. For facts closed before, it is
estimated once from what was stored: when the fact that closed it was
learned, the forget that closed it, otherwise the earlier of its end date and its last
change (never before the fact itself was learned). Read that
older history as approximate. The context block is rebuilt with today's
ranking from what is stored today: a memory edited since shows today's
text, and one deleted since stays out. A closure undone later (a memory
deleted that had closed a fact) reads as never having happened. Facts
mined again from stored memories (a re-extraction, such as
manage reextract on a self-hosted install) get a new
created_at.
Worked example: writing and reading temporal facts from the SDK
The example below shows the full loop: pinning a fact with
add_fact_triple, reading current state back with
get_facts, and walking the full supersede chain with
include_invalidated=true. The customer plan changes once; the
agent always reads the current plan and can inspect every prior value when
needed. The reads below filter on entity, which works
whatever family the model chose.
from korely_memory import Korely
korely = Korely() # reads KORELY_API_KEY from env
# Step 1, pin a fact directly (no note needed)fact = korely.add_fact_triple( subject="customer-4812", predicate="subscribes_to", object="Starter plan", user_id="customer-4812", agent_id="billing-bot",)# attribute access on the write shape:# fact.id -> "fct_b91e"# fact.valid_from -> "2026-05-01T00:00:00+00:00"# fact.invalidated -> [] (ids of facts this one superseded)
# Step 2, customer upgrades; add the new fact# The model judge compares the new fact with the open facts that could conflict with it.# It normally reads a plan change as a replacement and closes the old fact, so fact.invalidated lists its id.fact = korely.add_fact_triple( subject="customer-4812", predicate="subscribes_to", object="Business plan", user_id="customer-4812", agent_id="billing-bot",)# fact.id -> "fct_8f2c1a"# fact.invalidated -> ["fct_b91e"] (the Starter plan fact it retired)
# Step 3, read current state: only "Business plan" comes back.# get_facts returns a flat list of Fact, not a wrapper object.facts = korely.get_facts( entity="customer-4812", user_id="customer-4812",)# len(facts) -> 1# facts[0].predicate -> "subscribes_to" (predicate_raw holds the verb as written)# facts[0].object -> "Business plan"# facts[0].invalid_at -> None (null invalid_at == active)
# Step 4, walk the full chain when the agent needs "what changed?"history = korely.get_facts( entity="customer-4812", user_id="customer-4812", include_invalidated=True,)# history[0].object -> "Business plan" invalid_at None (active)# history[1].object -> "Starter plan" invalid_at "2026-06-01T00:00:00+00:00"# invalidated_by "fct_8f2c1a" add_fact_triple vs add: Use add() when
you have prose (a meeting note, a support transcript) and want Korely to
extract all facts automatically. Use add_fact_triple() when
you already know the structured triple and want to pin it precisely,
bypassing extraction. Both paths go through the same invalidation engine.
Why this matters for agents
Concrete failure modes this design eliminates:
- Support bots quoting dead pricing. A support agent backed by a plain vector store retrieves "the plan costs 40" because that chunk embeds closer to the question. With Korely, the default read simply does not contain the old price.
- Sales agents missing the budget cut. With
include_invalidated=truethe agent sees not just the current budget but that it changed, and when. "I see the budget was revised on June 7" is a materially better opener than quoting a number. - Personal AIs resurrecting ex-preferences. You stopped eating meat in March. A store that keeps both facts at equal rank will recommend the steakhouse eventually. An invalidated preference stays invalidated.
The temporal engine itself (supersede logic, validity intervals) and the
GET /v1/facts read both run on every tier, free hobby
included, a facts read needs only the memories:read scope and
a slice of your query quota. See
pricing for quotas.
Forgetting is stronger than invalidation
Invalidation and erasure are different operations, and the distinction is deliberate. Invalidated facts are history: hidden by default, retrievable on request. Erased facts are gone.
Three calls, three strengths. correct_fact supersedes a
fact with a corrected one, and forget_fact closes it: both
keep the old row as history. delete_all(user_id=...) erases
an end user: every memory and fact is physically deleted, and it does
not come back to agents, not even with
include_invalidated=true. Only an audit record with the
counts remains. You have the same Correct and Forget actions on each fact
in the dashboard.
Design your agent accordingly: include_invalidated=true shows facts superseded by newer
information and facts you forgot. It is not a backdoor to erased
data: erased rows no longer exist, so no parameter can return them.
If agents are going to carry long-term memory about people, the people need the delete button, so the calls behind it are part of the API from the start. Read human in the loop for the full model.
See also
- The knowledge graph, typed
(subject, predicate, object) triples are the fact layer of the graph.
This page explains how entities are extracted, what the 10 predicate
families are, and how
get_factsand search work together as two complementary access patterns. - Human in the loop , how to let end users read, correct, and forget the facts your agent writes, and where invalidation ends and erasure begins.
- The memory model, how the three layers (memory store, session scope, typed facts) fit together and why facts are Layer 3 on top of the memory store rather than a separate silo.
For the complete parameter reference for GET /v1/facts,
POST /v1/facts, and GET /v1/memories/{id}/history
see the API reference.
For write and query quotas per plan see pricing.