Korely

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:

FieldMeaning
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
Bi-temporal supersede: a fact is invalidated and replaced, never deleted. Default reads return the current fact; as_of queries reconstruct any past state.

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):

  1. 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.
  2. 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:

korely api GET /v1/facts
# 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:

korely api GET /v1/facts
$ 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):

korely api zsh
$ 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.

Terminal window
# what was true on 20 September, as the store knows it now: Turin
curl -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 yet
curl -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}
ClockFieldsRead 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=true the 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_facts and 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.