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.
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 your notes, meetings, and agent sessions, typed against canonical predicates in 9 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 being true. NULL while the fact is
active. Set automatically when a contradicting fact arrives.
|
A fact is active when invalid_at is
null — that null is 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 — plan — Pro<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: same subject + opposing predicate family<br/>→ candidate match found"]
E["Stage 2: conflict confirmed<br/>→ old fact stamped invalid_at: June"]
F["Old fact — INVALIDATED, not deleted<br/>Maria — plan — Pro<br/>valid_from: Jan · invalid_at: June"]
G["New fact — ACTIVE<br/>Maria — plan — Free<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 it can retire an existing one:
- Candidate matching. The new fact is checked against active facts that share a subject and either use the same predicate with a different object (costs 40 vs costs 50) or use a predicate from an opposing family (likes vs dislikes, works_at vs left). This stage is cheap and deliberately over-matches.
- Conflict verification. Each candidate pair gets a focused check: do these two facts genuinely conflict, or can they 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
stamped with the moment the new information arrived, and the new fact starts
its life with valid_from at that same moment. The chain is
intact: you can always trace what was believed, and when the belief changed.
All of this runs at write time, on our own infrastructure, as part of the write pipeline that also handles embeddings and entity extraction. By the time your agent reads, the conflict is already resolved.
A real example from our own memory store
This is not a synthetic demo. It comes from the corpus we run Korely on
ourselves. 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: active facts only (invalid_at is null)
$ 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:00Z",
"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
{
"facts": [
{
"id": "fct_b91e",
"subject": "EU server",
"predicate": "costs",
"object": "50 euro per month",
"predicate_family": "financial",
"valid_from": "2026-06-07T09:14:00Z",
"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:00Z",
"invalid_at": "2026-06-07T09:14:00Z",
"invalidated_by": "fct_b91e"
}
],
"total": 2
}
Output is a flat JSON list — {"facts": [...], "total": N} — one
object per fact, newest first. 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, and include_invalidated. The nine predicate
families are preferences, people, places, work, ownership, health,
financial, events, and other; note that the taxonomy is deliberately narrow,
so a predicate like plan maps to other rather than
work. 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 ever composes output on the read path. Contradiction detection runs at write time, so a facts read is a deterministic SQL lookup, typically under 50 ms even with thousands of facts in the store. That is also why read quotas are an order of magnitude 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:00Z",
"invalid_at": "2026-06-07T09:14:00Z",
"invalidated_by": "fct_b91e"
}
],
"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.
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 tier and can inspect every prior value when
needed. (Note that plan is typed into the
other family, so the read filters on entity rather
than a family.)
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="plan", object="developer", 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:00Z"# fact.invalidated -> [] (ids of facts this one superseded)
# Step 2 — customer upgrades; add the new fact# The old "developer" fact is automatically invalidated by two-stage# contradiction detection (same subject + same predicate, different object)fact = korely.add_fact_triple( subject="customer-4812", predicate="plan", object="team", user_id="customer-4812", agent_id="billing-bot",)# fact.id -> "fct_8f2c1a"# fact.invalidated -> ["fct_b91e"] (the developer fact it retired)
# Step 3 — read current state: only "team" comes back.# "plan" is typed into the "other" family, so filter on entity, not family.facts = korely.get_facts( entity="customer-4812", user_id="customer-4812",)# facts.total -> 1# facts.facts[0].predicate -> "plan" (predicate_raw holds the verb as written)# facts.facts[0].object -> "team"# facts.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.facts[0].object -> "team" invalid_at None (active)# history.facts[1].object -> "developer" invalid_at "2026-06-01T00:00:00Z"# 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.
The end user sees and controls the memory in the Korely desktop and web app.
The Memory Panel lists every fact, and each one can be edited in place or
forgotten entirely (erasure with an audit cascade). When a
user erases a fact there, it does not come back to agents,
not even with include_invalidated=true. The deletion leaves
only an audit stub; the content is removed.
Design your agent accordingly: include_invalidated=true shows facts superseded by newer
information. It is not a backdoor to user-deleted data. That boundary is
enforced server-side, per fact, and your agent cannot opt out of it.
Korely gives the end user a first-class UI over what agents remember about them. If agents are going to carry long-term memory about people, the people need the delete button, so we built it in 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 9 predicate
families are, and how
get_factsand search work together as two complementary access patterns. - Human in the loop — the Memory Panel where end users read, correct, and forget the facts your agent writes. Explains the invalidation vs erasure boundary and why it is enforced server-side rather than as an agent-side convention.
- The memory model — how the three layers (memory store, session scope, typed facts) fit together and why facts are Layer 3 on top of a shared knowledge base rather than a separate agent-only 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.