CortexDB · docs

CortexDB API Reference v1

Accuracy note (2026-07-12). Parts of this reference have drifted from the live router (audit DISC-009): a few documented admin/erasure routes were never implemented, and several live routes (/v1/compose, /v1/recall/stream, /v1/experiences, concepts/claims/conflicts, /v1/scopes/list, /v1/health) are newer than this document. The authoritative route list is API_ROUTES.md (auto-generated from the router), and the machine-readable contract is served at GET /v1/openapi.json. Semantics described here remain correct for the routes that exist. view=local is an alias of view=granular.

Status: Draft for review Last updated: 2026-05-14 Owner: pmalik Scope: the precise external HTTP / SDK contract for CortexDB. This is the reference manual. Companion: API_DESIGN_V1.md is the narrative walkthrough — read that first if you are new to CortexDB. This document is for implementers and SDK authors who need exact shapes, capability mappings, and error codes. Normative source: the JSON schemas in docs/schemas/ are the authoritative contract. Where this document disagrees with a schema, the schema wins. Both documents are explanatory views into the schemas. Predecessors: docs/ARCHITECTURE_REMEMBER_INDEX_RECALL.md, docs/storage-engine-architecture.md, docs/investor_deck/cortexdb_investor_deck_explainer.md. Constraint: there are no production users on a prior API. We may break freely; we are not designing v2-compatible.


Table of Contents

  1. Glossary
  2. Executive Summary
  3. Design Philosophy
  4. Core Concepts
  5. Authentication & Identity
  6. Authorization Framework
  7. HTTP Conventions
  8. Layer Reference
  9. Endpoint Reference
  10. The Stratified Pack
  11. Streaming
  12. Forget Semantics
  13. Temporal Model
  14. Lifecycle Events Catalog
  15. Audit Log Schema
  16. Importer Mappings
  17. Error Catalog
  18. Compatibility Matrix
  19. Deferred Decisions
  20. Implementation Order
  21. Appendices

0. Glossary

TermDefinition
ActorThe principal performing an operation. Typed: user:, agent:, service:, system:.
Bi-temporalRecords carry two independent time axes: when the fact was true in the world (event time) and when CortexDB learned of it (ingest time).
CapabilityA discrete, named permission (e.g., forget.gdpr). Granted/denied by the four-tier policy stack.
CaptureThe synchronous write of an event to the WAL. The only synchronous step in the write path.
ConsolidateThe async stage where facts merge, beliefs revise, and understanding nodes synthesize.
Derived layerAny layer whose contents are computed from events. Facts, Beliefs, Episodes, Understanding are derived; Events are not.
Experience envelopeThe structured input payload to POST /v1/experience. Replaces messages in peer APIs.
Holistic viewA recall that traverses upward through the scope hierarchy.
LayerOne of the five memory tiers: Events, Episodes, Facts, Beliefs, Understanding.
MemoryA polymorphic noun used in marketing. Internally we say "record at layer X." Avoid in API design.
PackThe stratified output of POST /v1/recall: layers + context block + provenance + diagnostics.
RefcountThe number of cross-scope references pointing at an event. Decisive for GDPR.
ScopeA /-delimited path of type:id segments that names a memory partition. Workspaces are scopes too.
Stratified packSee Pack.
TokenA signed bearer credential carrying actor identity and a capability set.
WALWrite-Ahead Log. RocksDB-backed. The append-only source of truth.

1. Executive Summary

CortexDB exposes a layer-aware, scope-unified, bi-temporal memory API for AI agents. It differs from peer systems on nine points:

  1. Five addressable memory layers, not one flat memory pool. Clients can read Beliefs, Facts, Episodes, Events, and Understanding separately or as a stratified pack.
  2. Unified scope paths that subsume "workspaces" — there is one namespace primitive, hierarchical with opt-in registration and ACLs.
  3. Four-tier authorization (deployment → tenant → scope → actor) with capability-level attribution on every denial.
  4. Signed-token identity mandatory on every call; tokens can only narrow what policy already allows.
  5. Async-first writes with an SSE lifecycle stream; sync available via wait parameter.
  6. Bi-temporal everywhere, with as_of, valid_during, recorded_during, and natural-language temporal qualifiers across all layer reads.
  7. Reference-counted GDPR erasure — events are deleted when refcount drops to zero, otherwise PII is redacted while preserving cross-scope referential integrity.
  8. Streaming recall in both granular (per-layer-as-ready) and narrative (token-by-token) modes.
  9. Native importers for Mem0, Zep, Letta, OpenAI memory exports, and generic JSONL — in both strict_temporal and batch_throughput ordering modes.

The public surface comprises roughly 30 endpoints under /v1/ plus an SSE channel and a token verifier protocol.


2. Design Philosophy

2.1 Five non-negotiables

  1. Events are immutable. The WAL is the source of truth. Every derived layer is reproducible by replay. There is no API that mutates a stored event payload.
  2. Bi-temporal at the boundary. The API accepts and returns both valid_* and recorded_* timestamps on every layer record. Single-time-axis models are not interoperable with us.
  3. Async writes are normal. Captures return immediately. Clients that need read-after-write must opt in via wait.
  4. Policy is observable. Every denial cites the tier and the specific capability. No opaque 403s.
  5. Forget means forget, but proportionally. Default forget wipes derived layers and keeps events. GDPR forget is reference-counted: shared evidence survives without authorship.

2.2 What we explicitly reject

2.3 Inspirations and what we did differently

SourceBorrowedDid differently
Mem0Async writes; reference_date temporal anchorKept a graph; added layers; added bi-temporal; added GDPR refcount
ZepBi-temporal facts; synthesized context blockExtended bi-temporal to all layers; stratified pack with layers + context; added Belief layer
LettaShared blocks across agentsScope = ACL primitive; no separate "blocks" concept
CogneeECL pipeline; partial+honest contractReplaced ECL with our five-stage cycle; ECL bleeds implementation; ours bleeds purpose
LangMemTuple namespaces with template variablesString paths (operator-readable); hierarchical inheritance
Du et al. (arXiv:2505.00675)Six atomic operations as observableSurfaced as SSE lifecycle events, not separate verbs
Generative AgentsCitation pointers as explainabilityRequired in every recall response; the provenance block

2.4 Stability Tiers

The full conceptual surface ships at v1. To remove ambiguity for implementers and SDK consumers, every endpoint and capability is assigned a stability tier. The tier governs change semantics, not feature completeness:

TierChange policyResponse header
StableAdditive only. Breaking changes require a major version bump (none planned pre-1.0). Capability defaults frozen in deployment presets. SDKs commit to these.X-Cortex-Stability: stable
BetaMay break with one-release notice. Shapes tracked but not yet pinned. Capability defaults may shift between minor releases.X-Cortex-Stability: beta
ExperimentalMay break or be removed without notice. Behind CORTEX_ENABLE_EXPERIMENTAL=1 in cloud presets (always on for on_prem_enterprise and dev_local). Documented and shipped — but not load-bearing for early adopters.X-Cortex-Stability: experimental

2.4.1 Stable surface (SDK-committed)

Endpoint familyNotes
POST /v1/experience (incl. /bulk)Core write
POST /v1/recall (non-streaming + granular streaming)Core read
POST /v1/answer (non-streaming)Recall + LLM, with full provenance contract
POST /v1/forget (cascades: derived_only, redact_events)Non-GDPR forget
GET /v1/events, /v1/episodes, /v1/facts, /v1/facts/timelineLayer reads
GET /v1/scopes, POST /v1/scopes, PUT /v1/scopes/members, DELETE /v1/scopesScope management (query-param addressed; see §8.8)
GET /v1/lifecycle/stream (SSE), GET /v1/lifecycle/event/{lce_id}, GET /v1/lifecycleLifecycle
GET /v1/policy/effective, GET /v1/policy/deploymentPolicy introspection (read)
GET /v1/audit, GET /v1/audit/{audit_id}, POST /v1/audit/verifyAudit
POST /v1/blobs, GET /v1/blobs/{blob_id}, DELETE /v1/blobs/{blob_id}Blobs
POST /v1/auth/revokeToken revocation
GET /v1/admin/health, GET /v1/admin/metrics, GET /v1/admin/versionAdmin introspection

2.4.2 Beta surface (shape solid, semantics maturing)

Endpoint familyMaturity notes
GET /v1/beliefs, GET /v1/beliefs/whyConfidence/CI math may shift; stance enum may extend
GET /v1/understanding, GET /v1/understanding/{id}, GET /v1/understanding/{id}/related, GET /v1/understanding/coverageReturns _partial: true until full synthesizer ships
POST /v1/import/* (mem0, zep, letta, openai, jsonl)Mapping tables evolve with source-system versions
POST /v1/export, GET /v1/export/{export_id}Export formats track import
POST /v1/vocabularies, GET /v1/vocabularies (list)Vocabulary surface (register + list; no per-name GET/PUT in v1)
POST /v1/temporal/phrases, GET /v1/temporal/phrasesCustom temporal phrase registration

2.4.3 Experimental surface (documented, ship-with-flag)

Gated by CORTEX_ENABLE_EXPERIMENTAL=1 outside on_prem_enterprise and dev_local. Calls return X-Cortex-Stability: experimental plus X-Cortex-Experimental-Notice with the migration link when the surface settles.

SurfaceWhy experimental
POST /v1/erasures (reference-counted GDPR erasure), POST /v1/erasures/preview, GET /v1/erasures/{erasure_id}, POST /v1/erasures/{erasure_id}/cancelRefcount algorithm needs hardening + legal-review feedback
POST /v1/recall with view=narrative; POST /v1/answer with stream=true (token stream)Narrative streaming — token-level UX and shape may evolve
POST /v1/understanding/synthesizeSynthesizer not yet stable
forget.gdpr.cross_workspace capability and propagationCo-owned workspace propagation rules likely to refine
POST /v1/scopes/pruneCleanup automation
POST /v1/admin/compaction/dedup, POST /v1/admin/index-auditOperator surface beyond introspection

2.4.4 Per-response signal

Every response carries X-Cortex-Stability with the tier of the endpoint hit. SDKs surface a one-time deprecation warning when calling non-Stable endpoints unless the caller has explicitly opted in via client.allow_unstable(true) or equivalent.

2.4.5 Tier promotion criteria

FromToGate
Experimental → BetaTwo independent customer integrations exercising the surface for ≥30 days without semantic complaints.
Beta → StableOne full minor-release cycle with no breaking-change PRs touching the endpoint, plus an SDK type freeze.
Stable → (next major)Explicit X-Cortex-Deprecation header set for ≥90 days before removal.

3. Core Concepts

3.1 The Five Memory Layers

LayerPurposeAtomic unitMutabilityTime semantics
EventsLossless capture of experienceA WAL entryImmutableobserved_at (event time) + recorded_at (ingest time)
EpisodesBounded spans of related eventsA causal chain with start/endSealed once consolidatedstarted_at / ended_at; bi-temporal at boundaries
FactsTriple-shaped atemporal/temporal assertions(subject, predicate, object, validity)SupersedableFull bi-temporal: (valid_from, valid_to, recorded_from, recorded_to)
BeliefsProbabilistic claims about state of the worldA claim with confidence + supportsContinuously revisableBi-temporal + last_revised_at
UnderstandingSynthesized conceptual modelA concept node + edgesVersionedversion + valid_from; revisions chained

3.1.1 Why these five and not three

3.1.2 Layer derivation graph

                                      (synthesize)
Events  ──(window)──▶  Episodes  ──(extract)──▶  Facts  ──(rank+revise)──▶  Beliefs
   │                                                │                          │
   └──────────────────(group + abstract)────────────┴──────────────────────────┴──▶  Understanding

Every derived record carries a supports: [event_id, ...] field. Walking this is the "why" trail.

3.2 The Six Atomic Operations

Adopted from Du et al. (arXiv:2505.00675). They are not separate endpoints — they are observable lifecycle events that fire as the async view builder works.

OperationFires whenVisible via
CaptureWAL append succeedsevent: captured on SSE; wait=captured returns
IndexEvent indexed in BM25 + HNSWevent: indexed; wait=indexed returns
UpdateFact ADD/UPDATE/NOOP decisionevent: extracted and event: consolidated
ConsolidateBeliefs revised; Understanding nodes touchedevent: consolidated; wait=consolidated returns
ForgetRecords deletedevent: forgotten
CompressEpisodes sealed; Understanding versions bumpedevent: compressed

Note: Retrieve is not in this list — recall is request/response, not a lifecycle event.

3.3 Bi-temporal Model

Every record (except Events, which carry only the two timestamps below as singletons) has four time fields:

FieldMeaning
valid_fromFirst moment in the world when the claim is true
valid_toFirst moment when the claim ceases to be true (exclusive). null means open-ended.
recorded_fromFirst moment CortexDB learned the claim
recorded_toFirst moment CortexDB superseded its own record (exclusive). null means current.

Events have observed_at and recorded_at as singletons — events are atomic in time and never superseded.

Why both axes: the question "what did the system believe about Acme on April 15 when looked at from May 1?" requires recorded_from ≤ May 1 AND valid_from ≤ Apr 15 < valid_to. Single-axis systems can only answer one or the other.

as_of queries (see §12.3) pin both axes. By default as_of pins recorded_* to now and valid_* to the query time.

3.4 Scope Paths

A scope is a /-delimited path of type:id segments. The path is the namespace. There is no separate "workspace" concept.

3.4.1 Grammar

scope         := segment ("/" segment)*
segment       := type ":" id
type          := lowercase identifier, [a-z][a-z0-9_]*
id            := URL-safe identifier, [A-Za-z0-9_\-]+

Maximum 32 segments. Maximum 4096 characters total.

Reserved types (the system attaches special semantics):

TypeSemantics
orgTop-level tenant
deptDepartment / business unit
teamSmaller cross-functional unit
appApplication instance
userEnd-user owner
agentAgent identity
serviceService account
wsWorkspace (collaborative, non-hierarchical conventionally)
projectTime-bounded initiative
globalCross-tenant pool (gated; see §5)
systemReserved for internal CortexDB scopes

Any other type is user-defined and treated as opaque.

3.4.2 Hierarchy and inheritance

Path order is hierarchical: ancestors precede descendants. A read with view=holistic traverses upward; view=descend traverses downward (admin only).

view=local means exactly this scope — no traversal. This is the default.

3.4.3 Workspaces as scopes

A scope like ws:acme-q3-launch is structurally identical to org:acme/user:alice. It differs only in:

There is no API distinction. A scope is a scope.

3.4.4 Registration

A scope path exists as soon as anything is written to it. Registration is optional and grants:

Unregistered scopes are the "scratchpad" tier — any caller with the deployment-level write capability can use them. First write auto-registers with members=[caller] and role=owner (see §8.8.1 for the auto-register schema).

3.5 The Experience Envelope

The input to POST /v1/experience. It generalizes over conversation messages, tool calls, document ingest, observations, and feedback.

3.5.1 The three identities

The envelope carries up to three distinct identities. Collapsing them is the most common API design error in this space; we keep them explicit:

IdentitySourceMeaning
callerToken sub, verified by signatureThe authenticated principal making the API call. Always present. Used for policy, audit, and rate-limit attribution.
observed_actorEnvelope observed_actor fieldWho performed the experience being recorded. May differ from caller (delegated agent, import job, admin write). Defaults to caller.
subjectEnvelope subject fieldWho or what the memory is about. May differ from both (a coach observing two players; an importer ingesting customer messages). Defaults to observed_actor.

Examples of when these diverge:

3.5.2 Time fields

Two timestamps, with distinct ownership:

The previous spec used happened_at and required clients to supply recorded_at. Both are now retired. Use observed_at (event time) and let the server own recorded_at (ingest time).

3.5.3 Envelope shape

{
  "scope": "org:acme/dept:eng/user:alice",
  "observed_actor": {
    "id": "user:alice",
    "type": "user",
    "session": "run_abc"
  },
  "subject": {
    "id": "user:alice",
    "type": "user"
  },
  "modality": "conversation",
  "content": {
    "kind": "message",
    "role": "user",
    "text": "Acme moved to 200 seats and signed by 3:42pm",
    "media": []
  },
  "context": {
    "observed_at": "2026-05-13T15:42:00Z",
    "source_recorded_at": null,
    "location": null,
    "preceded_by": ["evt_01H..."],
    "intent": "deal_close",
    "labels": ["sales", "q3-launch"]
  },
  "directives": {
    "extract": ["facts", "entities", "beliefs"],
    "consolidate_into": "ws:acme-q3-launch",
    "confidence_floor": 0.5,
    "ttl_for_belief_layer": "P30D",
    "embed": "lazy"
  },
  "idempotency_key": "alice-msg-1747147320-001"
}
FieldRequiredTypeNotes
scopeyesscope pathMust pass policy for scope.write
observed_actornoobjectDefaults to the token's sub. When set, must be authorized by scope.write.on_behalf_of capability if it differs from caller.
observed_actor.sessionnostringUsed for run/session grouping
subjectnoobjectDefaults to observed_actor. Cross-subject writes require scope.write.about_other capability.
modalityyesenumOne of conversation, document, tool_result, observation, feedback, imported
content.kindyesenumOne of message, text, json, blob_ref, triple
content.textconditionalstringRequired for message and text
content.roleconditionalenumRequired for message: user | assistant | tool | system
content.medianoarrayBlob refs (uploaded via /v1/blobs) for multimodal
context.observed_atnoRFC3339Event (occurrence) time; defaults to server now() if omitted. Also accepted as a top-level observed_at alias.
context.source_recorded_atnoRFC3339Upstream ingest timestamp (imports only); server preserves alongside its own recorded_at
context.preceded_bynoarrayCausality hints (memory event IDs evt_...)
context.intentnostringFree-text intent tag
context.labelsnoarray of stringsThe app-metadata extension point. Arbitrary client tags; stored verbatim and filterable on GET /v1/events. The context object is otherwise strict — an unknown key (e.g. context.memory_owner) is rejected with 422 INVALID_BODY. Encode app metadata as labels ("owner:shrey"-style key:value strings work well).
directives.extractnoarrayOverride default extraction stages. "extract": [] is the raw-index-only control ("index and embed me, derive nothing"): the event stays fully searchable but produces no facts, beliefs, or — since v0.8.12 — concepts (reflection previously read raw events and could promote opted-out content, e.g. runtime/tool metadata, into durable personal concepts). Use it for documentation ingestion, tool output, and runtime metadata that should be findable but never become "memory about the person".
directives.consolidate_intonoscopeTrigger consolidation into another scope; requires scope.write on that scope as well
idempotency_keynostringClient-generated; 64 chars max; replays return same event_id. If omitted, the server generates one (so the write is NOT deduplicated — supply your own for retry safety).

The server adds the following to the stored event:

Server-assigned fieldSource
idUUID v7, prefixed evt_
callerFrom token sub
recorded_atServer wall clock at WAL append
wal_offsetMonotonic position
tenant_idFrom token aud

Modalities are open-set; unknown values are accepted with a 202 and stored verbatim, but only the listed values trigger structured extraction.

3.5.4 Multimodal content in v1

content.media[] accepts blob references for multimodal payloads, but v1 ships text-only embeddings (§18.2 item 18). The semantic vector space is computed from content.text only. Blob references are indexed by metadata (content-type, size, perceptual hash for images) and reachable via blob_id — but not embedded into the recall vector space. Multimodal embedding is a v1.x add-on; the envelope shape is forward-compatible for that addition (content.media[] already accepts additional indexing hints when they arrive).

3.6 ID Namespaces

Different resources use different ID prefixes to make every identifier self-describing in logs, audit trails, and client code. Mixing namespaces is an error — evt_ IDs are not interchangeable with lce_ IDs.

PrefixResourceFormatNotes
evt_Memory event (WAL entry)UUID v7Time-ordered, monotonic per node
ep_EpisodeUUID v7
fact_FactUUID v7
belief_BeliefUUID v7
concept_Understanding conceptUUID v7Stable across versions
lce_Lifecycle event (SSE event)UUID v7Distinct from memory events; SSE resume uses these
job_Generic async jobUUID v7Catch-all for jobs without a typed prefix
batch_Bulk write batchUUID v7
imp_Import jobUUID v7
exp_Export jobUUID v7
ervw_Erasure previewUUID v7
erasure_Erasure executionUUID v7
pack_Recall packUUID v760s TTL; referenceable by /v1/answer
audit_Audit log rowUUID v7
blob_Uploaded blobUUID v7
ent_Resolved entityUUID v7Tenant-scoped
concept_alias_Concept alias mappingUUID v7
req_Request correlation IDUUID v7Echoed in X-Cortex-Request-ID
tok_Token revocation list entryUUID v7Internal

Lifecycle event IDs. SSE event resume uses since_lifecycle_id=lce_..., NOT memory event IDs. A memory event (evt_) progresses through multiple lifecycle events (lce_) as it moves through Capture → Extract → Index → Consolidate → Compress. Older drafts used since=evt_... for SSE resume; this was ambiguous and is retired.


4. Authentication & Identity

4.1 Signed Tokens

Every request requires a signed bearer token. CortexDB issues PASETO v4 public (asymmetric, Ed25519-signed) via the reference issuer cortex-auth-ref. CortexDB accepts both PASETO v4 public and JWT (RS256, ES256) — the JWT acceptance path exists so customers can integrate their existing OIDC identity providers (Okta, Auth0, Keycloak, Azure AD) without minting separate CortexDB-specific tokens.

FormWhenNotes
PASETO v4 publicIssued by cortex-auth-ref (the reference issuer we ship). Used for self-hosted, internal service accounts, dev environments, the benchmark harness.Versioned protocol, no algorithm negotiation, signed-but-not-encrypted. The safer default.
JWT (RS256, ES256)Issued by an external OIDC provider on the deployment's iss allowlist. Used by enterprise integrations.Asymmetric only — HS256 and other symmetric algorithms are rejected to avoid algorithm confusion.
JWT (HS256, "none", etc.)Never.Explicitly disallowed.

The verifier inspects the token's wire format and routes to the appropriate validator. Both formats share the same claim schema (token_claims.schema.json).

Authorization: Bearer <token>
X-Cortex-Actor: agent:planner_v3

The X-Cortex-Actor header is an explicit assertion of actor identity, required to match the token's sub claim. Mismatch → 401 actor_mismatch. The header is required because some clients send tokens that contain multiple identities (e.g., admin-impersonating); we force one identity per call.

Rust library choices (recorded here so engineers do not re-litigate): PASETO uses pasetors; JWT uses jsonwebtoken with the RS256/ES256 algorithms only.

4.2 Claims

ClaimRequiredMeaning
issyesThe issuer of the token. Server has a configured allowlist.
subyesThe actor identity, in our type:id form.
audyesThe tenant binding, e.g. cortexdb:tenant:acme.
expyesExpiry as Unix timestamp. Max 24h from issue.
iatyesIssue time.
jtiyesUnique token ID (for revocation lists).
deploymentnoOptional hint; ignored if mismatched.
capsnoCapability narrowing (see §5.2). Token can only restrict, not expand.
scopesnoPath-prefixed capability scoping (e.g., scope.write:org:acme/*).

4.3 Validation order

  1. Signature verification (against issuer's public key)
  2. iss in deployment allowlist
  3. aud matches deployment's tenant binding
  4. exp > now, iat ≤ now + 60s
  5. jti not on revocation list
  6. X-Cortex-Actor equals sub
  7. Token caps intersected with policy stack to produce effective capabilities
  8. The specific capability for this endpoint must be in the effective set

Any failure ⇒ 401 with body {error, hint, retriable: false}.

4.4 Service accounts

For server-to-server calls (importer workers, scheduled jobs), sub is of form service:<name> and tokens may have longer exp (up to 30d). Service tokens cannot perform forget.gdpr (must come from a user-authenticated session).

4.5 Revocation

POST /v1/auth/revoke {jti} (operator-only) adds a jti to the revocation list. Lists are TTL-keyed by exp and auto-pruned. Maximum list size 1M; tenants with high revocation volume should issue shorter-lived tokens.

4.6 Token issuer

In production presets the cortexdb binary only VERIFIES tokens. Two issuance paths exist:

  1. Local development only: under the default dev_local preset with no CORTEX_API_KEY, the binary runs an in-process ephemeral PASETO minter (POST /v1/auth/signup / POST /v1/auth/tokens) so the zero-config quickstart works. Setting CORTEX_API_KEY disables it unless CORTEX_V1_MINTER_ENABLE=1; tokens do not survive restart unless CORTEX_V1_MINTER_KEY_DIR is set. This minter is not a production issuer.

For production, operators integrate one of:

  1. Their existing OIDC provider (Okta, Auth0, Keycloak, Azure AD, custom). The provider's JWKS endpoint is registered in deployment policy; tokens it issues are accepted automatically. This is the recommended path for any enterprise that already runs an IdP.
  2. cortex-auth-ref, our reference issuer, which mints PASETO v4 public tokens. Backed by Turso (cloud) or SQLite (self-hosted). Ships as a separate binary alongside cortexdb. Recommended for self-hosted deployments without an existing IdP, for service accounts (importer workers, benchmark harness, scheduled jobs), and for the dev environment.

The two are not mutually exclusive — a deployment can register one or more JWT issuers AND run cortex-auth-ref for service accounts in parallel. The four-tier policy treats them identically once verified.


5. Authorization Framework

5.1 The Four-Tier Stack

Deployment policy   ← operator, at server boot
   ↓ narrows
Tenant policy       ← tenant admin
   ↓ narrows
Scope policy        ← scope owner
   ↓ narrows
Actor policy        ← scope owner or self

A capability is allowed iff every tier allows it. A tier's silence on a capability defaults to its parent's value (inherit-up).

5.1.1 Monotonicity rule

No tier can grant a capability its parent denies. Token caps, when present, are further narrowed against the effective tier stack — they cannot expand.

5.1.2 Decision attribution

Every 403 must cite the tier that denied:

{
  "error": "policy_denied",
  "capability": "scope.create.global",
  "denied_by_tier": "deployment",
  "deployment_preset": "cloud_shared_saas",
  "remediation": "global scopes are not available on cloud-shared deployments"
}

5.2 Capability Catalog

Capabilities are dot-delimited and form a shallow hierarchy. Wildcards allowed in policy expressions (scope.read.*) but not in token caps.

CapabilityGates
scope.create.globalRegistering a global:* scope
scope.create.cross_tenantRegistering a scope path that crosses tenant boundaries
scope.writeWriting to any scope (default-allow at deployment)
scope.write.elevatedWriting to a scope above the actor's natural level (e.g., user writing to dept). "Natural level" = the deepest scope path where the actor is an explicit owner or writer member.
scope.write.on_behalf_ofSetting observed_actor ≠ caller in the envelope (delegated writes, importer flows)
scope.write.about_otherSetting subject ≠ observed_actor (admin corrections, coach-observes-player scenarios)
scope.create.customRegistering a scope whose type segment is not in the deployment's allowed_scope_types builtin set
scope.read.localReading exactly the named scope (default-allow)
scope.read.holisticWalking up the hierarchy on read
scope.read.descendWalking down (admin)
scope.read.cross_tenantCrossing tenant boundaries on read
understanding.readReading concept nodes
understanding.read.cross_scopePulling concepts across sibling scopes
understanding.synthesizeForcing consolidation (admin/debug)
forget.cascade.derived_onlyDefault forget mode
forget.cascade.redact_eventsRedact mode
forget.gdprTrue erasure including events when refcount = 0
forget.gdpr.cross_workspaceGDPR propagation into shared workspaces
import.from.mem0Run the Mem0 importer
import.from.zepRun the Zep importer
import.from.lettaRun the Letta importer
import.from.openaiRun the OpenAI memory importer
import.from.jsonlRun the generic JSONL importer
export.format.mem0Export to Mem0 format
export.format.zepExport to Zep format
export.format.jsonlExport to JSONL
lifecycle.subscribeOpen an SSE lifecycle stream
llm.invokeTrigger an LLM call (used by /v1/answer, narrative views)
diagnostics.readInclude diagnostics: summary or diagnostics: full in recall/answer responses (otherwise diagnostics are stripped)
audit.readQuery the audit log
audit.read.cross_actorQuery audit log for actors other than self
auth.revokeRevoke tokens (operator or self)
temporal.phrases.readList custom temporal phrases
temporal.phrases.writeRegister/delete custom temporal phrases
vocabulary.readList vocabularies
vocabulary.writeRegister/edit vocabularies
policy.administer.deploymentOperator only
policy.administer.tenantTenant admin
policy.administer.scopeScope owner
policy.administer.actorScope owner or self
blob.uploadUpload to /v1/blobs
blob.readRead blob contents
admin.flushForce WAL flush (operator)
admin.compactTrigger compaction (operator)
admin.healthRead full health diagnostics

The full enumeration with defaults per preset is in Appendix A.

5.3 Deployment Presets

Operators pick one preset at server boot via CORTEX_DEPLOYMENT_PRESET=<name> or a custom YAML.

5.3.1 on_prem_enterprise

preset: on_prem_enterprise
allow: ["*"]
deny: []
defaults:
  actor.require_signed: true
  scope.auto_register: true
  forget.cascade.default: derived_only
  audit.retention: "P1Y"

Everything is on. Customer is responsible for their own internal policy.

5.3.2 cloud_shared_saas

preset: cloud_shared_saas
allow:
  - "scope.create.org"
  - "scope.create.user"
  - "scope.create.agent"
  - "scope.create.ws"
  - "scope.write"
  - "scope.read.local"
  - "scope.read.holistic"
  - "scope.read.descend"
  - "understanding.read"
  - "understanding.synthesize"
  - "forget.cascade.derived_only"
  - "forget.cascade.redact_events"
  - "forget.gdpr"
  - "forget.gdpr.cross_workspace"
  - "import.*"
  - "export.*"
  - "lifecycle.subscribe"
  - "audit.read"
  - "blob.*"
  - "policy.administer.tenant"
  - "policy.administer.scope"
  - "policy.administer.actor"
deny:
  - "scope.create.global"
  - "scope.create.cross_tenant"
  - "scope.read.cross_tenant"
  - "understanding.read.cross_scope"
  - "audit.read.cross_actor"
defaults:
  actor.require_signed: true
  scope.auto_register: true
  forget.cascade.default: derived_only
  audit.retention: "P2Y"
  rate_limit.write: "100/s"
  rate_limit.read: "500/s"

5.3.3 cloud_private

preset: cloud_private
# Single-tenant cloud — global is "their private public"
allow: ["*"]
deny:
  - "scope.create.cross_tenant"
  - "scope.read.cross_tenant"
defaults:
  actor.require_signed: true
  scope.auto_register: true
  forget.cascade.default: derived_only
  audit.retention: "P3Y"

5.3.4 dev_local

preset: dev_local
allow: ["*"]
deny: []
defaults:
  actor.require_signed: false   # ONLY for local dev
  scope.auto_register: true
  forget.cascade.default: derived_only
  audit.retention: "P7D"

For make dev. The only preset where unsigned actors are allowed.

5.4 Policy Propagation

A policy mutation (PUT on any tier) takes effect immediately for new requests. In-flight requests complete under the policy snapshot they started with.

For SSE lifecycle streams:

5.5 Policy Endpoints

Policy introspection is read-only in v1. The deployment preset and per-tenant policy lists are set at boot from configuration, not over HTTP. Mutation endpoints (PUT) and the per-scope / per-actor policy tiers are deferred to a later phase and are not wired — see cortex-auth::policy for why this is not an authorization gap (scope access is enforced via scope-registry membership and token caps, not per-scope policy lists).

MethodPathCapPurpose
GET/v1/policy/deployment(read)Read deployment preset
GET/v1/policy/effective?actor=...&scope=...(read for self)The effective capability set after stacking

Effective policy response:

{
  "actor": "user:alice",
  "scope": "org:acme/dept:eng/user:alice",
  "deployment_preset": "cloud_shared_saas",
  "allowed": [
    "scope.write", "scope.read.local", "scope.read.holistic",
    "understanding.read", "forget.cascade.derived_only", "lifecycle.subscribe", "..."
  ],
  "denied": [
    {"capability": "scope.create.global", "denied_by_tier": "deployment"},
    {"capability": "scope.read.cross_tenant", "denied_by_tier": "deployment"}
  ],
  "rate_limits": {"write": "100/s", "read": "500/s"}
}

6. HTTP Conventions

6.1 URLs and versioning

6.2 Content types

6.3 Idempotency

Every write endpoint accepts an idempotency_key field in the body. A duplicate (same key + same body) replays the original result: the single-write response carries replayed_from_idempotency: true and each bulk item carries the same flag in its results[] entry. (There is no X-Cortex-Replay response header — the signal is in the body.)

6.4 Pagination

Cursor-based pagination on all list endpoints.

GET /v1/facts?scope=...&limit=50
→
{
  "items": [...],
  "next_cursor": "eyJpZCI6ImZhY3RfMDFIWC4uLiJ9",
  "has_more": true
}

GET /v1/facts?scope=...&limit=50&cursor=eyJpZCI6ImZhY3RfMDFIWC4uLiJ9

6.5 Rate limits

Per-actor token bucket. Limits set by deployment defaults, overridable per tenant.

Headers on every response:

X-RateLimit-Limit: 500
X-RateLimit-Remaining: 487
X-RateLimit-Reset: 1715750460

On limit hit: 429 Too Many Requests with Retry-After.

6.6 Errors

Standard envelope. All error codes are UPPER_SNAKE_CASE — there is one casing rule, applied everywhere. The redundant lower-snake error field used in older drafts is retired.

{
  "error_code": "POLICY_DENIED",
  "message": "Cannot create scope under global:* on this deployment.",
  "request_id": "req_01HX...",
  "details": {
    "capability": "scope.create.global",
    "denied_by_tier": "deployment"
  },
  "retriable": false,
  "documentation": "https://docs.cortexdb.ai/errors/POLICY_DENIED"
}

Normative shape: error_envelope.schema.json in Appendix D. Fields:

FieldTypeRequiredNotes
error_codeUPPER_SNAKE stringyesStable identifier; clients pattern-match on this
messagestringyesHuman-readable; may be localized in the future
request_idstringyesEchoes X-Cortex-Request-ID
detailsobjectnoError-specific structured fields
retriableboolyesWhether the same request may succeed later
documentationURLnoLink to error reference
HTTPWhen
400Validation error in request body
401Auth failure (signature, expiry, jti revoked, actor mismatch)
403Policy denial (capability missing in effective set)
404Resource not found and the caller has read access — otherwise return 403 to avoid info leak
409Conflict (idempotency-key reuse with different body; scope path collision on register)
410Resource deleted (forget) — only when policy allows revealing existence
422Semantic validation failure (e.g., invalid scope grammar, malformed timestamp)
413Body exceeds the payload limit (PAYLOAD_TOO_LARGE, see §6.10) — split via /v1/experience/bulk or upload via /v1/blobs
429Rate limit
500Server error
502Write accepted (WAL-durable) but indexing failed (INDEXING_FAILED, retriable: true) — re-send with the same idempotency key to re-process
503Service degraded (e.g., embedding service unavailable; write WAL still succeeds but extraction queued)

Validation failures (422 INVALID_BODY) carry field-level metadata in details: field_path (where in the payload serde stopped, e.g. items[3].context.observed_at), reason (the parser's own message), and schema (the /v1/schemas/... URL of the normative contract to validate against).

6.7 Request IDs

Every response includes X-Cortex-Request-ID. Clients may set their own via the same header on request; if absent, server generates a UUID v7. Older X-Request-ID header is accepted on input as a fallback for compatibility but is not echoed in responses.

6.8 Headers we set

HeaderMeaning
X-Cortex-Request-IDRequest correlation
X-Cortex-Policytier=...; decision=allow; capability=... (set on policy-gated endpoints)
X-Cortex-Replaytrue if response was served from idempotency cache
X-Cortex-DeprecationSet when calling a deprecated endpoint; value is the migration link

6.9 Headers we accept

HeaderMeaning
AuthorizationBearer token (required)
X-Cortex-ActorActor identity (must match sub)
X-Cortex-Idempotency-KeyAlternative to body field for GET-shaped idempotency
X-Cortex-Trace-ParentW3C tracecontext propagation

6.10 Payload limits

LimitDefaultOverride
Request body (any v1 endpoint)32 MiBCORTEX_MAX_BODY_BYTES
Bulk items per request1,000—
Connector content guard10 MBper-connector config

Exceeding the body limit returns a structured 413 PAYLOAD_TOO_LARGE with details.max_body_bytes and remediation pointers. Recommended chunking: normalize captured data into message/turn-level items (one experience per message, stable source IDs as idempotency keys) and batch them through POST /v1/experience/bulk rather than posting whole conversation snapshots — smaller items index faster, dedupe better, and retrieve more precisely. For files, upload bytes via POST /v1/blobs and reference them as blob_ref content.

For large single writes, prefer ?wait=captured (§8.1): the response returns at the durable accept point and indexing proceeds in the background, so write latency stays flat regardless of payload size.


7. Layer Reference

7.1 Events

The append-only WAL. Source of truth.

Record shape:

{
  "id": "evt_01HX...",
  "scope": "org:acme/dept:eng/user:alice",
  "actor": "agent:support_bot_v3",
  "modality": "conversation",
  "content": { /* exactly as submitted in the envelope */ },
  "context": {
    "observed_at": "2026-05-13T15:42:00Z",
    "recorded_at": "2026-05-13T15:42:09Z",
    "preceded_by": ["evt_..."],
    "intent": "deal_close",
    "labels": [...]
  },
  "derives": ["fact_01HX...", "belief_01HX...", "ep_01HX..."],
  "wal_offset": 134892,
  "_partial": false
}
FieldTypeNotes
idUUID v7Time-ordered
wal_offsetuint64Position in the WAL; monotonic per node
derivesarrayRecords derived from this event (back-pointer for "why" trail)

Lifecycle: Created on Capture. Never updated. Deleted only by forget.gdpr with refcount=0.

7.2 Episodes

A causal chain of events.

{
  "id": "ep_01HX...",
  "scope": "org:acme/dept:eng/user:alice",
  "name": "Acme deal Q3 close",
  "summary": "Multi-day negotiation culminating in 200-seat signature on May 13.",
  "events": ["evt_01HX...", "evt_01HX...", "..."],
  "started_at": "2026-05-08T...",
  "ended_at": "2026-05-13T15:42:00Z",
  "valid_from": "2026-05-08T...",
  "valid_to": null,
  "recorded_from": "2026-05-13T15:42:09Z",
  "recorded_to": null,
  "actors_involved": ["user:alice", "user:priya@acme"],
  "centrality_score": 0.78,
  "causal_chain": [
    {"event": "evt_...", "role": "trigger"},
    {"event": "evt_...", "role": "decision"},
    {"event": "evt_...", "role": "outcome"}
  ],
  "_partial": false
}

Lifecycle: Built by the episode builder as related events accumulate. Sealed at Compress stage; once sealed, episode's events[] is immutable. New events with related context produce a new episode that may link as causal_successor.

7.3 Facts

Triple-shaped, optionally temporal.

{
  "id": "fact_01HX...",
  "scope": "org:acme/dept:eng/user:alice",
  "subject": {"type": "entity", "id": "ent_acme_corp", "name": "Acme Corp"},
  "predicate": "moved_to_seat_count",
  "object": {"type": "literal", "datatype": "integer", "value": 200},
  "supports": ["evt_01HX..."],
  "valid_from": "2026-05-13T15:42:00Z",
  "valid_to": null,
  "recorded_from": "2026-05-13T15:42:09Z",
  "recorded_to": null,
  "confidence": 0.94,
  "extractor": "gpt-4o-mini",
  "superseded_by": null,
  "supersedes": "fact_01HW...",
  "_partial": false
}
FieldTypeNotes
subjecttyped referenceEntity or concept reference
predicatestringFree-text or vocabulary-bound (see §7.3.1)
objecttyped valueEntity / literal / concept
confidencefloat0..1; extractor-provided
superseded_by / supersedesfact_idThe conflict-resolution chain

Lifecycle: Created at Update (extract stage). Superseded by a newer fact via the conflict resolver. When superseded, the old fact gets recorded_to=now and superseded_by; it remains queryable via as_of.

7.3.1 Predicate vocabularies

Predicates are free-text by default. Tenants may register a controlled vocabulary:

POST /v1/vocabularies
{
  "name": "sales_v1",
  "predicates": [
    {"id": "deal_stage", "datatype": "string", "values": ["intro","poc","close","signed","lost"]},
    {"id": "seat_count", "datatype": "integer"},
    {"id": "contact_at", "datatype": "entity"}
  ]
}

When a vocabulary is active for a scope, the extractor maps free-text predicates to vocabulary terms (with confidence). Unmapped predicates remain free-text.

7.4 Beliefs

Probabilistic claims with explicit stance, calibration, and revision policy.

{
  "id": "belief_01HX...",
  "scope": "org:acme/dept:eng/user:alice",
  "claim": {
    "subject": {"type": "entity", "id": "ent_acme_corp"},
    "predicate": "is_likely_to_renew",
    "object": {"type": "literal", "datatype": "boolean", "value": true}
  },
  "stance": "supported",
  "confidence": 0.62,
  "confidence_interval": {"lower": 0.48, "upper": 0.74, "method": "beta_posterior", "level": 0.90},
  "calibration": {
    "model": "platt_v2",
    "brier_score_30d": 0.18,
    "samples": 47,
    "last_calibrated_at": "2026-05-13T00:00:00Z"
  },
  "supports": [
    {"type": "fact",    "id": "fact_01HX...", "weight": 0.34, "polarity": "supports"},
    {"type": "fact",    "id": "fact_01HX...", "weight": 0.28, "polarity": "supports"},
    {"type": "episode", "id": "ep_01HX...",   "weight": 0.22, "polarity": "supports"}
  ],
  "contradicts": [
    {"belief_id": "belief_01HW...", "tension": 0.41, "resolution": "newer_wins"}
  ],
  "revision_policy": {
    "trigger": "new_supporting_evidence_or_contradiction",
    "min_evidence_delta": 0.05,
    "cooldown": "PT5M",
    "max_revisions_per_hour": 12
  },
  "valid_from": "2026-05-13T15:42:09Z",
  "valid_to": null,
  "recorded_from": "2026-05-13T15:42:09Z",
  "recorded_to": null,
  "last_revised_at": "2026-05-13T15:42:09Z",
  "revision_count": 3,
  "supporting_evidence_redacted": false,
  "supporting_evidence_redacted_fraction": 0.0,
  "why_url": "/v1/beliefs/why?belief_id=belief_01HX...",
  "_partial": false
}
FieldTypeNotes
claimobjectTriple form: {subject, predicate, object}
stanceenumOne of supported, contradicted, uncertain, deprecated. See §7.4.1
confidencefloatPoint estimate, 0..1
confidence_intervalobject{lower, upper, method, level} — calibrated CI. Methods: beta_posterior, bootstrap, wilson
calibrationobjectModel + recent Brier / ECE score; updated by the calibration job
supportsarrayWeighted contributing records, each with explicit polarity (supports | against)
contradictsarrayOther beliefs in tension, with tension score and proposed resolution
revision_policyobjectPer-belief revision rules (overrides tenant defaults)
last_revised_atRFC3339
revision_countintTotal revisions since creation
supporting_evidence_redactedboolTrue if any support was GDPR-redacted (see §11.3)
supporting_evidence_redacted_fractionfloat0..1; fraction of supports[] redacted
why_urlstringPermalink to the explainability endpoint for this belief
_partialboolTrue when belief is in revision or evidence-redacted-decayed state
_partial_reasonenumrevision_in_progress, supporting_evidence_redacted, calibration_stale

7.4.1 Stance enum

StanceMeaning
supportedEvidence supports the claim; confidence reflects this.
contradictedEvidence has overturned the claim; confidence reflects how strongly. The belief is kept (not deleted) for explainability.
uncertainEvidence is mixed or insufficient; confidence near 0.5.
deprecatedThe claim's predicate vocabulary or subject identity has changed; the record is retained for as_of queries but is not surfaced by default reads.

Lifecycle: Created at Consolidate. Revised when revision_policy.trigger fires; revision bumps last_revised_at and revision_count. When supporting_evidence_redacted_fraction == 1.0, confidence decays per policy and stance moves to uncertain. If confidence < tenant.confidence_floor (default 0.3), the belief is deleted and surfaces as 410 RESOURCE_DELETED on direct ID lookup; list endpoints silently omit it.

7.5 Understanding

Synthesized conceptual nodes. Beta tier in v1.

7.5.1 Identity, versioning, and staleness

PropertyRule
Concept identityA concept_id is stable across versions. The id is assigned at first synthesis and never changes; only version increments.
Concept versioningVersions are immutable. Revision creates a new version row linked to previous_version. All prior versions queryable via as_of.
CoalescenceWhen two concepts are determined to refer to the same thing, the older one's id is canonical; the newer becomes an alias (canonical_id field) and is deprecated. No data is lost.
SplittingWhen a concept is determined to conflate two things, a new concept_id is created for the split-out half; the original's valid_to is set; both new concepts list the original as derived_from.
Relation vocabularyEdges use a tenant-scoped, vocabulary-bound relation field. Default relations: is_a, part_of, specializes, generalizes, depends_on, causes, contradicts, co_occurs_with, related_to. Custom relations via POST /v1/vocabularies (kind=relation).
Synthesis inputsEach concept declares the input layers it was synthesized from in synthesis_inputs. Re-synthesis re-derives from these inputs; as_of synthesis is supported.
StalenessA concept becomes stale when its synthesis_inputs have updates more recent than last_synthesized_at exceeding a threshold. Stale concepts retain _partial: false but carry staleness_score: 0..1 and staleness_reason.
Coverage scoreTenant-level signal: fraction of supporting facts/episodes within scope that were actually consulted during synthesis. Surfaced on GET /v1/understanding/coverage.
Invalidation on forgetIf supporting facts/beliefs are deleted, the concept does NOT vanish — it gains support_loss_fraction: 0..1. If support_loss_fraction > tenant.understanding.invalidation_threshold (default 0.5), stance moves to deprecated and the concept is hidden from default reads (still queryable via ?include_deprecated=true).

7.5.2 Shape

{
  "id": "concept_01HX...",
  "scope": "org:acme/dept:eng",
  "name": "B2B SaaS Sales Cycle",
  "canonical_id": null,
  "version": 3,
  "previous_version": "concept_01HW...",
  "derived_from": [],
  "summary": "Multi-stage process typically involving prospecting, qualification, POC, negotiation, signature, onboarding.",
  "summary_embedding": null,
  "synthesis_inputs": {
    "layers": ["facts", "episodes", "beliefs"],
    "filters": {"topic": "sales_process"},
    "as_of": "2026-05-13T00:00:00Z",
    "synthesizer": "claude-opus-4-6",
    "synthesizer_version": "2026-05-08"
  },
  "supported_by": {
    "facts":    ["fact_01HX...", "..."],
    "episodes": ["ep_01HX...", "..."],
    "beliefs":  ["belief_01HX...", "..."],
    "external": [
      {"source": "wikipedia", "id": "Sales_process", "url": "..."}
    ]
  },
  "edges": [
    {"to": "concept_01HX...", "relation": "specializes", "weight": 0.81, "vocabulary": "default"},
    {"to": "concept_01HX...", "relation": "depends_on",   "weight": 0.55, "vocabulary": "default"}
  ],
  "valid_from": "2026-04-01T00:00:00Z",
  "valid_to": null,
  "recorded_from": "2026-05-13T15:42:09Z",
  "recorded_to": null,
  "last_synthesized_at": "2026-05-13T15:42:09Z",
  "stance": "supported",
  "confidence": 0.78,
  "staleness_score": 0.0,
  "staleness_reason": null,
  "support_loss_fraction": 0.0,
  "coverage_score": 0.91,
  "_partial": true,
  "_partial_reason": "consolidation_in_progress",
  "_progress": 0.42
}
FieldTypeNotes
idconcept_idStable across versions
canonical_idconcept_id | nullSet when this concept has been merged into another; lookups follow the alias
versionintBumped on revision
previous_versionconcept_idLinks to the prior version
derived_fromarraySet when this concept resulted from splitting another
synthesis_inputsobjectLayers + filters + synthesizer used to produce this version
edgesarrayRelations to other concepts, vocabulary-bound
stanceenumsupported, uncertain, deprecated (same semantics as §7.4.1)
staleness_scorefloat0..1; fraction of input updates not yet incorporated
support_loss_fractionfloat0..1; fraction of supports forgotten since last synthesis
coverage_scorefloat0..1; fraction of in-scope inputs consulted during synthesis
_partialboolTrue until full synthesis lands
_partial_reasonenumconsolidation_in_progress, insufficient_supporting_facts, synthesis_disabled_by_policy, no_implementation_yet, stale_inputs_pending_resynthesis
_progressfloat0..1, when meaningful

Lifecycle: Created/updated at Compress. Versioning is append-only — old versions retained for as_of. Coalescence and splitting do not delete history. Re-synthesis is triggered by (a) explicit POST /v1/understanding/synthesize, (b) staleness threshold breach, or (c) support_loss threshold breach.


8. Endpoint Reference

8.1 POST /v1/experience — Write

Capability: scope.write (or scope.write.elevated if writing above the actor's natural level)

Request: Experience envelope (see §3.5).

Response (async, default):

HTTP/1.1 202 Accepted
Content-Type: application/json
X-Cortex-Policy: tier=scope; decision=allow; capability=scope.write
X-Cortex-Request-ID: req_01HX...
{
  "event_id": "evt_01HX...",
  "status": "captured",
  "wal_offset": 134892,
  "lifecycle_stream": "/v1/lifecycle/stream?event_id=evt_01HX..."
}

Response (sync via ?wait=indexed):

HTTP/1.1 200 OK
{
  "event_id": "evt_01HX...",
  "status": "indexed",
  "wal_offset": 134892,
  "stages_completed": ["captured", "extracted", "indexed"],
  "derives": ["fact_01HX...", "belief_01HX..."],
  "elapsed_ms": {"capture": 4, "extract": 410, "index": 88}
}

wait parameter values:

ValueReturns whenTypical latency
(omitted) or acceptedFully indexed (BM25 + HNSW) — read-your-writes: /v1/recall sees the event as soon as the 202 returnsscales with payload size
capturedDurable accept point — indexing continues in the background (the fast path for large payloads; poll §8.18 or the lifecycle stream for progress)~10ms, flat in payload size
indexedBM25 + HNSW insert confirmed~100–500ms
consolidatedBeliefs/Understanding touched~500–3000ms

Any other value is rejected with 422 INVALID_BODY; the error message lists the valid values. accepted is an alias for the omitted default.

The response body's status field is "captured" in all async-accept cases; the distinction is the barrier the connection was held for.

Errors:

HTTPCodeWhen
401ACTOR_MISMATCHHeader doesn't match token sub
403POLICY_DENIEDCapability missing
422INVALID_BODYBody validation failed; details.field_path, details.reason, details.schema populated
409IDEMPOTENCY_CONFLICTSame key, different body
413PAYLOAD_TOO_LARGEBody over the §6.10 limit
502INDEXING_FAILEDAccepted (WAL-durable) but indexing failed; retriable: true — re-send with the same idempotency key to re-process (the record was dropped, so it will NOT replay)
503wal_unavailableWAL not writable (rare; usually disk pressure)

8.2 POST /v1/experience/bulk — Bulk Write

Capability: scope.write

Request:

{
  "items": [
    {"scope": "org:acme/dept:eng/user:alice", "modality": "...", "content": {...}, "context": {...}, "idempotency_key": "..."},
    ...
  ],
  "ordering": "strict_temporal" | "batch_throughput"
}

Each item is a full experience envelope carrying its own scope — there is no batch-level scope/actor/directives (items in one batch may target different scopes). experiences is accepted as an alias for items.

Maximum 1000 items per call. Larger imports should use /v1/import/jsonl.

Response (async, 202; or 200 with ?wait=):

{
  "batch_id": "batch_01HX...",
  "accepted": 999,
  "event_ids": ["evt_...", "..."],
  "results": [
    {"index": 0, "event_id": "evt_...", "replayed_from_idempotency": false},
    {"index": 1, "event_id": "evt_...", "replayed_from_idempotency": true}
  ],
  "lifecycle_stream": "/v1/lifecycle/stream?batch_id=batch_01HX..."
}

accepted and event_ids count distinct persisted events: items deduplicated by a shared idempotency_key (within the batch, or against a prior write) store one event, repeat its event_id in the index-aligned results array, and carry replayed_from_idempotency: true (v0.7.9 report F2).

Status contract (v0.7.9 report F1):

Items with content.kind: "triple" write their fact deterministically (no LLM) on the direct-fact path, same as the single-write endpoint — since v0.8.7 this holds for bulk too (previously bulk stored the triple event but produced no fact unless an enrichment LLM was configured). The batch's triples are embedded in one provider call; if the embedder is down, the facts are still written (without vectors) rather than dropped.

?wait=captured on bulk (v0.8.11) is the raw-first/derive-later fast path: every item acks at its WAL-durable capture point and the batched mirror (embedding + indexing + triple facts) runs in the background under admission control. This is the recommended mode for large backfills — previously bulk always awaited the mirror, so a 25-row batch could block ~90s in embedding and clients hit their own timeouts while the server kept working. Poll GET /v1/experience/status?idempotency_key=... per item (or the lifecycle stream) for indexing progress; a background indexing failure frees the item's idempotency key so re-sending exactly that item re-processes it.

Per-item write status is also queryable by idempotency_key via GET /v1/experience/status?idempotency_key=<key> (§8.18).

8.3 POST /v1/recall — Read

Capability: scope.read.local (always); scope.read.holistic if view=holistic; scope.read.descend if view=descend; diagnostics.read if diagnostics ≠ "none".

Request:

{
  "scope": "org:acme/dept:eng/user:alice",
  "view": "holistic",
  "query": "What did we decide about the Q3 launch?",
  "include": ["beliefs", "facts", "episodes"],
  "exclude_content": false,
  "temporal": {
    "as_of": null,
    "valid_during": null,
    "natural": "last 30 days",
    "reference_date": null
  },
  "filters": {
    "metadata": {
      "observed_actor": ["user:alice", "agent:planner_v3"],
      "labels": ["decision"]
    }
  },
  "budgets": {
    "max_tokens": 4000,
    "per_layer_limits": {
      "events": 0,
      "episodes": 5,
      "facts": 20,
      "beliefs": 10,
      "understanding": 3
    }
  },
  "citation_mode": "inline_with_markers",
  "diagnostics": "summary",
  "stream": false,
  "idempotency_key": "recall-alice-q3-001"
}

Field reference:

FieldTypeNotes
includearrayReplaces older layers field; whitelist of layers to populate. Empty/absent ⇒ all per the view default.
exclude_contentboolWhen true, omits large content/summary strings; returns IDs + metadata only. Saves tokens when callers will re-hydrate selectively.
temporal.reference_dateRFC3339Anchor for parsing temporal.natural phrases (e.g., "last week"). Defaults to request time.
filtersobjectExactly one field: metadata, a map of attribute → string or list of strings (list = match ANY, i.e. OR). Accepted attributes: observed_actor, caller, modality, labels, intent. labels matches the EXACT stored label string (the values you passed in context.labels at write time — note the asymmetry: you store labels top-level in context.labels but filter them under filters.metadata.labels). Boolean operators (AND/OR trees) and comparison objects (gte etc.) are NOT accepted; there is no confidence filter; unknown fields or attributes are rejected with 422. Multiple attributes are ANDed; multiple values within one attribute are ORed.
budgets.max_tokensintCross-layer token budget for the whole pack. Spent by priority (events + included layers first, then facts > beliefs > episodes > understanding); lowest-priority items are evicted from the tail until the pack fits, and diagnostics.knapsack_evictions reports how many. A generous budget evicts nothing. context_block is bounded to the same budget and never truncated mid-item; per_layer_limits are applied first as hard caps. Set CORTEX_CROSS_LAYER_KNAPSACK=0 to restore the legacy behavior (budget bounds only context_block; derived layers item-capped, not token-counted).
budgets.per_layer_limitsmapExplicit caps per layer; overrides default view budget split
citation_modeenumnone, inline_with_markers, block_at_end, structured_only
diagnosticsenumnone (default for non-admin tokens), summary (timings, plan name), full (scoring contributions, policy attribution, model identifiers). Requires diagnostics.read capability for non-none.
streamboolSee §10.1
idempotency_keystringOptional; if present, repeated calls return the same pack_id and cached pack within 60s
orderenumrelevance (default) or recency. recency replaces ranked retrieval for the events layer with a timeline tail: the scope's newest eligible events by observed_at, capped by budgets.per_layer_limits.events (else CORTEX_RECENCY_TAIL_EVENTS, default 100), rendered NEWEST-FIRST in the context block and events layer (v0.8.7; /v1/answer also tells the model about the ordering, so clients need no ordering instructions). Eligibility (scope/tenant, filters, temporal) is identical to ranked mode — only selection changes. Built for "recent/latest X" questions, where relevance ranking returns the most-repeated items and drops the newest ones. /v1/answer infers it for recency-shaped questions (kill-switches: CORTEX_RECENCY_INFERENCE=0 for inference, CORTEX_RECENCY_ORDER=0 for the mode itself).

Response (non-streaming): The stratified pack (see §9), including a pack_id that can be referenced by /v1/answer for follow-up.

Response (streaming, stream=true): SSE (see §10.1).

8.4 POST /v1/answer — Recall + LLM

Capability: scope.read.local + any traversal caps + llm.invoke + diagnostics.read if diagnostics ≠ "none".

/v1/answer shares the recall provenance contract: the same pack_id, provenance, and citations blocks appear in the response. It is recall composed with an LLM call, not a separate opaque endpoint.

{
  "scope": "org:acme/dept:eng/user:alice",
  "view": "holistic",
  "question": "Did Acme renew?",
  "answer_model": "claude-opus-4-6",
  "answer_max_tokens": 1500,
  "include": ["beliefs", "facts", "episodes"],
  "exclude_content": false,
  "temporal": {"as_of": null, "natural": "last 30 days"},
  "filters": {},
  "budgets": {"max_tokens": 4000, "per_layer_limits": {"facts": 20, "beliefs": 10}},
  "citation_mode": "inline_with_markers",
  "diagnostics": "summary",
  "use_pack_id": null,
  "stream": false
}
FieldTypeNotes
answer_modelstringLLM identifier (provider-routed)
answer_max_tokensintOutput ceiling
use_pack_idstring | nullSkip recall and use a previously generated pack (within 60s TTL). When set, recall-related fields are ignored.
orderenumrelevance (default) | recency — forwarded to the recall (§8.3). When absent, recency-shaped questions ("recent…", "latest…", "currently…", "next best step…") infer recency automatically; an explicit value always wins. Kill-switch for the inference: CORTEX_RECENCY_INFERENCE=0.
budgetsobjectForwarded to the recall (§8.3). per_layer_limits.events caps the evidence set — and the order=recency tail size. (v0.8.6: this documented field was previously dropped on deserialization.)
includearrayLayer whitelist forwarded to the recall — binds the answer's derived-layer backfills too, so include: ["events"] + a modality filter is airtight. (v0.8.6: previously dropped on deserialization; backfills previously bypassed it.)
(other fields)Same semantics as /v1/recall

Response:

{
  "pack_id": "pack_01HX...",
  "answer": "Acme upgraded to 200 seats on May 13. They did not renew their previous contract; they signed a new one. [1][2]",
  "citations": [
    {"marker": "[1]", "layer": "fact",  "id": "fact_01HX...", "support_strength": 0.91},
    {"marker": "[2]", "layer": "event", "id": "evt_01HX...", "support_strength": 0.87}
  ],
  "provenance": {
    "trail": [...],
    "citations": {...}
  },
  "diagnostics": {
    "recall_ms": 412,
    "llm_ms": 814,
    "total_ms": 1240,
    "pack_used": {"facts": 5, "beliefs": 2, "episodes": 1},
    "answer_model": "claude-opus-4-6",
    "answer_tokens": {"prompt": 1284, "completion": 218}
  },
  "as_of": "2026-05-13T15:42:09Z"
}

provenance and citations have the same shape as in /v1/recall (§9). as_of reflects the effective temporal anchor used.

Streaming variant: token-by-token answer, followed by pack_id, citations, provenance, diagnostics, and as_of (§10.2).

8.5 POST /v1/forget — Selective Forget

Capability: forget.cascade.<mode> matching the requested cascade.

Note: Earlier drafts used DELETE /v1/forget with a JSON body. We use POST instead because (a) DELETE with bodies is unreliable across HTTP clients/proxies, (b) forget is a job that may be long-running, and (c) it composes uniformly with the /v1/erasures job family for true erasure (§8.6).

{
  "scope": "org:acme/dept:eng/user:alice",
  "layers": ["beliefs", "facts"],
  "selector": {
    "about_subject": "user:alice",
    "about_entity": "ent_acme_corp",
    "predicate": "is_likely_to_renew",
    "memory_ids": [],
    "valid_during": null,
    "recorded_during": null
  },
  "cascade": "derived_only",
  "confirm_all": false,
  "audit_note": "User retracted speculation",
  "idempotency_key": "forget-alice-001"
}

Response:

{
  "deleted": {
    "events": 0,
    "episodes": 0,
    "facts": 2,
    "beliefs": 1,
    "understanding": 0
  },
  "audit_id": "audit_01HX..."
}

Cascade modes:

ModeCapabilityBehavior
derived_only (default)forget.cascade.derived_onlyDeletes from named layers; events untouched
redact_eventsforget.cascade.redact_eventsBlanks event payloads; keeps id and wal_offset

events_too is NOT a cascade value here — true erasure of events goes through /v1/erasures (§8.6).

confirm_all flag. When the selector is empty (or only layers is set), the server requires confirm_all: true to proceed — otherwise it 422s with EMPTY_SELECTOR_WITHOUT_CONFIRMATION. This is the foot-gun guard for "forget everything in this scope."

8.6 /v1/erasures — Reference-Counted GDPR Erasure

This family replaces the prior DELETE /v1/forget/gdpr endpoint. It is the only path to true event deletion. It exposes preview, manifest, status, and cancel — long-running jobs deserve a proper lifecycle.

8.6.1 POST /v1/erasures/preview — Dry-run

Capability: forget.gdpr (read-only preview is gated by the same cap as the destructive action, so callers can't enumerate without authorization).

{
  "scope": "org:acme/user:alice",
  "audit_note": "DSR #1234 — preview"
}

Response:

{
  "preview_id": "ervw_01HX...",
  "scope": "org:acme/user:alice",
  "estimated_affected": {
    "events": 9420,
    "episodes": 318,
    "facts": 4120,
    "beliefs": 318,
    "understanding": 12
  },
  "refcount_breakdown": {
    "in_scope_events":       12480,
    "events_to_delete":       9420,
    "events_to_redact":       3060,
    "events_under_legal_hold":   0
  },
  "estimated_duration_ms": 240000
}

estimated_affected.events is the headline delete count; refcount_breakdown carries the full in-scope tally (in_scope_events) and the delete-vs-redact split. There is no separate manifest endpoint or manifest_url in v1 — the plan above IS the preview. Execute returns the running job's manifest_url, which is its status resource GET /v1/erasures/{erasure_id} (§8.6.4), not a distinct manifest route.

8.6.3 POST /v1/erasures — Execute

Capability: forget.gdpr. Cross-workspace propagation additionally requires forget.gdpr.cross_workspace.

{
  "scope": "org:acme/user:alice",
  "from_preview_id": "ervw_01HX...",
  "audit_note": "DSR #1234 — Alice exercised right to erasure",
  "idempotency_key": "erasure-dsr-1234"
}

If from_preview_id is set and not expired, the server validates the manifest is still current (no new events under the scope since the preview); if drift exceeds a tenant-configurable threshold, the request 409s and a fresh preview is required. If from_preview_id is omitted, the server runs a preview inline before executing.

Response (always async, 202):

{
  "erasure_id": "erasure_01HX...",
  "status": "running",
  "manifest_url": "/v1/erasures/erasure_01HX...",
  "lifecycle_stream": "/v1/lifecycle/stream?erasure_id=erasure_01HX..."
}

8.6.4 GET /v1/erasures/{erasure_id} — Status

{
  "erasure_id": "erasure_01HX...",
  "status": "running",
  "phase": "delete",
  "fraction_complete": 0.78,
  "progress": {
    "deleted_events": 9420,
    "redacted_events": 3060,
    "demoted_beliefs": 18,
    "elapsed_ms": 180000
  },
  "audit_id": null
}

When status: "completed", the audit_id field is populated.

8.6.5 POST /v1/erasures/{erasure_id}/cancel

Best-effort cancellation. The server stops at the next phase boundary; already-deleted events cannot be restored (their WAL slots are gone). The response indicates what was completed before cancellation took effect.

{
  "erasure_id": "erasure_01HX...",
  "cancellation_accepted": true,
  "deleted_events_before_cancel": 4280,
  "audit_id": "audit_01HX..."
}

8.6.6 Lifecycle events emitted

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

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

event: erasure_progress
data: {"erasure_id": "...", "phase": "delete",    "fraction_complete": 0.78,
       "deleted_events": 9420, "redacted_events": 3060}

event: erasure_complete
data: {"erasure_id": "...", "summary": {...}, "audit_id": "audit_01HX..."}

Detailed semantics in §11.3.

8.7 Layer-Scoped Reads

8.7.1 GET /v1/events

Capability: scope.read.local + traversal as appropriate. Default view is local — see §18.2 item 14.

GET /v1/events
  ?scope=org:acme/dept:eng/user:alice
  &view=local
  &since=2026-04-01T00:00:00Z
  &observed_actor=user:alice
  &modality=conversation
  &limit=50
  &cursor=...

Pass view=holistic explicitly to walk up the scope hierarchy.

Response:

{
  "items": [ /* Event records (see §7.1) */ ],
  "next_cursor": "...",
  "has_more": true
}

8.7.2 GET /v1/episodes

GET /v1/episodes?scope=...&view=...&actor=...&overlapping=2026-04-01..2026-05-01&with_causal_chain=true&limit=50&cursor=...

8.7.3 GET /v1/facts

Default view is local — pass view=holistic explicitly for hierarchy traversal.

GET /v1/facts
  ?scope=...
  &view=local
  &subject=ent_acme_corp
  &predicate=deal_stage
  &as_of=2026-04-15T00:00:00Z
  &min_confidence=0.6
  &include_superseded=false
  &include_vectors=false
  &limit=50

as_of × include_superseded interaction (§18.2 item 1): both filters apply independently and conjunctively. as_of clips both axes (recorded_* ≤ as_of, valid_from ≤ as_of < valid_to). include_superseded=true returns superseded historical versions whose recorded_to ≤ as_of.

include_vectors (v0.8.11, also on GET /v1/beliefs and GET /v1/understanding[/{id}]): embedding vectors (summary_embedding) are omitted by default — two operator reports flagged hundreds of floats per record making listings unreadable for inspection. Pass include_vectors=true to opt back in.

8.7.4 GET /v1/facts/timeline

Returns the supersession chain for (subject, predicate):

{
  "subject": {"id": "ent_acme_corp", "name": "Acme Corp"},
  "predicate": "deal_stage",
  "timeline": [
    {"fact_id": "fact_01HV...", "value": "poc",    "valid_from": "...", "valid_to": "2026-04-10T..."},
    {"fact_id": "fact_01HW...", "value": "close",  "valid_from": "2026-04-10T...", "valid_to": "2026-05-13T..."},
    {"fact_id": "fact_01HX...", "value": "signed", "valid_from": "2026-05-13T...", "valid_to": null}
  ]
}

8.7.5 GET /v1/beliefs

GET /v1/beliefs?scope=...&about=ent_acme_corp&min_confidence=0.6&as_of=...&limit=50

8.7.6 GET /v1/beliefs/why

The "why do you think that?" endpoint. Unique to CortexDB.

GET /v1/beliefs/why?belief_id=belief_01HX...

Response:

{
  "belief": { /* belief record (§7.4) */ },
  "support_graph": {
    "nodes": [
      {"id": "fact_01HX...",  "type": "fact",   "weight": 0.34, "summary": "..."},
      {"id": "ep_01HX...",    "type": "episode","weight": 0.22, "summary": "..."},
      {"id": "evt_01HX...",   "type": "event",  "summary": "..."},
      {"id": "evt_01HX...",   "type": "event",  "summary": "..."}
    ],
    "edges": [
      {"from": "fact_01HX...", "to": "evt_01HX...", "relation": "extracted_from"},
      {"from": "fact_01HX...", "to": "evt_01HX...", "relation": "extracted_from"},
      {"from": "ep_01HX...",   "to": "evt_01HX...", "relation": "contains"},
      {"from": "belief_01HX...","to": "fact_01HX...", "relation": "supported_by"}
    ]
  },
  "narrative": "I think Acme is likely to renew because on May 13 they upgraded to 200 seats (fact_01HX), and this followed a positive POC episode (ep_01HX) in April. The signal weight is 0.62 with CI [0.48, 0.74].",
  "narrative_model": "claude-haiku-4-5"
}

8.7.7 GET /v1/understanding

GET /v1/understanding?scope=...&topic=B2B+SaaS&depth=2&limit=50

8.7.8 GET /v1/understanding/{concept_id}

Returns full concept record (§7.5).

8.7.9 GET /v1/understanding/{concept_id}/related

GET /v1/understanding/{concept_id}/related?relation=specializes&depth=2&limit=50

8.7.10 GET /v1/understanding/coverage

GET /v1/understanding/coverage?scope=org:acme/dept:eng

Response:

{
  "scope": "org:acme/dept:eng",
  "concept_count": 421,
  "by_topic": [
    {"topic": "sales_process",   "concepts": 38, "avg_confidence": 0.74},
    {"topic": "product_features","concepts": 92, "avg_confidence": 0.81},
    {"topic": "customer_personas","concepts": 24, "avg_confidence": 0.62}
  ],
  "synthesis_lag": "P2D",
  "_partial": true
}

8.7.11 POST /v1/understanding/synthesize

Capability: understanding.synthesize Async (audit FRI-10) — returns 202 immediately with a job id; the synthesizer runs in the background.

{
  "scope": "org:acme/dept:eng",
  "topics": ["sales_process"],
  "force": false
}

Response: 202 + lifecycle stream URL.

{
  "job_id": "synth_019e...",
  "status": "running",
  "lifecycle_stream": "/v1/lifecycle/stream?job_id=synth_019e..."
}

Poll pattern:

job = client.synthesize(scope=SCOPE)
# Subscribe to the SSE stream or poll until status == "complete":
#   GET {job['lifecycle_stream']}
# Then read derived concepts:
summary = client.understanding(scope=SCOPE)

Model + cost note (audit POL-1): the synthesizer renders concepts with Claude Opus 4.6, the most capable model for long-context reasoning. That's the differentiator — and the cost driver. Background concept reflection triggers per scope after ≥25 new writes or ≥1h staleness (CORTEX_V1_LAYERS_CONCEPT_MIN_WRITES / _MAX_AGE_SECS; the legacy CORTEX_V1_LAYERS_CONCEPT_EVERY tick-parity override still throttles globally when set), so frequent synthesize() calls on busy scopes can add up on usage-based plans. With enrichment (CORTEX_ENRICHMENT_DELAY_SECONDS) unset, background reflection is gated off entirely unless CORTEX_V1_LAYERS_AUTO=1 opts in — manual synthesize() calls are never gated.

8.8 Scopes

8.8.1 POST /v1/scopes — Register

Capability: policy.administer.scope for the path being created.

{
  "path": "ws:acme-q3-launch",
  "members": [
    {"actor": "user:alice",       "role": "owner"},
    {"actor": "agent:planner_v3", "role": "writer"},
    {"actor": "agent:summarizer", "role": "reader"}
  ],
  "policies": {
    "consolidation": "merge_compatible_beliefs",
    "conflict_resolution": "latest_wins_within_confidence_band",
    "inherit_from": ["org:acme"],
    "audit": "full",
    "children": {"auto_register": true}
  }
}

Response: 201 Created with the canonical scope record.

8.8.2 GET /v1/scopes — Lookup by path

Routing note: scope paths contain /, so we cannot put them in URL path segments without ambiguity. Scope lookup, member edit, and delete all use the query parameter ?path=... form. SDKs canonicalize and percent-encode the value.

GET /v1/scopes?path=org:acme%2Fdept:eng%2Fuser:alice

Returns the scope record including auto-provisioned status. 404 if the path has never been written to.

8.8.3 PUT /v1/scopes/members?path=...

Atomic member edit. Full member list submitted; server computes diff.

PUT /v1/scopes/members?path=ws:acme-q3-launch
{
  "members": [
    {"actor": "user:alice",       "role": "owner"},
    {"actor": "agent:planner_v3", "role": "writer"},
    {"actor": "agent:summarizer", "role": "reader"}
  ]
}

8.8.4 DELETE /v1/scopes?path=...

Capability: policy.administer.scope AND forget.gdpr if records=forget and any records exist.

Deletes the scope's policy and (configurably) its records. Two modes via query param:

8.8.5 GET /v1/scopes — List

GET /v1/scopes?prefix=org:acme/&auto_provisioned=false&limit=50

8.8.6 POST /v1/scopes/prune — Cleanup

Capability: policy.administer.tenant

Removes auto-provisioned scopes that have had no writes in N days and no explicit owner action.

{
  "older_than": "P90D",
  "dry_run": true
}

Response:

{
  "candidates": [
    {"path": "org:acme/.../user:bob/temp:debug",
     "last_write": "2026-02-01T...",
     "records": {"events": 4, "facts": 0}}
  ],
  "total": 73
}

8.9 Lifecycle

8.9.1 GET /v1/lifecycle/stream — SSE

Capability: lifecycle.subscribe

GET /v1/lifecycle/stream?scope=org:acme/user:alice&since_lifecycle_id=lce_01HX...
Accept: text/event-stream

The since_lifecycle_id parameter takes a lifecycle event ID (lce_...), not a memory event ID (evt_...). Server keeps a 1h replay buffer keyed by lifecycle event arrival time.

Filter to a specific memory event via ?event_id=evt_01HX... (subscribe only to the lifecycle stages of one event). Filter to a job via ?job_id= or one of ?import_id=, ?export_id=, ?erasure_id=, ?batch_id=.

Filter to event types via ?events=captured,consolidated (subscribe to a subset; full catalog §13).

Response: SSE stream. See §10 and §13.

8.9.2 GET /v1/lifecycle/event/{lce_id} — Poll one lifecycle event

Returns one lifecycle event row:

{
  "lifecycle_id": "lce_01HX...",
  "event_id": "evt_01HX...",
  "stage": "extracted",
  "ts": "2026-05-13T15:42:09.418Z",
  "payload": {"derived": {"facts": 3, "entities": 2, "beliefs": 1}}
}

8.9.3 GET /v1/lifecycle/memory-event/{evt_id} — Aggregated view

Returns the current aggregated state of a memory event (which stages are complete, which are pending):

{
  "event_id": "evt_01HX...",
  "stages_completed": ["captured", "extracted", "indexed"],
  "stages_pending":   ["consolidated"],
  "lifecycle_event_ids": ["lce_01HX...", "lce_01HX...", "lce_01HX..."],
  "derives": ["fact_01HX...", "belief_01HX..."],
  "errors": []
}

8.9.4 GET /v1/lifecycle — Catch-up paginated

GET /v1/lifecycle?scope=...&since_lifecycle_id=lce_01HX...&limit=100&cursor=...

For clients that can't hold SSE connections. Returns lifecycle event rows in lce_ order.

8.9.5 POST /v1/lifecycle/memory-event/{evt_id}/cancel

Capability: scope.write on the event's home scope; the caller must also be the event's caller OR have scope.administer on the home scope.

Cancels queued lifecycle stages (extracted, indexed, consolidated, compressed) for a memory event that has already been captured. The WAL capture itself is not reversible — the event remains in the WAL and remains directly addressable via GET /v1/events/{evt_id} — but the event is flagged excluded_from_recall: true and will not appear in /v1/recall, /v1/answer, or layer-scoped list endpoints.

Use cases: client submitted by accident; client realized post-submit that the content shouldn't be derived from; client wants to retry with corrected content.

Request:

{
  "audit_note": "Caller cancelled (incorrect content); will resubmit"
}

Response:

{
  "event_id": "evt_01HX...",
  "stages_cancelled": ["extracted", "indexed", "consolidated"],
  "stages_already_completed": ["captured"],
  "excluded_from_recall": true,
  "audit_id": "audit_01HX..."
}

If any non-captured stage had already completed before the cancel arrived, the derived records produced by that stage are also marked excluded_from_recall: true (they are not deleted — to delete, the caller follows up with a POST /v1/forget).

8.10 Audit

8.10.1 GET /v1/audit

Capability: audit.read. audit.read.cross_actor additionally required to query for actors other than self.

GET /v1/audit
  ?scope=org:acme/...
  &actor=agent:planner_v3
  &capability=scope.write
  &decision=deny
  &since=2026-05-01T00:00:00Z
  &limit=100

Returns audit log rows (§14).

8.10.2 GET /v1/audit/{audit_id}

Single row by ID.

8.10.3 POST /v1/audit/verify

Tamper-evidence check. Submit a request body; server hashes it and confirms whether it matches an audit row.

{
  "audit_id": "audit_01HX...",
  "body": "{ ... raw JSON of the original request ... }"
}

Response:

{
  "match": true,
  "hashed_at": "2026-05-13T15:42:09Z",
  "algorithm": "sha256",
  "tenant_pepper_version": 3
}

8.11 Import

8.11.1 POST /v1/import/mem0

Capability: import.from.mem0

{
  "scope_template": "org:acme/user:{user_id}",
  "source": {
    "format": "mem0_export_v3",
    "url": "s3://bucket/mem0-export.jsonl.gz",
    "inline": null,
    "credentials_ref": "secret:s3_reader_v1"
  },
  "options": {
    "ordering": "strict_temporal",
    "preserve_timestamps": true,
    "map_categories_to": "understanding",
    "extract": ["facts", "entities", "beliefs"],
    "embed": "lazy",
    "skip_on_conflict": false
  }
}

Response:

{
  "import_id": "imp_01HX...",
  "status": "running",
  "estimated_items": 12480,
  "lifecycle_stream": "/v1/lifecycle/stream?import_id=imp_01HX..."
}

Mapping table in §15.1.

8.11.2 POST /v1/import/zep

Capability: import.from.zep

Same shape; Zep-specific options:

{
  "options": {
    "preserve_bi_temporal": true,
    "ontology_mapping": {
      "PreferenceEdge": "user_preference",
      "PurchaseEdge": "purchase"
    }
  }
}

Mapping in §15.2.

8.11.3 POST /v1/import/letta

Capability: import.from.letta

{
  "scope_template": "agent:{agent_name}",
  "source": {"format": "letta_af", "url": "..."},
  "options": {
    "map_core_blocks_to": "understanding",
    "map_archival_to": "events"
  }
}

Mapping in §15.3.

8.11.4 POST /v1/import/openai

Capability: import.from.openai

For ChatGPT memory exports (JSON dump format).

8.11.5 POST /v1/import/jsonl

Capability: import.from.jsonl

Generic line-delimited envelope JSON. Each line is a full experience envelope.

8.11.6 GET /v1/import/{import_id}

Status poll.

{
  "import_id": "imp_01HX...",
  "status": "running",
  "fraction_complete": 0.42,
  "processed": 5240,
  "total": 12480,
  "errors": [
    {"line": 1208, "error": "invalid_envelope", "detail": "missing scope"}
  ]
}

8.11.7 POST /v1/import/{import_id}/cancel

8.12 Export

8.12.1 POST /v1/export

Capability: export.format.<target> matching requested format.

{
  "scope": "org:acme/user:alice",
  "format": "jsonl" | "mem0" | "zep" | "letta",
  "layers": ["events", "facts", "beliefs"],
  "as_of": null,
  "destination": {
    "kind": "url",
    "url": "s3://bucket/export.jsonl.gz",
    "credentials_ref": "secret:s3_writer_v1"
  }
}

Response:

{
  "export_id": "exp_01HX...",
  "estimated_size_bytes": 4823648,
  "lifecycle_stream": "/v1/lifecycle/stream?export_id=exp_01HX..."
}

8.12.2 GET /v1/export/{export_id}

Status poll.

8.13 Blobs

For multimodal content.

8.13.1 POST /v1/blobs — Upload

Capability: blob.upload

The request body is the raw file bytes; Content-Type declares the file's MIME type (e.g. application/pdf). Do not wrap in multipart/form-data or JSON — those are rejected with 415 unsupported_media_type. Maximum 100MB per blob by default.

curl -X POST https://api-v1.cortexdb.ai/v1/blobs \n  -H "Authorization: Bearer $CORTEX_TOKEN" \n  -H "X-Cortex-Actor: $CORTEX_ACTOR" \n  -H "Content-Type: application/pdf" \n  --data-binary @report.pdf

Response:

{
  "blob_id": "blob_01HX...",
  "size_bytes": 4823648,
  "content_type": "image/png",
  "sha256": "..."
}

Reference in an experience envelope via content.media: [{"blob_id": "blob_01HX..."}].

8.13.2 GET /v1/blobs/{blob_id}

Capability: blob.read

Returns the raw bytes. Content-Type reflects the original.

8.13.3 DELETE /v1/blobs/{blob_id}

Capability: blob.delete — authorized by ownership: only the actor that uploaded the blob may delete it, exactly as for GET. A blob owned by someone else answers 404 (the same answer a read gets — it never confirms the id exists).

Purges the stored object and its metadata.

{ "blob_id": "blob_01HX...", "bytes_deleted": true }

bytes_deleted: false means the metadata was dropped but the backing store already had no object; the end state is the same.

Use this when the source file is deleted upstream. Redacting the experience that references a blob removes the memory; it does not remove the object, so a right-to-be-forgotten flow over uploaded files must do both:

# 1. redact the experience that referenced the file
curl -X POST $CORTEX_URL/v1/forget -H "Authorization: Bearer $CORTEX_TOKEN" \n  -H "X-Cortex-Actor: $CORTEX_ACTOR" -H 'Content-Type: application/json' \n  -d '{"scope":"user:alice","selector":{"memory_ids":["evt_01HX..."]},"cascade":"redact_events"}'
# 2. purge the bytes
curl -X DELETE $CORTEX_URL/v1/blobs/blob_01HX... \n  -H "Authorization: Bearer $CORTEX_TOKEN" -H "X-Cortex-Actor: $CORTEX_ACTOR"

Deleting an unknown or already-deleted id returns 404; the call is safe to retry.

8.14 Vocabularies

8.14.1 POST /v1/vocabularies

Capability: policy.administer.tenant

See §7.3.1.

8.14.2 GET /v1/vocabularies/{name}

8.14.3 PUT /v1/vocabularies/{name}

8.15 Admin (existing surface, retained)

EndpointCapabilityPurpose
GET /v1/admin/healthadmin.healthLiveness + diagnostics
GET /v1/admin/readyadmin.healthReadiness probe
GET /v1/admin/metricsadmin.healthPrometheus/JSON metrics
GET /v1/admin/layers/statsadmin.healthPer-layer counts/health
POST /v1/admin/compaction/dedupadmin.compactTrigger dedup compaction
POST /v1/admin/index-auditadmin.compactAudit/rebuild derived indexes
GET /v1/admin/version(open)Build info

GET /v1/admin/layers/stats health section (extended in v0.8.14 — the layer-observability asks from the vault-migration report): llm_roles separates the four LLM cost centers (enrichment extraction, recall expansion + synthesis, answers, verifier) with per-role configured/model, and states explicitly that entity extraction is rule-based with no LLM cost; layer_sources gives every layer its derivation source and current gate (events: raw, episodes: raw_derived_no_llm, facts: llm_extracted_plus_deterministic_triples with status deterministic_triples_only when no enrichment model is set — which is why a text-only backfill adds no facts, beliefs: derived_from_facts_no_llm, concepts: llm_synthesized with the spend gate); and enrichment_backlog_stalled (+ note) flags the deferred-scanner backlog that can never drain because CORTEX_ENRICHMENT_MODEL is unset.

POST /v1/admin/scopes/migrate (v0.8.13) — the scope migration/merge workflow: applications that change their memory model (e.g. per-source scopes user:x/source:chatgpt folding into one root user:x) get a first-class copy/move instead of hand-rolled re-sync scripts. Body:

{ "from": "user:x/source:chatgpt", "to": "user:x",
  "mode": "copy",              // or "move"
  "dry_run": false,
  "batch_size": 100,
  "confirm_erase_source": false }

Returns 202 {job_id, status_url}; poll GET /v1/admin/scopes/migrate/{job_id} (job records are process-local, like erasure jobs). Semantics: events copy through the normal write path with event_time, content, and metadata (labels, extraction directives) preserved; destination event ids are deterministic functions of the source ids, so re-running resumes (already- copied events are skipped) and never duplicates. [triple] events regenerate their deterministic facts under the target synchronously; other derived layers regenerate through the deployment's normal enrichment/synthesis (enrichment-off deployments: trigger POST /v1/understanding/synthesize on the target, or wait for organic activity). mode: "move" additionally erases the source through the standard scope-wide forget — gated on a clean, count-verified copy AND an explicit confirm_erase_source: true; on any verification failure the source is left untouched and the job reports why. Requires admin.compact. Migrates the EXACT scope string only — migrate a subtree by calling once per scope.

POST /v1/admin/index-audit body: {"repair": bool, "scope": "user:...", "vectors": bool}, all optional. repair=false (default) reports events whose search-index entries are missing; repair=true re-indexes them — since v0.8.7 this includes rebuilding missing content-store rows from the WAL event content (previously such events were unrepairable: "content row missing; cannot repair from stored state"). scope restricts the audit and the repair to one scope subtree (exact or descendant); before v0.8.7 it was accepted and silently ignored (repair always ran globally). Caveat: if a failed bulk batch was already re-sent under fresh events (pre-v0.8.7 behavior), repairing its stranded originals re-indexes duplicate content — erase those scopes first.

vectors (default false, since v0.8.21) widens the membership test to the VECTOR index. The default check is BM25-only on purpose: a cold-tier demotion removes the in-memory graph node while the vector stays intact and searchable, so treating "not in the graph" as missing would flag healthy consolidated data. With vectors: true the audit asks whether a stored row exists in either tier, which separates "demoted" from "truly gone" — the state an embedding outage leaves behind, since the fulltext document is written whether or not embedding succeeded. Such events stay keyword-findable but are invisible to semantic recall, and nothing else reports them. The counts are separate: missing_vectors / missing_vectors_total / vectors_repaired alongside the fulltext missing / missing_total / repaired, so existing consumers read unchanged. With repair: true the lost vectors are re-embedded from stored content — so fix a broken embedding key BEFORE repairing, or every chunk lands in vector_repair_skipped instead.

GET /v1/admin/metrics (JSON, gated on diagnostics.read) reports, beyond the request/error/rate-limit counters: version, per-route endpoints[] (call count, errors, p50/p95/p99/max latency ms), errors_by_code[] (structured error responses grouped by error_code), writes.indexing_failures_total (accepted-but-unindexed writes), and storage.data_dir_bytes_approx (on-disk footprint, cached ~60s). The /metrics Prometheus endpoint exposes the same counters as cortexdb_* series including cortexdb_indexing_failures_total and cortexdb_errors_by_code_total{code=...}.

8.16 Auth

8.16.1 POST /v1/auth/revoke

Capability: auth.revoke (operator or, for self-revocation, the token holder).

Adds a token's jti to the revocation list. The token becomes invalid for all subsequent calls until exp passes.

{
  "jti": "01HX...",
  "reason": "rotated"
}

Response: 204 No Content.

Revocation lists are TTL-keyed by exp and auto-pruned. Maximum list size 1M; tenants with high revocation volume should issue shorter-lived tokens.

8.16.2 GET /v1/auth/whoami

Capability: (open to authenticated callers)

Returns the effective identity and capability set for the current token. Useful for clients and SDKs to introspect their environment.

{
  "caller": "agent:planner_v3",
  "tenant_id": "acme",
  "deployment_preset": "cloud_shared_saas",
  "token": {"jti": "01HX...", "iss": "...", "exp": 1715836800},
  "effective_capabilities": ["scope.write", "scope.read.holistic", "lifecycle.subscribe", "..."]
}

8.16.3 POST /v1/auth/tokens — Mint scoped tokens

Capability: auth.mint — 🔒 not in free-tier tokens (audit FRI-3). Free-tier signups can't mint child tokens; you'll get 403 POLICY_DENIED with "caller lacks auth.mint capability". This is by design — auth.mint is granted to operator/tenant-admin tokens on Starter+, or to any token on self-hosted on_prem_enterprise deployments.

Self-hosted (free): flip the deployment preset to on_prem_enterprise and you get auth.mint deployment-wide. Cloud: upgrade to Starter+ to get auth.mint on your tenant scope, or contact sales for an operator token if you're building multi-tenant infrastructure.

{
  "subject": "user:bob",
  "scope": "org:acme/team:design",
  "ttl_seconds": 3600,
  "caps": ["scope.read.local", "scope.write"]
}

Response: 200 with the minted token, or 403 when the caller lacks auth.mint, or 503 NOT_CONFIGURED when the deployment has no minter wired (e.g., dev_local without CORTEX_V1_MINTER_ENABLE=1).

8.17 Temporal Phrases

8.17.1 POST /v1/temporal/phrases

Capability: temporal.phrases.write (tenant admin)

Register a custom natural-language temporal phrase. See §12.4 for the parsing model.

{
  "name": "fiscal_q3",
  "pattern": "fiscal Q3",
  "resolves_to": {"valid_during": ["2026-07-01", "2026-10-01"]}
}

Response: 201 Created.

8.17.2 GET /v1/temporal/phrases

Capability: temporal.phrases.read (any authenticated reader)

{
  "items": [
    {"name": "fiscal_q3", "pattern": "fiscal Q3", "resolves_to": {...}}
  ]
}

8.17.3 DELETE /v1/temporal/phrases/{name}

Capability: temporal.phrases.write

8.18 GET /v1/experience/status — Write Status

Capability: scope.write (same auth as the write itself; keys are scoped per-caller, so one tenant can never probe another's keys).

Answers "did my write land, and how far has it progressed?" — the resume path after a client-side timeout, and the poll target for ?wait=captured async-accept writes.

GET /v1/experience/status?idempotency_key=<key>    # by key (24h window)
GET /v1/experience/status?event_id=evt_01HX...     # by event id
{
  "found": true,
  "event_id": "evt_01HX...",
  "status": "indexed",
  "stages_completed": ["captured", "extracted", "indexed"],
  "note": "stored and queryable"
}

status is "captured" | "indexed" | "failed". "failed" adds failure_reason and means: re-send the write with the same idempotency key — it will re-process (the record was dropped). found: false means never stored / expired (>24h) / failed-and-evicted — in every case re-sending is safe.

8.19 GET /v1/schemas — Machine-Readable Contracts

Unauthenticated (like /v1/admin/health). Lists and serves the normative JSON Schemas for every request/response shape, embedded in the binary at build time so the running server and its published contract cannot drift.

GET /v1/schemas                        → {"schemas": [{"name": "...", "url": "..."}]}
GET /v1/schemas/experience_envelope    → the JSON Schema (application/schema+json)
GET /v1/schemas/error_envelope         → the error envelope contract

Use these for client-side preflight validation before runtime — 422 responses reference the matching schema in details.schema.


9. The Stratified Pack

The output of POST /v1/recall in non-streaming mode.

{
  "request_id": "req_01HX...",
  "scope": "org:acme/dept:eng/user:alice",
  "view": "holistic",
  "context_block": "Acme is a B2B SaaS in late-stage negotiation. They moved to 200 seats on May 13, 2026 and signed. Prior history includes a successful POC in April and an executive intro in February.",
  "layers": {
    "beliefs": [
      {"id": "belief_01HX...", "claim": {...}, "confidence": 0.62, "ranked_position": 1, "score": 0.94}
    ],
    "facts": [
      {"id": "fact_01HX...", "subject": {...}, "predicate": "...", "object": {...}, "ranked_position": 1, "score": 0.91}
    ],
    "episodes": [
      {"id": "ep_01HX...", "name": "Acme deal Q3 close", "summary": "...", "ranked_position": 1, "score": 0.88}
    ],
    "events": [],
    "understanding": [
      {"id": "concept_01HX...", "name": "B2B SaaS Sales Cycle", "ranked_position": 1, "score": 0.42}
    ]
  },
  "provenance": {
    "trail": [
      {"phase": "ARIL_classify", "plan": "DIRECT_LOOKUP_RECHECK", "elapsed_ms": 12},
      {"phase": "hybrid_retrieve", "bm25_hits": 124, "hnsw_hits": 2000, "elapsed_ms": 88},
      {"phase": "rerank", "model": null, "elapsed_ms": 0},
      {"phase": "graph_expand", "added_nodes": 14, "elapsed_ms": 22},
      {"phase": "score", "weights": {"semantic": 0.4, "graph": 0.2, "recency": 0.15, "importance": 0.15, "confidence": 0.1}, "elapsed_ms": 4},
      {"phase": "mmr", "lambda": 0.7, "elapsed_ms": 6}
    ],
    "citations": {
      "belief_01HX...": ["fact_01HX...", "fact_01HX...", "ep_01HX..."],
      "fact_01HX...":   ["evt_01HX..."],
      "ep_01HX...":     ["evt_01HX...", "evt_01HX...", "evt_01HX..."]
    }
  },
  "diagnostics": {
    "policy": {"tier_attribution": "scope", "capability": "scope.read.holistic"},
    "filters_applied": ["actor.in", "confidence.gte"],
    "scopes_traversed": ["org:acme/dept:eng/user:alice", "org:acme/dept:eng", "org:acme"],
    "time_ms": {
      "auth": 3, "policy_check": 1, "recall": 412, "rerank": 0,
      "rank": 8, "render_context_block": 84, "total": 508
    },
    "_partial": false
  }
}

9.1 Views

viewWhat gets filled
rawOnly layers.events. Useful for debugging and replay.
granularAll five layers[] arrays. context_block is empty.
holistic (default)All layers + a synthesized context_block.
narrativeEmpty layers; context_block contains an LLM-rendered story. Provenance preserved.
structuredLayers empty; a single knowledge_subgraph field with JSON-LD shape.

9.2 Ranking and limits

budgets.per_layer_limits caps each layer's array (applied first), and budgets.max_tokens is a cross-layer token budget over the whole pack: lowest-priority items are evicted from the tail until it fits, with the count in diagnostics.knapsack_evictions. context_block is bounded to the same budget and never truncated mid-item. Degraded/partial retrieval is reported via diagnostics._partial and diagnostics.degraded_sources.

9.3 Citation map

provenance.citations is a directed acyclic graph from derived records back to events. Walking it gives the "why" trail for any item in the pack. The same data is reachable via /v1/beliefs/why for beliefs specifically.


10. Streaming

10.1 Granular streaming

Used for /v1/recall with stream=true (any view except narrative).

event: plan
data: {"plan": "DIRECT_LOOKUP_RECHECK", "filters_applied": [...]}

event: layer
data: {"layer": "facts", "items": [...], "elapsed_ms": 210}

event: layer
data: {"layer": "beliefs", "items": [...], "elapsed_ms": 315}

event: layer
data: {"layer": "episodes", "items": [...], "elapsed_ms": 488}

event: layer
data: {"layer": "understanding", "items": [...], "elapsed_ms": 612}

event: context_block
data: {"text": "Acme is a B2B SaaS in late-stage..."}

event: provenance
data: {"trail": [...], "citations": {...}}

event: diagnostics
data: {"time_ms": {...}, "scopes_traversed": [...]}

event: done
data: {"total_ms": 1042}

10.2 Narrative streaming

Used for /v1/answer with stream=true and /v1/recall with view=narrative&stream=true.

event: token
data: {"text": "Acme"}

event: token
data: {"text": " upgraded"}

event: token
data: {"text": " to 200"}

...

event: citations
data: [{"marker": "[1]", "layer": "fact", "id": "fact_01HX..."}, ...]

event: diagnostics
data: {"recall_ms": 412, "llm_ms": 814, "total_ms": 1240}

event: done
data: {}

10.3 Lifecycle streaming

Used for /v1/lifecycle/stream. Full event catalog in §13.

10.4 Reconnection and resume

Clients pass Last-Event-ID: lce_01HX... (standard SSE) or ?since_lifecycle_id=lce_01HX... on reconnect. The ID is a lifecycle event ID (lce_ prefix), not a memory event ID.

Server-side: every recall job's events are cached for 60s under the request's idempotency_key. Reconnect-with-resume replays from the next unsent event.

Lifecycle subscriptions support ?since_lifecycle_id=lce_01HX... for resume — server keeps a 1-hour replay buffer anchored from lifecycle-event arrival (§18.2 item 10). The window is "any lifecycle event whose server-side arrival timestamp is within the last hour is available for resume." Subscriber lifecycle (connect/disconnect timing) is irrelevant. Buffer is per-server-node; in cluster mode, replay across node failover may be incomplete for events that were in-flight during the failover.

10.5 Backpressure

We rely on HTTP/2 flow control. No application-level token bucket on streaming responses; we don't buffer events beyond the OS socket buffer.

If a client falls > 30s behind on a lifecycle stream, the connection is closed with event: lagging and they must reconnect with ?since=.


11. Forget Semantics

11.1 Cascade modes

ModeEndpointCapabilityWhat happens
derived_onlyDELETE /v1/forgetforget.cascade.derived_onlyWipes named derived layers; events untouched. Rederivable from WAL replay.
redact_eventsDELETE /v1/forgetforget.cascade.redact_eventsBlanks event payloads; keeps id, wal_offset, scope, actor (or redacts actor). Permanent.
GDPRDELETE /v1/forget/gdprforget.gdprReference-counted true erasure (§11.3).

11.2 Selector semantics

The selector block on /v1/forget is conjunctive:

{
  "about_entity": "ent_acme_corp",
  "predicate": "is_likely_to_renew",
  "memory_ids": [],
  "valid_during": ["2026-04-01", "2026-05-01"],
  "created_during": null
}

A record matches if ALL non-null filters match. Empty selector matches every record in the named layers within scope.

memory_ids is an "OR" with the other filters — if present, those IDs are deleted regardless of other filters (within the scope).

11.3 GDPR reference-counted erasure

Triggered by DELETE /v1/forget/gdpr. Five-phase pipeline.

Phase 1 — Enumerate. Walk WAL and find every event where author_actor matches the scope's owners OR home_scope ⊆ <scope>. Emit event: erasure_progress phase=enumerate.

Phase 2 — Refcount. For each candidate event E:

Phase 3 — Categorize.

Phase 4 — Execute.

Phase 5 — Demote. Any belief whose supporting_evidence_redacted_fraction == 1.0 is demoted: confidence decays by confidence_after_full_redaction_factor from policy (default 0.5), _partial: true with _partial_reason: "supporting_evidence_redacted". If the resulting confidence falls below tenant.confidence_floor (default 0.3), the belief is deleted.

Audit. A single audit row with gdpr: true, erasure_id, and a summary {deleted_events, redacted_events, deleted_facts, demoted_beliefs} is written. GDPR audit rows are exempt from normal retention TTLs.

11.4 Cross-workspace propagation

When forget.gdpr.cross_workspace is enabled and the actor exercising erasure is a member of cross-workspace scopes:

11.5 What forget does NOT do


12. Temporal Model

12.1 Time axes

Two independent axes, four endpoints per axis:

valid_from ─────────────────── valid_to
              event time

recorded_from ─────────────── recorded_to
              ingest time
FieldMeaning
valid_fromFirst moment the claim is true in the world
valid_toFirst moment it ceases to be true; null = open
recorded_fromFirst moment CortexDB learned the claim
recorded_toFirst moment CortexDB superseded its own record; null = current

Events have observed_at and recorded_at as singletons.

12.2 Temporal query primitive

The temporal block on any layer read or recall:

{
  "temporal": {
    "as_of": "2026-04-15T00:00:00Z",
    "valid_during": ["2026-03-01", "2026-04-01"],
    "recorded_during": null,
    "natural": "last 30 days"
  }
}

Semantics:

12.3 As-of semantics

as_of defaults:

FieldDefault
as_ofnull (= now)
Implicit recorded_to pinnow
Implicit valid_from pinas_of (or now)

?include_superseded=true on layer reads disables the recorded_to=null filter, returning all historical versions.

12.4 Natural-language parsing

A small deterministic parser handles common phrases. Unparseable strings 422 with unparseable_temporal. Tenants can register custom phrase mappings:

POST /v1/temporal/phrases
{
  "name": "fiscal_q3",
  "pattern": "fiscal Q3",
  "resolves_to": {"valid_during": ["2026-07-01", "2026-10-01"]}
}

12.5 The bi-temporal record store (implemented)

The axes above are populated and enforced by the bi-temporal engine (BITEMPORAL_CONFLICT_DESIGN.md; crates/cortex-bitemporal). Rollout is mode-gated: CORTEX_BITEMPORAL_MODE = off | shadow (default) | enforce. In shadow mode the engine runs on every triple-derived fact, persists BITEMPORAL WAL events, detects conflicts, and exports coverage metrics — without changing legacy Fact fields. In enforce mode stated dates ground t_valid_from/t_valid_to (real world-time, not ingest time).

The record-level query surface:

Every finite validity boundary carries a basis: stated (the source asserted the world date — render "on/since ") or observed (we bounded it by when we learned it — render "by ", never "on"). This is how undated state changes stay honest.


13. Lifecycle Events Catalog

All events delivered via SSE on /v1/lifecycle/stream. Every event has event_id, timestamp, scope, and an event-specific payload.

Event nameFires whenPayload
capturedWAL append succeeds{event_id, actor, modality, wal_offset}
extractedAsync extract stage done{event_id, derived: {facts, entities, beliefs, episodes}}
indexedBM25 + HNSW insert done{event_id, layers_indexed}
consolidatedBelief / understanding revised{event_id, beliefs_updated, conflicts_resolved, superseded_facts}
compressedEpisode sealed / understanding versioned{episode_id, understanding_ids}
forgottenRecords deleted{layer, count, by_actor, cascade}
policy_changedA policy mutation affected this stream{tier, scope_or_actor, capability_diff}
policy_revokedThe subscription is no longer allowed{reason} (stream closes after)
import_progressImport job tick{import_id, fraction_complete, processed, total}
import_completeImport job done{import_id, summary}
import_errorImport row failed{import_id, line, error}
export_progressExport job tick{export_id, fraction_complete}
export_completeExport job done{export_id, url, size_bytes}
erasure_progressGDPR job tick{erasure_id, phase, fraction_complete}
erasure_completeGDPR job done{erasure_id, summary}
laggingClient too slow; stream closing{behind_by}

Filtering: ?events=captured,consolidated to subscribe to a subset.


14. Audit Log Schema

Every policy-gated call writes one row. Schema:

{
  "id": "audit_01HX...",
  "ts": "2026-05-13T15:42:09.018Z",
  "actor": "agent:planner_v3",
  "token_jti": "...",
  "tenant_id": "acme",
  "scope": "org:acme/dept:eng/user:alice",
  "endpoint": "POST /v1/experience",
  "capability": "scope.write.elevated",
  "decision": "allow",
  "decided_by_tier": "scope",
  "request": {
    "method": "POST",
    "path": "/v1/experience",
    "body_hash": "sha256:abc...",
    "headers_hash": "sha256:def...",
    "body_bytes": 4823
  },
  "response_status": 202,
  "event_id": "evt_01HX...",
  "client_ip": "1.2.3.4",
  "user_agent": "cortex-py/1.0.0",
  "request_id": "req_01HX...",
  "elapsed_ms": 12,
  "gdpr": false
}

Hashes:

tenant_pepper_version is rotated quarterly. Old peppers are retained for audit.retention + audit.pepper_retention_buffer (default P3M buffer) so any audit row hashed under that pepper remains verifiable for the duration of its retention window plus the safety margin. See §18.2 item 11.

Body bytes storage. The body_bytes field is controlled by tenant setting audit.store_body_bytes (default true; see §18.2 item 15). When false, the audit row omits the field but retains the hash.

Retention:

Storage: Audit rows are appended to a dedicated RocksDB column family + mirrored to Tantivy for search. Not indexed in HNSW; not part of the recall pipeline.


15. Importer Mappings

15.1 Mem0

Mem0 fieldCortexDB targetNotes
idevent.context.labels["mem0_source_id"]Preserved for round-trip
memory (text)event.content.textModality = imported
hashevent.context.labels["mem0_hash"]
metadataevent.context.labels["mem0_metadata"]Flattened
categoriesMapped to Understanding concept tagsConfigurable via options.map_categories_to
scoreDiscardedNot a stored field
immutabledirectives.no_supersede = true
expiration_datedirectives.ttl_for_event_layerStored but NOT enforced yet — memories do not auto-expire (roadmap)
created_atevent.context.recorded_atAnd valid_from if no event-time info
updated_atNew event with preceded_by linkEach update becomes a new event
user_id{user_id} template variableSubstituted into scope_template
agent_idevent.actor.id (as agent:)
run_idevent.actor.session
app_id{app_id} template variable

Graph memory (legacy v1 exports only): edges become Facts with predicate= the edge label.

15.2 Zep

Zep fieldCortexDB targetNotes
Messageevent.content with kind=messagerole and name preserved
Fact edgeFactBi-temporal preserved: valid_at→valid_from, invalid_at→valid_to
source_node_uuid / target_node_uuidsubject / objectEntity references resolved via Zep's node table
episodes[]event.context.preceded_by
EpisodeEpisodeDirect mapping
Group (group_id)Scope (ws:-prefixed by default)
Custom entity typesVocabulary + Understanding concepts
min_fact_rating per factfact.confidence

15.3 Letta

Letta fieldCortexDB targetNotes
Memory blockUnderstanding conceptConfigurable: options.map_core_blocks_to
Archival passageEvent (modality=imported)Configurable: options.map_archival_to
Conversation historyEvents with kind=messageOne per message
Agent file (.af)Bundle import: scope register + blocks + archivalFull state snapshot
persona blockConcept tagged actor_self_description
human blockConcept tagged actor_subject_description

15.4 OpenAI memory export

Format: ChatGPT's JSON dump.

OpenAI fieldCortexDB target
memories[].textevent.content.text
memories[].createdevent.context.recorded_at
memories[].source.session_idevent.actor.session

15.5 Generic JSONL

Each line MUST be a full experience envelope. No mapping needed.

{"scope":"...", "actor":{...}, "modality":"...", "content":{...}, "context":{...}}
{"scope":"...", "actor":{...}, "modality":"...", "content":{...}, "context":{...}}

16. Error Catalog

Selected entries. Full list maintained at docs/errors/.

HTTPCodeMeaningRetriable
400INVALID_BODYJSON parse errorno
400MISSING_REQUIRED_FIELDRequired field absentno
401MISSING_TOKENNo Authorization headerno
401INVALID_TOKEN_SIGNATURESignature verification failedno
401EXPIRED_TOKENexp < nowno
401REVOKED_TOKENjti on revocation listno
401ACTOR_MISMATCHX-Cortex-Actor ≠ token subno
401WRONG_TENANTaud doesn't match deploymentno
403POLICY_DENIEDEffective capability missingno
403NOT_A_MEMBERWriting to registered scope without membershipno
404NOT_FOUNDResource absent (and caller has read access)no
409IDEMPOTENCY_CONFLICTKey reused with different bodyno
409SCOPE_REGISTRATION_EXISTSPath already registeredno
410RESOURCE_DELETEDForgotten recordno
422INVALID_SCOPE_GRAMMARScope path malformedno
422INVALID_TIMESTAMPRFC3339 parse failureno
422UNPARSEABLE_TEMPORALtemporal.natural not understoodno
422INVALID_ENVELOPEExperience envelope failed validationno
429RATE_LIMITEDToken bucket exhaustedyes (after Retry-After)
500INTERNALUnexpected server erroryes
503WAL_UNAVAILABLECannot accept writesyes
503DERIVED_LAYER_UNAVAILABLERecall succeeds but one layer is degraded; partial responsesometimes

17. Compatibility Matrix

Framing. Peer systems are not flat. Mem0 has conversation/session/user/org scopes with filter/rerank operators; Zep ships bi-temporal facts on graph edges plus context blocks plus semantic/full-text/graph retrieval; Letta has tiered memory tools and shared blocks; Cognee has 14 search strategies. CortexDB's differentiation is not "they're simple, we're rich." It is event-sourced provenance, all-layer first-class access, policy with attribution, lifecycle observability, and reference-counted erasure — the rows below reflect that re-framing. We also benchmark our ergonomics against Supermemory's "raw content in + customId + tags" simple-SDK happy path: CortexDB keeps the rich contract, but the SDK MUST offer a one-liner experience for the happy path.

CapabilityCortexDBMem0ZepLettaCogneeSupermemory
Memory layers separately addressable as API resources✅ 5⚠️ 1 (filterable)✅ 2 (facts, episodes)⚠️ 3 (block tiers)⚠️ 2 (graph, vector)⚠️ 1 (chunks/memories)
Bi-temporal across ALL layers (valid_+recorded_)✅❌⚠️ facts/edges only❌⚠️ via Graphiti episodes❌
as_of point-in-time recall on any layer✅⚠️ via filters⚠️ facts only❌⚠️ episode-anchored❌
Hierarchical scope with inheritance✅⚠️ org/project/user/agent/run (no inherit)⚠️ user/group/thread (flat)⚠️ project-only⚠️ datasets + node_set tags⚠️ container tags
Workspace = scope (unified primitive)✅❌ scoping is intersection-only⚠️ groups separate⚠️ shared blocks separate⚠️ dataset is shared❌
Per-tier authorization with capability attribution✅ four-tier❌ API-key flat❌ API-key flat❌ API-key flat❌❌
Signed-token identity (caller / observed_actor / subject split)✅❌❌❌⚠️ user param❌
Async-first writes with lifecycle visibility✅ + SSE✅ async; ❌ no lifecycle❌❌✅; ❌ no lifecycle✅ queued
SSE / push lifecycle stream (capture→consolidate observability)✅❌❌⚠️ agent step stream❌❌
Hard delete (no soft delete in user-visible API)✅⚠️ delete by id⚠️ delete by id⚠️ block edit⚠️ dataset delete⚠️
Reference-counted erasure preserving co-owned evidence✅❌❌❌❌❌
Erasure preview + manifest before destructive run✅❌❌❌❌❌
Streaming recall (granular per-layer)✅❌❌❌❌❌
Streaming recall (narrative token stream)✅❌❌⚠️ agent reply❌❌
Provenance + citation trail returned in every recall✅❌⚠️ episodes/edges only❌⚠️ graph trace❌
"Why do you think that?" endpoint (explainability primitive)✅❌❌❌❌❌
Layer-aware forget (forget just beliefs / just facts)✅❌❌❌❌❌
Bulk importer from peer systems✅ Mem0/Zep/Letta/OpenAI/JSONLn/a❌❌❌❌
Bulk export to peer systems✅⚠️ memory export❌⚠️ Agent File (.af)❌❌
Custom predicate / relation vocabularies✅⚠️ custom categories✅ Pydantic ontology (10/10/10)❌✅ Pydantic graph_model❌
Multimodal blob attachment✅⚠️ partial❌❌⚠️ partial✅ files/URLs
Auto-registered scopes with prune tooling✅n/an/an/an/an/a
Audit log with tamper-evident peppered hashes✅❌❌❌❌❌
Simple-SDK happy-path one-liner (raw text in, ID out)✅ via client.experience(text=...)✅✅⚠️ verbose⚠️ verbose✅ very ergonomic

(⚠️ = partial / different shape; ❌ = absent; ✅ = present)

Reading the matrix. The right way to look at CortexDB vs the field is not "do they have it" but "is it observable, addressable, and explainable through the API." Mem0 has reranking — but you cannot ask why a result ranked where it did. Zep has bi-temporal facts — but only on facts. Letta has shared memory — but no policy. Our bar: every claim has a trail, every trail has an endpoint, every endpoint has a stability tier.


18. Deferred Decisions & Open Ambiguities

18.1 Deferred (intentionally out of v1)

  1. Federation / multi-region replication API. Cluster mode exists internally; the API contract for cross-region writes is deferred until we have a paying customer that needs it.
  2. Differential privacy on recall. Mentioned by enterprise prospects; not standard enough to commit a shape.
  3. Vector store BYO. Letting customers point at their own pgvector / Pinecone. Out of v1 scope.
  4. Memory editing tool surface for agents. Letta's tool-callable memory editing is interesting but conflicts with our "events are immutable" stance. Could ship as a higher-level shim that maps tool calls to experience + forget; deferred.
  5. Webhook (push) lifecycle in addition to SSE. SSE covers most use cases; webhooks add infra (retries, signing). Deferred until requested.
  6. GraphQL surface. REST + SDK feels sufficient; GraphQL adds maintenance burden. Deferred.

18.2 Resolved Ambiguities (formerly "Open before Implementation")

All 22 items from the prior round of disambiguation have been resolved on 2026-05-14. Each item below states the canonical resolution that the schemas and prose sections of this document have been updated to reflect. These are normative — if the schemas or prose disagree, the schemas win; if both disagree with this section, this section is authoritative for design intent.

The Stable surface gating list at the bottom of the prior version is cleared. Phase 1 of §19 is unblocked.

  1. as_of × include_superseded interaction (Stable: §8.7.3 facts). Resolved: both filters apply independently and conjunctively. as_of clips both axes (recorded_* ≤ as_of, valid_from ≤ as_of < valid_to). include_superseded toggles whether the recorded_to=null constraint is enforced — when true, superseded versions whose recorded_to ≤ as_of are returned alongside the current versions. Conformance: schema fact.schema.json declares both query params; an integration test pair exists in §12 examples.

  2. Orphan derives[] back-pointers after GDPR redaction (Experimental: §11.3). Resolved: option (c). When a derived record is deleted by GDPR and its source event is redacted-but-kept, the event's derives[] entries pointing at the deleted record are replaced with redacted:<original_id> (e.g., redacted:fact_01HX...). This preserves the audit signal that something was derived without leaking what. Conformance: event.schema.json derives entries match pattern ^(evt_|ep_|fact_|belief_|concept_|redacted:[a-z]+_)[A-Za-z0-9_-]+$.

  3. Lifecycle event ordering within a single event_id (Stable: §8.9, §13). Resolved: strict ordering within one memory event. The stages for any single evt_<id> are emitted in fixed order: captured → extracted → indexed → consolidated → (optionally) compressed. No cross-event ordering guarantee. Conformance: SSE delivery has a per-event sequence number; an SSE conformance test validates monotonicity within event_id.

  4. view=descend with partial read access (Stable: §8.3 recall). Resolved: option (b) — opaque placeholders with count-only stats. When admin descends into a tree containing child scopes the caller cannot read, the response's scopes_traversed includes {scope, denied: true, item_count: N, denied_reason} entries. Counts reveal existence without leaking content; denied_reason cites the policy tier. Conformance: stratified_pack.schema.json scopes_traversed[] item is a discriminated union over {scope, items} and {scope, denied, item_count, denied_reason}.

  5. "Natural level" for scope.write.elevated (Stable: §5.2). Resolved: the deepest scope path in which the caller appears as an explicit owner or writer member. Writes to any scope at or below this depth do not require scope.write.elevated; writes to ancestor scopes do. For unregistered scopes (no membership), the caller's natural level is null and any write requires the capability. Conformance: capability_set.schema.json includes natural_level: scope-path | null.

  6. Bulk import rate-limit attribution (Beta: §8.11). Resolved: synthetic per-tenant bucket service:importer_<tenant_id>, with default capacity = 10× the rate_limit.write value from the deployment preset. Importer-job submissions consume from this bucket; the originating caller's personal bucket is not charged. Override via tenant policy rate_limit.importer. Conformance: policy.schema.json allows rate_limit.importer override.

  7. Cancellation of in-flight async writes (Stable: §8.1). Resolved: ship POST /v1/lifecycle/memory-event/{evt_id}/cancel. Drops queued stages (extract, index, consolidate, compress). The WAL capture itself cannot be reversed (it has already happened). The cancellation marks the event as excluded_from_recall: true; it remains queryable directly via GET /v1/events/{evt_id} but does not surface in /v1/recall or /v1/answer. Endpoint added to §8.9; see §8.9.5 below.

  8. _partial: true outside Understanding (Beta: §7.4 Belief). Resolved: the _partial/_partial_reason/_progress triple is promoted to every derived layer (Episode, Fact, Belief, Understanding). Events never carry these fields. Each layer's enum of _partial_reason values is its own (e.g., Belief uses revision_in_progress, supporting_evidence_redacted, calibration_stale; Understanding uses consolidation_in_progress, insufficient_supporting_facts, etc.). Conformance: each layer schema declares the fields and its specific reason enum.

  9. idempotency_key collision scope (Stable: §6.3). Resolved: scope is (scope, caller, endpoint_family, key), where scope is the full scope path the write targets (org:acct_46/team:eng/user:alice). Keys are therefore independent across tenants even when the tenants share one caller identity (a single deployment API key writing into many org: scopes): cross-tenant reuse of a key is a plain miss, never a 409 IDEMPOTENCY_CONFLICT, and a conflict body can only ever carry an existing_event_id from the requester's own tenant (v0.7.8 bug_12). Since v0.8.20 the namespace is the whole path rather than just its tenant root, so two sibling scopes under one tenant are also independent: writing the same item into …/project:a and …/project:b stores both, where it previously 409'd the second (report C4). Records written by an earlier binary still replay a genuine retry for the remainder of their 24h TTL, but never produce a conflict. Endpoint family buckets by logical write resource, not raw path: /v1/experience and /v1/experience/bulk share ONE family (v0.7.9 report F3 — they are two transports for the same write, so a retry that switches single↔bulk, or a proxy that coalesces single writes into a batch, replays instead of double-storing); /v1/import/mem0 and /v1/import/zep remain separate (the same external id from two sources is two writes, not a retry); /v1/erasures and /v1/erasures/preview are separate. GET /v1/experience/status?idempotency_key= accepts an optional scope= for an exact probe — it must be the SAME scope the write named, since that is the namespace the record lives under; without it, all of the calling actor's own namespaces are searched (never another caller's).

  10. SSE replay buffer window (Stable: §10.4). Resolved: anchored from lifecycle-event arrival at the server. The 1-hour window means "any lifecycle event whose server-side arrival timestamp is within the last hour is available for resume via ?since_lifecycle_id=." Subscriber lifecycle (when they connected, when they disconnected) is irrelevant. Buffer is per-server-node; in cluster mode, replay across node failover may be incomplete for events that were in-flight during the failover.

  11. Tenant pepper rotation (Stable: §14). Resolved: peppers rotate quarterly. Old peppers are retained for audit.retention + 1 quarter — i.e., for as long as any audit row hashed under that pepper might still be present (plus a one-quarter safety margin for verification). When audit.retention = P2Y, peppers persist for ~2.25 years. Conformance: policy.schema.json deployment-level audit.pepper_rotation_period (default P3M) and audit.pepper_retention_buffer (default P3M).

  12. directives.consolidate_into cross-scope write (Stable: §3.5, §8.1). Resolved: dual capability required — scope.write on both the write scope and the consolidation target. No implicit elevation. If the caller lacks scope.write on the consolidation target, the envelope is rejected at validation time (422 INVALID_ENVELOPE, details.field = "directives.consolidate_into").

  13. Empty-selector forget (Stable: §8.5). Resolved: POST /v1/forget with empty selector (all filter fields null/empty AND memory_ids: []) requires confirm_all: true. Without it, 422 EMPTY_SELECTOR_WITHOUT_CONFIRMATION. Schema validation rule: selector is "empty" iff selector.about_subject ∈ {null, ""} ∧ selector.about_entity ∈ {null, ""} ∧ selector.predicate ∈ {null, ""} ∧ selector.memory_ids = [] ∧ selector.valid_during = null ∧ selector.recorded_during = null.

  14. Default view on layer reads (Stable: §8.7). Resolved: default is local for all GET /v1/events|episodes|facts|beliefs|understanding. Principle of least surprise: a caller passing a scope gets exactly that scope. To aggregate up the tree, they must pass view=holistic explicitly. The /v1/recall endpoint is the only one whose default is holistic (rationale: recall is intent-aware; layer reads are not).

  15. Audit row body_bytes (Stable: §14). Resolved: add tenant-level setting audit.store_body_bytes (boolean), defaulting to true. When false, the audit row omits the field. Preset defaults: on_prem_enterprise = true, cloud_shared_saas = true (overridable per tenant), cloud_private = true, dev_local = true. Conformance: audit_row.schema.json makes body_bytes optional.

  16. Belief deletion threshold behavior (Beta: §7.4). Resolved: confirmed as documented. When confidence < tenant.confidence_floor (default 0.3), the belief record is deleted (not soft-deleted, no tombstone). Direct ID lookup at GET /v1/beliefs/{id} returns 410 RESOURCE_DELETED. List endpoints (GET /v1/beliefs?...) silently omit deleted beliefs. The audit_id of the deletion is recorded in the audit log.

  17. view=raw and temporal filters (Stable: §8.3, §9). Resolved: for view=raw (events-only response), recorded_during and as_of apply (to recorded_at on events); valid_during is ignored with no error (events have no validity interval, so the filter is a no-op). A valid_during filter present in a view=raw request causes X-Cortex-Warning: valid_during_ignored_for_view_raw to be set on the response.

  18. Multimodal embedding strategy (Stable: §3.5). Resolved: v1 ships text-only embeddings. Image and other binary blobs are indexed by metadata (content-type, size, perceptual hash) and reachable via blob_id, but not embedded into the semantic vector space. Multimodal embedding is a v1.x add-on; the API contract is forward-compatible (envelope content.media[] is already structured to accept additional indexing hints).

  19. Capability scope.create.* granularity (Stable: §5.2). Resolved: the enumerated scope.create.<type> set is closed at the deployment policy tier. The default set is {org, user, agent, service, ws, project, team, dept, global, system}. Operators may extend the set via the deployment YAML's allowed_scope_types. Any user-defined scope type not in the allowed set is rejected at scope registration time with 422 UNREGISTERED_SCOPE_TYPE. A single capability scope.create.custom controls whether custom (non-builtin) types may be registered at all.

  20. use_pack_id lifetime in /v1/answer (Stable: §8.4). Resolved: 60 seconds from pack creation, no extension on reuse. Consistency over caching efficiency. After 60s the pack ID is invalid; the caller must re-run recall.

  21. pack_id and stateless recall (Stable: §8.3, §8.4). Resolved: ephemeral, 60s server-side TTL, not a stable client handle. Clients that need durable references should extract and store the constituent evt_/fact_/belief_/... IDs from provenance.citations immediately after receiving the pack.

  22. Error envelope casing consistency (Stable: §6.6, §16). Resolved 2026-05-14: unified on error_code (UPPER_SNAKE) as the single error identifier field; lower-snake error field retired. See §6.6. Normative source: error_envelope.schema.json.

Resolution discipline going forward. Any future contract change that is non-additive against a Stable endpoint requires:

  1. An entry in this section documenting the prior behavior, the new behavior, and the rationale.
  2. A schema update in docs/schemas/.
  3. An updated example in the affected endpoint section.
  4. A regression test under tests/api_contract/.
  5. A bump of X-Cortex-Stability to stable-revising for one minor release before the change becomes default.

19. Implementation Order

Updated 2026-05-14 to match the grounded gap analysis (docs/GAP_ANALYSIS.md). The earlier numbered plan was theoretical; this version is informed by what actually exists in the codebase. Each phase is shippable in isolation; phases 1-3 are prerequisite (they reshape the contract). Estimated cumulative time: 10-14 weeks at full focus; the agent's gap-analysis estimate of 19 weeks is the conservative upper bound.

Phase 1 — Foundation (Weeks 1-2)

Goal: every request authenticates, identifies three actors, names a scope, and gets a policy decision with attribution.

Phase 2 — Core Memory Layers (Weeks 3-4)

Goal: every layer has its full record shape in storage, even if the layer's full pipeline isn't running yet.

Phase 3 — Async Write Pipeline (Week 5)

Goal: write path returns 202 on capture, derives asynchronously, exposes wait modes.

Phase 4 — Stratified Recall (Week 6)

Goal: recall returns a stratified pack, not a flat context string.

Phase 5 — Lifecycle + SSE (Week 7)

Goal: the async pipeline is observable in real time.

Phase 6 — Layer Reads (Week 8)

Goal: every layer has its own GET endpoint.

Phase 7 — Scopes + ACL (Week 9)

Goal: scope registration, member ACLs, holistic reads with denied placeholders.

Phase 8 — Forget + Erasures (Weeks 10-11)

Goal: hard delete works in proportional + reference-counted modes.

Phase 9 — Audit + Compliance (Week 12)

Goal: every policy decision is tamper-evidently logged.

Phase 10 — Imports + Exports (Weeks 13-14)

Goal: customers can move data in and out.

Phase 11 — Understanding Synthesizer (Weeks 15-16)

Goal: drop _partial: true on the Understanding layer.

Phase 12 — Vocabularies + Temporal Phrases + Blobs (Week 17)

Phase 13 — Streaming + Narrative (Weeks 18-19)

Goal: streaming surfaces for the experimental tier.

Risk gates between phases

After phaseRequired to proceed
1All four presets load without error; cortex-auth-ref mints a token that the binary accepts
2Schemas validate the new record shapes against docs/schemas/*.json
3LongMemEval-S and LoCoMo benchmarks pass through the new write/recall API at the regression bar (§19.1)
4Recall regression bar holds
8Erasure preview/execute pair validated against the manifest schema; legal sign-off on redaction semantics
11Understanding endpoint can return _partial: false for at least one real concept

19.1 Benchmark Regression Bar

Concrete thresholds for any PR that touches the read or write pipeline (and therefore could regress retrieval quality).

LongMemEval-S (full 500 questions):

LoCoMo (categories 1-4, judge score):

Recovery PRs. A PR may merge below the floor if and only if it is explicitly tagged regression-fix and the previous (last-green) commit is identified. The next non-fix PR must restore the bar.

Latency budgets (informational, not gating for v1):

These are tracked in docs/LATENCY_BASELINE.md; deviations beyond 20% trigger a perf-review checkbox on the PR.

Cost budget:

What is NOT in the bar. Code-correctness tests, API contract tests, and unit tests live separately and are not part of this gate — they are independent merge requirements. The regression bar is specifically about retrieval and answer quality.


20. Appendices

Appendix A: Capability Reference (Full)

Defaults per preset. Y = allowed, N = denied, ~ = configurable per tenant default.

Capabilityon_premcloud_sharedcloud_privatedev_local
scope.create.globalYNYY
scope.create.cross_tenantYNNY
scope.create.orgYYYY
scope.create.userYYYY
scope.create.agentYYYY
scope.create.wsYYYY
scope.writeYYYY
scope.write.elevatedY~YY
scope.write.on_behalf_ofY~YY
scope.write.about_otherY~YY
scope.create.customYNYY
scope.read.localYYYY
scope.read.holisticYYYY
scope.read.descendY~YY
scope.read.cross_tenantYNNY
understanding.readYYYY
understanding.read.cross_scopeYNYY
understanding.synthesizeYYYY
forget.cascade.derived_onlyYYYY
forget.cascade.redact_eventsYYYY
forget.gdprYYYY
forget.gdpr.cross_workspaceYYYY
import.from.mem0YYYY
import.from.zepYYYY
import.from.lettaYYYY
import.from.openaiYYYY
import.from.jsonlYYYY
export.format.mem0YYYY
export.format.zepYYYY
export.format.jsonlYYYY
lifecycle.subscribeYYYY
llm.invokeYYYY
diagnostics.readY~YY
audit.readYYYY
audit.read.cross_actorYNYY
auth.revokeYYYY
temporal.phrases.readYYYY
temporal.phrases.writeYYYY
vocabulary.readYYYY
vocabulary.writeYYYY
policy.administer.deploymentY(op)Y(op)Y(op)Y
policy.administer.tenantYYYY
policy.administer.scopeYYYY
policy.administer.actorYYYY
blob.uploadYYYY
blob.readYYYY
admin.flushY(op)Y(op)Y(op)Y
admin.compactY(op)Y(op)Y(op)Y
admin.healthYYYY

Y(op) = operator-only (deployment-tier admin).

Appendix B: Scope Path Grammar (ABNF)

scope         = segment *( "/" segment )
segment       = type ":" id
type          = ALPHA *( lowercase / DIGIT / "_" )
id            = 1*( ALPHA / DIGIT / "_" / "-" )
ALPHA         = %x61-7A   ; a-z
lowercase     = %x61-7A
DIGIT         = %x30-39

Constraints:

Appendix C: Sample Token (PASETO v4 public)

Decoded payload:

{
  "iss": "https://auth.acme.com/cortexdb",
  "sub": "agent:planner_v3",
  "aud": "cortexdb:tenant:acme",
  "exp": 1715836800,
  "iat": 1715750400,
  "jti": "01HX...",
  "deployment": "cloud_shared_saas",
  "caps": [
    "scope.write",
    "scope.read.local",
    "scope.read.holistic",
    "lifecycle.subscribe"
  ],
  "scopes": [
    "scope.write:org:acme/dept:eng/*",
    "scope.read.holistic:org:acme/*"
  ]
}

Appendix D: JSON Schemas (Normative)

Status: normative. The schemas in docs/schemas/ define the contract. Where this prose spec disagrees with a schema, the schema wins. SDK code generators, request validation middleware, and server-side validators all run against these files. Spec text is explanatory; schemas are authoritative.

Schemas (JSON Schema draft 2020-12):

FileDefines
experience_envelope.schema.jsonThe POST /v1/experience request body
event.schema.jsonEvent records (§7.1)
episode.schema.jsonEpisode records (§7.2)
fact.schema.jsonFact records (§7.3)
belief.schema.jsonBelief records (§7.4) including stance, confidence_interval, calibration, revision_policy
concept.schema.jsonUnderstanding concept records (§7.5) including identity/versioning/staleness fields
scope.schema.jsonScope registration + policy block
policy.schema.jsonDeployment / tenant / scope / actor policy documents
audit_row.schema.jsonAudit log row (§14)
stratified_pack.schema.jsonThe POST /v1/recall response
answer_response.schema.jsonThe POST /v1/answer response (subset of pack + LLM block)
lifecycle_event.schema.jsonEach SSE event payload, discriminated by event name
erasure_preview.schema.jsonPreview / manifest response shape
erasure_job.schema.jsonErasure execution / status / cancel responses
token_claims.schema.jsonRequired and optional JWT/PASETO claims
temporal_block.schema.jsonThe temporal block shared by recall / answer / layer reads
capability_set.schema.jsonEffective capability response from GET /v1/policy/effective

Change discipline. A schema change accompanies every breaking change in this doc, in the same PR. CI fails if the schemas don't validate the examples in this spec.

Appendix E: SDK shape (illustrative, Python)

client = CortexClient(
    base_url="https://api-v1.cortexdb.ai",
    token_provider=OktaTokenProvider(...),
)

# Write
res = client.experience(
    scope="org:acme/dept:eng/user:alice",
    modality="conversation",
    content={"kind": "message", "role": "user", "text": "..."},
    context={"happened_at": "...", "recorded_at": "..."},
    wait="indexed",
)
print(res.event_id, res.stages_completed)

# Recall (stratified pack)
pack = client.recall(
    scope="org:acme/dept:eng/user:alice",
    view="holistic",
    query="What did we decide about Q3?",
    layers=["beliefs", "facts", "episodes"],
    temporal={"natural": "last 30 days"},
)
print(pack.context_block)
for b in pack.layers.beliefs:
    print(b.claim, b.confidence)

# Why
why = client.beliefs.why(belief_id="belief_01HX...")
print(why.narrative)

# Streaming recall
async for chunk in client.recall_stream(
    scope="...", view="narrative", query="...",
):
    print(chunk.text, end="", flush=True)

# Forget
client.forget(
    scope="org:acme/user:alice",
    layers=["beliefs"],
    selector={"about_entity": "ent_acme_corp"},
    cascade="derived_only",
)

# GDPR
job = client.forget_gdpr(scope="org:acme/user:alice", audit_note="DSR #1234")
async for ev in client.lifecycle(erasure_id=job.erasure_id):
    print(ev)

Sign-off

This spec is the contract we will implement against. Each phase in §19 ships an internally consistent slice; the spec evolves only through explicit revision PRs that update this file and the schemas in Appendix D in lockstep.

Open items (acknowledged):