CortexDB · docs

CortexDB

Status: Draft for review · Last updated: 2026-05-14 · Owner: pmalik

This document is the narrative walkthrough. It follows one user — Alice, a sales rep at Acme — through a working week with her AI agent, and shows what CortexDB does at every step. Concepts are introduced when the story needs them.

For exact endpoint shapes, schemas, capability tables, error codes, and the full ambiguity catalog, see the companion document: API_REFERENCE_V1.md.

The JSON schemas in docs/schemas/ are the normative source. Both documents are explanatory views into the schemas.


What CortexDB is

CortexDB is an event-sourced memory database for AI agents. An agent submits experiences as they happen; CortexDB captures them losslessly, derives structured memory from them in five distinct layers, and lets the agent (or anyone with the right authorization) recall, explain, revise, and forget any of it later — with full bi-temporal history and a provenance trail back to the original events.

It is not a vector store. It is not a chat history. It is the persistent, queryable, explainable substrate that lets an AI system learn from its own experience over time.

Three sentences of philosophy:

  1. Events are immutable. The write-ahead log is the source of truth. Everything else is a derived view, reproducible by replay.
  2. Memory is layered. Events, Episodes, Facts, Beliefs, Understanding — each addressable, each governed by its own lifecycle, each able to point back to the experiences that produced it.
  3. Every claim has a trail. When the system says "Acme is likely to renew," it can tell you exactly why — which facts, which episodes, which raw events. Memory without an audit trail is not memory; it is gossip.

The rest of this document is one example, threaded all the way through.


The mental model

The five layers

                                          (synthesize)
Events  ──(window)──▶  Episodes  ──(extract)──▶  Facts  ──(rank+revise)──▶  Beliefs
   │                                                │                          │
   └──────────────────(group + abstract)────────────┴──────────────────────────┴──▶  Understanding
LayerPurposeAtomic unitMutability
EventsLossless capture of what was experiencedA WAL entryImmutable
EpisodesBounded spans of related eventsA causal chainSealed once consolidated
FactsTriple-shaped assertions, with validity intervals(subject, predicate, object, valid_from, valid_to)Supersedable
BeliefsProbabilistic claims with confidence and supporting evidenceA claim + stance + supportsContinuously revisable
UnderstandingSynthesized concepts that span many beliefs and episodesA versioned concept node + relationsVersioned, never overwritten

The five-stage cycle

Every experience goes through five stages, all observable via a server-sent events stream:

StageWhat happensSynchronous?
CaptureThe event is appended to the WALYes (~5ms)
ExtractAn LLM pulls structured facts and entitiesNo (~200-500ms after)
IndexThe content is indexed for retrieval (BM25 + vector)No (~50-100ms after extract)
ReconcileConflicting facts supersede; beliefs reviseNo (~100-300ms after extract)
ConsolidateEpisodes seal; understanding nodes updateNo (background, periodic)

Only Capture blocks the client. Everything else happens asynchronously and is visible on the lifecycle stream, which is how UIs show "I just learned that" in real time.

Why five and not three

Other memory systems collapse layers. Mem0 stores "facts" (really, sentences) and ranks them at recall time — they cannot tell you why a result ranked where it did, because the derivation isn't stored. Zep separates facts from episodes but folds Belief into Fact, losing the distinction between what I observed and what I concluded. Letta has tiered memory but no explicit Belief layer at all.

CortexDB's five layers are the minimum set that lets us answer the question every operator eventually asks: "Why does the system think that?" Beliefs cite Facts; Facts cite Episodes and Events; Understanding cites everything. The trail is always walkable.

The rest of this document shows that trail being built and walked, in motion.


Meet the cast

For the rest of this walkthrough, one company and one set of characters:

WhoWhat
AliceA sales rep at her company, Initech
support_botAn AI agent Alice uses, built on CortexDB
Acme CorpA B2B SaaS customer Alice is selling to
PriyaAlice's main contact at Acme
BobAnother sales rep at Initech, joining later
SarahA sales manager at Initech, joining at the end

Alice's agent (support_bot) holds an API token issued by Initech's identity provider. Alice doesn't talk to CortexDB directly — she talks to her agent, and her agent talks to CortexDB. Every API call carries the agent's token; every call also declares who the experience is about.

Initech's scope tree looks like this:

org:initech
├── dept:sales
│   ├── team:enterprise
│   │   ├── user:alice
│   │   └── user:bob
│   └── manager:sarah
└── dept:engineering
    └── ...

We'll come back to that hierarchy later when scopes start mattering.


Day 1, Monday May 8 — An experience lands

It's Monday morning. Alice opens her chat with support_bot and types her first message of the week.

Alice (Monday 9:14am): Just got off a call with Priya at Acme. She said they're impressed with the POC but the procurement team is dragging — she's worried about a Q3 close.

support_bot doesn't just put this in chat history. It submits it as an experience envelope to CortexDB.

The envelope

POST /v1/experience HTTP/1.1
Authorization: Bearer <support_bot's token>
X-Cortex-Actor: agent:support_bot
Content-Type: application/json

{
  "scope": "org:initech/dept:sales/team:enterprise/user:alice",
  "observed_actor": {"id": "user:alice", "type": "user", "session": "chat_2026_05_08"},
  "subject":        {"id": "user:alice", "type": "user"},
  "modality": "conversation",
  "content": {
    "kind": "message",
    "role": "user",
    "text": "Just got off a call with Priya at Acme. She said they're impressed with the POC but the procurement team is dragging — she's worried about a Q3 close."
  },
  "context": {
    "observed_at": "2026-05-08T13:14:00Z",
    "intent": "deal_status_update",
    "labels": ["acme", "sales", "q3-launch"]
  },
  "directives": {
    "extract": ["facts", "entities", "beliefs"]
  },
  "idempotency_key": "alice-chat-2026-05-08-001"
}

A few things to notice:

The envelope carries three distinct identities.

Collapsing these into one field is the most common API design mistake in this space. We keep them separate because delegated agents, importer jobs, admin writes, and observer-vs-subject patterns all break the moment you can't tell them apart.

The scope is a path, not a tenant ID. It is Alice's personal scope under the sales team under the org. Reads can walk up this tree (the manager will see this later); writes go into it.

Time is split. observed_at is when the experience happened in the world (Alice supplies it). The server will assign recorded_at when it hits the WAL. We keep both axes so that later we can answer "what did the system know on May 10?" — a query that requires distinguishing event-time from ingest-time.

Extraction is declarative. directives.extract: ["facts", "entities", "beliefs"] tells the system which derivation stages to run. The default is "all of them"; agents that know they're submitting junk can short-circuit.

What comes back

HTTP/1.1 202 Accepted
X-Cortex-Request-ID: req_01HX...
X-Cortex-Policy: tier=scope; decision=allow; capability=scope.write
X-Cortex-Stability: stable

{
  "event_id": "evt_01HX_msg_001",
  "status": "captured",
  "wal_offset": 134892,
  "lifecycle_stream": "/v1/lifecycle/stream?event_id=evt_01HX_msg_001"
}

202 Accepted because the call returns the moment the WAL has fsynced. Everything else — extraction, indexing, consolidation — happens asynchronously.

That's not a hedge or a corner-cut. It's the central design choice. A high-volume agent submits hundreds of events a minute; if every submit waited for the full derivation pipeline, latency would dominate. Instead, the WAL append is the synchronous guarantee; the derived layers materialize behind it.

Clients that need read-after-write can opt in to ?wait=indexed (waits until the event shows up in search) or ?wait=consolidated (waits until beliefs/understanding have updated). Almost nobody needs this; the default is async.

Watching the pipeline run

support_bot's UI shows a small "thinking" indicator. Behind it, the SSE stream is delivering the derivation pipeline in real time.

GET /v1/lifecycle/stream?event_id=evt_01HX_msg_001
Accept: text/event-stream
event: captured
id: lce_01HX_a
data: {"event_id":"evt_01HX_msg_001","wal_offset":134892,"ts":"2026-05-08T13:14:00.082Z"}

event: extracted
id: lce_01HX_b
data: {"event_id":"evt_01HX_msg_001","derived":{
  "entities":["ent_acme_corp","ent_priya"],
  "facts":["fact_01HX_a","fact_01HX_b","fact_01HX_c"],
  "beliefs":["belief_01HX_a"]
}}

event: indexed
id: lce_01HX_c
data: {"event_id":"evt_01HX_msg_001","layers_indexed":["events","facts","beliefs"]}

event: consolidated
id: lce_01HX_d
data: {"event_id":"evt_01HX_msg_001","beliefs_updated":1,"episodes_touched":["ep_01HX_acme_deal"]}

Four lifecycle events for one experience. The IDs are distinct from the memory event itself: evt_ for the experience, lce_ for each lifecycle stage. Keeping these namespaces separate is what lets SSE clients resume cleanly: ?since_lifecycle_id=lce_01HX_c picks up exactly where you left off, even across server restarts.

In about 400 milliseconds, Alice's one sentence has produced two entities, three facts, one belief, and updated an episode. None of those existed before. Let's look at what they actually are.


Days 2-5 — The five layers form

By Friday, Alice has had several more interactions about Acme. Each one lands as an event; each one feeds the derivation pipeline; the layers fill in.

Layer 1: Events (the WAL)

A query for Alice's events about Acme:

GET /v1/events?scope=org:initech/dept:sales/team:enterprise/user:alice&labels=acme&limit=10

Returns the raw stream:

{
  "items": [
    {
      "id": "evt_01HX_msg_001",
      "scope": "org:initech/dept:sales/team:enterprise/user:alice",
      "caller": "agent:support_bot",
      "observed_actor": "user:alice",
      "subject": "user:alice",
      "modality": "conversation",
      "content": {"kind": "message", "role": "user", "text": "Just got off a call with Priya at Acme..."},
      "context": {
        "observed_at": "2026-05-08T13:14:00Z",
        "recorded_at": "2026-05-08T13:14:00.082Z",
        "labels": ["acme", "sales", "q3-launch"]
      },
      "derives": ["fact_01HX_a", "fact_01HX_b", "fact_01HX_c", "belief_01HX_a"],
      "wal_offset": 134892
    },
    {
      "id": "evt_01HX_msg_002",
      "observed_actor": "user:alice",
      "content": {"kind": "message", "role": "user", "text": "Priya emailed — she's setting up a procurement call for Thursday."},
      "context": {"observed_at": "2026-05-09T10:02:00Z", "labels": ["acme"]},
      "derives": ["fact_01HX_d", "ep_01HX_acme_deal"]
    },
    // ... more events
  ],
  "next_cursor": "..."
}

Each event has a derives[] field — back-pointers to every record in higher layers that came from it. This is the "why" trail walked in reverse.

Events are append-only. Nothing in the public API mutates an event payload. Forget-mode deletions either leave events alone (default) or redact their content while keeping the slot (rare, for legal compliance). True deletion of an event only happens through GDPR erasure, and only when no other scope references it. We'll see this on Day 21.

Layer 2: Episodes (causal chains)

The episode builder watched the first event arrive and opened a new episode for it. As subsequent Acme-related events accumulated, they joined the same episode. By Friday, ep_01HX_acme_deal looks like this:

GET /v1/episodes?scope=org:initech/dept:sales/team:enterprise/user:alice&overlapping=2026-05-08..2026-05-12&with_causal_chain=true
{
  "items": [
    {
      "id": "ep_01HX_acme_deal",
      "scope": "org:initech/dept:sales/team:enterprise/user:alice",
      "name": "Acme Q3 deal pursuit",
      "summary": "Multi-step negotiation across procurement, with Priya as the champion. POC complete; awaiting procurement signoff.",
      "events": ["evt_01HX_msg_001", "evt_01HX_msg_002", "evt_01HX_msg_003", "evt_01HX_msg_004"],
      "started_at":  "2026-05-08T13:14:00Z",
      "ended_at":    null,
      "valid_from":  "2026-05-08T13:14:00Z",
      "valid_to":    null,
      "centrality_score": 0.78,
      "causal_chain": [
        {"event": "evt_01HX_msg_001", "role": "trigger",       "summary": "Procurement concern raised"},
        {"event": "evt_01HX_msg_002", "role": "follow_up",     "summary": "Procurement call scheduled"},
        {"event": "evt_01HX_msg_003", "role": "complication",  "summary": "POC results presented"},
        {"event": "evt_01HX_msg_004", "role": "current_state", "summary": "Awaiting procurement signoff"}
      ],
      "_partial": false
    }
  ]
}

Episodes are not just buckets — they have a shape. The causal chain tags each event with its role (trigger / follow_up / complication / decision / outcome). When Alice asks "what's happening with Acme?" the recall pipeline can return this single episode and convey four messages' worth of context in one record.

Episodes are sealed at consolidation time. As long as new related events keep arriving, the episode's ended_at stays null and the chain grows. When activity stops for the configured idle window (default 7 days), the episode seals, ended_at is set, and a new episode opens for any subsequent activity.

Layer 3: Facts (triple-shaped, bi-temporal)

The extractor pulled several facts from Alice's messages. By Friday, the fact store for Acme contains:

GET /v1/facts?scope=org:initech/dept:sales/team:enterprise/user:alice&subject=ent_acme_corp&limit=20
{
  "items": [
    {
      "id": "fact_01HX_b",
      "subject": {"type": "entity", "id": "ent_acme_corp", "name": "Acme Corp"},
      "predicate": "deal_stage",
      "object": {"type": "literal", "datatype": "string", "value": "poc_complete"},
      "supports": ["evt_01HX_msg_003"],
      "valid_from":    "2026-05-10T11:00:00Z",
      "valid_to":      null,
      "recorded_from": "2026-05-10T11:00:18Z",
      "recorded_to":   null,
      "confidence": 0.91,
      "extractor": "gpt-4o-mini"
    },
    {
      "id": "fact_01HX_c",
      "subject": {"type": "entity", "id": "ent_acme_corp"},
      "predicate": "primary_contact",
      "object": {"type": "entity", "id": "ent_priya"},
      "supports": ["evt_01HX_msg_001"],
      "valid_from":    "2026-05-08T13:14:00Z",
      "valid_to":      null,
      "recorded_from": "2026-05-08T13:14:00.521Z",
      "recorded_to":   null,
      "confidence": 0.97,
      "extractor": "gpt-4o-mini"
    },
    {
      "id": "fact_01HX_a",
      "subject": {"type": "entity", "id": "ent_acme_corp"},
      "predicate": "procurement_blocker",
      "object": {"type": "literal", "datatype": "boolean", "value": true},
      "supports": ["evt_01HX_msg_001", "evt_01HX_msg_003"],
      "valid_from":    "2026-05-08T13:14:00Z",
      "valid_to":      null,
      "recorded_from": "2026-05-08T13:14:00.521Z",
      "recorded_to":   null,
      "confidence": 0.78
    }
  ]
}

Three things to notice:

Triple form: every fact is (subject, predicate, object) plus its validity interval. That's a knowledge-graph edge.

Bi-temporal: every fact has two time intervals. valid_from/valid_to is when the fact is true in the world. recorded_from/recorded_to is when CortexDB knew the fact. These are independent. If you ingest a historical document tomorrow that describes a fact from last year, the new fact has valid_from last year and recorded_from tomorrow.

Supersession: facts are immutable in shape — but later facts can supersede earlier ones. When that happens, the old fact's recorded_to is set to the moment we learned the new one, and a superseded_by pointer is set. The old fact is still queryable via as_of. We'll see this on Day 13 when the deal closes and deal_stage advances.

Layer 4: Beliefs (probabilistic, with stance)

While facts are observations, beliefs are conclusions. The Acme belief currently says:

GET /v1/beliefs?scope=org:initech/dept:sales/team:enterprise/user:alice&about=ent_acme_corp
{
  "items": [
    {
      "id": "belief_01HX_a",
      "claim": {
        "subject": {"type": "entity", "id": "ent_acme_corp"},
        "predicate": "will_close_q3",
        "object": {"type": "literal", "datatype": "boolean", "value": true}
      },
      "stance": "uncertain",
      "confidence": 0.42,
      "confidence_interval": {"lower": 0.28, "upper": 0.56, "method": "beta_posterior", "level": 0.90},
      "supports": [
        {"type": "fact", "id": "fact_01HX_b", "weight": 0.38, "polarity": "supports"},
        {"type": "fact", "id": "fact_01HX_a", "weight": 0.41, "polarity": "against"},
        {"type": "episode", "id": "ep_01HX_acme_deal", "weight": 0.21, "polarity": "supports"}
      ],
      "valid_from":    "2026-05-12T16:00:00Z",
      "valid_to":      null,
      "recorded_from": "2026-05-12T16:00:00Z",
      "recorded_to":   null,
      "last_revised_at": "2026-05-12T16:00:00Z",
      "revision_count": 4,
      "why_url": "/v1/beliefs/why?belief_id=belief_01HX_a"
    }
  ]
}

Beliefs differ from facts in three ways:

Stance. Every belief carries one of four stances: supported (evidence agrees), contradicted (evidence has overturned it), uncertain (mixed or insufficient), deprecated (predicate or subject identity has changed). The stance is what you look at to know how to use the belief. A contradicted belief is kept for explainability; it does not vanish.

Confidence + interval. Beliefs carry a point confidence and a calibrated interval. The calibration model (Platt scaling, isotonic regression, etc.) is named explicitly so consumers know how to compare across tenants.

Weighted supports with polarity. The supports array shows which records contributed and how — weight is the contribution magnitude, polarity says whether each piece of evidence pushes the belief toward or against the claim. That polarity: "against" on the procurement_blocker fact is why Alice's belief is uncertain and not supported — the system has evidence pulling both ways.

Beliefs revise themselves as new evidence arrives. The revision_count of 4 means this belief has been updated four times since it was first created on Monday. Each revision is governed by a revision_policy (cooldowns, minimum evidence delta) that prevents thrashing on noisy data.

Layer 5: Understanding (synthesized concepts)

Above the per-deal layer, the system has started building general concepts. A query for the concept space:

GET /v1/understanding?scope=org:initech/dept:sales/team:enterprise/user:alice&topic=sales

returns:

{
  "items": [
    {
      "id": "concept_01HX_b2b_sales_cycle",
      "name": "B2B SaaS Sales Cycle",
      "version": 1,
      "summary": "Multi-stage process. Procurement is a recurring late-stage blocker.",
      "supported_by": {
        "facts":    ["fact_01HX_a"],
        "episodes": ["ep_01HX_acme_deal"],
        "beliefs":  ["belief_01HX_a"]
      },
      "edges": [
        {"to": "concept_01HX_procurement", "relation": "specializes", "weight": 0.54}
      ],
      "stance": "supported",
      "confidence": 0.58,
      "coverage_score": 0.40,
      "staleness_score": 0.0,
      "_partial": true,
      "_partial_reason": "insufficient_supporting_facts",
      "_progress": 0.6
    }
  ]
}

This concept is still partial — it's been seeded by just one episode and a handful of facts. The _partial: true flag and _progress: 0.6 are explicit signals so the UI can say "still synthesizing." As Alice and other reps interact with more customers, the concept gathers support, the coverage score climbs, and the synthesizer drops _partial.

Understanding concepts are versioned. When a concept's synthesizer revises it, a new version is created with previous_version pointing back. This means as_of queries can ask "what did the system understand about B2B sales on April 1?" and get a coherent answer — old versions don't go away.

When two concepts are determined to refer to the same thing, one becomes an alias (canonical_id) pointing to the other. When a concept is determined to conflate two things, it splits — a new concept ID is created for the split-out half, and both new concepts carry the original as derived_from. No data is lost, ever.


Day 13, 3:42pm — The big moment

A week and a half later, Priya emails. Alice messages her agent:

Alice (Wed 3:42pm): Priya just signed. 200 seats. They're locked in for the year.

support_bot submits the experience. The pipeline runs. The lifecycle stream shows it happen:

event: captured
id: lce_01HX_z1
data: {"event_id":"evt_01HX_signed","wal_offset":135412,"ts":"2026-05-13T15:42:09.082Z"}

event: extracted
id: lce_01HX_z2
data: {"event_id":"evt_01HX_signed","derived":{
  "facts":["fact_01HX_signed","fact_01HX_seats_200"],
  "beliefs_to_revise":["belief_01HX_a"]
}}

event: consolidated
id: lce_01HX_z3
data: {
  "event_id":"evt_01HX_signed",
  "facts_superseded":[
    {"old":"fact_01HX_b","new":"fact_01HX_signed","predicate":"deal_stage"}
  ],
  "beliefs_revised":[
    {"id":"belief_01HX_a","confidence_before":0.42,"confidence_after":0.94,"stance_before":"uncertain","stance_after":"supported"}
  ],
  "episodes_touched":["ep_01HX_acme_deal"]
}

A lot just happened in one event:

The fact deal_stage = poc_complete was superseded. A new fact deal_stage = signed was added; the old fact's recorded_to was set to 2026-05-13T15:42:09.418Z. The supersession chain now reads:

GET /v1/facts/timeline?subject=ent_acme_corp&predicate=deal_stage
{
  "subject": {"id": "ent_acme_corp", "name": "Acme Corp"},
  "predicate": "deal_stage",
  "timeline": [
    {"fact_id": "fact_01HX_b",      "value": "poc_complete", "valid_from": "2026-05-10T11:00:00Z", "valid_to": "2026-05-13T15:42:09Z"},
    {"fact_id": "fact_01HX_signed", "value": "signed",        "valid_from": "2026-05-13T15:42:09Z", "valid_to": null}
  ]
}

The belief revised. will_close_q3 went from uncertain at confidence 0.42 to supported at confidence 0.94. The interval tightened from [0.28, 0.56] to [0.89, 0.97]. The supports array was updated to include the new fact with high positive weight.

The episode advanced. ep_01HX_acme_deal got a new event in its chain, tagged with role outcome. The episode is now seal-eligible — the next consolidation pass will likely seal it and start a new "post-signature relationship" episode.

This is what bi-temporal for — the old belief, the old fact, the old episode-without-outcome are all still queryable. If someone asks tomorrow "what did we believe about Acme yesterday at noon?" the system has the answer. We'll do exactly that query in a moment.


Day 13, 4:00pm — Asking the system

Alice's manager Sarah pings her: "What's going on with Acme?" Alice asks her agent. The agent calls /v1/recall:

POST /v1/recall
{
  "scope": "org:initech/dept:sales/team:enterprise/user:alice",
  "view": "holistic",
  "query": "What's the current status of the Acme deal?",
  "include": ["beliefs", "facts", "episodes"],
  "temporal": {"natural": "last 30 days"},
  "budgets": {"max_tokens": 2000, "per_layer_limits": {"facts": 8, "beliefs": 3, "episodes": 2}},
  "citation_mode": "inline_with_markers",
  "diagnostics": "summary"
}

The response is a stratified pack — facts, beliefs, episodes side-by-side, with a synthesized prose summary on top:

{
  "pack_id": "pack_01HX_q1",
  "context_block": "Acme Corp signed this afternoon for 200 seats [1]. The deal stage went from poc_complete to signed at 3:42pm [2]. The will_close_q3 belief revised from uncertain (0.42) to supported (0.94) as a result [3]. Primary contact remains Priya. The pursuit episode started May 8 with a procurement concern raised by Priya [4].",
  "layers": {
    "beliefs":  [/* the will_close_q3 belief, now at 0.94 */],
    "facts":    [/* deal_stage=signed, seats=200, primary_contact=Priya, ... */],
    "episodes": [/* ep_01HX_acme_deal */]
  },
  "provenance": {
    "trail": [
      {"phase": "ARIL_classify",   "plan": "DIRECT_LOOKUP", "elapsed_ms": 12},
      {"phase": "hybrid_retrieve", "bm25_hits": 18, "hnsw_hits": 240, "elapsed_ms": 88},
      {"phase": "score",           "weights": {"semantic": 0.45, "graph": 0.20, "recency": 0.20, "confidence": 0.15}, "elapsed_ms": 4},
      {"phase": "mmr",             "lambda": 0.7, "elapsed_ms": 6}
    ],
    "citations": {
      "[1]": {"layer": "fact",    "id": "fact_01HX_seats_200"},
      "[2]": {"layer": "fact",    "id": "fact_01HX_signed"},
      "[3]": {"layer": "belief",  "id": "belief_01HX_a"},
      "[4]": {"layer": "episode", "id": "ep_01HX_acme_deal"}
    }
  },
  "diagnostics": {
    "scopes_traversed": ["org:initech/dept:sales/team:enterprise/user:alice"],
    "time_ms": {"recall": 110, "render_context_block": 84, "total": 194}
  }
}

The context_block is what you'd put in an LLM prompt. The layers are what you'd render in a UI. The provenance is what you'd show when someone asks "why?". The diagnostics are what you'd log for debugging.

For a one-shot answer, Alice's agent calls /v1/answer instead:

POST /v1/answer
{
  "scope": "org:initech/dept:sales/team:enterprise/user:alice",
  "view": "holistic",
  "question": "What's the current status of the Acme deal?",
  "use_pack_id": "pack_01HX_q1",
  "stream": false
}

use_pack_id skips re-recall and reuses the pack from the previous call (60-second TTL). The response is an LLM-rendered answer plus the same citations:

{
  "pack_id": "pack_01HX_q1",
  "answer": "Priya signed at Acme this afternoon — 200 seats, locked in for the year [1][2]. This closes the Q3 deal we were tracking; the will_close_q3 belief went from uncertain to supported on the news [3].",
  "citations": [
    {"marker": "[1]", "layer": "fact",   "id": "fact_01HX_signed",    "support_strength": 0.95},
    {"marker": "[2]", "layer": "fact",   "id": "fact_01HX_seats_200", "support_strength": 0.93},
    {"marker": "[3]", "layer": "belief", "id": "belief_01HX_a",        "support_strength": 0.91}
  ],
  "provenance": {/* same as recall */},
  "as_of": "2026-05-13T16:00:00Z"
}

/v1/answer shares /v1/recall's provenance contract. The LLM does not get to invent citations; it gets to select from the pack the recall pipeline returned, and the API surfaces exactly which ones it used.

"Why do you think that?"

Sarah's a good manager and presses harder: "Why does the bot think will_close_q3 went from 0.42 to 0.94?" Alice's agent calls:

GET /v1/beliefs/why?belief_id=belief_01HX_a

The response is a full support graph:

{
  "belief": {/* belief record */},
  "support_graph": {
    "nodes": [
      {"id": "belief_01HX_a",       "type": "belief",  "summary": "will_close_q3 = true @ 0.94"},
      {"id": "fact_01HX_signed",    "type": "fact",    "weight": 0.45, "summary": "deal_stage = signed (May 13)"},
      {"id": "fact_01HX_seats_200", "type": "fact",    "weight": 0.30, "summary": "seat_count = 200 (May 13)"},
      {"id": "fact_01HX_a",         "type": "fact",    "weight": 0.10, "summary": "procurement_blocker = true (no longer relevant after sign)"},
      {"id": "ep_01HX_acme_deal",   "type": "episode", "weight": 0.15, "summary": "Acme Q3 deal pursuit (Apr 8 - May 13)"},
      {"id": "evt_01HX_signed",     "type": "event",   "summary": "Alice: 'Priya just signed. 200 seats. They're locked in for the year.'"},
      {"id": "evt_01HX_msg_003",    "type": "event",   "summary": "Alice: 'POC complete...'"}
    ],
    "edges": [
      {"from": "belief_01HX_a",       "to": "fact_01HX_signed",    "relation": "supported_by"},
      {"from": "belief_01HX_a",       "to": "fact_01HX_seats_200", "relation": "supported_by"},
      {"from": "belief_01HX_a",       "to": "fact_01HX_a",          "relation": "supersedes_against"},
      {"from": "belief_01HX_a",       "to": "ep_01HX_acme_deal",   "relation": "supported_by"},
      {"from": "fact_01HX_signed",    "to": "evt_01HX_signed",     "relation": "extracted_from"},
      {"from": "fact_01HX_seats_200", "to": "evt_01HX_signed",     "relation": "extracted_from"},
      {"from": "ep_01HX_acme_deal",   "to": "evt_01HX_msg_003",    "relation": "contains"}
    ]
  },
  "narrative": "Confidence rose from 0.42 to 0.94 because two new high-weight facts landed on May 13: deal_stage=signed (weight 0.45) and seat_count=200 (weight 0.30). The pre-existing procurement_blocker fact lost weight because its blocking condition is moot post-signature. Net evidence shift: +0.52, well above the 0.05 minimum delta in the revision policy.",
  "narrative_model": "claude-haiku-4-5"
}

That's the explainability moat. The graph walks from belief → facts → events, each edge labeled, weights shown, and a natural-language narrative on top. Nobody else in the space ships this.

Asking about yesterday

Sarah follows up: "What did we believe about Acme at lunch on May 12, before the signature?"

POST /v1/recall
{
  "scope": "org:initech/dept:sales/team:enterprise/user:alice",
  "view": "holistic",
  "query": "What did we know about Acme?",
  "include": ["beliefs", "facts"],
  "temporal": {"as_of": "2026-05-12T12:00:00Z"}
}

This returns the pre-signature state: deal_stage = poc_complete, will_close_q3 at confidence 0.42, stance uncertain. The bi-temporal model means we never lose the history; the as_of parameter clips both axes to the moment in question, and the response shows what the system would have said yesterday.

The same query without as_of (i.e., "what do we know about Acme now") returns the post-signature state. The data is the same; the temporal lens is different.


Day 14 — A retraction

Alice writes a Slack post about Acme that ends with "honestly I think Priya might leave the company soon." Her agent dutifully extracts a belief: belief_01HX_b: priya might_leave_company = true, confidence 0.31.

That night Alice realizes she shouldn't have said that. She tells her agent to forget it.

POST /v1/forget
{
  "scope": "org:initech/dept:sales/team:enterprise/user:alice",
  "layers": ["beliefs"],
  "selector": {
    "memory_ids": ["belief_01HX_b"]
  },
  "cascade": "derived_only",
  "audit_note": "User retracted speculation about contact's tenure"
}

Response:

{
  "deleted": {"events": 0, "episodes": 0, "facts": 0, "beliefs": 1, "understanding": 0},
  "audit_id": "audit_01HX_forget_01"
}

The belief is gone. The underlying event (Alice's Slack post) is not. That distinction matters: events are immutable history. Forgetting a belief drops the conclusion the system reached from an event, without rewriting what was actually said.

This isn't just principle — it's load-bearing. Two weeks later, if Priya genuinely starts hinting she might leave, the new event combined with the retained old event might re-derive the belief. The system isn't pretending the past didn't happen. It's saying "the conclusion you drew was premature; here's the underlying record if circumstances change."

Three forget modes are available, gated separately:

ModeWhat staysWhat goesCapability
derived_only (default)All eventsSelected facts/beliefs/episodes/understandingforget.cascade.derived_only
redact_eventsEvent IDs and WAL slotsEvent payloads (replaced with [REDACTED])forget.cascade.redact_events
(GDPR — separate endpoint)Depends on cross-scope refcountTrue erasure where unreferencedforget.gdpr

The third mode lives at its own endpoint (/v1/erasures) because it's qualitatively different — it deletes events, which is the only way to truly remove information from the system. We use it on Day 21.


Day 15 — Bob joins the workspace

Bob is another sales rep at Initech. He's been working a parallel deal at Acme — a different product line, same company. Alice and Bob agree to share notes via a shared workspace.

Alice (or her agent) creates the workspace:

POST /v1/scopes
{
  "path": "ws:initech-acme-account",
  "members": [
    {"actor": "user:alice", "role": "owner"},
    {"actor": "user:bob",   "role": "writer"},
    {"actor": "agent:support_bot", "role": "writer"},
    {"actor": "agent:bob_bot",     "role": "writer"}
  ],
  "policies": {
    "consolidation": "merge_compatible_beliefs",
    "conflict_resolution": "latest_wins_within_confidence_band",
    "inherit_from": [],
    "audit": "full"
  }
}

A few important things about workspaces:

A workspace is just a scope. ws:initech-acme-account is a scope path like any other. It's not nested under org:initech/dept:sales — it lives in its own top-level namespace because multiple departments could share it without being under any one of them. The unified scope model means there's no separate "workspace" concept to learn.

Membership is explicit. Both humans and agents are members. Roles (owner, writer, reader) are enforced at write and read time. A non-member writing to this scope gets a 403 NOT_A_MEMBER with a clear remediation.

Consolidation rules belong to the workspace. When Alice's agent submits a belief and Bob's agent submits a contradicting one, the workspace's conflict_resolution policy decides how to merge — not the agents. This is critical: each agent's submissions need not agree with the others, but the workspace presents a coherent view.

A multi-agent write

Bob's agent submits an experience to the workspace:

POST /v1/experience
Authorization: Bearer <bob_bot's token>
X-Cortex-Actor: agent:bob_bot

{
  "scope": "ws:initech-acme-account",
  "observed_actor": {"id": "user:bob", "type": "user"},
  "subject": {"id": "user:bob", "type": "user"},
  "modality": "conversation",
  "content": {"kind": "message", "role": "user", "text": "Talked with Acme's engineering team about the API product. They want a POC next month."},
  "context": {"observed_at": "2026-05-15T10:00:00Z", "labels": ["acme", "api-product"]}
}

This event lands in ws:initech-acme-account, not in Bob's personal scope. The extractor finds new facts (acme.interested_in = api-product, acme.poc_target = "next month") and merges them into the workspace's fact store.

Now when Alice asks her agent "what's happening with Acme?" the agent calls /v1/recall with view=holistic and the workspace scope:

POST /v1/recall
{
  "scope": "ws:initech-acme-account",
  "view": "holistic",
  "query": "What do we know about Acme right now?"
}

The response contains both Alice's signed-deal context and Bob's API-product POC context. Each fact's citations point back to events Bob or Alice authored. The provenance.citations map shows exactly whose event produced which fact — so Alice can see that Bob is the source of the API-product line, and vice versa.

Authorization in action

What if Alice's agent tried to read Bob's personal scope (org:initech/dept:sales/team:enterprise/user:bob)?

GET /v1/events?scope=org:initech/dept:sales/team:enterprise/user:bob
Authorization: Bearer <support_bot's token>

The policy evaluator runs through the four-tier stack: deployment allows scope-reads, tenant allows them within the org, but the scope (user:bob) is registered with members: [{actor: user:bob, role: owner}, {actor: agent:bob_bot, role: writer}]. Alice's agent is not on the list.

HTTP/1.1 403 Forbidden

{
  "error_code": "NOT_A_MEMBER",
  "message": "agent:support_bot is not a member of scope org:initech/dept:sales/team:enterprise/user:bob",
  "details": {
    "scope": "org:initech/dept:sales/team:enterprise/user:bob",
    "actor": "agent:support_bot",
    "denied_by_tier": "scope"
  },
  "retriable": false
}

denied_by_tier makes the failure debuggable. The denial isn't "you don't have access" — it's "the scope tier of your policy stack denied you, here's the specific capability you'd need." When the same scope is opened up later (say, Bob gives Alice reader role), the same call succeeds without code changes.


Day 20 — Sarah asks at the org level

Sarah, the sales manager, gets onboarded with the company's CortexDB-powered dashboard. Her first question is broad: "Show me everything going on with Acme across the team."

Her agent calls:

POST /v1/recall
{
  "scope": "org:initech/dept:sales/team:enterprise",
  "view": "descend",
  "query": "What's the current state of customer engagement with Acme?",
  "temporal": {"natural": "last 30 days"}
}

view=descend walks down the scope tree from the named scope. The recall pipeline visits:

Because Sarah doesn't have access to the workspace directly, the response returns it as a denied_placeholder:

{
  "context_block": "Across the enterprise team, two reps have active Acme engagements...",
  "layers": {
    "beliefs": [/* aggregated across Alice and Bob */],
    "facts":   [/* aggregated */],
    "episodes":[/* the deal pursuit, the API POC */]
  },
  "scopes_traversed": [
    {"scope": "org:initech/dept:sales/team:enterprise/user:alice", "items": 14},
    {"scope": "org:initech/dept:sales/team:enterprise/user:bob",   "items": 8},
    {"scope": "ws:initech-acme-account", "denied": true, "item_count": 22, "denied_reason": "not_a_member"}
  ]
}

Sarah sees that 22 items exist in a workspace she can't read. She can request access from Alice or Bob (the workspace owners); she can't see the content until they grant it. The system has revealed that something exists without leaking what it isitem_count: 22 is the deliberate compromise.

This is what hierarchical scope + ACL gives you. The CEO can ask broad questions and get aggregated answers. Individuals retain control over their personal scope and shared workspaces. The system is honest about what was traversed and what was denied.


Day 21 — Right to erasure

A hypothetical: Alice decides to leave Initech and exercises her right to erasure under GDPR. Her data — every message she submitted, every fact extracted from her conversations, every belief and episode derived from her experience — must be removed.

But here's the wrinkle: Alice is a member of the shared workspace ws:initech-acme-account. The workspace contains facts and beliefs that were synthesized partially from Alice's events. If we delete Alice's events outright, what happens to Bob's reading of the workspace?

This is what /v1/erasures is for. It's the only endpoint that deletes events.

Preview first

POST /v1/erasures/preview
{
  "scope": "org:initech/dept:sales/team:enterprise/user:alice",
  "audit_note": "DSR #2026-101 — Alice exercising right to erasure"
}

The preview walks Alice's scope and every cross-scope reference, returning a manifest:

{
  "preview_id": "ervw_01HX_dsr_001",
  "scope": "org:initech/dept:sales/team:enterprise/user:alice",
  "estimated_affected": {
    "events": 1248,
    "episodes": 47,
    "facts": 412,
    "beliefs": 38,
    "understanding": 0
  },
  "refcount_breakdown": {
    "events_to_delete":  982,
    "events_to_redact":  266,
    "events_under_legal_hold": 0
  },
  "cross_scope_propagation": {
    "affected_workspaces": [
      {"scope": "ws:initech-acme-account", "events_referenced": 266, "co_owners": ["user:bob"]}
    ],
    "requires_capability": "forget.gdpr.cross_workspace"
  },
  "legal_holds": [],
  "estimated_duration_ms": 142000,
  "manifest_url": "/v1/erasures/preview/ervw_01HX_dsr_001/manifest"
}

The numbers reveal the policy:

This is the central GDPR design choice. Pure deletion of Alice's data would invalidate Bob's view of the workspace — facts he'd been relying on would lose their citation anchors. Pure preservation would violate Alice's right to erasure. Reference counting threads the needle: data that exists only in Alice's scope is gone; data that's been integrated into shared work survives as evidence-without-attribution.

Legal review can pull the full manifest:

GET /v1/erasures/preview/ervw_01HX_dsr_001/manifest

returns the line-by-line list — every event ID, every cross-scope reference, every belief that will be demoted. Legal signs off; the operator triggers the execution.

Execute

POST /v1/erasures
{
  "scope": "org:initech/dept:sales/team:enterprise/user:alice",
  "from_preview_id": "ervw_01HX_dsr_001",
  "audit_note": "DSR #2026-101 — Alice exercising right to erasure",
  "idempotency_key": "erasure-dsr-2026-101"
}

The job runs. The lifecycle stream shows it phase by phase:

event: erasure_progress
data: {"erasure_id":"erasure_01HX_dsr_001","phase":"enumerate","fraction_complete":0.12}

event: erasure_progress
data: {"erasure_id":"erasure_01HX_dsr_001","phase":"refcount","fraction_complete":0.45}

event: erasure_progress
data: {"erasure_id":"erasure_01HX_dsr_001","phase":"delete","fraction_complete":0.78,
       "deleted_events":860,"redacted_events":266}

event: erasure_complete
data: {"erasure_id":"erasure_01HX_dsr_001",
       "summary":{"deleted_events":982,"redacted_events":266,"demoted_beliefs":4},
       "audit_id":"audit_01HX_gdpr_001"}

When it's done:

This is what "hard delete with reference counting" looks like in practice. No soft-delete tombstones, no half-deleted records, no broken citations. Just a clean answer: deleted if unreferenced, redacted if not, with the system honest about which is which.


At scale

Most of this walkthrough has been about one person and one company. The same primitives carry up to thousands of users in an enterprise. A few notes on what scales how.

Scope hierarchy

Initech's scope tree, full version:

org:initech
├── dept:sales
│   ├── team:enterprise
│   │   ├── user:alice
│   │   └── user:bob
│   │   └── manager:sarah
│   ├── team:smb
│   │   └── user:carol
│   └── ...
├── dept:engineering
│   ├── team:platform
│   └── team:product
├── ws:initech-acme-account
├── ws:initech-q3-launch
└── ws:initech-customer-feedback

Reads can walk up the tree (with the right capabilities) to aggregate context across users, teams, and departments. Writes always go into a specific scope; you don't write "at" the org level by writing into multiple child scopes — you write into a workspace that the org consults.

The tree is operator-defined. Initech happens to use org/dept/team/user; another company might use org/region/team/user. CortexDB doesn't impose the shape; it imposes the grammar (slash-delimited type:id segments) and lets the hierarchy emerge from how scopes get registered.

Authorization

The four-tier policy stack (deployment → tenant → scope → actor) is the load-bearing primitive that lets the same software run on-premise for an enterprise and cloud-multi-tenant for SaaS without code changes. Each tier can narrow what the tier above allows; none can expand it.

In on-prem mode, the deployment policy says "everything is allowed." A tenant admin can lock things down further — say, deny audit.read.cross_actor so no rep can see another rep's audit log. A scope owner can lock further — say, restrict membership to a specific list. An actor's token can lock further still — say, scope a service account to read-only access on org:initech/dept:engineering/*.

In cloud-shared mode, the deployment policy denies cross-tenant capabilities entirely. No tenant configuration can re-enable them. The same code, the same API, different posture.

When a policy decision matters, the API tells you:

X-Cortex-Policy: tier=tenant; decision=allow; capability=scope.read.holistic

And when it denies, the response body says which tier and which capability — so an integrator can fix the right thing without guessing.

Audit

Every policy-gated call writes an audit row. The row contains the hashed request body (peppered per-tenant), the decision, the tier attribution, the capability, the response status, and the request ID. The bodies themselves are not stored — only their hashes.

This gives the operator two things:

  1. Tamper evidence. If someone asks "did Alice's agent submit a request at 3:42pm on May 13 with this body?", the operator can hash the body, look up the audit row, and confirm. Bodies can't be retroactively forged because the pepper rotates and old peppers are kept only for verification.
  2. Forensic trail. Every policy denial is auditable. Every successful write is auditable. Every recall and answer is auditable. The audit table is the system's compliance answer.

Importers and exporters

Customers don't migrate to CortexDB by hand. They import from wherever their memory currently lives:

SourceEndpoint
Mem0POST /v1/import/mem0
ZepPOST /v1/import/zep
LettaPOST /v1/import/letta
OpenAI memory exportsPOST /v1/import/openai
Generic JSONLPOST /v1/import/jsonl

Each importer maps the source system's records into CortexDB's five-layer model — Mem0's facts become our facts, Zep's bi-temporal edges preserve their valid_at/invalid_at as our valid_from/valid_to, Letta's archival passages land as imported events. The mapping tables are in the reference document; the point here is that migration is a one-API-call story.

Exports work symmetrically:

POST /v1/export
{
  "scope": "org:initech/...",
  "format": "jsonl" | "mem0" | "zep" | "letta",
  "destination": {"kind": "url", "url": "s3://bucket/..."}
}

Trust requires the door swings both ways. A database you cannot leave is a database that owns you. CortexDB is designed to be left.

Stability tiers

Not every part of this API is at the same level of maturity. The reference document classifies every endpoint as Stable, Beta, or Experimental:

Every response carries an X-Cortex-Stability header so SDKs can warn callers when they're using something unstable. Production code can opt-out of unstable surfaces entirely.

This is how we ship a complete API today without overpromising on every corner of it. The shape is committed; the maturity is honest.


What this gets you

To recap, in one paragraph: an AI agent's experience lands as a single envelope; CortexDB captures it losslessly, derives five layers from it asynchronously, exposes each layer as an addressable resource with bi-temporal history; recall returns a stratified pack with provenance; "why?" walks the trail back to events; forget is hard but proportional (events are history); GDPR is reference-counted (shared evidence survives); workspaces are scopes (the same primitive); authorization is four-tier and attributable; audit is tamper-evident; importers and exporters mean the database can be left; and stability tiers mean the API can be complete without pretending everything is equally mature.

The point of the layered model is not to be a clever taxonomy. The point is that every claim has a trail, and the trail is walkable from any angle. Ask the system what it thinks; ask it why; ask it what it thought last week; ask it to forget the conclusion without forgetting the evidence. These are the questions an AI memory system has to answer to be trustworthy. The five layers are the smallest shape that lets us answer all of them with the same API.


Where to go next

If you want...Read...
Every endpoint with full request/response shapesAPI_REFERENCE_V1.md
The normative JSON schemasdocs/schemas/ (drives SDK code generation)
The full capability catalog and deployment presetsAPI_REFERENCE_V1.md §5, §A
The error catalogAPI_REFERENCE_V1.md §16
The list of ambiguities still to resolve before StableAPI_REFERENCE_V1.md §18.2
Migration mapping tables from Mem0/Zep/LettaAPI_REFERENCE_V1.md §15
The implementation phasingAPI_REFERENCE_V1.md §19
The story you just readThis document

The schemas drive everything. If this document or the reference contradicts a schema, the schema wins. CI validates the examples in both prose documents against the schemas on every commit. The truth is in the contract.


This document is the third major revision after a four-way external review and tier classification pass. Earlier versions, alternate framings, and the full disambiguation history live in git log -- docs/API_DESIGN_V1.md.