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.

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:

FieldMeaning
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
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 it can retire an existing one:

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

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

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

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: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=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.

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_facts and 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.