# RememberStack: full documentation

> RememberStack is an open-source memory engine for AI agents. It keeps what each source said (claims), what is held true now (facts), when it was true, and the passage every answer came from. `remember` is its Python client, CLI and MCP server (`pip install remember`).

---

Source: https://remember.dev/docs/start/what-is-a-memory-system

# What is a memory system?

This page is for anyone, technical or not. It explains the problem a memory
system solves and what one does. If you already build AI agents, you can
skip to [Why RememberStack](https://remember.dev/docs).

## Two kinds of assistant

Imagine two assistants helping your team.

The first is new every morning. Before each task they read whatever files
you hand them, work quickly and well, and then forget everything overnight.
Tomorrow you hand them the files again. If you forget to include last
week's decision, they don't know it happened. If two files disagree, they
pick one without telling you. If you ask "who is in charge of this?", they
answer from whichever file they happened to read, even if it is a year out
of date.

The second has been on the team for a year. They remember what was decided
and when. They know that the plan changed in March and why. When two people
told them different things, they tell you so. When they don't know, they
say "I don't know" rather than guessing. And when you ask how they know
something, they can point you to the email or the meeting where it came up.

Today's AI assistants are the first kind. A memory system turns them into
the second.

## Why AI assistants forget

An AI model does not learn from your conversations. Everything it knows
about your work has to be put in front of it, as text, every time you ask
something. That text is called the *context*, and it has a size limit. When
the conversation ends, the context is thrown away.

So every session starts from zero. The usual workarounds are to paste the
same notes in again, to keep ever-longer instructions, or to let the
assistant search your files. Each helps a little. None of them is memory.

## Why searching your documents is not remembering

The most common fix is to let the assistant search your documents and read
the passages that look most relevant. It helps, and it has three blind
spots.

**It cannot tell old from new.** Search finds what *sounds* like your
question. The plan from January and the correction from June both sound
like "when does the project launch?". The assistant gets both, with nothing
to say which one still holds.

**It cannot tell what is true from what was said.** A document records what
someone said at one moment. People change their minds, correct each other,
and get things wrong. Search treats every sentence as equally current.

**It always answers.** Ask about something that was never written down and
search still returns the closest text it can find. The assistant then
answers from that, confidently, and nobody notices the gap.

![Asked who leads the billing migration, search returns three passages that look equally relevant; a memory system returns one current fact with its date and source, and keeps the older statements as history.](https://remember.dev/docs/diagrams/search-vs-memory.svg)

## What a memory system does

A memory system reads your documents the way a careful colleague would, and
keeps four things that search loses.

**What each source said.** Every statement worth keeping is stored with the
exact sentence it came from and the date it was said. The source itself is
never altered or thrown away.

**What is true now.** From everything that was said, the memory works out
what currently holds. When a newer source changes something, the memory
updates, and the older statement stays on record as history. When two
sources disagree, the memory keeps both and marks the disagreement.

**When it was true.** Facts carry dates. You can ask what is true today,
what was true last spring, or how something changed over the year.

**Where it came from.** Every answer points back to the sentence, in the
document, that supports it. You can check it in seconds.

With these, an assistant can answer "who leads the project?" with the
current answer, say since when, name the meeting where it was decided, and
mention that one older document still says otherwise.

## An example

Here is how a small team's memory changes over a few months.

| Date | What the documents say | What the memory holds afterwards |
|---|---|---|
| January | Kickoff notes: "Ravi leads the billing migration. Launch in June." | Ravi leads the billing migration, since January. Launch planned for June. |
| April | Planning doc: "Launch moves to October; the invoice system needs a rewrite." | Launch planned for October, since April. The June date is kept as history, with the kickoff notes as its source. |
| June | Retro: "Ravi moved to the search team on 1 June. Dana now leads billing." | Dana leads the billing migration, since 1 June. Ravi led it from January to 1 June. |
| July | A slide deck from February is added: "Ravi leads billing." | The slide is kept as something a source said in February. It supports Ravi's January to June period; it does not change who leads now. |

Ask "who leads the billing migration?" in August and the answer is Dana,
since 1 June, according to the June retro. Ask "who led it in March?" and
the answer is Ravi. Ask "when is the launch?" and you get October, with a
note that it was planned for June until April.

A search over the same four documents would return all of them and leave
the rest to chance.

## Where a memory system helps

- **Teams.** Decisions, owners and plans that survive staff changes and
  long projects, without anyone maintaining a wiki by hand.
- **Personal assistants.** An assistant that knows your commitments,
  preferences and history across months of email, notes and messages.
- **Customer-facing agents.** Support and account agents that know each
  customer's history, and which of the things a customer said last year
  still hold.
- **Coding agents.** Agents that remember why the code is the way it is:
  the decision, the constraint, the incident that caused it.
- **Research and analysis.** Keeping track of which source claimed what,
  where sources disagree, and how the picture changed over time.

## How it compares to things you know

| | What it keeps | What it cannot do |
|---|---|---|
| **Chat history** | The conversation, word for word | Tell what still holds; survive beyond one conversation or one tool |
| **Notes or a wiki** | Whatever someone writes down | Update itself when things change; point back to where each line came from |
| **Search over documents** | The documents | Tell old from new, said from true, or known from unknown |
| **A vector database** | The documents, indexed by meaning | The same as search: it finds similar text, not current facts |
| **A memory system** | What was said, what is true now, when, and where it came from | Replace your documents: it works from them, so you keep them |

## RememberStack

RememberStack is a memory system for AI agents.

Centralize all your information in a single place.
Expose that information to AI agents in the best possible way.

It is open source. Continue with
[Why RememberStack](https://remember.dev/docs), or take the
[five-minute tour](https://remember.dev/docs/start/how-it-works) to see what happens to a document once
you send it.

---

Source: https://remember.dev/docs

# Give your agents a past

Centralize all your information in a single place.
Expose that information to AI agents in the best possible way.

Your information is scattered across your life or your organisation: email,
chat, documents, meeting notes, tickets, agent transcripts. Every AI session
starts from zero. The agent does not know which decision still holds, which
file said it, or what changed overnight. You paste the same notes in again,
or the model fills the gap with something plausible.

RememberStack is memory for that agent. You give it the documents your work
already produces. It reads them, keeps what matters, and answers your
agent's questions with facts it can trace back to the source. It knows what
is true now and what was true a month ago.

*RememberStack is the open-source memory engine. `remember` is its Python
client and CLI.*

New to memory systems? Start with
[What is a memory system?](https://remember.dev/docs/start/what-is-a-memory-system), written for
any reader.

## What RememberStack keeps that a context window loses

**What each source said.** Every statement worth keeping is stored as a
claim, together with the exact passage it came from and the date the source
said it. Claims are never edited. If a spec from March said the billing
migration would ship in June, that stays on record even after the plan
changes.

**What is true now.** From those claims, RememberStack maintains facts about
the people, systems and decisions in your work. When a newer source changes
a fact, the fact changes, and the claim it replaced stays on record. When two
sources disagree, you see both, marked as a contradiction.

**When it was true.** Facts carry the period in which they held. You can ask
what is true today, what was true on a given date, what held during a
quarter, or how something changed over time.

**Where it came from.** Every fact links to the claims that support it, and
every claim to the characters in the source document. Your agent can quote
and cite. You can check.

## What your agent gets back

When your agent asks a question, RememberStack does not return the passages
most similar to the question. It returns the facts that answer it, each with
its time window, the evidence behind it, and any contradiction, in one typed
result that is ready to put into a prompt.

If the memory has nothing on the subject, the result says so. It does not
hand back the closest text it could find. If a name could mean two different
people, it says that too. An agent that is told "unknown" stops inventing.

No language model writes the answer. The only model involved in reading is
the embedding model that turns your question into a search vector. The same
question against the same memory returns the same result, and you can
inspect exactly why each item is there.

## Why not a vector database

A vector search finds the passages that sound most like your question. It
cannot tell you which of them is still current, which one a later document
corrected, or whether two of them contradict each other. It has no idea when
anything was true. And it always returns something, even when nothing
relevant exists.

| Where typical agent memory goes wrong | What RememberStack does instead |
|---|---|
| Source text is treated as the truth | What a source said (a claim) is kept apart from what is held true now (a fact). Claims are the transcript; facts are the verdict. |
| A correction overwrites what came before | The old fact's time window is closed, not erased. You can still ask what held before. |
| Contradictions are averaged away or hidden | Both sides come back, marked as a contradiction. |
| Re-sending a file makes it look more certain | Support counts distinct documents. Re-processing a file, editing it or repeating a sentence does not add support. |
| The search index is the authority | Indexes only nominate candidates. The database confirms each one against what is currently held before it is returned, and the result says how many were dropped. |
| A model writes the answer at query time | No language model writes the answer. Your agent plans; RememberStack returns what is known, with its evidence. |
| "No results" could mean anything | The result says which: the entity is unknown, or it is known and nothing matches. |

RememberStack still uses vector search, as one signal among several. The
others are keyword search, the graph of how entities relate, and time.

## Where it runs

RememberStack is open source under Apache-2.0 and runs on your own
infrastructure. Everything it does with your documents is in the public
repository. [Requirements](https://remember.dev/docs/self-hosting/requirements) says what it
needs.

## Start here

- [What is a memory system?](https://remember.dev/docs/start/what-is-a-memory-system): the idea
  in plain language, for any reader.
- [A five-minute tour](https://remember.dev/docs/start/how-it-works): what happens to a document
  from the moment you send it to the moment your agent asks about it.
- [Quickstart](https://remember.dev/docs/start/quickstart): send a document and ask your first
  question.
- [Connect your coding agent](https://remember.dev/docs/start/connect-your-agent): give Claude Code,
  Cursor, Codex or Claude Desktop access to your memory.
- [Time](https://remember.dev/docs/concepts/time): the idea that sets RememberStack apart from
  retrieval over text.

---

Source: https://remember.dev/docs/start/how-it-works

# A five-minute tour

This page follows one document through RememberStack. The team in the
example is working on a billing migration. Dana is the product lead and
Ravi is an engineer. The document is the notes from Thursday's stand-up.

## 1. You send the document

```python
from datetime import UTC, datetime

import remember

client = remember.Client.from_env()
version = client.ingest(
    "notes/2026-09-17-standup.md",
    source_kind="file",
    source_ref="notes/2026-09-17-standup.md",
    source_modified_at=datetime(2026, 9, 17, 9, 30, tzinfo=UTC),
)
```

RememberStack stores the file straight away and returns a `version_id`. The
`source_kind` and `source_ref` pair names where the file lives, so a later
edit of the same file becomes a new version of the same document rather than
an unrelated one. `source_modified_at` tells RememberStack when the source
said what it says.

Sending the same bytes again stores nothing new and returns `created: false`.
See [Documents, versions and sources](https://remember.dev/docs/concepts/documents-and-sources).

## 2. RememberStack reads it

The document now goes through a pipeline of stages that run in the
background.

1. **Convert.** The file becomes Markdown. Markdown and plain text pass
   through unchanged; other formats need a converter.
2. **Structure.** RememberStack finds the document's sections and what each
   is for, such as body, appendix or references.
3. **Chunk.** Each section is cut into passages along paragraph boundaries.
4. **Select and extract claims.** RememberStack picks out the statements
   worth keeping and drops opinions, advice and hypotheticals. It records
   why each dropped statement was dropped. Each kept statement becomes a
   *claim*, rewritten to stand on its own ("Ravi said the migration moves to
   October" rather than "he said it moves"), and tied to the exact
   characters it came from.
5. **Check grounding.** A deterministic check rejects any claim that uses
   words the source does not contain. This is where invented detail is
   caught.

![The stages a document passes through, from storing the file to being queryable.](https://remember.dev/docs/diagrams/pipeline.svg)

## 3. RememberStack connects it

1. **Resolve entities.** Names become *entities*. "Ravi", "Ravi K." and
   "the backend engineer on billing" can resolve to one person. A merge can
   be undone.
2. **Form facts.** Claims become *facts*: relations between two entities
   ("Ravi owns the invoice exporter") or observations about one ("the
   billing migration targets October").
3. **Adjudicate.** Each new claim is weighed against what the memory
   already holds. It either confirms an existing fact, adjusts the period in
   which that fact held, supersedes it, or is marked as contradicting it.
   Nothing is silently overwritten, and every decision is recorded.

If last week's planning doc said the migration targets June, the June fact
now has an end date, the October fact starts, and both keep their evidence.

## 4. The document becomes queryable

This takes minutes, not milliseconds, because every stage above does real
work. You can ask when a document is ready:

```python
client.wait_for_readiness([version.version_id])
```

See [Wait until a document is queryable](https://remember.dev/docs/guides/wait-for-readiness).

## 5. Your agent asks

```python
result = client.facts_context("When does the billing migration ship?")
```

`facts_context` finds the entities in the question, walks the graph around
them, and ranks the facts it finds there. The result contains:

- the current fact: the migration targets October;
- its time window: held since 17 September 2026;
- its evidence: the passage in Thursday's stand-up notes, with character
  positions;
- the fact it replaced, if you ask for history;
- anything contradicting it.

No language model writes this answer; the question is only embedded for
the semantic part of the search. The same question returns the same answer
until the memory changes.

## 6. You check the answer

Every fact links to the claims behind it. Every claim links to a passage,
and every passage to a document version. Your agent can quote the stand-up
notes word for word. You can open the file and find the sentence.

![From a fact back to the sentence in the source document.](https://remember.dev/docs/diagrams/provenance.svg)

## Where to go next

- [Quickstart](https://remember.dev/docs/start/quickstart): do this yourself.
- [Claims](https://remember.dev/docs/concepts/claims), [Facts](https://remember.dev/docs/concepts/facts) and
  [Time](https://remember.dev/docs/concepts/time): the ideas behind each step.
- [The pipeline and readiness](https://remember.dev/docs/concepts/pipeline): every stage in
  detail.

---

Source: https://remember.dev/docs/start/quickstart

# Quickstart

By the end of this page you will have sent a document to RememberStack,
waited for it to be processed, and asked a question that it answers with a
fact and the passage behind it.

*RememberStack is the open-source memory engine. `remember` is its Python
client and CLI.*

## 1. Get an endpoint

You need Docker Engine 28.0.0 or later with Compose, and an
[OpenRouter](https://openrouter.ai) API key. On an older Docker Engine,
turn on API authentication before the first start; see
[Requirements](https://remember.dev/docs/self-hosting/requirements#docker-and-compose).

```bash
git clone https://github.com/writeitai/remember-stack.git
cd remember-stack
cp .env.example .env
printf 'REMEMBERSTACK_POSTGRES_PASSWORD=%s\nREMEMBERSTACK_MINIO_ACCESS_KEY=%s\nREMEMBERSTACK_MINIO_SECRET_KEY=%s\nREMEMBERSTACK_SELFHOST_DEPLOYMENT_ID=%s\n' \
  "$(openssl rand -hex 32)" "$(openssl rand -hex 12)" "$(openssl rand -hex 32)" \
  "$(openssl rand -hex 16 | sed -E 's/^(.{8})(.{4}).(.{3}).(.{3})(.{12})$/\1-\2-4\3-8\4-\5/')" >> .env
# edit .env: set REMEMBERSTACK_OPENROUTER_API_KEY
docker compose up -d
```

The `printf` line generates the database password, the object-store
credentials and the deployment id; Compose refuses to start without them. The first start builds the PostgreSQL image and runs
migrations. When `docker compose ps` shows `api` as healthy, the endpoint
is `http://localhost:8000`. No token is needed by default, because the API
is reachable from this machine only. To open it to others, see
[Before you expose it](https://remember.dev/docs/self-hosting/install#before-you-expose-it).

## 2. Install the client

```bash
pip install remember
```

This installs the Python client and the `remember` command. It needs
Python 3.12 or later.
The current release is
[v0.17.2](https://github.com/writeitai/remember-stack/releases/tag/v0.17.2)
([on PyPI](https://pypi.org/project/remember/0.17.2/)).

## 3. Point the client at your endpoint

```bash
export REMEMBER_API_URL=http://localhost:8000
```

## 4. Send a document

Save this as `standup.md`:

```markdown
# Stand-up, 17 September 2026

Ravi said the billing migration moves from June to October, because the
invoice exporter needs a rewrite. Dana agreed and will tell finance.
Ravi owns the invoice exporter.
```

Then send it:

```python
from datetime import UTC, datetime

import remember

client = remember.Client.from_env()
version = client.ingest(
    "standup.md",
    source_kind="file",
    source_ref="notes/standup.md",
    source_modified_at=datetime(2026, 9, 17, 9, 30, tzinfo=UTC),
)
print(version.version_id, version.created)
```

The client sends `.md` files as `text/markdown`, whatever Python you run.

`created` is `True` the first time. Send the same bytes again and it is
`False`: nothing new is stored.

## 5. Wait until it is processed

```python
client.wait_for_readiness([version.version_id])
```

Processing reads, structures and connects the document. It takes minutes.
`wait_for_readiness` checks every 15 seconds for up to 30 minutes by
default, and stops with an error at once if a stage fails for good.

## 6. Ask

```python
result = client.facts_context("Who owns the invoice exporter?")
print(result.model_dump_json(indent=2))
```

The result is an envelope. Look for:

- `facts`: each fact, such as Ravi owns the invoice exporter, with its
  `validity` (when it held) and `evidence_count`;
- `evidence`: the claims behind each fact, with the document, the passage
  and its character positions;
- `negative`: set instead of facts when the memory knows nothing about what
  you asked.

[Reading a result](https://remember.dev/docs/concepts/reading-results) explains every field.

## The same with the CLI

```bash
remember ingest standup.md \
  --source-kind file --source-ref notes/standup.md \
  --source-modified-at 2026-09-17T09:30:00+00:00

remember query "Who owns the invoice exporter?"
```

`remember query "<text>"` runs `facts_context`. Add `--combined` to
`remember query text` to get facts together with claims and source passages.

## Next

- [Connect your coding agent](https://remember.dev/docs/start/connect-your-agent) so it can use this
  memory directly.
- [Give an agent context](https://remember.dev/docs/guides/agent-context): which operation to
  call for which kind of question.
- [Ingest files](https://remember.dev/docs/guides/ingest-files): PDFs, HTML, bulk loads.

---

Source: https://remember.dev/docs/start/connect-your-agent

# Connect your coding agent

Your coding agent can read from and write to RememberStack directly, as a
set of MCP tools (Model Context Protocol, the standard way agents call
external tools). Once connected, the agent can look up what the project
decided, who owns what, and what changed, and it can store new notes as it
works.

The `remember` package includes an MCP server that runs next to your
agent and talks to your engine. `remember setup` writes the
configuration for you:

```bash
pip install remember
remember setup --self-hosted
```

It detects which agents you use in the current directory and on your
machine (Cursor, Claude Code, Claude Desktop, Codex, Antigravity) and
configures each one. To configure one agent only:

```bash
remember setup --self-hosted --agent cursor   # or claude, codex, agy
```

Add `--dry-run` to see what it would write, and `--api-url` if your engine
is not at `http://localhost:8000`. Then check the result:

```bash
remember doctor
```

The files `remember setup` writes, and how to write them by hand, are
in the [MCP reference](https://remember.dev/docs/reference/mcp).

For an agent that connects to a URL rather than starting a program, run the
server over HTTP and point the agent at `http://127.0.0.1:8765/mcp`:

```bash
remember mcp --transport http --api-url http://localhost:8000
remember setup --mcp-url http://127.0.0.1:8765/mcp
```

Cursor, Claude Code and Codex get the URL; Claude Desktop and Antigravity
still start `remember mcp` themselves.

The HTTP server holds no key: it passes each agent's `Authorization` header
to the engine. See [MCP tools: Over HTTP](https://remember.dev/docs/reference/mcp#over-http).

## What the agent can do

The `remember mcp` server gives the agent these tools:

| Tool | What it is for |
|---|---|
| `resolve_entity` | Find which person, system or thing a name refers to. Reports ambiguity instead of guessing. |
| `facts_context` | The current facts about the entities in a question, with evidence and time windows. The usual first call. |
| `claims_and_sources_context` | What sources said, with the passages. Use it when the exact wording matters. |
| `combined_context` | Facts and claims together, when the agent wants everything in one call. |
| `ingest` | Store a note, a decision or a file. |
| `pipeline_readiness` | Check whether stored documents have finished processing. |
| `delete_document` | Remove a document from the memory when the user asks. |
| `query_sql` and six related tools | Read-only SQL over the memory, for questions the operations above do not cover. |

Run `remember mcp --read-only` instead of `remember mcp` to leave out the
two tools that change memory (`ingest` and `delete_document`).

## Teach the agent the order

Agents use memory best when they follow one order: resolve the entity,
then ask for facts, then fall back to claims and sources, then to SQL.
`remember setup` writes this guidance as a Cursor rule
(`.cursor/rules/remember.mdc`) and an Antigravity skill
(`.agents/skills/remember/SKILL.md`). For other agents, put this in the
system prompt or project instructions:

```text
You have a project memory available through the "remember" tools.
1. When a question names a person, system or thing, call resolve_entity first.
   If it returns more than one candidate, ask which one is meant.
2. Call facts_context for what is currently true. Pass time={"mode": "history"}
   when the question is about how something changed.
3. When the exact wording or the source matters, call claims_and_sources_context.
4. If the memory reports it knows nothing, say so. Do not guess.
5. Quote evidence with its document when you rely on it.
```

## Next

- [Give an agent context](https://remember.dev/docs/guides/agent-context): which operation fits
  which question.
- [Handle unknowns and ambiguity](https://remember.dev/docs/guides/unknowns-and-ambiguity).

---

Source: https://remember.dev/docs/concepts/documents-and-sources

# Documents, versions and sources

An agent's memory is only as trustworthy as its knowledge of where each piece of
knowledge came from. If the spec for the billing migration changes three
times, you need to know which text said what, and when. If the same meeting
notes are sent twice, you do not want them counted twice. If a file is
renamed, it is still the same file.

RememberStack handles this at the very first step, before any model reads
your text. Every file you send becomes a **version** of a **document**, and
every document is tied to the place it came from, its **source**.

## Document and version

A **document** (`doc_id`) is one logical file over its whole life: the
billing migration spec, one meeting transcript, one chat thread. It has a
stable identity that does not change when the content changes.

A **version** (`version_id`) is one snapshot of that document's bytes, as you
sent it at one moment. Versions are append-only: a new upload with different
bytes adds a new version and never edits an old one. Each version records:

- the SHA-256 hash of its bytes (`content_hash`),
- the time the source says it was last modified (`source_modified_at`),
- an optional revision marker from the source system (`source_version_ref`),
- who ingested it (`ingested_by`, see [below](#who-ingested-a-version)).

Everything RememberStack later derives from the file (its converted text,
sections, chunks, claims) hangs off one specific version. That is what lets
an answer point back to the exact text that produced it, even after the
document has moved on.

The bytes themselves are stored once per content hash, as a **content
object**. If two documents contain identical bytes, or a document is changed
and later changed back, the bytes are not stored or converted a second time.

## Source: `source_kind` and `source_ref`

A **source** tells RememberStack which real-world thing a document stands
for. You give it as a pair:

- `source_kind`: the class of source, a short name you choose, for example
  `notes`, `drive`, `slack`, `agent`.
- `source_ref`: the stable identifier of this item inside that class, for
  example a file path, a Drive file ID, or a thread ID.

The pair is the document's identity. Send the same `source_kind` and
`source_ref` again with different bytes and you get a new version of the same
document. Send a new `source_ref` and you get a new document.

The two values are always given together. A `source_ref` alone is ambiguous
(`42` could be a ticket, a message or a file), so the engine refuses one
without the other: the HTTP API returns `422` and the Python client raises
`ValueError` with `source_kind and source_ref must be supplied together`.

```python
from datetime import datetime, UTC

import remember

with remember.Client() as memory:
    version = memory.ingest(
        "specs/billing-migration.md",
        source_kind="notes",
        source_ref="specs/billing-migration.md",
        source_modified_at=datetime(2026, 3, 4, 15, 0, tzinfo=UTC),
    )
    print(version.doc_id, version.version_id, version.created)
```

**Note:**

Choose a `source_ref` that stays the same for the life of the item. A
path is fine if files do not move; a Drive file ID or a database key is
better if they do. Renaming is metadata; a new `source_ref` is a new
document.

### Ingesting without a source

You can leave out both values. RememberStack then treats the upload as a
one-shot file: `source_kind` becomes `upload` and `source_ref` becomes the
content hash, so the document's identity *is* its bytes. Sending the same
bytes again is a no-op; sending edited bytes creates an unrelated document.

Use this only for files that will never change. For anything an agent will
write to again (notes, a running log, a spec), give a source so that later
writes become versions of one document. Without a source you also cannot set
`source_modified_at`, `source_version_ref` or `living` mode; the engine
rejects them with `source timestamps, revisions, and living mode require
source_kind/source_ref`.

## `source_modified_at` becomes the said-on time

`source_modified_at` is when the source says the content was written or last
changed: the timestamp of the meeting, the send time of a message, the
last-modified time of a file. It must be timezone-aware UTC.

RememberStack copies it onto every claim extracted from that version as
`asserted_at`, the time the source said it. This is one of the three clocks
described in [Time](https://remember.dev/docs/concepts/time). It also anchors relative dates: when a
transcript from 2026-03-04 says "last Friday", the extractor resolves that
against the document's date.

If you leave it out, `asserted_at` is empty and relative dates can be
resolved only against a date written inside the document itself.

`source_modified_at` is fixed once the version exists. Re-sending identical
bytes with a different timestamp does not change it, because the claims were
already extracted against the original value.

## Re-ingesting the same bytes: `created=false`

Every ingest returns an `IngestedVersion`:

| Field | Meaning |
|---|---|
| `deployment_id` | The deployment that stored it. |
| `doc_id` | The document (stable across versions). |
| `version_id` | The version this call produced or matched. |
| `content_hash` | SHA-256 of the bytes. |
| `created` | `true` if this call created a new version; `false` if the bytes match the document's latest version. |
| `mime` | The MIME type conversion uses for these bytes. |
| `title` | The document's title, set by its first ingest. |
| `versioning_mode` | `snapshot` or `living`, set by its first ingest. |

When the bytes match the document's **latest** version, nothing new is
created and no processing is scheduled. You get the existing `version_id`
back with `created=false`. This makes ingest safe to retry and safe to run
from a loop that re-sends everything: unchanged files cost nothing.

If you pass a new `source_version_ref` with unchanged bytes, the version's
revision marker advances so a sync process does not fetch the same revision
forever. Nothing else about the version changes.

Bytes that match an **older** version (the file was changed and then changed
back) create a new version. The document moves forward; it never silently
jumps back to an old snapshot.

Bytes sent again after the document was [deleted](#deleting-a-document)
also create a new version (`created=true`), and it is processed from the
start.

## Deleting a document

Deleting a document removes it from the memory: every version of it, in one
call (`DELETE /documents/{doc_id}`, `MemoryClient.delete_document`,
`remember documents delete`, or the `delete_document` MCP tool).

Say Dana uploaded a draft of the billing migration plan that says "the
migration finishes in March", and Ravi's status note says the same. Dana
deletes the draft. From then on:

- the draft is gone from `GET /documents`, search, facts, the graph and SQL
  queries;
- its claims stop counting as evidence (their currency ends with reason
  `version_deleted`);
- "the migration finishes in March" stays believed, because Ravi's note still
  supports it; it now has one supporter instead of two;
- a fact that only the draft supported is closed, and the closure is recorded
  as a retraction (`retracted_source_removal`), exactly as when a living
  document drops a passage.

Deleting is not erasing. The claims, the stored original and the record of
what changed stay in the deployment as history, which is why a deletion can
be audited. Erasing a document's bytes and every trace of it is a separate,
heavier operator operation that is not offered through the API.

If you send the same file again later, it is added back as a new version and
processed like a new document. The facts that were closed stay closed; its
new claims support facts in the usual way.

A deletion is all or nothing. A document that was still being processed
when you deleted it publishes no further claims, and anything it had already
produced is never visible and is retired when its version reaches the
`reconcile` stage. Details and errors:
[`DELETE /documents/{doc_id}`](https://remember.dev/docs/reference/http-api/ingest#delete-documentsdoc_id).

## Document statuses

Each version carries a processing status for its early stages:

| Status | Meaning |
|---|---|
| `ingesting` | Accepted; bytes are being recorded. |
| `converting` | Waiting for or running conversion to text. |
| `structuring` | Converted; sections are being detected. |
| `ready` | Converted and structured. |
| `failed` | Conversion or structuring failed; the version's `error` says why. |
| `deleted` | Removed. |

**Warning:**

`ready` here means the version has been converted and structured. It
does **not** mean its claims and facts are queryable yet. Extraction and
fact building run after this. To know when a version can be recalled,
check [readiness](https://remember.dev/docs/concepts/pipeline#readiness), not the document status.

`GET /documents` lists documents newest first, reports the newest version's
status, and adds a `serving` flag that is `true` when any version of the
document has reached `ready`. A document whose newest upload failed can still
be serving an older version. See
[Ingest, readiness, documents](https://remember.dev/docs/reference/http-api/ingest).

## Who ingested a version

A version can record the actor that created it (`ingested_by`), as one of
three kinds:

- `user`: a person,
- `api_credential`: a machine credential that a person created,
- `service`: the deployment's own automation.

The three are never collapsed: activity by a token is not attributed to the
person who minted it. The actor's identifier is opaque to the engine and
treated as personal data that can be erased.

Attribution is sent in the `X-Ingest-Principal-Kind` and
`X-Ingest-Principal-Ref` headers of `POST /ingest`. The engine honours them
only when the deployment declares its network perimeter trusted and the
caller holds full write authority; otherwise it ignores them without failing
the upload. Attribution is set once, when a version is created. Re-sending
the same bytes as someone else changes nothing.

The Python client does not send these headers, and no read route returns the
recorded actor yet.

## Where to go next

- [Claims](https://remember.dev/docs/concepts/claims): what RememberStack extracts from each version.
- [Updating a source](https://remember.dev/docs/concepts/updating-sources): what a new version means, in
  `snapshot` and `living` mode.
- [Ingest files](https://remember.dev/docs/guides/ingest-files) and
  [Keep a source up to date](https://remember.dev/docs/guides/keep-sources-current).
- [Ingest, readiness, documents](https://remember.dev/docs/reference/http-api/ingest) for every
  parameter and error.

---

Source: https://remember.dev/docs/concepts/claims

# Claims: what a source said

Most memory systems hand your agent a text chunk and let the model work out
what it means. The chunk says "he agreed to move it to June". Who agreed?
Move what? Which June? The agent guesses, or asks you again.

RememberStack does that reading once, at write time. It turns each document
into **claims**: short statements that stand on their own, each tied to the
exact characters of the source that support it. A claim records *what a
source said*, not whether it is true. Deciding what is true is the job of
[facts](https://remember.dev/docs/concepts/facts). Claims are the transcript; facts are the verdict.

![Three sources make claims about Ravi. Two support the fact that he works on the billing migration; the retro supports a new fact, that he works on the search team from 1 June, and closes the earlier fact on that date.](https://remember.dev/docs/diagrams/claims-and-facts.svg)

## What a claim is

A claim is one coherent assertion from one document version, rewritten so a
reader needs no surrounding context. From a meeting transcript dated
2026-03-04:

> **Ravi:** Dana and I agreed yesterday to move the cutover to June 8.

RememberStack might store:

| Field | Value |
|---|---|
| `claim_text` | `Ravi said that Dana and Ravi agreed on 2026-03-03 to move the billing migration cutover to June 8.` |
| `source_span` | `Dana and I agreed yesterday to move the cutover to June 8.` |
| `is_attributed` | `true` |
| `asserted_at` | `2026-03-04T15:00:00Z` |

The claim resolves pronouns ("I" becomes Ravi), partial references ("the
cutover" becomes the billing migration cutover, when the document says so)
and relative dates ("yesterday" becomes 2026-03-03 when the document carries a
date). The original wording is kept in `source_span`.

A claim keeps an assertion together even when its support spans several
sentences, and keeps apart things that differ: independently dated events,
statements by different speakers, and propositions that sound alike but are
not. "Ravi finished the migration", "Ravi worked on the migration" and "Ravi
enjoyed the migration" are three different claims.

Every claim carries:

- `claim_id`, `doc_id` and `chunk_id` (the passage it came from),
- `claim_text` and `source_span`,
- `char_start` and `char_end`, the origin span in the version's converted
  text, and `evidence_spans`, every span that supports it (see
  [Evidence](https://remember.dev/docs/concepts/evidence)),
- `is_attributed` (see [below](#attributed-claims)),
- `asserted_at`, when the source said it,
- `claim_valid_from`, `claim_valid_until`, `claim_valid_precision` and
  `claim_valid_kind`, when the claim says it happened or was true (see
  [Time](https://remember.dev/docs/concepts/time)),
- `is_current_testimony` (see [below](#current-and-superseded-testimony)).

## How claims are made

Extraction runs two model calls per chunk, in two pipeline stages.

### 1. Selection: what is worth keeping

The first call, **Selection**, reads a chunk with its surrounding context
and judges every statement in it. Each candidate gets exactly one outcome:
`keep`, `keep_flagged` (kept, but marked borderline), or a drop with a named
reason. The drop reasons are a fixed list:

| Reason | What it drops |
|---|---|
| `opinion` | An unattributed opinion ("this approach is cleaner"). |
| `advice` | Advice or recommendations ("you should back up first"). |
| `hypothetical` | Hypotheticals ("if we delayed, costs would rise"). |
| `generic` | Generic truisms ("migrations are risky"). |
| `question` | Questions. |
| `intro` | Section introductions ("this section covers…"). |
| `conclusion` | Section conclusions and wrap-ups. |
| `no_info` | Statements that say nothing is known ("we don't know yet"). |
| `ambiguous` | Statements whose meaning the source leaves open. |
| `references_boilerplate` | Reference lists and boilerplate. |

Selection keeps specific, checkable assertions about events, states,
decisions, quantities, policies and relationships, including quantities,
dates and changes of state phrased as opinions. When unsure, it prefers
`keep_flagged` to a drop.

An opinion **with a holder** is kept. "Dana thinks the June date is too
tight" is a claim about Dana's stance, not an assertion that the date is too
tight.

Selection also notes the people, companies, works and events the chunk
introduces, so later chunks of the same document can refer back to them.

### 2. Claimify: make each statement stand alone

The second call, **Claimify**, rewrites each kept statement into a standalone
claim (decontextualise), splits unrelated assertions apart (decompose), and
cites the passages that support it. It may only use the document itself:
the header, the target chunk, permitted neighbouring passages and the quoted
passages of earlier references. It never uses outside knowledge. If the
source leaves several readings possible, the candidate is omitted.

Every piece of text Claimify adds from outside the target chunk is listed in
the claim's `added_context`, tagged with where it came from.

### 3. The grounding gate: no text without a source

Before a claim is stored, a deterministic check (no model involved) verifies
it:

- Every cited passage must exist. The first, the origin, must lie in the
  target chunk and overlap a statement Selection kept.
- Every word Claimify added must occur in the document's permitted context.
  Numbers get no exception, except an ISO date the claim itself resolved and
  also recorded in its structured time fields.

A claim that fails is not stored. RememberStack does not ask the model to
try again; it drops the candidate and records why.

The model also returns its own judgement of whether the source supports the
whole claim (`entailment_self_verdict`). It is stored for audit but does not
decide anything: a matching word is not proof of meaning, and the engine
does not pretend otherwise.

## The audit ledger

Every decision along the way is written to an append-only ledger, one row
per decision:

| Decision type | Recorded when |
|---|---|
| `selection_drop` | Selection dropped a statement (with its reason). |
| `selection_keep_flagged` | Selection kept a borderline statement. |
| `decontext_edit` | Claimify added context to a statement. |
| `claimify_omitted` | Claimify left out a kept statement. |
| `grounding_rejected` | The grounding gate refused a claim (with the check that failed). |

So "why is this sentence not in memory?" has a recorded answer. The
ledger lives in the deployment's PostgreSQL database. It is not yet exposed
through the HTTP API, the SDK or the `memory_v1` query space.

## Claims never change

A claim is immutable. Nothing edits its text, its dates or its source after
it is written. If a later source contradicts it, that is a new claim from a
new source. If the extractor improves and reads the same file differently,
that produces new claims, and the old ones remain as history.

This is what keeps provenance honest. A claim is testimony: this
source, in this version, said this. Belief can change; testimony does not.
The part that changes when evidence changes is the [fact](https://remember.dev/docs/concepts/facts).

When a new version of a document keeps a passage unchanged, RememberStack
does not extract it again: the unchanged passage keeps its existing claims,
with the same `claim_id`. The cost of a new version grows with the size of
the edit, not the size of the document.

## Attributed claims

`is_attributed` is `true` when the claim records someone's statement or
stance rather than asserting something directly: "Ravi said the cutover
moved", "Dana believes June is too tight".

Attribution is never dropped. "Ravi said he finished the migration" does not
become "Ravi finished the migration". When the claim becomes a fact, an
attributed claim becomes an observation about the speaker's stance, not a
fact about the subject (see [Facts](https://remember.dev/docs/concepts/facts#observations)).

## Current and superseded testimony

`is_current_testimony` says whether a claim still counts as what its source
currently says. It starts `true`. It becomes `false` when:

| Reason | What happened |
|---|---|
| `reextracted` | A newer extractor processed the same file; its claims replace these. |
| `version_superseded` | In `living` mode, a new version of the document no longer contains this passage. |
| `version_deleted` | The version was deleted. |

A fourth reason, `review_restored`, returns a claim to current testimony.

Currency is bookkeeping, not truth. A non-current claim still exists, can
still be read for audit, and still says what it said. What changes is that it
no longer counts as support for a fact. In `snapshot` mode (the default) a
new version flips nothing: every version stays standing testimony. See
[Updating a source](https://remember.dev/docs/concepts/updating-sources).

The assured operations return current testimony only. Historical claims are
visible through the `memory_v1` query space (`claims_visible_history`) for
audit.

## Where to go next

- [Facts](https://remember.dev/docs/concepts/facts): how claims become what memory holds true.
- [Evidence](https://remember.dev/docs/concepts/evidence): how a claim points back to the source text.
- [Time](https://remember.dev/docs/concepts/time): the dates a claim carries.
- [Cite the source of an answer](https://remember.dev/docs/guides/cite-sources).

---

Source: https://remember.dev/docs/concepts/facts

# Facts: what is held true

Claims tell you what each source said. An agent usually needs something
else: what is true now, how sure the memory is, and what changed. Five
meeting notes that each say Ravi works on the billing migration should be
one piece of knowledge with five sources behind it, not five search hits.
A retro that says he moved to the search team in June should close that
knowledge, not sit next to it as an equal.

A **fact** is that piece of knowledge. RememberStack builds facts from
claims, attaches every supporting claim as evidence, and revises a fact when
new evidence arrives. Claims never change; facts do.

## Two kinds of fact

### Relations

A **relation** connects two entities with a predicate:

```
Ravi  works_on  billing migration
Dana  reports_to  Head of Product
```

Relations are the edges of the knowledge graph. You can traverse them
(`graph_neighborhood`, `graph_path`), and `facts_context` expands through
them from the entities you ask about. See [Retrieval](https://remember.dev/docs/concepts/retrieval).

### Observations

An **observation** is a statement about one entity:

```
Billing migration: the cutover is scheduled for 2026-06-08.
Dana: Dana believes the June date is too tight.
```

Observations carry values, properties and anything that does not fit a
two-entity predicate: numbers, descriptions, states, stances. They are
anchored on their entity and searchable, but they are not graph edges.

An attributed claim ("Dana believes…", "Ravi said…") always becomes an
observation on the person who holds the view, never a fact about the thing
they talked about.

Both kinds share one shape in results: a `FactResult` with `kind` set to
`relation` or `observation`, a readable `label`, a `validity` window and an
`evidence_count`. See [Reading a result](https://remember.dev/docs/concepts/reading-results).

## Predicates

A relation's predicate comes from a governed vocabulary, not free text.
Free-text predicates grow into hundreds of near-synonyms ("works at",
"employed by", "is on the team of") that no query can gather. A fixed
vocabulary keeps the graph queryable.

The core vocabulary has 16 predicates:

`related_to`, `works_for`, `member_of`, `affiliated_with`, `founded`,
`located_in`, `part_of`, `authored`, `created`, `about`, `knows_about`,
`knows`, `participated_in`, `works_on`, `uses`, `reports_to`.

The normalizer maps synonyms onto these ("employed by" becomes `works_for`).
A relational fact that fits none of them may use an escape value,
`other:<short_snake_case>`, for example `other:sponsors`. The value must
match `other:` followed by a lower-case letter and 1 to 40 lower-case
letters, digits or underscores. Any other unknown predicate is dropped; the
claim still holds the statement.

**Extension packs** add predicates for a domain. The engine ships one, `work`,
with `blocks`, `depends_on`, `concerns`, `decided_by`, `assigned_to` and
`pursues`. Packs are installed at the engine level; there is no HTTP route or
CLI command to enable one yet.

## How a claim changes the facts

After claims are extracted, each claim is normalized into zero or more
assertions (relations and observations), its entities are resolved (see
[Entities](https://remember.dev/docs/concepts/entities)), and each assertion is then **adjudicated** against
the existing facts about the same entity. The adjudicator sees the incoming
assertion, the candidate facts, their evidence and their dates, and makes
one decision:

| Decision | What happens | Recorded outcome |
|---|---|---|
| **Add** | The assertion is a new proposition. A new fact is created with it as evidence. | `add` |
| **Confirm** | The assertion repeats an existing fact. The claim is attached as supporting evidence; nothing else changes. | `noop` |
| **Adjust** | The assertion is the same fact with better dates. The fact's world-time window is replaced, and the decision names the claims that justify the new dates. | `update` |
| **Supersede** | The assertion is a successor to an existing fact ("moved to the search team"). The earlier fact's window is closed at the successor's start in the world, never at the time the source spoke. | `update` on the earlier fact |
| **Contradict** | The assertion conflicts with an existing fact. Either the claim attaches to that fact as evidence with stance `contradicts`, or the assertion becomes its own fact and the two are linked in a contradiction group. | `contradict` for facts placed in a group |

Every decision is written to the fact's decision transcript with its
confidence, method and rationale. You can read it with
`transcript_relation` (see [Evidence](https://remember.dev/docs/concepts/evidence#why-do-we-believe-this)).

Rules that keep this safe:

- **Low confidence never merges.** If the adjudicator's confidence is below
  the floor (0.75 by default), the assertion becomes a separate new fact
  instead of being merged into an existing one. A wrong split is visible and
  cheap to live with; a wrong merge silently destroys a fact.
- **No candidates, no model call.** When the entity has no facts yet, the
  assertion becomes a new fact directly.
- **Distinct events stay distinct.** Two wins of the same tournament in
  different years are two facts. Equal wording or equal dates do not make
  two assertions the same fact, and different dates do not make them
  different.
- **Corrections move dates, not statements.** A correction from 5 November
  to 6 November keeps the fact and changes its window.

### Two adjudication engines

Self-hosted deployments choose how this decision is made with
`REMEMBERSTACK_FACT_ADJUDICATION_ENGINE`:

- `prompt` (the default) asks a chat model for the decision as a structured
  answer.
- `jev` answers the same questions with a decision model (TypeSafe AI's
  System One) that picks from fixed choices (match, stance, window action)
  instead of writing free text. It needs a TypeSafe API key.

Both engines go through the same validation, the same confidence floor and
the same writer. See [Models and providers](https://remember.dev/docs/self-hosting/models).

## Evidence count

`evidence_count` is the number of **distinct documents** whose current
testimony supports the fact.

It does not count claims, versions or repetitions. Five claims from one
transcript count once. Ten versions of one spec count once. A new extractor
re-reading the same file does not add support. Two different documents
saying the same thing count twice.

Claims attached with stance `contradicts` are counted separately and
reported per stance in `evidence_totals` (see
[Reading a result](https://remember.dev/docs/concepts/reading-results)).

## Support withdrawn

A fact's `support` is normally `current`. It becomes `withdrawn` when every
source that asserted it has stopped doing so for a processing reason: a new
extractor version failed to derive the claim again from a file that did not
change. RememberStack cannot tell by itself whether the old reading was
wrong or the new extractor regressed, so it does not delete the fact. It
flags it.

A withdrawn fact is still returned, with `support: "withdrawn"`, so an agent
sees that the ground moved before it relies on it.

This is different from a source removing content. When a `living` source
drops the text that was a fact's only support, or a document is deleted, the
fact is closed and the closure is recorded. See
[Updating a source](https://remember.dev/docs/concepts/updating-sources).

## Labels

Every fact has a readable `label`. A relation's label is built without a
model, from its entity names and predicate ("Ravi works on billing
migration"). An observation's label is its statement. Labels hold only the
statement; the dates live in the `validity` fields, never in the text.

## Where to go next

- [Time](https://remember.dev/docs/concepts/time): the window every fact carries and how to query it.
- [Contradictions](https://remember.dev/docs/concepts/contradictions): what happens when facts disagree.
- [Entities](https://remember.dev/docs/concepts/entities): what a fact is about.
- [Assured operations](https://remember.dev/docs/reference/assured-operations): `facts_context`.

---

Source: https://remember.dev/docs/concepts/entities

# Entities and identity

"Ravi", "Ravi S." and "our backend lead" may be one person. Two people
called Dana are two people. If memory gets this wrong in one direction,
facts about different people blur into one; in the other, one person's
history splits across duplicates and no query sees all of it. An agent that
plans on top of either makes confident mistakes.

An **entity** is one real-world referent (a person, a team, a project, a
document, an event), with one `entity_id` however it is spelled. Facts are
about entities. RememberStack decides identity carefully at write time and
never guesses at read time.

## What an entity has

- `entity_id`: its stable identity.
- A canonical name and **aliases**: every spelling the sources used for it.
- A **profile**: a short prose summary built from its most important
  observations and relations. The profile helps decide identity; it is not
  the identity.

An entity has no type. See [No entity types](#no-entity-types).

## Matching a mention at write time

Every relation or observation names its entities. For each name, the
resolver works through a cascade, cheapest step first. The first three steps
only *find candidates*; they never decide on their own.

1. **Exact alias.** Is there an entity with exactly this cleaned-up name?
   An exact match lists every such entity, which may be several (two people
   called Dana). It is a candidate list, not a verdict.
2. **Similar spelling.** Trigram similarity finds near-spellings: "Ravi
   Shankar" and "Ravi Shanker".
3. **Similar sound.** A phonetic code (Daitch–Mokotoff) finds names that
   sound alike but are spelled differently.
4. **Profile embedding.** The mention, together with its claim, is embedded
   and compared with each candidate's profile. A strong match accepts a
   repeat of a known entity without calling a model. This is how routine
   mentions of known people stay cheap.
5. **One small-model choice.** When the profile step cannot decide (no
   profile yet, a conflict with the claim, several plausible candidates),
   one call to a small model sees the mention, its claim and the bounded
   candidate set, and returns one existing candidate or "new". It prefers an
   existing compatible candidate unless the evidence positively
   distinguishes a new referent.

If no candidate matches, a new entity is created. Every verdict is recorded
with its step, scores and the resolver version.

Two refinements matter in practice:

- Within one document, once a name has been matched to an entity, the same
  exact name in the same document reuses that match rather than asking
  again.
- Bare head nouns ("the system", "the app", "a card") are not entities
  unless the claim pins down a specific referent.

## Merges and reversibility

Identity is revisited as knowledge grows. When an entity's profile is
refreshed, RememberStack re-examines its neighbourhood: entities that share
aliases or similar profiles are grouped by profile similarity, never by
chaining pairwise guesses, so the result does not depend on the order in
which documents arrived.

A merge, when applied, is a redirect from the absorbed entity to the
survivor, stored with a snapshot of the state before the merge. Undoing it
replays that snapshot. Nothing is overwritten. Merges that would touch many
facts, and entity groups that grow suspiciously large, are held back rather
than applied.

**Note:**

Automatic merging is off by default. Merge proposals are recorded for
review instead of applied, and there is no user-facing command to review
them yet. In practice the write-time cascade above decides identity.

## Resolving a name at query time

When you or your agent ask about "Ravi", the `resolve_entity` operation
turns the name into entity IDs. It uses the same exact, spelling and sound
steps, and falls back to profile embedding search only when those find
nothing. It never calls a model and never makes the small-model choice.

It also never picks for you:

- **One candidate**: you have your entity.
- **Several candidates**: that is ambiguity, and every candidate is
  returned, ranked, with the step that found it (`tier`: `T0` exact, `T1`
  spelling, `T2` sound, `T3` profile embedding). Your agent chooses, or asks.
- **No candidate**: the envelope carries a typed negative, `unknown_entity`,
  instead of an empty list that could be mistaken for "no facts".

```python
import remember

with remember.Client() as memory:
    result = memory.resolve_entity("Dana")
    if result.negative is not None:
        print(result.negative.kind, result.negative.explanation)
    elif len(result.entities) > 1:
        for candidate in result.entities:
            print(candidate.entity_id, candidate.canonical_name, candidate.tier)
    else:
        dana = result.entities[0]
        facts = memory.facts_context("billing migration", entity_ids=[dana.entity_id])
```

Returning an ambiguity is the point. A system that silently picks the
likelier Dana answers confidently about the wrong person. See
[Handle unknowns and ambiguity](https://remember.dev/docs/guides/unknowns-and-ambiguity).

## No entity types

Entities carry no class such as Person, Company or Project. This is a
deliberate choice.

Types force a decision at the first mention, when the least is known, and
then that decision gets in the way. A name used for both a person and their
company splits into twins; two homonyms of different types cannot be
compared; "is a bank" becomes a type in one place and a fact in another.

What a type would have said lives in **observations** instead ("Northwind is
a payment provider based in Dublin") and in the profile. "List the payment
providers" is answered by searching fact text, not by filtering a type
column. Relations need no type check either: `works_for` works whether its
object is a company or a person.

## Where to go next

- [Facts](https://remember.dev/docs/concepts/facts): what is recorded about an entity.
- [Retrieval](https://remember.dev/docs/concepts/retrieval): how entity IDs scope `facts_context` and the
  graph.
- [Entities and facts routes](https://remember.dev/docs/reference/http-api/entities-and-facts):
  `GET /resolve` and the lookup routes.

---

Source: https://remember.dev/docs/concepts/time

# Time

Ask a vector store "who works on the billing migration?" and it returns the
passages that sound most like the question. The January note that says Ravi
joined, and the June retro that says he left, both sound like the question.
The store has no idea that one replaced the other. Your agent gets both,
with nothing to tell them apart, and answers with whichever the model
happened to read first.

Time is where memory for agents most often fails, and where RememberStack
does the most work. Every fact knows when it was true in the world. Every
claim knows when its source said it. Every fact knows when the memory
learned it and when the memory stopped believing it. And every query says
which time it is asking about.

## Three clocks

RememberStack keeps three separate clocks. Mixing them up is the most common
source of wrong answers about time, so they never share a field.

| Clock | Question it answers | Fields |
|---|---|---|
| **World time** (valid time) | When was this true, or when did it happen? | Facts: `valid_from`, `valid_until`, `valid_precision`. Claims: `claim_valid_from`, `claim_valid_until`, `claim_valid_precision`, `claim_valid_kind`. |
| **Said-on time** | When did the source say it? | Claims: `asserted_at`. |
| **Belief time** (system time) | When did the memory learn it, and when did it stop believing it? | Facts: `ingested_at`, `invalidated_at`. |

![One fact on three timelines: valid 14 January to 1 June in the world, said on 15 January and 12 June by two sources, learned by the memory on 15 January and still believed. A query at 1 March finds it true; a query at 1 July finds it ended.](https://remember.dev/docs/diagrams/three-clocks.svg)

A worked example makes the difference concrete. The team's notes contain
two documents:

- **Kickoff notes**, ingested with `source_modified_at` 2026-01-15 10:00 UTC:
  "Ravi has been on the billing migration since yesterday."
- **Retro notes**, ingested with `source_modified_at` 2026-06-12 14:00 UTC:
  "Ravi moved from the billing migration to the search team on 1 June."

For the fact "Ravi works on billing migration", the three clocks read:

| Clock | Value | Where it came from |
|---|---|---|
| World time | from 2026-01-14 until 2026-06-01 | "since yesterday", resolved against the kickoff date; closed by the retro's "on 1 June" |
| Said-on time | 2026-01-15 (kickoff claim), 2026-06-12 (retro claim) | each document's `source_modified_at` |
| Belief time | learned 2026-01-15, a few minutes after ingest; still believed | when the pipeline wrote the fact; no retraction since |

Notice what the retro did *not* do. It did not end the fact on 12 June, the
day the retro was written. Said-on time is provenance, never validity. The
fact ends on 1 June because that is when the retro says the move happened.

## World time on claims

Every [claim](https://remember.dev/docs/concepts/claims) carries the world time its source stated, if any,
as four fields:

- `claim_valid_from` and `claim_valid_until`: the stated bounds.
- `claim_valid_precision`: how exact they are (see
  [Precision](#precision)).
- `claim_valid_kind`: what the bounds describe:

| Kind | Meaning | Example |
|---|---|---|
| `event_time` | When something happened. | "Ravi merged the schema change on 2026-03-03." |
| `effective_period` | When a state or arrangement applied. | "Ravi worked on the billing migration from January to May." |
| `measurement_period` | The period a figure covers. | "Q1 billing errors fell to 12." |
| `proposition_validity` | When a proposition holds. | "Dana has led product since 2024." |

Most claims state no world time. They keep `unknown` precision and empty
bounds. RememberStack never falls back to the said-on time or the ingest
time to fill the gap; an undated statement stays undated.

**Relative dates are resolved.** When a source says "yesterday", "last
Friday" or "last year" and the document has a date, the extractor resolves
the expression and writes the absolute date into both the structured fields
and the claim text: "since yesterday" in a note dated 2026-01-15 becomes
"since 2026-01-14". The original wording stays in `source_span`. When there
is no date to resolve against, or the expression is too vague ("a few weeks
ago"), the wording is kept as spoken and no date is invented. A relative
phrase still present in `claim_text` is itself the signal: read it against
`asserted_at`.

The document date comes from `source_modified_at` when you ingest. Set it,
especially for transcripts and chat logs, where "yesterday" is common. See
[Documents, versions and sources](https://remember.dev/docs/concepts/documents-and-sources#source_modified_at-becomes-the-said-on-time).

Claim times never change. Two sources may state different, even conflicting,
windows for the same thing; both stand as evidence.

## World time on facts

Each [fact](https://remember.dev/docs/concepts/facts) has **one** chosen world-time window: `valid_from`,
`valid_until` and `valid_precision`, returned together in the fact's
`validity`. Unlike a claim's window, a fact's window changes as evidence
arrives:

- A new fact takes the window of the claim it came from, but only when that
  window describes this particular assertion. A claim that mentions a 2019
  hire and a 1990 founding does not give both facts the same dates.
- A later claim can correct the dates ("the cutover was 9 June, not 8
  June"). The fact keeps its identity; its window is replaced, and the
  decision names the claims that justify it.
- A successor closes its predecessor. Ravi's move to the search team caps
  "Ravi works on billing migration" at 2026-06-01, the successor's start in
  the world.

Each change is written to the fact's decision transcript with the window
before and after. See [Evidence](https://remember.dev/docs/concepts/evidence#why-do-we-believe-this).

### Precision

Precision says how exact a window is. It is never faked: a claim that says
"in 2025" is a year, not 1 January 2025.

| Precision | Meaning | Example |
|---|---|---|
| `instant` | An exact moment. | "the deploy finished at 16:30 UTC" |
| `day` | A calendar day. | "on 2026-06-01" |
| `month` | A calendar month. | "in March 2026" |
| `quarter` | A calendar quarter. | "in Q2 2026" |
| `year` | A calendar year. | "in 2025" |
| `open` | A known start, still ongoing. | "since 2026-01-14" |
| `unknown` | No usable date. | "Ravi is the on-call engineer" |

**Claim ends are inclusive; fact ends are exclusive:**

A claim stores the bounds as the source stated them, with an inclusive
end: "in 2025" is `claim_valid_from` 2025-01-01, `claim_valid_until`
2025-12-31, precision `year`. A fact stores the canonical interval with
an exclusive end: `valid_from` 2025-01-01T00:00:00Z, `valid_until`
2026-01-01T00:00:00Z. A day-precision fact for 2026-06-01 ends at
2026-06-02T00:00:00Z. Compare fact bounds with `valid_from <= t <
valid_until`.

A fact's window may also be partly known: a start without an end (and not
`open`), or an end without a start. Partial windows are kept as they are,
never completed with an invented date.

## Asking about time

`facts_context` and `combined_context` take a `time` argument that selects
which facts count, by their world-time window. There are four modes.

| Mode | Returns facts that… | Use it for |
|---|---|---|
| `current` (default) | are true now: start at or before now, and end after now or have no end. | "Who works on the billing migration?" |
| `at` | were true at one instant `at`. | "Who worked on it on 1 March?" |
| `overlap` | overlap the inclusive range `from`–`to`. | "Who worked on it in May and June?" |
| `history` | began at or before now, whether or not they have ended. | "Who has ever worked on it?" |

```python
import remember

with remember.Client() as memory:
    now = memory.facts_context("who works on the billing migration")
    march = memory.facts_context(
        "who works on the billing migration",
        time={"mode": "at", "at": "2026-03-01T00:00:00Z"},
    )
    summer = memory.facts_context(
        "who works on the billing migration",
        time={"mode": "overlap", "from": "2026-05-15T00:00:00Z", "to": "2026-06-15T00:00:00Z"},
    )
    ever = memory.facts_context(
        "who works on the billing migration",
        time={"mode": "history"},
    )
```

Instants must carry a timezone. The mode you asked for comes back in the
result's `temporal_scope`, with the instant the query was evaluated
(`evaluated_at`), so an answer always states the time it describes. See
[Reading a result](https://remember.dev/docs/concepts/reading-results).

All four modes read **current belief**: only facts the memory believes now
(`invalidated_at` is empty) are returned.

### Confirmed and possible

A fact with no dates, or with only one known end, cannot be ruled in or out
by a time filter. RememberStack does not drop it and does not pretend it
matches. It returns it with `temporal_match: "possible"`.

| `temporal_match` | Meaning |
|---|---|
| `confirmed` | The fact's window is complete (both ends known, or a known start that is `open`), so the match is established. |
| `possible` | The window is missing or partial. The fact passed the filter because nothing rules it out, not because its dates match. |

Your agent should treat a `possible` fact as a lead, not an answer, when the
question is about time.

### The worked example, queried

Add a third fact with no date, "Ravi is the on-call engineer for billing
incidents". Evaluated on 2026-09-23, the modes return:

| Query | Ravi works on billing migration (2026-01-14 to 2026-06-01) | Ravi works on search team (since 2026-06-01, open) | Ravi is on-call (no date) |
|---|---|---|---|
| `current` | not returned (ended) | `confirmed` | `possible` |
| `at` 2026-03-01 | `confirmed` | not returned (not started) | `possible` |
| `overlap` 2026-05-15 to 2026-06-15 | `confirmed` | `confirmed` | `possible` |
| `history` | `confirmed` | `confirmed` | `possible` |

A vector search over the same notes has one mode: similar text. It returns
the kickoff line and the retro line side by side in every case.

## Belief time and "as of"

`ingested_at` is when the memory first held a fact. `invalidated_at` is when
it stopped believing it, for example because a `living` source removed the
fact's only support (see [Updating a source](https://remember.dev/docs/concepts/updating-sources)). Facts
are never deleted by this: an invalidated fact stays in memory with the
instant it ended.

The graph operations take both clocks together, for a two-axis question:
"what did the memory believe on `believed_at` about the world on
`valid_at`?"

```python
from datetime import datetime, UTC

import remember

with remember.Client() as memory:
    ravi = memory.resolve_entity("Ravi").entities[0]
    before_the_retro = memory.graph_neighborhood(
        entity_id=ravi.entity_id,
        valid_at=datetime(2026, 5, 1, tzinfo=UTC),
        believed_at=datetime(2026, 6, 1, tzinfo=UTC),
    )
```

`valid_at` and `believed_at` must be given together or not at all. The
result's `temporal_scope` has mode `as_of` and names both. See
[Graph](https://remember.dev/docs/reference/http-api/graph).

**Note:**

A fact's window is corrected in place. A `believed_at` read filters on
`ingested_at` and `invalidated_at`, and shows each fact with its window
as it stands today. The windows a fact had before a correction are kept
in its decision transcript, not replayed by the read.

## Time on claims and passages

The claims-and-sources side of memory answers "what did the sources say?",
and there the said-on time is what matters. Every evidence result carries
`asserted_at` and the claim's own world-time fields; every passage
(`ChunkEvidenceResult`) carries its version's `source_modified_at`.

To ask what sources asserted about a period, the `examples.claims_as_of`
saved query returns the claims whose stated window overlaps the range you
give it. Claims with `unknown` precision cannot match a window; the query
leaves them out and reports how many there are in its
`unknown_precision_excluded` column, so an empty answer is never mistaken
for "nothing was said". See [Saved queries](https://remember.dev/docs/guides/saved-queries)
and [Ask about the past](https://remember.dev/docs/guides/ask-about-the-past).

## Where to go next

- [Ask about the past](https://remember.dev/docs/guides/ask-about-the-past): recipes for
  point-in-time and period questions.
- [Facts](https://remember.dev/docs/concepts/facts): how windows are chosen and corrected.
- [Reading a result](https://remember.dev/docs/concepts/reading-results): `validity`, `temporal_match` and
  `temporal_scope` field by field.
- [Assured operations](https://remember.dev/docs/reference/assured-operations): the `time`
  argument schema.

---

Source: https://remember.dev/docs/concepts/evidence

# Evidence and provenance

An agent that cannot show where a statement came from cannot be checked,
and an agent that cannot be checked will not be trusted with real work. "The
cutover is on June 8" is useful. "The cutover is on June 8, per Ravi in the
2026-04-28 standup, characters 1,204 to 1,262 of that transcript" is
something a person can verify in ten seconds.

In RememberStack every fact is backed by claims, and every claim is backed by
exact character spans in one immutable version of one document. You can
walk from any answer down to the text that produced it, and see why the
memory decided what it did.

## Evidence spans

A [claim](https://remember.dev/docs/concepts/claims) points to its source in two ways:

- **The origin span**: `source_span`, with `char_start` and `char_end`. This
  is the passage in the target chunk where the extractor found the
  statement. It never changes.
- **All supporting spans**: `evidence_spans`, a list of `{char_start,
  char_end}` ranges. A coherent statement is often supported by several
  sentences, sometimes in different passages ("Ravi owns the schema change"
  in one paragraph, "it ships in June" two paragraphs later). The list
  holds every range the claim relies on, origin first.

Spans are half-open (`char_end` is one past the last character) and
always lie within one version of the document, in one conversion of it.
They are character positions in the version's converted text,
`document.md`: the Markdown RememberStack produced from your file. For
Markdown and plain text this is close to your original; for PDFs, office
files and images it is the converted reading of them. RememberStack does not ingest audio yet.

The extractor does not write these positions itself. The engine labels the
source passages it shows the model; the model cites labels; the engine
turns the labels into positions and checks them. A claim cannot point at
text that is not there.

When a new version of a document keeps a passage unchanged, the claim keeps
its `claim_id` and its spans are mapped onto the new version. The `memory_v1`
view `claim_occurrences_live` lists where each current claim appears, with
`evidence_spans` in that version's text.

## The IDs along the path

| ID | Identifies |
|---|---|
| `fact_id` | A relation or observation. |
| `claim_id` | One claim. |
| `chunk_id` | The passage (a run of whole blocks of the converted text) the claim came from. |
| `doc_id` | The document (all versions). |
| `version_id` | One snapshot of the document's bytes. |
| `representation_id` | One conversion of that version into text. |

A claim result carries `claim_id`, `doc_id` and `chunk_id`. A passage result
(`ChunkEvidenceResult`) also carries `version_id`, `representation_id`, its
own `char_start`/`char_end`, its section's role and the version's
`source_modified_at`.

## From a fact to the characters

![The fact that Dana leads the billing migration links to the claim, the claim to a passage in the June retro, and the passage to the exact characters in the original file.](https://remember.dev/docs/diagrams/provenance.svg)

Here is the complete path, from a fact in an answer to the text.

**1. The answer names the fact.** A `facts_context` result lists facts, and
links each one to a few of its claims in `fact_evidence`:

```json
{"fact_kind": "relation", "fact_id": "…", "claim_id": "…", "stance": "supports"}
```

The claims themselves are in the same envelope's `evidence` list, and
`evidence_totals` says how many exist in total for each fact and stance, so
you know when you are seeing a sample. See
[Reading a result](https://remember.dev/docs/concepts/reading-results).

**2. Hydrate the fact for all its evidence.** For a relation,
`hydrate_relation` returns the fact, every supporting claim with its spans,
and the source documents:

```python
import remember

with remember.Client() as memory:
    answer = memory.facts_context("who owns the billing migration schema change")
    fact = answer.facts[0]
    if fact.kind == "relation":
        full = memory.hydrate_relation(relation_id=fact.fact_id)
        for claim in full.evidence:
            print(claim.claim_text)
            print("  said on", claim.asserted_at, "in", claim.doc_id)
            print("  origin:", claim.char_start, claim.char_end, repr(claim.source_span))
            for span in claim.evidence_spans:
                print("  support:", span.char_start, span.char_end)
        for source in full.sources:
            print(source.doc_id, source.title, source.source_kind, source.markdown_uri)
```

Hydration works on invalidated relations too, and says so in their
`validity`. It is the audit path: it reports what happened rather than
refusing to answer.

**3. Open the text.** Each source carries `markdown_uri`, the object-store
key of the converted text the spans index into. For a passage, read the
chunk directly (`search_chunks`, `adjacent_chunks`) or query `chunks_live`
with SQL queries over the query space.

**4. For converted media, find the original location.** When the text came
from a conversion, OCR or an image description, the
`claim_occurrences_live` view says so and where:

| Column | Meaning |
|---|---|
| `derivation_kind` | How the text was derived from the source, for example `markitdown` (document conversion), `ocr` (text read from an image) or `vlm_description` (a vision model's description of an image). Empty when the conversion recorded no label. |
| `evidence_mode` | How mediated it is: `source_expression` (the source's own words), `model_observation` (a model described what it saw or heard), `model_interpretation`. |
| `source_locators` | Where in the original the text sits, such as a page or a time range. |

A claim that rests on a model's description of an image is labelled as such,
so an agent can weigh it accordingly.

## Why do we believe this

Evidence says what the sources said. The **transcript** says what the memory
decided about it. `transcript_relation` returns a relation's decision history,
oldest decision first:

```python
with remember.Client() as memory:
    history = memory.transcript_relation(relation_id=fact.fact_id)
    for entry in history.transcript:
        print(entry.decided_at, entry.outcome, entry.method, entry.confidence)
```

Each entry (`TranscriptEntry`) has:

| Field | Meaning |
|---|---|
| `subject_kind` | What the decision was about (`relation`). |
| `outcome` | What was decided: `add`, `update`, `noop`, `contradict`, `retracted_source_removal`, and others. |
| `method` | How: for example `novelty_gate` (no candidates, so added without a model), `small_model` (a model decided), `exact` (a deterministic rule). |
| `confidence` | The decision's confidence, where one exists. |
| `related_id` | The other fact involved, if any. |
| `decided_by` | Who decided (the engine or a person). |
| `decided_at` | When. |
| `features` | The decision's details, such as the window before and after. |

The transcript returns the 40 most recent entries. When more exist, the
oldest are left out and the envelope's `truncation` says so.

There is no transcript route for observations or entities yet. With SQL
queries over the query space, the `memory_v1` view `identity_events_visible`
shows entity identity decisions, and `evidence_lineage` shows which
documents support each fact.

## Where to go next

- [Cite the source of an answer](https://remember.dev/docs/guides/cite-sources): turn this path
  into citations in your agent's output.
- [Contradictions](https://remember.dev/docs/concepts/contradictions): evidence with stance `contradicts`.
- [Entities and facts routes](https://remember.dev/docs/reference/http-api/entities-and-facts):
  hydrate and transcript.
- [Query space](https://remember.dev/docs/reference/query-space): the evidence views.

---

Source: https://remember.dev/docs/concepts/contradictions

# Contradictions, corroboration and supersession

Sources disagree. The spec says the cutover is on June 8; a standup
transcript says June 15. A memory that quietly keeps one of them hands your
agent a confident answer that is wrong half the time, and nobody finds out
until the date arrives.

RememberStack does not pick a side for you. When facts conflict, every side
is returned together, each with its own evidence. When sources agree, it
tells you how many independent sources agree. And when something changed
rather than conflicted, it records the change instead of calling it a
disagreement.

## Contradictions

Two facts **contradict** when they cannot both be true: two different
cutover dates for the same cutover, two different owners of the same task at
the same time. During [adjudication](https://remember.dev/docs/concepts/facts#how-a-claim-changes-the-facts)
a conflict is recorded in one of two ways:

- **Contradicting evidence on one fact.** The incoming claim disputes an
  existing fact directly ("Ravi did not approve the schema change"). The
  claim attaches to that fact as evidence with stance `contradicts`.
- **A contradiction group.** The incoming claim states a different,
  incompatible value. It becomes its own fact, and the facts are linked in a
  contradiction group. Each fact in the group is a **co-member** of the
  others.

### Every side, every time

A fact that belongs to a live contradiction group is never returned alone.
Its `FactResult` carries:

- `contradiction_group`: the group's ID,
- `contradiction`: a block with the other sides inline.

```json
"contradiction": {
  "group_id": "5b0e2f3c-7a41-4d8e-9c55-0f6a2d1e8b90",
  "co_members": [
    {
      "fact_id": "a3f1c9e2-1b7d-4c0a-8e6f-2d9b4c7a1e53",
      "label": "The billing migration cutover is scheduled for 2026-06-15.",
      "evidence_count": 1,
      "validity": {
        "valid_from": null,
        "valid_until": null,
        "valid_precision": "unknown",
        "ingested_at": "2026-04-28T09:12:44Z",
        "invalidated_at": null
      }
    }
  ],
  "returned": 1,
  "total": 1,
  "continuation": null
}
```

Up to 25 co-members come back inline. Beyond that, the block still carries
`group_id`, `returned` and `total`, so the agent knows how many sides exist.
Returning one side of a contradiction without the others is treated as a
bug, not a ranking choice.

What your agent does with it is its decision: prefer the side with more
independent evidence, prefer the more recent source, or tell the user the
sources disagree and cite both. See
[Handle unknowns and ambiguity](https://remember.dev/docs/guides/unknowns-and-ambiguity).

### Stance on evidence

Every link between a fact and a claim has a stance: `supports` or
`contradicts`. In a `facts_context` result:

- `fact_evidence` lists the sampled links, each with its `stance`,
- `evidence_totals` gives the exact total per fact **and per stance**, with
  how many were returned.

So an agent can see "3 documents support this, 1 disputes it" without
fetching every claim.

## Corroboration

**Corroboration** is independent sources saying the same thing. RememberStack
counts it by **distinct documents**, never by versions, claims or
repetitions:

- `evidence_count` on a fact is the number of distinct documents whose
  current testimony supports it.
- `corroboration_count` on a claim in a `claims_and_sources_context` result
  is the number of distinct documents that stated the same claim. Claims are
  grouped only when their normalized text, their said-on time, their
  world-time fields and their attribution all agree; `grouped_claim_ids`
  lists the claims folded into the one shown.

The rule protects the count from inflating by accident:

| Situation | Counts as |
|---|---|
| One transcript repeats a statement five times. | 1 |
| A spec has ten versions that all say it. | 1 |
| A newer extractor re-reads the same file. | 1 (the new claims replace the old ones as current testimony) |
| The spec and a separate meeting note both say it. | 2 |

A high count means many separate sources, not one source that talks a lot.

## Supersession is not contradiction

"Ravi works on the billing migration" (January) and "Ravi moved to the
search team" (June) do not conflict. Both were true, at different times.
Treating them as a contradiction would force a choice that should not be
made; treating them as unrelated would leave both "current".

RememberStack handles this as **supersession**: the later fact is the
successor, and the earlier fact's world-time window is closed at the
successor's start. The earlier fact stays in memory, true of its period,
and stops matching `current` queries. See [Time](https://remember.dev/docs/concepts/time).

| | Contradiction | Supersession |
|---|---|---|
| What it means | Sources disagree about the same thing at the same time. | The world changed; both statements were true in turn. |
| What happens | Both facts stay current and are linked in a group, or the claim attaches with stance `contradicts`. | The earlier fact's `valid_until` is set to the successor's start. |
| What a `current` query returns | Every side, together. | Only the successor. |
| What a `history` query returns | Every side, together. | Both, each with its window. |

Corrections are a third case. "The cutover moved from June 8 to June 9" is
neither a new fact nor a conflict when the source is correcting the date of
the same cutover: the fact keeps its identity and its window is replaced.

## Where to go next

- [Facts](https://remember.dev/docs/concepts/facts): the adjudication decisions behind all three cases.
- [Evidence](https://remember.dev/docs/concepts/evidence): the claims on each side.
- [Reading a result](https://remember.dev/docs/concepts/reading-results): `contradiction`,
  `evidence_totals` and `corroboration_count` in context.

---

Source: https://remember.dev/docs/concepts/updating-sources

# Updating a source: snapshot and living

Documents change. A spec is edited every week; a status page is rewritten;
a meeting note is corrected the next morning. When a line disappears from
the spec, did the team stop believing it, or did someone tidy the page?
The answer depends on what kind of source it is, and a memory that guesses
wrong either keeps serving facts nobody stands behind any more, or forgets
things that were only moved.

RememberStack asks you to say what an edit means, once per document, with
its **versioning mode**.

## Two modes

| Mode | An edit means… | Right for |
|---|---|---|
| `snapshot` (default) | Another dated statement. Every version stays standing testimony, forever. | Archives and anything whose versions are separate statements: meeting notes, dated reports, transcripts, exported chat logs, rolling logs. |
| `living` | The source's current statement. The newest version replaces what the older ones said. | Documents that are kept up to date in place: a spec, a roadmap, a README, a status page, an agent's own running notes. |

You choose the mode when you ingest, and it needs a source
(`source_kind` and `source_ref`):

```python
import remember

with remember.Client() as memory:
    version = memory.ingest(
        "specs/billing-migration.md",
        source_kind="notes",
        source_ref="specs/billing-migration.md",
        versioning_mode="living",
    )
```

`snapshot` is the default because it is the safe one: it never removes
anything. Use `living` when "the newest version is what we mean now" is
true of the source.

Ask what a missing line means before you pick `living`. A rolling log that
keeps only its last thousand lines, or a chat export that holds the last 30
days, drops old lines because they are old, not because anyone took them
back. Ingest those as `snapshot`. As `living`, every line that scrolled off
would retract the facts it alone supported.

## What happens on a new version

In both modes a new version is processed the same way: it is converted,
chunked and read. Passages that did not change keep their existing claims
(same `claim_id`) without being extracted again; changed passages produce new
claims. New claims go through ordinary fact adjudication, so a changed value
updates, supersedes or contradicts facts like any other new testimony.

The modes differ in what happens to the **old** claims.

### Snapshot

Nothing happens to them. The claims of every version stay current
testimony. If version 1 said the cutover is June 8 and version 2 says June
15, both statements stand, each dated by its own `asserted_at`, and the
facts reflect both, as a correction, a successor or a contradiction.

### Living

When the new version has finished processing, RememberStack compares it with
the previous one. Claims that no current passage carries any more stop being
current testimony (reason `version_superseded`). A claim that moved to
another passage of the same document is still carried, so it stays current.

Then every fact those claims supported is recounted:

- **Other current support remains**: the fact's `evidence_count` goes down.
  Nothing else changes.
- **The removed claims were its only support**: the fact is **retracted**.
  Its `invalidated_at` is set, so it stops appearing in queries that read
  current belief. The retraction is written to the fact's decision
  transcript with the outcome `retracted_source_removal`.

Retraction ends belief; it does not invent a world-time end. The fact's
`valid_until` is left as it was, because a removed line says nothing about
when the thing stopped being true.

For documents ingested directly (`POST /ingest`, `memory.ingest`), the check
runs as soon as the new version is processed. For documents fed by a
connector sync, it waits until the whole sync cycle has finished, so a
passage that moved from one file to another within one sync counts as a
change of support, never as a retraction followed by a re-assertion.

This is how a living source takes something back: by no longer saying it.
Removing "Ravi owns the schema change" from the spec retracts that fact if
no other document supports it.

## Retraction is recorded, not deleted

A retraction never deletes anything:

- The claims remain, marked non-current with the reason, and readable for
  audit (`claims_visible_history`, `testimony_currency_events_visible`).
- The fact remains, with the instant it was retracted in `invalidated_at`.
  `hydrate_relation` still returns it and shows its evidence.
- The transcript says what happened and in which reconciliation.

If the content comes back in a later version, it is new testimony and goes
through ordinary processing again.

## Two different problems

A document's claims can change for two unrelated reasons, and RememberStack
keeps them apart:

| What changed | Example | A fact that loses its only support |
|---|---|---|
| **The source itself** | The spec no longer says "Ravi owns the schema change". | Is retracted in a `living` document (`invalidated_at` set, recorded as `retracted_source_removal`). In a `snapshot` document the old version still supports it, so it does not lose support. |
| **Only the reading of it** | A new release re-reads an unchanged file with a newer extractor, converter or chunker, and does not find the claim again. | Is not retracted. It is marked `support: "withdrawn"`, flagged for review and still returned. |

The first is the source speaking: it stopped saying something. The second
is RememberStack reading the same bytes differently, and it cannot tell
whether the old reading or the new one is right. So a fact is taken back
only when the source acts, or when its document is deleted. See [Facts](https://remember.dev/docs/concepts/facts#support-withdrawn).

## Unchanged bytes and `source_version_ref`

Sending bytes identical to the document's latest version creates nothing
(`created=false`), in both modes. See
[Documents, versions and sources](https://remember.dev/docs/concepts/documents-and-sources#re-ingesting-the-same-bytes-createdfalse).

`source_version_ref` is an optional revision marker from the source system:
an ETag, a Drive revision ID, a commit SHA. RememberStack stores it on the
version. When a sync sees a new revision marker but identical bytes, the
existing version's marker advances, so the sync does not fetch that revision
again. The version's `source_modified_at` does not change.

## Deleting a document

Deleting a document ends the currency of its claims (reason
`version_deleted`) and retracts facts that only it supported, the same way
as a living removal. You delete a whole document, every version at once,
with `DELETE /documents/{doc_id}`, `MemoryClient.delete_document`,
`remember documents delete` or the `delete_document` MCP tool. Deleting a
single version is not offered. See
[Deleting a document](https://remember.dev/docs/concepts/documents-and-sources#deleting-a-document).

## Where to go next

- [Keep a source up to date](https://remember.dev/docs/guides/keep-sources-current): a working
  sync loop.
- [Claims](https://remember.dev/docs/concepts/claims#current-and-superseded-testimony): testimony currency.
- [Contradictions](https://remember.dev/docs/concepts/contradictions): what a changed value does to facts.

---

Source: https://remember.dev/docs/concepts/pipeline

# The pipeline and readiness

RememberStack does its thinking when you write, not when you ask. Reading a
document, splitting it into claims, resolving who is who and deciding what is
true all happen once, in the background, after ingest. That is why a query
can answer from settled facts without calling a language model, and why the
same question gets the same answer twice.

The cost is that knowledge is not queryable the instant you send it.
Processing a document takes minutes, not milliseconds. This page explains
what happens in those minutes and how to know when it is done.

## The stages

![A document is stored, converted, structured, chunked, turned into claims and checked against the source; then names are resolved to entities, claims become facts, facts are adjudicated and reconciled, and everything is indexed until the document is queryable.](https://remember.dev/docs/diagrams/pipeline.svg)

`POST /ingest` (or `memory.ingest`) returns as soon as the bytes are stored
and a version is recorded. Everything after that runs in workers, one stage
at a time, with some stages running in parallel:

![The pipeline stages and the order they run in.](https://remember.dev/docs/diagrams/pipeline-stages.svg)

| Stage | What it does | Uses a model |
|---|---|---|
| `ingest` | Stores the bytes once per content hash and records a new version, or returns the existing one if the bytes are unchanged. Runs inside the API call. | No |
| `convert` | Turns the file into Markdown text (`document.md`) and a grid of blocks. Text and office formats convert without a model; images are read with OCR and described by a vision model. | For images, and for OCR routes you configure |
| `structure` | Finds the document's sections from its headings, gives each a role, and writes short section summaries used only for orientation. | Yes (roles and summaries) |
| `chunk` | Cuts the text into chunks: non-overlapping runs of whole blocks. | No |
| `embed_chunk` | Computes a vector for each chunk for semantic search. | Embedding model |
| `extract_claims` | **Selection**, per chunk: decides which statements to keep and which to drop, with a reason. | Yes |
| `ground_claims` | **Claimify** plus the grounding gate, per chunk, once every chunk has been through Selection: writes standalone claims and checks every one against the source. | Yes |
| `normalize_relations` | Per claim: turns it into relations and observations, and resolves their entities. | Yes |
| `adjudicate_observations` | Per entity: decides how each new assertion changes the facts (add, confirm, adjust, supersede, contradict). | Yes, when the entity already has facts |
| `adjudicate_supersession` | Refreshes the profiles of entities whose facts changed, and re-examines their identity. | Yes (profiles) |
| `embed_claim` | Computes a vector for each claim for semantic search. | Embedding model |
| `reconcile` | Updates which claims are current testimony, recounts every affected fact's evidence, and retracts or flags facts that lost all support. | No |
| `label_relation` | Writes each fact's readable label and computes its vector. | Embedding model |

The details of each step are in [Claims](https://remember.dev/docs/concepts/claims), [Facts](https://remember.dev/docs/concepts/facts),
[Entities](https://remember.dev/docs/concepts/entities) and [Updating a source](https://remember.dev/docs/concepts/updating-sources).

When a new version of a document arrives, unchanged passages reuse their
earlier work: their conversion, their claims and their vectors. Only what
changed is processed again.

## How long it takes

Expect minutes per document, and longer for large ones. Every model-backed
stage waits on a provider, and some stages cannot start until every chunk or
every claim of the version has finished the one before. The structure stage
alone has been measured at about 11 minutes on a 2.5 KB file.

Plan for this. An agent that ingests a note and immediately asks about it
will not find it. Write first, then wait for readiness before relying on the
content.

## No model on the read path

Once processing is done, reading is cheap and repeatable. The
[assured operations](https://remember.dev/docs/concepts/retrieval) and retrieval primitives run on
PostgreSQL and embedding search only. The one outside call a query may make
is to embed the query text for semantic search. No language model rewrites,
ranks or summarises your answer at read time.

## Stage statuses

For each version and stage, readiness reports one status:

| Status | Meaning |
|---|---|
| `missing` | No work for this stage exists yet, usually because an earlier stage has not finished. |
| `pending` | Queued, waiting for a worker (or waiting to retry after a failure). |
| `running` | A worker is on it. |
| `succeeded` | Done. |
| `skipped` | Nothing to do for this version. Counts as done. |
| `failed` | The last attempt failed; it will be retried. |
| `dead_letter` | It will not be retried automatically. |

Stages that fan out (per chunk, per claim, per entity) report one combined
status for the whole version, so a version is never shown as done while
part of it is still running.

### Dead letters

A unit of work is **dead-lettered** when it has used up its attempts (three
by default) or fails with an error that retrying cannot fix. It stops there,
and so does everything after it for that version. Readiness shows the stage
as `dead_letter`.

A dead letter does not fix itself. When you see one, stop waiting and look
at why. On a self-hosted deployment, see
[Operating the pipeline](https://remember.dev/docs/self-hosting/operating) for how to inspect and
replay it.

A file whose type has no configured converter is not dead-lettered. The
original is stored and its conversion waits, reported as `pending`, until a
route for that type exists.

## Readiness

**Readiness** answers one question: can I recall this yet? You ask it for
one or more `version_id`s (up to 1,000 per call) and name which
capabilities you need. It reports each capability separately and a single
`ready` that is `true` only when every capability you required is ready.

| Capability | Ready when | What it makes possible |
|---|---|---|
| `pipeline` | Every stage above has succeeded (or been skipped) for every version you asked about. | Claims and facts from those versions exist. |
| `p1` | The deployment's search indexes are built for all seven channels: semantic search over chunks, claims, relations, observations and entities, and BM25 keyword search over chunks and claims. | Search-backed operations can find them. This is deployment-wide, not per version. |
| `live_graph` | The deployment's PostgreSQL graph passes its health checks. | Graph traversal and the neighbourhood expansion in `facts_context`. Deployment-wide. |
| `p3` | A filesystem snapshot of the corpus has been published after your versions finished. | The read-only filesystem views. Self-hosted only; see [Filesystem views](https://remember.dev/docs/self-hosting/filesystem-views). |

For ordinary recall, require `pipeline`, `p1` and `live_graph`, and leave
`p3` off unless you read the filesystem views.

```python
import remember

with remember.Client() as memory:
    version = memory.ingest(
        "notes/2026-04-28-standup.md",
        source_kind="notes",
        source_ref="notes/2026-04-28-standup.md",
    )
    report = memory.wait_for_readiness([version.version_id])
    print(report.ready)
```

`wait_for_readiness` requires `pipeline`, `p1` and `live_graph` (add
`require_p3=True` for `p3`). It checks every 15 seconds for up to 30
minutes by default and raises `TimeoutError` when the time runs out. It
keeps waiting through a `failed` stage, which is being retried, and stops
at once with `PipelineDeadLettered` on a `dead_letter` stage. See
[Wait until a document is queryable](https://remember.dev/docs/guides/wait-for-readiness).

The report also carries `build_revision` and `model_bindings`: which engine
build and which models are processing your documents.

**Note:**

A document's status (`ready` in `GET /documents`) is not readiness. A
document is `ready` once it is converted and structured; its claims and
facts come later. Use readiness to decide when to query.

## Where to go next

- [Wait until a document is queryable](https://remember.dev/docs/guides/wait-for-readiness)
- [Ingest, readiness, documents](https://remember.dev/docs/reference/http-api/ingest):
  `POST /readiness`.
- [Operating the pipeline](https://remember.dev/docs/self-hosting/operating) (self-hosted).

---

Source: https://remember.dev/docs/concepts/architecture

# What lives where

A RememberStack deployment is a small number of processes around one
PostgreSQL database and one object store. Knowing which piece holds what
tells you what to back up, what can be rebuilt, and why a search can miss
something new but never return something withdrawn. This page describes a
deployment as the shipped `compose.yaml` runs it.

## The pieces

| Piece | What it does |
|---|---|
| **PostgreSQL** | Holds the memory: every document and version, every claim, entity and fact, the decisions that changed them, and the queue of pipeline work. The search indexes and the graph are in the same database. |
| **Object storage** | Holds files: the original bytes you sent, the Markdown converted from them, and filesystem-view snapshots. Any S3-compatible store; Compose runs SeaweedFS. |
| **The API** | One process that answers HTTP. Ingest stores the original and records a version; reads run against PostgreSQL. |
| **The workers** | One process per [pipeline stage](https://remember.dev/docs/concepts/pipeline), from `convert` to `label_relation`. Each takes work from the queue in PostgreSQL, calls models where the stage needs one, and writes its results back. |
| **`setup`** | Runs once before the others start: database migrations, buckets, the deployment record. |
| **Model provider** | Outside the deployment. Workers call it to read documents and compute vectors; the API calls it only to embed the text of a search. |
| **The `remember` package** | On your machine: the CLI, the Python client and the MCP server. It talks to the API over HTTP and holds no memory of its own. |

![The client talks to the API over HTTP; the API and the workers use PostgreSQL, object storage and the model provider.](https://remember.dev/docs/diagrams/architecture.svg)

## Where each kind of data lives

An **authority** is the copy every answer is checked against. A **derived**
copy is built from an authority to make something faster or easier, and it
can fall behind.

| What | Where | Authority or derived |
|---|---|---|
| Documents, versions, source identity | PostgreSQL | Authority |
| Claims, and the passages they came from | PostgreSQL | Authority |
| Entities, aliases, merges | PostgreSQL | Authority |
| Facts (relations and observations), their time windows and evidence links | PostgreSQL | Authority |
| Decisions that changed a fact or an identity | PostgreSQL | Authority |
| Pipeline work: what ran, what failed, what waits | PostgreSQL | Authority |
| Model spend (the cost ledger) | PostgreSQL | Authority |
| Search indexes: vectors (`pgvector`) and keyword ranking (BM25, `pg_textsearch`) | PostgreSQL | Derived. Written by the embedding and labelling stages after the rows they index. |
| The live graph | PostgreSQL property-graph definitions over the current tables | Neither: a view. It reads the tables themselves, so it cannot fall behind them. |
| The query space `memory_v1` for [SQL queries](https://remember.dev/docs/concepts/retrieval#sql-queries-over-the-query-space) | PostgreSQL views and functions | A read-only view of the authority. |
| Original files | Object storage, bucket `remember-raw` | Authority. Identical bytes are stored once. |
| Converted Markdown and the files produced with it | Object storage, bucket `remember-artifacts` | Derived from the original. Chunks point at positions in it, so it is kept, not rebuilt. |
| Filesystem-view snapshots | Object storage, bucket `remember-corpusfs` | Derived. Rebuilt on demand. |

The bucket names are the defaults of a Compose deployment. Everything in
PostgreSQL and the first two buckets belongs together: back them up at the
same moment ([Upgrades and migrations](https://remember.dev/docs/self-hosting/upgrades#back-up)).

## Why everything is in one database

Claims, facts, their evidence, the search indexes and the graph share one
PostgreSQL database, so there is no second store to keep in step, and a
read can confirm its results against all of them in one consistent
snapshot. The graph reads the current
fact tables directly, so it never shows a relation those tables have
already withdrawn.

Inside the database, the one thing that lags is the search index: vectors are computed after the
rows they describe, by later pipeline stages. Readiness reports this lag as
the `p1` capability ([The pipeline and readiness](https://remember.dev/docs/concepts/pipeline#readiness)).

## How a read uses the pieces

![A question goes to meaning, keyword and graph search, which nominate candidates; PostgreSQL confirms each against the live state and drops anything withdrawn or superseded; the result says how many were dropped.](https://remember.dev/docs/diagrams/read-path.svg)

Every read that searches works in three steps:

1. **The search indexes nominate candidates.** Vector and keyword search
   each propose the chunks, claims or facts that look relevant. This is
   fast, and it may be out of date.
2. **PostgreSQL confirms them.** Every candidate is read again from the
   authority tables, all in one consistent snapshot. A candidate that no
   longer holds (a fact that was withdrawn or replaced, a claim that is no
   longer current, an entity merged into another, or one outside the time
   you asked about) is dropped.
3. **The result accounts for what was dropped.** The count is in
   `dropped_by_hydration`, so you can tell a short answer from a complete
   one.

The consequence: a stale index can cost recall, because something it has
not indexed yet cannot be nominated. It cannot serve a withdrawn fact,
because nothing reaches the answer without passing the check against the
authority. No language model takes part in any of this; see
[Retrieval](https://remember.dev/docs/concepts/retrieval#how-hybrid-retrieval-works).

## Where to go next

- [The pipeline and readiness](https://remember.dev/docs/concepts/pipeline): what each worker produces.
- [Retrieval](https://remember.dev/docs/concepts/retrieval): the operations, search, graph and SQL queries.
- [Install with Docker Compose](https://remember.dev/docs/self-hosting/install): the services
  and volumes of a self-hosted deployment.

---

Source: https://remember.dev/docs/concepts/retrieval

# Retrieval: operations, search, graph and SQL

An agent needs different things from memory at different moments: who a
name refers to, what is currently true about a project, what exactly a
source said, how two people are connected. One search box that returns
"similar text" answers none of these well. It returns a pile of passages and
leaves the model to sort fact from rumour and old from new.

RememberStack gives you three layers to read from, from most guided to most
open:

1. **Four assured operations**: fixed, documented reads that cover most
   agent context.
2. **Retrieval primitives**: search, lookup, graph traversal, hydration and
   neighbouring passages, for when you need a specific shape.
3. **SQL queries** over the query space, for questions no fixed read
   anticipates.

None of them calls a language model. The operations and primitives return
the same self-describing [envelope](https://remember.dev/docs/concepts/reading-results); SQL queries return
a table with its own account of limits and drops.

## Ways to reach the memory

The same reads are available from four places. Pick by who is asking:

| Surface | Use it for | What it reaches |
|---|---|---|
| [Python client](https://remember.dev/docs/reference/python-sdk) (`import remember`) | Your own code: sync jobs, pipelines, an agent you build. | Everything: ingest, readiness, the four operations, every primitive, SQL queries and saved queries, with typed results. |
| [CLI](https://remember.dev/docs/reference/cli) (`remember`) | A terminal or a shell script: one-off questions, checking what the memory holds, trying a SQL query. | Ingest, the four operations (`remember query`, `remember operations run`), SQL and saved queries, adjacent chunks. Prints JSON. |
| [MCP](https://remember.dev/docs/reference/mcp) (`remember mcp`) | A coding agent such as Claude Code or Codex. | Ingest, readiness, the four operations and the seven SQL query tools. No primitives. |
| [HTTP API](https://remember.dev/docs/reference/http-api) | Any other language or runtime. | Every route; the other three are built on it. |

## The four assured operations

An **assured operation** is a read whose behaviour, parameters and result
contract are fixed by RememberStack and registered in every deployment. They
are the default tools for an agent; the `remember` MCP server exposes them
as tools. `GET /operations` lists them with their full schemas.

| Operation | Answers | Returns |
|---|---|---|
| `resolve_entity` | "Who or what is this name?" | Ranked entity candidates, never a silent pick. |
| `facts_context` | "What does memory hold true about this?" | Adjudicated facts (relations and observations) under a time scope, with sampled evidence. |
| `claims_and_sources_context` | "What exactly did the sources say?" | Current claims and matching source passages. |
| `combined_context` | "Give me both." | Claims-and-sources and facts, side by side, in `ContextBundle/v2`. |

### When to use each

**Start with `resolve_entity`** whenever the question names someone or
something. It turns "Ravi" into an `entity_id`, or tells you there are two
Ravis, or that there is none. Pass the chosen IDs to the next call as
`entity_ids`. See [Entities](https://remember.dev/docs/concepts/entities#resolving-a-name-at-query-time).

**Use `facts_context` for what is true.** Current state, who owns what, what
changed, what was true on a date. Facts are de-duplicated, dated and counted
across sources, so ten notes saying the same thing come back as one fact
with `evidence_count` 10. Arguments:

| Argument | Default | Limits |
|---|---|---|
| `query` | required | 1 to 8,192 characters |
| `entity_ids` | none | 1 to 19 IDs |
| `k` | 15 | 1 to 30 facts |
| `evidence_per_fact` | 3 | 1 to 5 claims |
| `hops` | 1 | 1 or 2 |
| `predicate` | none | one predicate name |
| `time` | `{"mode": "current"}` | `current`, `at`, `overlap`, `history`; see [Time](https://remember.dev/docs/concepts/time#asking-about-time) |

With `entity_ids` and a `current` or `at` time scope, `facts_context` first
expands the graph `hops` steps out from those entities, then searches fact
text inside that neighbourhood. That is how "what is Ravi working on?" finds
the facts about Ravi's projects, not only facts with Ravi in the text.

**Use `claims_and_sources_context` for what was said.** Exact wording,
quotes, tone, who said what and when, or anything the fact layer did not
capture. It returns current testimony only. Arguments: `query`, optional
`entity_ids` (up to 20), `k` (default 50, up to 100) and `candidate_k`
(default 200, up to 400) nominations per search channel.

**Use `combined_context` when you want both** and do not want to make two
calls. It runs the two operations independently and returns them side by
side, never blended. It takes `query`, `entity_ids`, `hops`, `predicate` and
`time`.

```python
import remember

with remember.Client() as memory:
    who = memory.resolve_entity("Ravi")
    ravi = who.entities[0].entity_id if len(who.entities) == 1 else None

    facts = memory.facts_context(
        "what is Ravi working on", entity_ids=[ravi] if ravi else None
    )
    said = memory.claims_and_sources_context("Ravi cutover date")
    both = memory.combined_context("billing migration cutover")
```

**Note:**

The Python client's `claims_and_sources_context` sends only `query`. To
pass `entity_ids`, `k` or `candidate_k`, call
`memory.run_operation(name="claims_and_sources_context", arguments={...})`.

A good default order for an agent: resolve the names, ask `facts_context`,
and fall back to `claims_and_sources_context` for exact wording or for
anything the facts do not cover. See
[Give an agent context](https://remember.dev/docs/guides/agent-context).

## Retrieval primitives

Primitives are the building blocks the operations are made of, exposed for
when you need one exact shape. They are available over HTTP and in the
Python client. They are not assured operations and are not separate MCP
tools.

| Primitive | Client method | What it returns |
|---|---|---|
| Search claims | `search_claims(query=, k=10, channel="semantic")` | Claims, from one search channel (`semantic` or `bm25`), `k` up to 400. |
| Search chunks | `search_chunks(query=, k=10, channel="semantic")` | Source passages, likewise. |
| Adjacent chunks | `adjacent_chunks(chunk_id=, window=1)` | The passages either side of a chunk in document order, `window` 1 or 2. Use it when an answer is cut off at a chunk boundary. |
| Resolve | `resolve(name=, context_entity_ids=())` | Like `resolve_entity`, optionally reordered by up to 8 entities already in focus. |
| Lookup relations | `lookup_relations(subject_entity_id=, predicate=, object_entity_id=, valid_at=, k=50)` | Relations matching a pattern, current or at one instant, at most `k`. |
| Lookup observations | `lookup_observations(entity_id=, property_query=, k=10)` | One entity's observations, optionally by property text. |
| Graph neighbourhood | `graph_neighborhood(entity_id=, hops=2, ...)` | The entities and relations within `hops` (up to 4), capped at `limit` (up to 500) with a continuation. |
| Graph path | `graph_path(from_entity_id=, to_entity_id=, max_hops=4)` | Shortest connections between two entities (up to 6 hops). |
| Citation path | `graph_citation_path(from_doc_id=, to_doc_id=, max_hops=6)` | How one document leads to another through citations. |
| Hydrate relation | `hydrate_relation(relation_id=)` | A relation with all its claims and source documents. |
| Relation transcript | `transcript_relation(relation_id=)` | Why the memory believes a relation: its decision history. |

The graph reads take `valid_at` and `believed_at` together for a two-clock
read (see [Time](https://remember.dev/docs/concepts/time#belief-time-and-as-of)). A graph path is returned
whole or not at all: if any edge on it no longer holds, the path is dropped,
because a path with a gap is a false statement about a connection.

See [Search](https://remember.dev/docs/reference/http-api/search),
[Graph](https://remember.dev/docs/reference/http-api/graph) and
[Resolve, lookup, hydrate, transcript](https://remember.dev/docs/reference/http-api/entities-and-facts).

## SQL queries over the query space

For questions the operations and primitives do not cover ("which documents
mention both Dana and the billing migration, newest first?"), you can run
**SQL queries** over **the query space**, `memory_v1`: a set of prepared,
read-only views and functions over claims, facts, entities, documents,
chunks, evidence and history.

It is not open access to the database. Every statement is parsed and
validated against the query space before it runs, and anything outside it
is rejected. Results are bounded and read-only.

```python
with remember.Client() as memory:
    result = memory.open_query(
        "SELECT claim_id, claim_text, asserted_at FROM claims_live"
        " ORDER BY asserted_at DESC NULLS LAST LIMIT 20"
    )
    for claim_id, claim_text, asserted_at in result.rows:
        print(asserted_at, claim_text)
```

Each row is a list of values in the order of `result.columns`.

You can explore the query space (`describe_query_space`,
`search_query_space`), check a statement without running it (`explain_query`),
and store reviewed statements as **saved queries** that agents run by name
with parameters.
See [Explore memory with SQL](https://remember.dev/docs/guides/sql),
[Saved queries](https://remember.dev/docs/guides/saved-queries) and
[Query space](https://remember.dev/docs/reference/query-space).

## How hybrid retrieval works

![A question goes to meaning, keyword and graph search, which nominate candidates; PostgreSQL confirms each against the live state in one snapshot and drops anything withdrawn or superseded; the result lists facts, evidence, time windows and contradictions, and how many candidates were dropped.](https://remember.dev/docs/diagrams/read-path.svg)

Underneath, every read that searches follows the same pattern: **nominate,
fuse, confirm**.

**Nominate.** Independent channels each propose candidates:

- **Semantic**: vector similarity search with pgvector, over embeddings of
  chunks, claims, facts and entity profiles. The query text is embedded with
  the deployment's embedding model.
- **BM25**: keyword ranking over chunks and claims, with PostgreSQL's
  `pg_textsearch`. It catches exact names, codes and numbers that vectors
  blur.
- **Graph**: for `facts_context` with entity anchors, the live PostgreSQL
  graph expands to the anchors' neighbours, and fact search runs inside that
  scope.

**Fuse.** Channel rankings are merged with reciprocal rank fusion (RRF): each
candidate scores the sum of `1 / (60 + rank)` over the channels that found
it. Something that ranks well in both semantic and keyword search rises; the
per-channel scores stay visible in the result's `ranking`.

**Confirm (hydrate).** Search indexes are a way to *find* candidates; they
are never the authority. Every candidate is re-read from the live PostgreSQL
tables in one consistent snapshot before it is returned. A candidate that no
longer holds (a claim that is no longer current testimony, a fact that was
retracted or falls outside the time scope, an entity that was merged away)
is dropped, and the envelope counts it in `dropped_by_hydration`. Stale
index entries can cost recall; they cannot put a false answer in front of
your agent.

In `claims_and_sources_context` this runs twice, once for claims and once
for passages, each with its own semantic and BM25 nomination (up to
`candidate_k` each), fusion and confirmation. Claims that say the same thing
from several documents are grouped, with a
[corroboration count](https://remember.dev/docs/concepts/contradictions#corroboration). The two lists come
back in one envelope, kept apart: `evidence` for claims, `chunks` for
passages.

## Where to go next

- [Reading a result](https://remember.dev/docs/concepts/reading-results): the envelope field by field.
- [Assured operations](https://remember.dev/docs/reference/assured-operations): full schemas.
- [Give an agent context](https://remember.dev/docs/guides/agent-context) and
  [Build a memory-backed agent](https://remember.dev/docs/guides/build-an-agent).
- [MCP tools](https://remember.dev/docs/reference/mcp): what the `remember` MCP server exposes.

---

Source: https://remember.dev/docs/concepts/reading-results

# Reading a result

A list of text snippets tells your agent nothing about itself. Is this
everything, or the top ten of thousands? Is it current? Did the search find
nothing, or does the entity not exist? Are these facts or somebody's
opinion? An agent that cannot tell guesses, and guesses sound exactly like
answers.

Every RememberStack read returns an **envelope**: the results plus an
account of what they are. The envelope says what kind of truth it holds,
which time it describes, whether it was cut short, what was dropped and why,
and, when the answer is "no", which kind of "no".

## The envelope at a glance

| Field | What it tells you |
|---|---|
| `grain` | What kind of truth the results are. |
| `temporal_scope` | Which time the answer describes, and when it was evaluated. |
| `entities` | Entity candidates (from resolving a name). |
| `facts` | Relations and observations. |
| `evidence` | Claims, each with its provenance. |
| `fact_evidence` | Which claim backs which fact, and with which stance. |
| `evidence_totals` | Exact evidence counts per fact and stance. |
| `chunks` | Source passages. |
| `sources` | Source documents. |
| `transcript` | Decision history. |
| `nodes`, `edges`, `paths` | Graph results. |
| `ranking` | The order results were ranked in, with each score. |
| `changes`, `aggregate`, `pages` | Change feeds, counts and compiled pages (used by specialised reads). |
| `freshness` | How current the data behind the answer is. |
| `truncation` | Whether the results were capped, and how many exist. |
| `dropped_by_hydration` | How many candidates were found but failed confirmation. |
| `excluded_unstamped` | How many undated claims a time filter had to leave out. |
| `negative` | Why the answer is empty, as a typed reason. |

Lists a read does not fill are empty (`[]`); single values it does not fill
are `null`.

## Read a result in this order

Before your agent uses the results, have it check the envelope in this
order. Each step can change what the results mean.

1. **`grain`**: is this what the question needs? `evidence` is testimony;
   do not answer "is it true now" from it.
2. **`negative`**: if it is set, the answer is empty. Branch on its `kind`
   and stop.
3. **`contradiction`** on each fact: report every side, not only the
   first. When its `returned` is less than its `total`, there are more
   sides than you see.
4. **`truncation`**: if `truncated` is `true`, the list is not complete.
   Raise `k`, narrow the query, or say the answer is partial.
5. **`dropped_by_hydration`**: a large number right after an ingest means
   processing is still settling; ask again later.
6. **`fact_evidence` and `evidence_totals`**: which claims back which fact,
   and whether you are seeing all of them or a sample.
7. **[ContextBundle/v2](#contextbundlev2)**: read each of its two
   envelopes on its own, steps 1 to 6 for each.

Each field is explained under [Field reference](#field-reference).

## An annotated example

Ravi's `entity_id` was found with `resolve_entity`. This asks what he is
working on now:

```python
import remember

with remember.Client() as memory:
    result = memory.facts_context(
        "what is Ravi working on",
        entity_ids=["0b6c4f7e-3d2a-4e91-8c1f-5a7d9e2b4c60"],
    )
    print(result.model_dump_json(indent=2, by_alias=True))
```

```json
{
  "grain": "fact",
  "temporal_scope": {
    "mode": "current",
    "evaluated_at": "2026-09-23T08:15:02.113000Z",
    "believed_at": "2026-09-23T08:15:02.113000Z",
    "identity_regime": "current"
  },
  "entities": [],
  "facts": [
    {
      "fact_id": "7d2e9a14-58c3-4f0b-a6e1-3c9b2d7f8e05",
      "kind": "relation",
      "label": "Ravi works on search team",
      "evidence_count": 2,
      "validity": {
        "valid_from": "2026-06-01T00:00:00Z",
        "valid_until": null,
        "valid_precision": "open",
        "ingested_at": "2026-06-12T14:11:37Z",
        "invalidated_at": null
      },
      "temporal_match": "confirmed",
      "contradiction_group": null,
      "contradiction": null,
      "support": "current"
    },
    {
      "fact_id": "c41f0b8e-92a7-4d63-b5e2-8f1a6c3d9e27",
      "kind": "observation",
      "label": "Ravi is on call for billing incidents.",
      "evidence_count": 1,
      "validity": {
        "valid_from": null,
        "valid_until": null,
        "valid_precision": "unknown",
        "ingested_at": "2026-02-02T09:30:12Z",
        "invalidated_at": null
      },
      "temporal_match": "possible",
      "contradiction_group": null,
      "contradiction": null,
      "support": "current"
    }
  ],
  "evidence": [
    {
      "claim_id": "e5a8c2d1-6f3b-4a97-8d0e-1b4c7f2a9e36",
      "doc_id": "3f9d1c6a-2b8e-5c47-9a1d-6e0f3b8c2d74",
      "chunk_id": "91c7e4b2-0d5a-4f86-b3e9-7a2c1d8f6e40",
      "claim_text": "Ravi moved from the billing migration to the search team on 2026-06-01.",
      "source_span": "Ravi moved from the billing migration to the search team on 1 June.",
      "char_start": 412,
      "char_end": 479,
      "evidence_spans": [{"char_start": 412, "char_end": 479}],
      "is_attributed": false,
      "is_current_testimony": true,
      "asserted_at": "2026-06-12T14:00:00Z",
      "claim_valid_from": "2026-06-01T00:00:00Z",
      "claim_valid_until": "2026-06-01T00:00:00Z",
      "claim_valid_precision": "day",
      "claim_valid_kind": "event_time",
      "document_title": "2026-06-12-retro",
      "source_kind": "notes",
      "corroboration_count": null,
      "grouped_claim_ids": []
    },
    {
      "claim_id": "2b7f9d3e-4c1a-4e58-a0d6-9f3e2c7b1a85",
      "doc_id": "8a2e6d4f-1c9b-5f30-8e7a-2d5c9b1f4e63",
      "chunk_id": "4e8a1f6c-7b3d-4c29-9e05-3a6d8f2c1b97",
      "claim_text": "Ravi is on call for billing incidents.",
      "source_span": "Ravi is on call for billing incidents.",
      "char_start": 96,
      "char_end": 134,
      "evidence_spans": [{"char_start": 96, "char_end": 134}],
      "is_attributed": false,
      "is_current_testimony": true,
      "asserted_at": "2026-02-02T09:00:00Z",
      "claim_valid_from": null,
      "claim_valid_until": null,
      "claim_valid_precision": "unknown",
      "claim_valid_kind": null,
      "document_title": "oncall-rota",
      "source_kind": "notes",
      "corroboration_count": null,
      "grouped_claim_ids": []
    }
  ],
  "fact_evidence": [
    {
      "fact_kind": "relation",
      "fact_id": "7d2e9a14-58c3-4f0b-a6e1-3c9b2d7f8e05",
      "claim_id": "e5a8c2d1-6f3b-4a97-8d0e-1b4c7f2a9e36",
      "stance": "supports"
    },
    {
      "fact_kind": "observation",
      "fact_id": "c41f0b8e-92a7-4d63-b5e2-8f1a6c3d9e27",
      "claim_id": "2b7f9d3e-4c1a-4e58-a0d6-9f3e2c7b1a85",
      "stance": "supports"
    }
  ],
  "evidence_totals": [
    {"fact_kind": "relation", "fact_id": "7d2e9a14-58c3-4f0b-a6e1-3c9b2d7f8e05", "stance": "supports", "returned": 1, "total": 2},
    {"fact_kind": "relation", "fact_id": "7d2e9a14-58c3-4f0b-a6e1-3c9b2d7f8e05", "stance": "contradicts", "returned": 0, "total": 0},
    {"fact_kind": "observation", "fact_id": "c41f0b8e-92a7-4d63-b5e2-8f1a6c3d9e27", "stance": "supports", "returned": 1, "total": 1},
    {"fact_kind": "observation", "fact_id": "c41f0b8e-92a7-4d63-b5e2-8f1a6c3d9e27", "stance": "contradicts", "returned": 0, "total": 0}
  ],
  "chunks": [],
  "sources": [],
  "transcript": [],
  "nodes": [
    {"entity_id": "5e1b8c3a-9d7f-4a26-b0c4-6f2e8d1a3b59", "name": "search team", "hops": 1}
  ],
  "paths": [],
  "edges": [],
  "ranking": [],
  "changes": [],
  "aggregate": null,
  "pages": [],
  "freshness": {
    "pg_live_ts": "2026-09-23T08:15:02.113000Z",
    "p1_written_inline": true,
    "p1_believed_at_horizon": null,
    "k": null
  },
  "truncation": {
    "truncated": false,
    "returned": 2,
    "estimated_total": 2,
    "total_is_exact": true,
    "continuation": null,
    "reason": null
  },
  "dropped_by_hydration": 0,
  "excluded_unstamped": 0,
  "negative": null
}
```

Reading it top to bottom:

- **`grain: "fact"`**: these are adjudicated facts, not raw testimony.
- **`temporal_scope`**: the answer describes the world *now*
  (`mode: "current"`), as of 2026-09-23 08:15 UTC, using today's identities.
- **The first fact** is a relation, true since 2026-06-01 and still open,
  supported by 2 distinct documents. Its window is complete, so its time
  match is `confirmed`. Ravi's earlier billing migration work is not here:
  it ended on 2026-06-01, and this is a `current` read.
- **The second fact** has no date. It is returned because nothing rules it
  out, and marked `possible` so the agent does not mistake it for a
  confirmed current fact.
- **`evidence` and `fact_evidence`**: one claim per fact was returned; each
  link says which fact it backs and that it supports it. The first claim
  shows the relative-date resolution: the retro said "on 1 June", the claim
  says 2026-06-01.
- **`evidence_totals`**: the relation has 2 supporting documents and only
  1 claim was returned, so there is more evidence to fetch
  (`hydrate_relation`). Nothing contradicts either fact.
- **`nodes`**: the graph expansion from Ravi reached the search team, one
  hop away.
- **`truncation`**: both matching facts were returned; the total is exact.
- **`negative: null`**: the answer is not empty.

## Field reference

### `grain`

What kind of truth the result holds. Never mixed within one envelope.

| Value | Meaning | Returned by |
|---|---|---|
| `fact` | Adjudicated facts and entity candidates. | `facts_context`, `resolve_entity`, lookups |
| `evidence` | Claims and source passages: what sources said. | `claims_and_sources_context`, search |
| `composite` | A fact together with its evidence, sources or history. | `hydrate_relation`, `transcript_relation` |
| `compiled` | Compiled knowledge pages. | Not served by the default routes. |

### `temporal_scope`

Always present. Its `mode` is one of `current`, `at` (with `at`),
`overlap` (with `from` and `to`), `history` or `as_of` (with `valid_at`).
Every mode carries `evaluated_at` (the instant the read ran), `believed_at`
(the belief-time instant it read) and `identity_regime`: `current` means
today's aliases and merges were used, even for a past time. See
[Time](https://remember.dev/docs/concepts/time#asking-about-time).

### `entities`

`EntityCandidate` objects from name resolution: `entity_id`,
`canonical_name`, `tier` (`T0` exact alias, `T1` similar spelling, `T2`
similar sound, `T3` profile embedding) and `context_hits` (how many current
relations connect it to the entities you said were in focus). More than one
candidate means ambiguity. See [Entities](https://remember.dev/docs/concepts/entities).

### `facts`

`FactResult` objects:

| Field | Meaning |
|---|---|
| `fact_id` | The relation or observation ID. |
| `kind` | `relation` or `observation`. |
| `label` | The fact as a readable sentence, without dates. |
| `evidence_count` | Distinct documents whose current testimony supports it. |
| `validity` | `valid_from`, `valid_until` (exclusive), `valid_precision`, `ingested_at`, `invalidated_at`. See [Time](https://remember.dev/docs/concepts/time). |
| `temporal_match` | `confirmed` or `possible`. |
| `contradiction_group` | The contradiction group's ID, or `null`. |
| `contradiction` | The other sides of the contradiction, inline. See [Contradictions](https://remember.dev/docs/concepts/contradictions). |
| `support` | `current`, or `withdrawn` when its only support was lost to a processing change. See [Facts](https://remember.dev/docs/concepts/facts#support-withdrawn). |

### `evidence`

`EvidenceResult` objects, one per claim:

| Field | Meaning |
|---|---|
| `claim_id`, `doc_id`, `chunk_id` | Where the claim lives. |
| `claim_text` | The standalone claim. |
| `source_span`, `char_start`, `char_end` | The origin passage and its position in the version's converted text. |
| `evidence_spans` | Every supporting range, origin first. |
| `is_attributed` | Whether it records someone's statement or stance. |
| `is_current_testimony` | Whether it still counts as what its source says. |
| `asserted_at` | When the source said it. |
| `claim_valid_from`, `claim_valid_until`, `claim_valid_precision`, `claim_valid_kind` | When the claim says it happened or was true (inclusive end). |
| `document_title`, `source_kind` | Which document. |
| `corroboration_count` | Distinct documents that stated the same claim (set by `claims_and_sources_context`). |
| `grouped_claim_ids` | The claims folded into this one by that grouping. |

See [Claims](https://remember.dev/docs/concepts/claims) and [Evidence](https://remember.dev/docs/concepts/evidence).

### `fact_evidence` and `evidence_totals`

`fact_evidence` links facts to the claims in `evidence`: `fact_kind`,
`fact_id`, `claim_id`, `stance` (`supports` or `contradicts`).
`evidence_totals` has one entry per fact and stance with `returned` (links in
this envelope) and `total` (links that exist). When `returned` is less than
`total`, you are seeing a sample.

### `chunks`

`ChunkEvidenceResult` objects, source passages: `chunk_id`, `doc_id`,
`version_id`, `representation_id`, `chunk_text`, `context_prefix`,
`char_start`, `char_end`, `section_role`, `document_title`, `source_kind`,
`source_modified_at`, `published_at`. Passages are kept separate from claims:
a passage is raw source text, a claim is an extracted statement.

### `sources`

`SourceRecord` objects: `doc_id`, `title`, `source_kind`, `markdown_uri`
(the converted text), and, where the read computes them, `mention_count`,
`first_mentioned_at` and `last_mentioned_at`.

### `transcript`

`TranscriptEntry` objects: the decision history. See
[Evidence](https://remember.dev/docs/concepts/evidence#why-do-we-believe-this).

### `nodes`, `edges`, `paths`

Graph results. A `GraphNode` has `entity_id`, `name` and `hops` (distance
from the start). A `GraphEdge` is a relation: `relation_id`, `subject_id`,
`object_id`, `predicate`, `fact` (its label), `evidence_count`, the window
and belief fields, and `support`. A `GraphPath` has `length`, `nodes` and
`edges`, and is returned whole or not at all.

### `ranking`

`RankedItem` objects in rank order: `item_id`, `score` and `signals`. For a
fused search the score is the RRF sum, and `signals` holds each channel's
contribution (`channel_0` semantic, `channel_1` BM25). See
[Retrieval](https://remember.dev/docs/concepts/retrieval#how-hybrid-retrieval-works).

### `freshness`

`pg_live_ts` is the database instant the answer was read at. The other
fields (`p1_written_inline`, `p1_believed_at_horizon`, `k`) describe search
index lag and compiled pages; in current deployments search indexes are
written with the data (`true`) and have no belief-time horizon (`null`).

### `truncation`

`truncated` says whether more results exist than were returned. `returned`
is how many came back, `estimated_total` how many were found, and
`total_is_exact` whether that total is exact. `continuation` is an opaque
cursor for reads that can page (the graph neighbourhood). A capped answer
always says so.

### `dropped_by_hydration`

How many candidates the search found but that failed confirmation against
live data: no longer current, retracted, outside the time scope, or merged
away. A non-zero value is normal and means stale index entries were filtered
out, not that something is broken.

### `excluded_unstamped`

For reads that filter claims by their stated time: how many claims were left
out because they carry no date. None of the assured operations or HTTP
routes filters claims this way today, so it is `0`.

### `negative`

When the answer is empty, `negative` says why, and each kind calls for a
different reaction:

| `kind` | Meaning | What your agent should do |
|---|---|---|
| `unknown_entity` | The name or ID does not match any entity in memory. | Check the spelling, try another name, or say memory has nothing on it. |
| `known_empty` | The entity or query is valid, but nothing matches. | Answer "nothing recorded", or broaden the query. |
| `boundary` | The read cannot answer this shape (a limit of the capability, such as an unavailable graph). | Use the `workaround` the negative names. |

Each negative has an `explanation` and, where one exists, a `workaround`.
Forgotten content is indistinguishable from content that never existed; there
is no separate "deleted" kind. See
[Handle unknowns and ambiguity](https://remember.dev/docs/guides/unknowns-and-ambiguity).

## ContextBundle/v2

`combined_context` returns two envelopes side by side instead of one:

```json
{
  "contract": "ContextBundle/v2",
  "claims_and_sources": {"grain": "evidence", "...": "..."},
  "facts": {"grain": "fact", "...": "..."}
}
```

`claims_and_sources` is exactly a `claims_and_sources_context` envelope
(grain `evidence`) and `facts` is exactly a `facts_context` envelope (grain
`fact`). They are never merged, so testimony and belief stay distinguishable,
and each keeps its own truncation, drops and negative. In Python it is a
`remember.ContextBundleV2`:

```python
with remember.Client() as memory:
    bundle = memory.combined_context("billing migration cutover")
    for fact in bundle.facts.facts:
        print("fact:", fact.label, fact.evidence_count)
    for claim in bundle.claims_and_sources.evidence:
        print("said:", claim.claim_text, claim.asserted_at)
```

## Where to go next

- [Result types](https://remember.dev/docs/reference/result-types): the complete schemas.
- [Handle unknowns and ambiguity](https://remember.dev/docs/guides/unknowns-and-ambiguity).
- [Cite the source of an answer](https://remember.dev/docs/guides/cite-sources).

---

Source: https://remember.dev/docs/guides/file-types

# Supported file types

RememberStack reads text. Every document is turned into Markdown before
anything else happens, so what a deployment accepts depends on which
formats it can turn into Markdown.

## At a glance

| Format | As installed | With converters configured |
|---|---|---|
| Markdown (`.md`) | Yes | Yes |
| Plain text (`.txt`) | Yes | Yes |
| Chat logs and transcripts, as Markdown or text | Yes | Yes |
| HTML | Yes | Yes |
| Word (`.docx`), PowerPoint (`.pptx`), Excel (`.xlsx`) | Yes | Yes |
| PDF, including scanned PDFs | No | Yes, with `mistral_ocr` (needs a Mistral API key) |
| PNG and JPEG images | No | Yes, with `image_ocr_description` (OCR plus a written description of the image) |
| Legacy Office (`.doc`, `.ppt`, `.xls`) | No | No: save as `.docx`, `.pptx` or `.xlsx` first |
| Audio and video | No | No |
| Web addresses (URLs) | No | No: download the page and send the HTML |

## Formats and converters

A new self-hosted deployment converts Markdown, plain text, HTML, Word,
PowerPoint and Excel files locally, with no API key. Other formats are
accepted and stored, but they wait unprocessed until you add a converter
for their type. The upload response tells you: its `parked` field is
`"no_route"`, and `remember ingest` prints a warning.

To read PDFs and images, map each file type to a converter in
`REMEMBERSTACK_SELFHOST_CONVERSION_ROUTES`. [File formats and
converters](https://remember.dev/docs/self-hosting/converters) shows the setting, what each
converter does and costs, and how to release files that were stored before
you added a route.

## Send the right type

The type sent with the upload decides which converter reads the file. The
`remember` CLI, Python client and MCP `ingest` tool take it from the file
extension, the same way on every Python installation: `.md` is
`text/markdown`, `.txt` is `text/plain`, `.pdf` is `application/pdf`, and
so on ([the full table](https://remember.dev/docs/guides/ingest-files#the-mime-type)). A file
whose extension does not say what it is needs the type passed explicitly:

```python
client.ingest("notes/standup", mime="text/markdown")
```

```bash
remember ingest notes/standup --mime text/markdown
```

## Conversations

There is no separate conversation format. Write each conversation as one
Markdown document with one line per turn, and send it like any other file.
[Ingest conversations and transcripts](https://remember.dev/docs/guides/ingest-conversations)
shows the layout.

---

Source: https://remember.dev/docs/guides/bring-your-data

# Bring your existing data

You probably already have the material an agent needs: a folder of specs,
a wiki export, meeting notes, chat logs, or the text you once loaded into a
vector database. This page shows how to move it in so that the memory
knows what each source said, when it said it, and where it came from.

## What moves, and what does not

RememberStack builds its memory from text. It reads each document, keeps
the statements worth keeping, and works out the facts itself. So what you
bring over is **the text and when it was written**. Three things do not
move:

- **Embeddings.** A vector database's vectors were made by a different
  model for a different index. RememberStack computes its own from the
  text, so vectors are left behind.
- **Chunks as documents.** Your old store cut documents into pieces.
  RememberStack needs whole documents, because a claim is read in the
  context of its section and its date. Put the pieces back together first.
- **Another tool's extracted "memories".** A list of summaries that another
  memory product produced has already lost its sources and dates. Bring the
  original conversations and documents instead whenever you still have
  them.

## Three rules that make the import worth it

**Keep the dates.** Pass each document's original date as
`source_modified_at`. It becomes the date the document's statements were
made, which is what lets the memory tell January's plan from June's
correction and read "yesterday" in the text correctly. A whole archive
sent without dates looks as if everything was said today.

**Name every source.** Give each document a stable `source_kind` and
`source_ref`, such as `drive` and the file's ID, or `file` and its path.
Re-running the import is then safe: unchanged documents return
`created=False` and cost nothing, and changed ones become new versions of
the same document. See [Name the
source](https://remember.dev/docs/guides/ingest-files#name-the-source-source_kind-and-source_ref).

**One document per real source.** One file, one page, one meeting, one
conversation. Do not merge a whole folder into one document, and do not
send chunks one by one.

## From files

If your sources are files, convert the ones that are not Markdown or text,
then send each with its modification time. This example uses
[markitdown](https://github.com/microsoft/markitdown), installed on your
own machine with `pip install "markitdown[all]"`, to convert PDF, Word,
PowerPoint, Excel and HTML:

```python
from datetime import UTC, datetime
from pathlib import Path

from markitdown import MarkItDown

import remember
from remember import MemoryApiError

client = remember.Client.from_env()
converter = MarkItDown()
root = Path("shared-drive-export")
TEXT_SUFFIXES = {".md", ".txt"}
CONVERTED = {".pdf", ".docx", ".pptx", ".xlsx", ".html", ".htm"}
pending = []

for path in sorted(root.rglob("*")):
    suffix = path.suffix.lower()
    if not path.is_file() or (suffix not in TEXT_SUFFIXES and suffix not in CONVERTED):
        continue
    ref = path.relative_to(root).as_posix()
    if suffix in TEXT_SUFFIXES:
        body, filename = path.read_bytes(), path.name
    else:
        body, filename = converter.convert(str(path)).text_content.encode("utf-8"), f"{path.stem}.md"
    if not body.strip():
        print(f"skipped {ref}: no text")
        continue
    try:
        version = client.ingest(
            content=body,
            filename=filename,
            title=path.stem,
            source_kind="file",
            source_ref=ref,
            source_modified_at=datetime.fromtimestamp(path.stat().st_mtime, tz=UTC),
        )
    except MemoryApiError as error:
        print(f"skipped {ref}: {error.status_code} {error.detail}")
        continue
    if version.created:
        pending.append(version.version_id)

print(f"{len(pending)} new versions")
```

The client takes the MIME type from `filename`, so naming converted text
`….md` sends it as `text/markdown`.

File modification times are often reset by copying and exporting. If your
source system knows the real date (a document's last edit, a meeting's
date), use that instead.

Scanned PDFs have no text layer. markitdown returns little or nothing for
them, which the loop skips; run them through an OCR tool first. A
self-hosted deployment can do OCR itself with the `mistral_ocr` converter.

## From a vector database

A vector database stores chunks of text next to their vectors. To move it,
read out the text and its metadata, put the chunks of each source back
together in order, and send each source as one document.

This works only if the text is stored in the database. If your store holds
vectors and IDs only, go back to the original files and use the section
above.

### Put chunks back together

Whichever database you use, reduce each record to four fields: the source
it came from, its position in that source, its text, and a date. This
function groups them into documents and removes the overlap that most
chunkers leave between neighbouring chunks:

```python
from collections import defaultdict

def rebuild_documents(chunks):
    """Group (source, position, text, date) records into one text per source.

    Returns {source: (text, newest date)}. Neighbouring chunks that repeat the
    end of the previous chunk (a chunker's overlap) are joined without the repeat.
    """
    by_source = defaultdict(list)
    for source, position, text, date in chunks:
        by_source[source].append((position, text, date))
    documents = {}
    for source, parts in by_source.items():
        parts.sort(key=lambda part: part[0])
        text = parts[0][1]
        for _, chunk, _ in parts[1:]:
            # Treat a shared run of at least 20 characters as chunker overlap;
            # shorter matches are coincidence.
            overlap = next(
                (n for n in range(min(len(text), len(chunk), 2000), 19, -1)
                 if text.endswith(chunk[:n])),
                0,
            )
            text += ("" if overlap else "\n\n") + chunk[overlap:]
        dates = [date for _, _, date in parts if date is not None]
        documents[source] = (text, max(dates) if dates else None)
    return documents
```

Then send each document the same way as a file:

```python
import remember

client = remember.Client.from_env()
pending = []

for source, (text, date) in rebuild_documents(chunks).items():
    version = client.ingest(
        content=text.encode("utf-8"),
        filename="document.md",
        title=source,
        source_kind="vector-import",
        source_ref=source,
        source_modified_at=date,  # a timezone-aware UTC datetime, or None
    )
    if version.created:
        pending.append(version.version_id)
```

If a record carries no date, `source_modified_at` is `None` and the
document's statements are dated when they are ingested. Recover real dates
from the original system where you can; it is the part of the import that
most affects answers about time.

### Read the records out

The field names below (`text`, `source`, `chunk_index`, `updated_at`) are
examples. Use whatever your import pipeline stored.

**Qdrant**, with `qdrant-client`:

```python
from datetime import datetime

from qdrant_client import QdrantClient

qdrant = QdrantClient(url="http://localhost:6333")
chunks, offset = [], None
while True:
    points, offset = qdrant.scroll(
        collection_name="docs", limit=256, offset=offset,
        with_payload=True, with_vectors=False,
    )
    for point in points:
        p = point.payload
        date = datetime.fromisoformat(p["updated_at"]) if p.get("updated_at") else None
        chunks.append((p["source"], p["chunk_index"], p["text"], date))
    if offset is None:
        break
```

**Chroma**:

```python
from datetime import datetime

import chromadb

collection = chromadb.PersistentClient(path="./chroma").get_collection("docs")
chunks, start = [], 0
while True:
    page = collection.get(include=["documents", "metadatas"], limit=500, offset=start)
    if not page["ids"]:
        break
    for text, meta in zip(page["documents"], page["metadatas"]):
        date = datetime.fromisoformat(meta["updated_at"]) if meta.get("updated_at") else None
        chunks.append((meta["source"], meta["chunk_index"], text, date))
    start += len(page["ids"])
```

**PostgreSQL with pgvector**:

```python
import psycopg

with psycopg.connect("postgresql://localhost/app") as conn:
    chunks = conn.execute(
        "SELECT source, chunk_index, content, updated_at FROM chunks"
    ).fetchall()
```

Make sure the dates you pass are timezone-aware and in UTC; the client
rejects anything else. For a naive timestamp known to be UTC, use
`date.replace(tzinfo=UTC)`.

**Pinecone, Weaviate and other stores** follow the same pattern: page
through the records with the store's own listing or export API, keep the
text and metadata, and skip the vectors. If the text was never stored in
the metadata, use the original files.

## From chat history

Export conversations as text and write each one as a Markdown document,
one line per turn, with the conversation's date as `source_modified_at`.
[Ingest conversations and transcripts](https://remember.dev/docs/guides/ingest-conversations)
shows the layout and a loop to send them.

If all you have is a list of "memories" that another tool extracted, you
can still send it, one document per person or topic, dated when the tool
exported it. Expect less from it: each line is a summary with no source
behind it, so the memory can only cite the export itself.

## Wait, then check

Processing takes minutes per document, and a large archive takes a while.
Wait for the new versions in batches of up to 1,000, as in
[Load a folder](https://remember.dev/docs/guides/ingest-files#load-a-folder), then check a few
answers you already know:

```python
print(client.resolve_entity("Dana"))
print(client.facts_context("Who leads the billing migration?").model_dump_json(indent=2))
```

If names resolve and facts come back with the dates you expect, the import
worked. If answers come back empty, see [Why is my answer empty or
wrong?](https://remember.dev/docs/guides/unknowns-and-ambiguity#why-is-my-answer-empty-or-wrong).

## Next

- [Keep a source up to date](https://remember.dev/docs/guides/keep-sources-current) once the
  import is done.
- [Time](https://remember.dev/docs/concepts/time): why the dates matter so much.

---

Source: https://remember.dev/docs/guides/ingest-files

# Ingest files

An agent can only remember what you give it. This page shows how to send a
file, a string or raw bytes to RememberStack, how to name it so an edited
file becomes a new version of the same document instead of a stranger, and
how to load a folder of notes in one go.

You need a configured client. If you have not set `REMEMBER_API_URL` and a
token yet, follow the [Quickstart](https://remember.dev/docs/start/quickstart) first.

## Send one file

```python
from datetime import UTC, datetime

import remember

client = remember.Client.from_env()

version = client.ingest(
    "notes/2026-09-17-standup.md",
    title="Stand-up, 17 September 2026",
    source_kind="file",
    source_ref="notes/2026-09-17-standup.md",
    source_modified_at=datetime(2026, 9, 17, 9, 30, tzinfo=UTC),
)
print(version.doc_id, version.version_id, version.created)
```

The call returns as soon as the bytes are stored. The result
(`IngestedVersion`) carries:

| Field | Meaning |
|---|---|
| `deployment_id` | The deployment that stored the bytes. |
| `doc_id` | The document. Stable for one `source_kind` + `source_ref` pair. |
| `version_id` | This exact snapshot of the bytes. You wait on it and cite it. |
| `content_hash` | SHA-256 of the bytes, in hex. |
| `created` | `True` when this call stored a new version; `False` when the bytes were already the document's latest version. |
| `mime` | The MIME type conversion uses for these bytes. |
| `title` | The document's title. |
| `versioning_mode` | `snapshot` or `living`. |
| `parked` | `"no_route"` when the file's conversion is parked waiting for a conversion route for its MIME type: the bytes are stored but not read until an operator adds a route if needed and runs `remember ops resume-no-route`, or you send the same bytes again with a type that has a route. `None` means only that it is not parked for `no_route`; readiness tells you the processing state. |

`title` and `versioning_mode` are set by a document's first ingest, and the
MIME type by the first upload of those bytes. Later values you send are not
applied, so compare the result with what you sent. The one exception is a
type with no converter route: send the bytes again with a routable type and
the new type applies, releasing the parked conversion. See
[What the first ingest fixes](https://remember.dev/docs/reference/http-api/ingest#what-the-first-ingest-fixes).

Processing (reading, structuring, extracting claims, adjudicating facts)
happens afterwards and takes minutes. The document is not queryable until
it finishes: see [Wait until a document is queryable](https://remember.dev/docs/guides/wait-for-readiness).

The same with the CLI:

```bash
remember ingest notes/2026-09-17-standup.md \
  --title "Stand-up, 17 September 2026" \
  --source-kind file \
  --source-ref notes/2026-09-17-standup.md \
  --source-modified-at 2026-09-17T09:30:00+00:00
```

The CLI prints the same fields as one JSON line. It has no `--filename`
flag: the filename is always the file's own name.

## Three ways to pass the body

`Client.ingest` takes the body in one of three forms.

**A path**, as a string or a `pathlib.Path`. The client reads the file, uses
its name as the filename and takes the MIME type from the file's
extension.

```python
client.ingest("specs/billing-migration.md")
```

A string that is not an existing file raises `ValueError("file not found:
…")`. The client never treats a string as document text.

**Bytes as the first argument.** You must name the file.

```python
text = "Dana: the finance sign-off moves to 3 October."
client.ingest(text.encode("utf-8"), filename="dana-update.md")
```

**Bytes as `content=`.** Same rules as bytes; use it when the first
argument reads better as nothing at all.

```python
client.ingest(content=b"...", filename="ravi-notes.txt")
```

With bytes, the MIME type comes from the `filename` you pass.

## The MIME type

The MIME type decides which converter reads the file. The client picks it
from the extension: of the file's real name for a path, of `filename` for
bytes. The formats a deployment can convert map the same way on every
Python installation:

| Extension | MIME type |
|---|---|
| `.md`, `.markdown` | `text/markdown` |
| `.txt` | `text/plain` |
| `.html`, `.htm` | `text/html` |
| `.pdf` | `application/pdf` |
| `.png` | `image/png` |
| `.jpg`, `.jpeg` | `image/jpeg` |
| `.docx`, `.pptx`, `.xlsx` | The Office Open XML types (`application/vnd.openxmlformats-officedocument.…`) |

Any other extension is looked up in Python's `mimetypes` database, and a
name it does not know is sent as `application/octet-stream`. Pass `mime=`
(or `--mime` on the CLI) to send a different type; an explicit value always
wins.

The deployment keeps the first MIME type it saw for a given set of bytes.
If you send the same bytes again with a corrected `mime`, the stored type
does not change. Get the type right on the first send.

## Filenames and titles

- `filename` is required and must not be empty. A path supplies it for you.
- The extension of the filename is kept with the stored original. The
  file is read according to `mime`; for bytes sent without `mime`, that
  type comes from the filename's extension.
- `title` is optional. Without it, the document's title is the filename
  without its extension (`2026-09-17-standup`).
- The title is set when the document is first created. Sending a new
  version with a different `title` does not rename the document.

## Name the source: `source_kind` and `source_ref`

A document's identity is the pair `source_kind` + `source_ref`:

- `source_kind` is the kind of place the file comes from, such as `file`,
  `drive`, `meeting`.
- `source_ref` is the file's stable identifier within that kind, such as a
  relative path or an upstream file ID.

Send the same pair again with changed bytes and you get a new version of
the same document. Send it with identical bytes and nothing is stored
(`created=False`). That is what makes re-running an import safe.

The two must be supplied together. One without the other raises
`ValueError` in the client and returns HTTP 422 from the API.

Without the pair, the document's identity is its content hash. Sending the
same bytes twice is still a no-op, but an edited copy of the file becomes a
second, unrelated document, and both keep speaking. Use the pair for
anything you will send more than once. [Documents, versions and
sources](https://remember.dev/docs/concepts/documents-and-sources) explains the model.

`source_modified_at` is when the source last changed. It becomes the time
the document's claims were asserted, which is how RememberStack reads
"yesterday" or "next week" inside the text. It must be a timezone-aware UTC
`datetime`; a naive or non-UTC value raises `ValueError`. It requires the
source pair.

`versioning_mode` and `source_version_ref` also require the pair. They
matter when a file changes: see [Keep a source up to
date](https://remember.dev/docs/guides/keep-sources-current).

## Load a folder

This loop sends every Markdown and text file under a folder, keyed by its
path, and collects the versions that need processing:

```python
from datetime import UTC, datetime
from pathlib import Path

import remember
from remember import MemoryApiError

SUFFIXES = {".md", ".txt"}

client = remember.Client.from_env()
root = Path("billing-migration")
pending = []

for path in sorted(root.rglob("*")):
    if path.suffix.lower() not in SUFFIXES or not path.is_file():
        continue
    ref = path.relative_to(root).as_posix()
    modified = datetime.fromtimestamp(path.stat().st_mtime, tz=UTC)
    try:
        version = client.ingest(
            path,
            source_kind="file",
            source_ref=f"billing-migration/{ref}",
            source_modified_at=modified,
        )
    except MemoryApiError as error:
        print(f"skipped {ref}: {error.status_code} {error.detail}")
        continue
    if version.created:
        pending.append(version.version_id)

print(f"{len(pending)} new versions")
```

Run it again after editing one note and only that note produces a new
version. Unchanged files return `created=False` and cost nothing.

Then wait for the new versions. A readiness check takes at most 1,000
versions, so wait in batches. A bulk load takes longer than the default
30-minute `timeout`, so raise it:

```python
for start in range(0, len(pending), 1000):
    client.wait_for_readiness(pending[start:start + 1000], timeout=3600)
```

The client does not retry a failed ingest for you. If a call fails with a
transport error, sending the same bytes with the same source pair again is
safe: at worst it returns `created=False`.

## Send a document through MCP

An agent connected over MCP uses the `ingest` tool. Exactly one of `text`,
`content_base64` or `path` carries the body:

```json
{
  "name": "ingest",
  "arguments": {
    "text": "# Decision log\n\nDana moved the finance sign-off to 3 October.",
    "filename": "decision-log.md",
    "source_kind": "agent",
    "source_ref": "billing-migration/decision-log",
    "source_modified_at": "2026-09-18T08:00:00+00:00"
  }
}
```

- `text` is UTF-8 and needs `filename`; `mime` is the text type of the
  filename's extension (`decision-log.md` is `text/markdown`), else
  `text/plain`.
- `content_base64` is standard base64 and needs `filename`; `mime` comes
  from the filename's extension as in the table above, else
  `application/octet-stream`.
- `path` reads a local file on the machine running `remember mcp`. It is
  refused unless the operator lists allowed directories in
  `REMEMBERSTACK_MCP_INGEST_ROOTS`.

Limits on the tool arguments: `filename` up to 512 characters, `mime` 255,
`title` 512, `source_kind` 128, `source_ref` 512, `source_version_ref` 512.
The tool's reply includes the arguments to pass to `pipeline_readiness`
next. When the file was parked (`"parked": "no_route"`), the reply's
`pipeline.status` is `parked_no_route` and it tells the agent to report
that to the user instead of waiting. See [Connect your coding agent](https://remember.dev/docs/start/connect-your-agent).

## What gets read

Every ingest is stored. Whether it is read depends on the MIME type.

A fresh self-hosted deployment reads Markdown, plain text, HTML and
Word, PowerPoint and Excel files (`.docx`, `.pptx`, `.xlsx`). A file
with any other MIME type is stored and its processing is parked, not
refused: the ingest succeeds with `"parked": "no_route"`, and the
version waits until an operator adds a converter for its type. PDFs and
images need converters that you configure with your own provider key.
See [File formats and converters](https://remember.dev/docs/self-hosting/converters).

There is no body size limit unless the operator sets
`REMEMBERSTACK_SELFHOST_INGEST_BODY_MAX_BYTES`. Over that limit the API
answers HTTP 413 `body_too_large`. A request without a
`Content-Length` header is refused with HTTP 411 when a limit is set.

A parked version never becomes ready, so a readiness wait on it runs until
its timeout. Check `parked` in the ingest result before you wait;
`remember ingest` prints a warning when it is set.

## Errors

| Status | When |
|---|---|
| 413 | Body over the deployment limit (`body_too_large`). Split the file. |
| 422 | Missing half of the source pair, a non-UTC `source_modified_at`, or living mode or a revision without a source pair. |
| 401, 403 | Missing or wrong token, or a token without write access. |

The Python client raises `remember.MemoryApiError` with `status_code` and
`detail`. Client-side checks (the source pair, UTC, a missing file) raise
`ValueError` before anything is sent.

## Next

- [Wait until a document is queryable](https://remember.dev/docs/guides/wait-for-readiness)
- [Ingest conversations and transcripts](https://remember.dev/docs/guides/ingest-conversations)
- [Keep a source up to date](https://remember.dev/docs/guides/keep-sources-current)

---

Source: https://remember.dev/docs/guides/ingest-conversations

# Ingest conversations and transcripts

Close the chat and the conversation is gone, along with every decision made
in it. This page shows how to keep conversations: chat sessions with an
agent, meeting transcripts, message threads. RememberStack has no separate
conversation API. You render each conversation as a Markdown document and
ingest it like any other file. The format below keeps who said what, and
when, in a form the extractor reads reliably.

Setup (endpoint and token) is in the [Quickstart](https://remember.dev/docs/start/quickstart).

## One document per session

Render one Markdown document per session (one meeting, one chat, one day
of a thread). Put one line per turn, in this shape:

```text
[<turn-id> | <timestamp>] <speaker>: <text>
```

For the billing migration stand-up:

```markdown
# Billing migration stand-up — 2026-09-17

Participants: Dana, Ravi

[t1 | 2026-09-17T09:30:00Z] Dana: Where are we on the invoice exporter?

[t2 | 2026-09-17T09:31:10Z] Ravi: It needs a rewrite. The migration moves from June to October.

[t3 | 2026-09-17T09:32:05Z] Dana: Agreed. I will tell finance today.
```

Why this shape:

- **The turn ID** gives every statement a stable anchor. Evidence points to
  character positions in the document; a turn ID in the quoted passage lets
  you find the turn again.
- **The timestamp on every line** puts each statement's own time next to
  it in the text, where the extractor can read "next week" in turn 40
  against turn 40's time rather than the start of the meeting.
- **The speaker name** on every line lets each claim be attributed to the
  person who said it.
- **A blank line between turns** keeps turns apart when the document is cut
  into sections and chunks.

Write timestamps in UTC with a zone (`Z` or `+00:00`). If your source has
no time zone, say so in a header line rather than guessing silently.

This is the format RememberStack's own long-conversation benchmark uses
(`render_session` in `benchmarks/locomo/protocol.py`).

## Render and ingest a session

```python
from dataclasses import dataclass
from datetime import UTC, datetime

import remember

@dataclass
class Turn:
    turn_id: str
    at: datetime
    speaker: str
    text: str

def render_session(title: str, participants: list[str], turns: list[Turn]) -> str:
    lines = [f"# {title}", "", f"Participants: {', '.join(participants)}"]
    for turn in turns:
        stamp = turn.at.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
        text = " ".join(turn.text.split())  # one line per turn
        lines += ["", f"[{turn.turn_id} | {stamp}] {turn.speaker}: {text}"]
    return "\n".join(lines) + "\n"

turns = [
    Turn("t1", datetime(2026, 9, 17, 9, 30, tzinfo=UTC), "Dana",
         "Where are we on the invoice exporter?"),
    Turn("t2", datetime(2026, 9, 17, 9, 31, 10, tzinfo=UTC), "Ravi",
         "It needs a rewrite. The migration moves from June to October."),
    Turn("t3", datetime(2026, 9, 17, 9, 32, 5, tzinfo=UTC), "Dana",
         "Agreed. I will tell finance today."),
]

client = remember.Client.from_env()
body = render_session("Billing migration stand-up — 2026-09-17", ["Dana", "Ravi"], turns)
version = client.ingest(
    body.encode("utf-8"),
    filename="standup-2026-09-17.md",
    title="Billing migration stand-up — 2026-09-17",
    source_kind="meeting",
    source_ref="standup/2026-09-17",
    source_modified_at=turns[0].at,
)
print(version.version_id, version.created)
```

Three choices in that call matter:

- **`source_kind` + `source_ref` name the conversation.** Use your own
  stable ID for it: a meeting ID, a chat session ID, a thread ID plus a
  date. The same pair later means "the same conversation".
- **`source_modified_at` is the session time.** Use the session's start
  time (or its end time, consistently). Every claim extracted from the
  document gets it as `asserted_at`, the time the source made the
  statement. Never leave it as the moment you happened to upload.
- **A `.md` `filename`.** Bytes have no path, so the client takes the MIME
  type from `filename`: `standup-2026-09-17.md` is sent as `text/markdown`.

The same file from the CLI:

```bash
remember ingest standup-2026-09-17.md \
  --source-kind meeting --source-ref standup/2026-09-17 \
  --source-modified-at 2026-09-17T09:30:00+00:00
```

From an agent over MCP, pass the rendered text:

```json
{
  "name": "ingest",
  "arguments": {
    "text": "# Billing migration stand-up — 2026-09-17\n\nParticipants: Dana, Ravi\n\n[t1 | 2026-09-17T09:30:00Z] Dana: Where are we on the invoice exporter?\n",
    "filename": "standup-2026-09-17.md",
    "source_kind": "meeting",
    "source_ref": "standup/2026-09-17",
    "source_modified_at": "2026-09-17T09:30:00+00:00"
  }
}
```

## Conversations that keep growing

A meeting ends; a chat with an agent or a support thread may not. You have
two shapes to choose from.

**Close sessions and start new documents (recommended).** Cut the
conversation into sessions (per day, per topic, per agent run) and ingest
each finished session once, with its own `source_ref`, in the default
`snapshot` mode. Each session stays dated testimony forever. Nothing is
reprocessed, and a question about "what Ravi said on Tuesday" has a
document that is exactly Tuesday.

**Re-send one growing document in `living` mode.** If you must keep one
document per conversation, send the full rendered conversation each time it
grows, with the same source pair and `versioning_mode="living"`:

```python
version = client.ingest(
    body.encode("utf-8"),
    filename="support-4411.md",
    source_kind="chat",
    source_ref="support/4411",
    source_modified_at=last_turn_at,
    versioning_mode="living",
)
```

Each send is a new version. Unchanged passages reuse their earlier
extraction. In living mode the latest
version is the conversation's standing statement: if you ever send a
version with turns removed, facts that rested only on those turns are
closed. Set `source_modified_at` to the time of the newest turn.

The cost of this shape: claims extracted from a new version take that
version's `source_modified_at` as their `asserted_at`. Passages that are
unchanged, with unchanged neighbours, keep the claims and dates they
already had; passages that are re-read get the newer date, even for old
turns. Closed sessions keep every date exact, which is why they are the
recommended shape.

Choose the mode on the first send. A document keeps the mode it was
created with; a later `versioning_mode` for the same source pair is
ignored. [Keep a source up to date](https://remember.dev/docs/guides/keep-sources-current) and [Updating
a source: snapshot and living](https://remember.dev/docs/concepts/updating-sources) explain the
difference in full.

## Transcripts from audio or video

RememberStack does not ingest audio yet. Transcribe the recording with a
tool of your choice, render the transcript in the turn format above using
the recording's clock (or offsets added to the recording's start time), and
ingest the Markdown.

## Next

- [Wait until a document is queryable](https://remember.dev/docs/guides/wait-for-readiness)
- [Ask about the past](https://remember.dev/docs/guides/ask-about-the-past): questions like "what did we
  decide in the June meetings?"
- [Cite the source of an answer](https://remember.dev/docs/guides/cite-sources)

---

Source: https://remember.dev/docs/guides/keep-sources-current

# Keep a source up to date

Specs get edited. The billing migration plan said June; now it says
October. If you send the edited file as a new, unrelated document, the
memory holds both plans and cannot tell which one the team still stands
behind. This page shows how to send the edit as a new version of the same
document, and how to tell RememberStack whether the newest version replaces
what the old one said.

Setup is in the [Quickstart](https://remember.dev/docs/start/quickstart). The model behind this
page is in [Updating a source: snapshot and
living](https://remember.dev/docs/concepts/updating-sources).

## Re-send with the same source pair

A document is identified by `source_kind` + `source_ref`. Send the edited
file with the same pair:

```python
from datetime import UTC, datetime

import remember

client = remember.Client.from_env()

v2 = client.ingest(
    "specs/billing-migration-plan.md",
    source_kind="file",
    source_ref="specs/billing-migration-plan.md",
    source_modified_at=datetime(2026, 9, 17, 14, 0, tzinfo=UTC),
    versioning_mode="living",
    source_version_ref="git:4f2c9e1",
)
print(v2.doc_id, v2.version_id, v2.created)
```

What happens:

- **Changed bytes** create a new version of the same document: the same
  `doc_id`, a new `version_id`, `created=True`. Wait on the new
  `version_id` before you query ([Wait until a document is
  queryable](https://remember.dev/docs/guides/wait-for-readiness)).
- **Identical bytes** (the file's latest version already has this content
  hash) store nothing and return the existing version with
  `created=False`. Nothing is reprocessed.
- **Bytes equal to an older version** (you reverted an edit) are a new
  observation and become a new version. The document moves forward; it
  never silently falls back to an old version.

Only the edited passages are extracted again. Passages whose text and
neighbours did not change reuse the claims they already had.

The same with the CLI:

```bash
remember ingest specs/billing-migration-plan.md \
  --source-kind file --source-ref specs/billing-migration-plan.md \
  --source-modified-at 2026-09-17T14:00:00+00:00 \
  --versioning-mode living \
  --source-version-ref git:4f2c9e1
```

## Choose snapshot or living

`versioning_mode` says what an edit means.

| Mode | Use it for | What a new version does |
|---|---|---|
| `snapshot` (default) | Minutes, reports, dated notes, rolling logs, anything where each version is a record of its moment. | Every version stays dated testimony forever. An old version's claims keep counting. |
| `living` | Specs, plans, wikis, a README: documents whose latest version is what the author currently stands behind. | The latest version is the document's standing statement. Claims whose passages left it stop counting as current testimony. |

A file that drops old lines by itself, such as a log that keeps its last
thousand lines, is `snapshot` even though you re-send the same path. The
lines that scroll off were not taken back; in `living` mode their removal
would retract the facts they alone supported.

The mode belongs to the document and is set by its first ingest. Later
calls with a different `versioning_mode` for the same source pair are
accepted but do not change it, and neither does a later `title`. Decide
before the first send; if you got it wrong, use a new `source_ref`.

`versioning_mode="living"` and `source_version_ref` require the source
pair; without it the client raises `ValueError` and the API answers 422.

## What happens to facts

Facts are what the memory holds true, each backed by claims from one or
more documents. When a new version arrives:

**Changed statements are new testimony.** The October date in version 2 is
a new claim. It goes through adjudication like any other: it can supersede
the June fact, contradict it, or corroborate something else. The June
statement is not deleted; it becomes history with an end date.

**In `living` mode, removal retracts.** If a fact's only current support
was a passage that is gone from the latest version, the fact is retracted:
its `invalidated_at` is set and the decision is recorded. Its `valid_until`
is not touched, because a removed line says nothing about when the thing
stopped being true in the world. It only says the source no longer says
it.

If other documents still support the fact, it only loses this document's
support; its `evidence_count` goes down by one and it stays current.

**In `snapshot` mode, nothing is retracted by a new version.** Removing a
sentence from version 3 does not unsay what version 2 said.

Retraction is visible, never silent. A retracted fact is no longer
believed, so the fact operations stop returning it, in every time mode. The
SQL view `facts_visible_history` still shows it with its `invalidated_at`,
and `hydrate_relation` still returns a retracted relation with its
evidence.

**A new release is a different problem.** When an upgrade re-reads a file
you did not change and no longer finds a claim, nothing is retracted: the
fact is marked `support: "withdrawn"` and still returned. Only a change in
the source takes a fact back. See [Updating a
source](https://remember.dev/docs/concepts/updating-sources#two-different-problems).

## `source_modified_at` and `source_version_ref`

- `source_modified_at` is when the source last changed, as a
  timezone-aware UTC `datetime`. Claims extracted from the version get it
  as `asserted_at`. Send the source's own modification time, not the time you
  uploaded. It is fixed once the version exists; re-sending identical bytes
  with a different `source_modified_at` does not change it.
- `source_version_ref` is your label for the upstream revision: a git
  commit, a Drive revision ID, an ETag. It is stored with the version.
  When you re-send identical bytes with a new `source_version_ref`, no
  version is created but the stored label moves to the new value, so a
  sync job can remember how far it got.

## A sync loop for a folder of specs

```python
from datetime import UTC, datetime
from pathlib import Path

import remember

client = remember.Client.from_env()
root = Path("specs")
new_versions = []

for path in sorted(root.glob("*.md")):
    version = client.ingest(
        path,
        source_kind="file",
        source_ref=f"specs/{path.name}",
        source_modified_at=datetime.fromtimestamp(path.stat().st_mtime, tz=UTC),
        versioning_mode="living",
    )
    if version.created:
        new_versions.append(version.version_id)

if new_versions:
    client.wait_for_readiness(new_versions)
```

Run it on a schedule. Unchanged files are no-ops; edited files become new
versions; the loop waits only for what changed.

## Removing a file

Not sending a file does not remove its document: RememberStack has no
signal that the file is gone, and its claims keep counting. When a file
leaves the folder, delete its document too. Each document the loop above
sent lists its `source_ref` as `source_uri`, so one pass over
`list_documents` finds the ones whose file is gone:

```python
from pathlib import Path

import remember

client = remember.Client.from_env()
root = Path("specs")
present = {f"specs/{path.name}" for path in root.glob("*.md")}

cursor = None
while True:
    page = client.list_documents(cursor=cursor)
    for document in page.documents:
        if document.source_kind == "file" and document.source_uri not in present:
            client.delete_document(doc_id=document.doc_id)
    if page.cursor is None:
        break
    cursor = page.cursor
```

Deleting removes the document's claims from the evidence and closes the
facts only it supported; the claims stay as history. If the file comes back
later, the sync loop adds it back as a new version. See
[Deleting a document](https://remember.dev/docs/concepts/documents-and-sources#deleting-a-document).

To remove only some passages of a living document, send a version without
them instead.

## Next

- [Wait until a document is queryable](https://remember.dev/docs/guides/wait-for-readiness)
- [Ask about the past](https://remember.dev/docs/guides/ask-about-the-past): see the June plan and the
  October plan side by side in time.
- [Contradictions, corroboration, supersession](https://remember.dev/docs/concepts/contradictions)

---

Source: https://remember.dev/docs/guides/wait-for-readiness

# Wait until a document is queryable

Ingest returns in a second; processing takes minutes. Until it finishes, a
question about the new document gets an answer that does not include it,
and nothing in that answer tells you so. This page shows how to wait for
exactly the versions you sent, how long to wait, and what to do when a
version fails.

Setup is in the [Quickstart](https://remember.dev/docs/start/quickstart). What each stage does
is in [The pipeline and readiness](https://remember.dev/docs/concepts/pipeline).

## Wait with the Python client

```python
import remember

client = remember.Client.from_env()

version = client.ingest(
    "notes/2026-09-17-standup.md",
    source_kind="file",
    source_ref="notes/2026-09-17-standup.md",
)

report = client.wait_for_readiness([version.version_id])
print(report.ready)
```

`wait_for_readiness(version_ids, *, timeout=1800.0, poll_interval=15.0,
require_p3=False)` checks the deployment at once, then every
`poll_interval` seconds, until every listed version is ready, and returns
the last report. The defaults, 30 minutes and 15 seconds, are starting
points sized for single documents, where processing takes minutes. Raise
`timeout` for bulk loads. If `timeout` seconds pass first, it raises
`TimeoutError`, whose message includes the last report.

It checks three capabilities for you: the pipeline stages for your
versions, the search index (`p1`) and the live graph (`live_graph`). Pass
`require_p3=True` only if you also read the published corpus snapshot
(filesystem views on a self-hosted deployment).

`wait_for_readiness` accepts version IDs as strings or UUIDs. A readiness
check covers at most 1,000 versions; split larger lists.

A version with `created=False` needs no new processing, but wait on it
anyway: the earlier run of the same bytes may still be going, and a
finished one returns ready on the first check.

## Stop on failure

A stage that is `failed` has a retry scheduled and can still succeed, so
`wait_for_readiness` keeps waiting through it. A stage that is
`dead_letter` has used all its attempts and will never become ready, so
`wait_for_readiness` stops at the first check that shows one and raises
`remember.PipelineDeadLettered`:

```python
import remember

try:
    client.wait_for_readiness([version.version_id])
except remember.PipelineDeadLettered as error:
    for version_id, stage, status in error.dead_lettered:
        print(f"{version_id}: {stage} is {status}")
    # error.report is the full readiness report that showed the dead letter.
```

`dead_lettered` lists every `(version_id, stage, status)` that is
dead-lettered, and the message names them too. What to do next is under
[When a version fails](#when-a-version-fails).

## Read the report

`pipeline_readiness` and `wait_for_readiness` return a
`PipelineReadinessReport`:

| Field | Meaning |
|---|---|
| `ready` | `True` when every required capability is ready. |
| `versions[]` | One entry per version: `version_id`, `ready`, and `stages[]`. |
| `versions[].stages[]` | `stage`, `component_version`, `status`, `finished_at`, `defer_reason`. |
| `capabilities` | `pipeline`, `p1`, `live_graph`, `p3`, each with `required`, `ready`, `checked_at` and a `reason`. |
| `model_bindings`, `build_revision`, `document_binding_generation` | Which code and models are serving, for your records. |

A stage `status` is one of `missing`, `pending`, `running`, `succeeded`,
`failed`, `dead_letter`, `skipped`. A version is ready when every expected
stage has `succeeded` or been `skipped` and has a `finished_at`. A
`pending` stage with a `defer_reason` of `no_route` is parked: it will not
move until an operator adds a conversion route, so waiting does not help.
With `budget`, a spend budget has reached its ceiling: the work resumes by
itself when the budget window ends, or sooner if an operator raises the
ceiling.

Capability reasons when not ready:

| Capability | Reason | Meaning |
|---|---|---|
| `pipeline` | `stage_incomplete` | At least one stage of one version has not finished. |
| `p1` | `search_channel_incomplete` | The search index is not ready. |
| `live_graph` | a `graph_…` reason, such as `graph_catalog_mismatch` | The live graph failed its catalog or health check. This is a deployment problem, not a problem with your document. |
| `p3` | `corpus_snapshot_incomplete` | No published corpus snapshot newer than your versions. |

The check reads state; it never starts or speeds up work.

## When a version fails

- **`failed`** means the last attempt failed and a retry is scheduled; it
  can still succeed. **`dead_letter`** means the stage ran out of attempts.
  It does not heal by waiting.
- **Stuck at `pending` on the first stage** on a self-hosted deployment
  usually means the file's MIME type has no converter: the version is
  parked until one is configured. See [File formats and
  converters](https://remember.dev/docs/self-hosting/converters).
- On a self-hosted deployment, the operator can inspect and replay
  dead-lettered work. See [Operating the pipeline](https://remember.dev/docs/self-hosting/operating).

Re-sending the same bytes does not restart a failed version: identical
bytes are a no-op.

## Over HTTP

`POST /readiness` takes the version IDs and an explicit requirement for
each of the four capabilities:

```bash
curl -sS "$REMEMBER_API_URL/readiness" \
  -H "Authorization: Bearer $REMEMBER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "version_ids": ["6f1c2d0e-8a3b-4d5e-9f10-2a3b4c5d6e7f"],
    "require": {"pipeline": true, "p1": true, "live_graph": true, "p3": false}
  }'
```

The body is at most 1,000 version IDs. It is a read: a read-only token may
call it. See [Ingest, readiness, documents](https://remember.dev/docs/reference/http-api/ingest).

## Over MCP

An agent uses the `pipeline_readiness` tool with the arguments the
`ingest` tool returned in `pipeline.poll_with`:

```json
{
  "name": "pipeline_readiness",
  "arguments": {
    "version_ids": ["6f1c2d0e-8a3b-4d5e-9f10-2a3b4c5d6e7f"],
    "require": {"pipeline": true, "p1": true, "live_graph": true, "p3": false}
  }
}
```

Both the tool's description and the `ingest` result spell out the poll
algorithm. Your agent's instructions should say the same:

1. Wait about 30 seconds after ingest before the first check.
2. Then check every 30 to 60 seconds, backing off gently. Never check more
   often than every 15 seconds.
3. A `failed` stage is retrying: keep polling, and describe it as retrying
   if you report progress.
4. Stop at once if any `stages[].status` is `dead_letter`, and report the
   `version_id` and that stage.
5. After 20 to 30 minutes without `ready: true` and without a dead letter,
   stop and escalate to the operator with the `version_id` and the last
   `stages[]`.

Require `pipeline`, `p1` and `live_graph`, and set `p3` to `false` unless
the agent also reads a published corpus snapshot. `ready: true` means the
assured operations can see the content; whether a given question finds it
still depends on relevance.

An ingest that returned `created: false` started no new processing, but an
earlier run of the same bytes may still be going. Check readiness:
`ready: true` means the content is already queryable. Otherwise poll it
exactly as above, stopping only on `dead_letter` or the time limit.

## Next

- [Give an agent context](https://remember.dev/docs/guides/agent-context)
- [Ingest files](https://remember.dev/docs/guides/ingest-files)

---

Source: https://remember.dev/docs/guides/agent-context

# Give an agent context

The next session opens on an empty window. The agent does not know which
decision still holds or which file said it, so you give it context from
memory before it answers. This page shows which call to make for which kind
of question, how big to make it, and how to put the result into a prompt so
the model keeps facts, testimony and gaps apart.

Setup is in the [Quickstart](https://remember.dev/docs/start/quickstart). The four operations
are defined in [Assured operations](https://remember.dev/docs/reference/assured-operations).

## Choose the operation

RememberStack has four assured operations. Each returns a typed result with
explicit guarantees about time, truncation and absence.

| The question | Operation | Returns |
|---|---|---|
| "Who is Ravi?" "Which 'billing' do you mean?" Any name you will use as an anchor. | `resolve_entity` | Ranked entity candidates. Never a silent guess. |
| "Is the migration still planned for October?" "Who owns the invoice exporter?" What is true now, or at a given time. | `facts_context` | Adjudicated facts (relations and observations) with their validity and supporting evidence. |
| "What did the team say about the exporter?" "Quote the spec." What sources said, word for word. | `claims_and_sources_context` | Current claims and source passages. Testimony, not verdicts. |
| A general question where you want both what is held true and what was said. | `combined_context` | Both results side by side, each complete and labelled. |

Default to `facts_context` for "is it true" questions and fall back to
claims only when facts are missing or you need a verbatim quote. Use
`combined_context` when you cannot tell in advance which the model needs.

## Parameters and limits

| Operation | Parameter | Default | Allowed |
|---|---|---|---|
| `resolve_entity` | `name` | required | non-empty string |
| `facts_context` | `query` | required | 1 to 8,192 characters |
| | `k` (facts returned) | 15 | 1 to 30 |
| | `evidence_per_fact` (claims per fact per stance) | 3 | 1 to 5 |
| | `hops` (graph expansion around anchors) | 1 | 1 to 2 |
| | `predicate` | none | 1 to 200 characters |
| | `entity_ids` (anchors) | none | 1 to 19 unique UUIDs |
| | `time` | `{"mode": "current"}` | see [Ask about the past](https://remember.dev/docs/guides/ask-about-the-past) |
| `claims_and_sources_context` | `query` | required | 1 to 8,192 characters |
| | `k` (results) | 50 | 1 to 100 |
| | `candidate_k` (nominations per channel) | 200 | 1 to 400, at least `k` |
| | `entity_ids` | none | 1 to 20 unique UUIDs |
| `combined_context` | `query`, `hops`, `predicate`, `entity_ids`, `time` | as `facts_context` | as `facts_context` |

`combined_context` always runs its halves at their defaults: 50 claims and
passages, 15 facts with 3 claims each.

One fact-context result carries at most 60 evidence links in total, however
you set `k` and `evidence_per_fact`. Fact retrieval has a 25-second
database budget; if the database cannot answer inside it you get a
`boundary` result, not a partial one.

## Call it from Python

The client has a method per operation:

```python
import remember

client = remember.Client.from_env()

candidates = client.resolve_entity("Ravi")
facts = client.facts_context("Who owns the invoice exporter?")
said = client.claims_and_sources_context("invoice exporter rewrite")
both = client.combined_context("What changed in the billing migration plan?")
```

`facts_context` accepts `time`, `hops`, `predicate` and `entity_ids`;
`combined_context` accepts `time`; `claims_and_sources_context` and
`resolve_entity` accept only the query or name. For every other parameter,
including `k` and `evidence_per_fact`, use `run_operation`:

```python
facts = client.run_operation(
    name="facts_context",
    arguments={
        "query": "invoice exporter owner",
        "k": 10,
        "evidence_per_fact": 2,
        "entity_ids": [str(ravi_id)],
    },
)
```

`run_operation` returns an `Envelope`, or a `ContextBundleV2` for
`combined_context`.

## Anchor on entities

Resolve the names in the question first, then pass the chosen entity IDs
as `entity_ids`. Facts are then searched in the anchors and their graph
neighbours (one hop by default), which keeps "the exporter Ravi owns" from
matching every exporter in the memory.

```python
resolved = client.resolve_entity("Ravi")
if resolved.negative is None and len(resolved.entities) == 1:
    ravi_id = resolved.entities[0].entity_id
    facts = client.facts_context("What does Ravi own?", entity_ids=[ravi_id])
```

When a name resolves to more than one candidate, decide which one you mean
(or pass all of them); see [Handle unknowns and
ambiguity](https://remember.dev/docs/guides/unknowns-and-ambiguity). An anchor that is not a current
entity makes the whole call return `unknown_entity`.

## The same from the CLI and MCP

```bash
remember query "Who owns the invoice exporter?"            # facts_context
remember query text "Who owns the invoice exporter?" --combined

remember operations run facts_context \
  --arg query="invoice exporter owner" --arg k=10 --arg evidence_per_fact=2
remember operations run claims_and_sources_context \
  --arg query="invoice exporter rewrite" --arg k=20
remember operations run resolve_entity --arg name=Ravi
```

Each `--arg` value is parsed as JSON when it can be, so `k=10` is a number
and `entity_ids='["…"]'` is a list; anything else is a string.

An agent connected over MCP sees the four operations as tools with the same
names and arguments:

```json
{"name": "facts_context", "arguments": {"query": "invoice exporter owner", "k": 10}}
```

## Put the result into a prompt

Do not paste the raw JSON unless the model is good at reading it and you
have the room. Render the parts that matter, and keep facts and testimony
under separate headings so the model does not mistake a quote for a
verdict.

```python
from remember import Envelope

def render_facts(envelope: Envelope) -> str:
    if envelope.negative is not None:
        return f"No facts: {envelope.negative.kind}. {envelope.negative.explanation}"
    claims = {claim.claim_id: claim for claim in envelope.evidence}
    lines = []
    for fact in envelope.facts:
        v = fact.validity
        when = f"valid {v.valid_from:%Y-%m-%d}" if v.valid_from else "validity unknown"
        if v.valid_until:
            when += f" until {v.valid_until:%Y-%m-%d}"
        notes = []
        if fact.support.value == "withdrawn":
            notes.append("support withdrawn, verify before relying on it")
        if fact.contradiction is not None:
            rivals = "; ".join(member.label for member in fact.contradiction.co_members)
            notes.append(f"contradicted by: {rivals}")
        suffix = f" [{'; '.join(notes)}]" if notes else ""
        lines.append(f"- {fact.label} ({when}; {fact.evidence_count} sources){suffix}")
        for link in envelope.fact_evidence:
            if link.fact_id == fact.fact_id and link.claim_id in claims:
                claim = claims[link.claim_id]
                title = claim.document_title or claim.doc_id
                lines.append(f'    {link.stance}: "{claim.source_span}" ({title})')
    if envelope.truncation is not None and envelope.truncation.truncated:
        lines.append(
            f"(Showing {envelope.truncation.returned} of about "
            f"{envelope.truncation.estimated_total}; this list is not complete.)"
        )
    return "\n".join(lines)

def render_claims(envelope: Envelope) -> str:
    if envelope.negative is not None:
        return f"No source passages: {envelope.negative.explanation}"
    lines = []
    for claim in envelope.evidence:
        said_at = f"{claim.asserted_at:%Y-%m-%d}" if claim.asserted_at else "undated"
        lines.append(f"- {claim.claim_text} ({claim.document_title or claim.doc_id}, {said_at})")
    return "\n".join(lines)

bundle = client.combined_context("What changed in the billing migration plan?")
context = (
    "## What the memory holds true\n"
    + render_facts(bundle.facts)
    + "\n\n## What sources said\n"
    + render_claims(bundle.claims_and_sources)
)
```

Put `context` in the system prompt or in a clearly marked block before the
user's question, together with instructions on how to use it. [Handle
unknowns and ambiguity](https://remember.dev/docs/guides/unknowns-and-ambiguity) has a system-prompt
paragraph you can copy.

## Size it to your token budget

The result size is set almost entirely by these numbers:

- **`facts_context`**: `k` facts, each with up to `evidence_per_fact`
  supporting claims and as many contradicting ones, capped at 60 evidence
  links. Each claim adds its text and its quoted passage. For a tight
  budget, `k=8, evidence_per_fact=1` keeps one quote per fact.
- **`claims_and_sources_context`**: up to `k` claims and up to `k`
  passages, default 50 each.
  Passages are whole chunks of source text and are the largest part of any
  result. Lower `k` first.
- **`combined_context`**: fixed at both defaults. When it is too large,
  call `facts_context` and `claims_and_sources_context` separately with
  smaller `k`.

Render, count, and trim from the bottom of each list: results come ranked.
If you trim, tell the model the list is partial, the same way the
truncation line above does.

## Mistakes to avoid

Each of these turns a correct result into a wrong answer. Put the ones
your agent is prone to into its instructions.

| Don't | Do |
|---|---|
| Answer "is it true now?" from `claims_and_sources_context`. A claim is what one source said, possibly months ago. | Answer from `facts_context`. Use claims to quote and cite. |
| Read a claim's `claim_valid_from`/`claim_valid_until` as proof that something held at a date. That window is the source's statement. | Ask `facts_context` with `time` set, and read the fact's `validity`. See [Ask about the past](https://remember.dev/docs/guides/ask-about-the-past). |
| Read `asserted_at` or `ingested_at` as when something happened. | Use the fact's `valid_from`/`valid_until`; `asserted_at` is when a source said it, `ingested_at` when the memory learned it. |
| Treat an empty result as "no" or "unknown name". | Read `negative.kind`: `unknown_entity`, `known_empty` and `boundary` need three different answers. |
| Count a truncated list as complete. | When `truncation.truncated` is `true`, say "at least N", raise `k`, or narrow the query. |
| Report one side of a contradiction, or pick the side with more sources. | Give every side in `contradiction.co_members` with its sources, and say when `returned` is less than `total`. |
| Take the first of several `resolve_entity` candidates. | Ask which one is meant, rank them with context, or pass them all as `entity_ids`. See [Handle unknowns and ambiguity](https://remember.dev/docs/guides/unknowns-and-ambiguity#names-that-match-more-than-one-entity). |
| Count `temporal_match: possible` facts as matches. | Report them apart from `confirmed` ones. |
| Present a fact with `support: withdrawn` as settled. | Say it is unconfirmed and check its evidence. |
| Ask about a document right after ingesting it and conclude it says nothing. | Wait until [readiness](https://remember.dev/docs/guides/wait-for-readiness) reports `ready`. |

## Next

- [Ask about the past](https://remember.dev/docs/guides/ask-about-the-past)
- [Cite the source of an answer](https://remember.dev/docs/guides/cite-sources)
- [Handle unknowns and ambiguity](https://remember.dev/docs/guides/unknowns-and-ambiguity)
- [Reading a result](https://remember.dev/docs/concepts/reading-results)

---

Source: https://remember.dev/docs/guides/ask-about-the-past

# Ask about the past

"When was the billing migration supposed to go live, back in June?" is not
the same question as "when is it going live?". The first needs the plan as
it stood in June; the second needs today's. RememberStack keeps both,
because every fact records when it held in the world and when the memory
learned it. This page shows how to ask each kind of past question.

Setup is in the [Quickstart](https://remember.dev/docs/start/quickstart). The two clocks are
explained in [Time](https://remember.dev/docs/concepts/time).

## The time modes

`facts_context` and `combined_context` take a `time` argument. It selects
facts by when they were true in the world (their validity), always as the
memory believes them now.

| Mode | Argument | Returns facts that |
|---|---|---|
| current (default) | `{"mode": "current"}` | hold now. |
| at | `{"mode": "at", "at": "<timestamp>"}` | held at that instant. |
| overlap | `{"mode": "overlap", "from": "<timestamp>", "to": "<timestamp>"}` | held at any point in the window, bounds included. |
| history | `{"mode": "history"}` | ever held, including ones that have ended, as long as they began by now. |

Timestamps are ISO 8601 with a time zone; they are converted to UTC. `to`
must not be before `from`. The result's `temporal_scope` echoes the mode
you asked for, with the instant it was evaluated.

## Choose a time mode

| The question | Mode |
|---|---|
| "Who owns the invoice exporter?" "Is the migration still planned for October?" | `current` |
| "Who owned the exporter on 1 July?" | `at` |
| "Who worked on the migration during Q3?" | `overlap` |
| "Which teams has Ravi been on?" "Has the date ever changed?" Biographies, achievements, timelines, anything with "ever". | `history` |

A `current` read leaves out everything that has ended, so it is the wrong
mode for "what has Ravi done": his finished work is exactly what you want.
Use `history` for those.

**Counting.** "How many times did the go-live date move?" Count only facts
whose `temporal_match` is `confirmed`. If any returned fact is `possible`,
or the result is truncated, you cannot state an exact count: say "at least
N", and list the possible ones separately.

**Two steps for "when X happened".** "Who owned the exporter when the
migration went live?" names a time by an event. Ask for the event first,
read its date, then ask the real question at that date:

```python
import remember

client = remember.Client.from_env()
event = client.facts_context("billing migration went live", time={"mode": "history"})
dated = [f for f in event.facts if f.validity.valid_from is not None]

if dated:
    went_live = dated[0].validity
    owners = client.facts_context(
        "Who owns the invoice exporter?",
        time={"mode": "at", "at": went_live.valid_from.isoformat()},
    )
```

Check the event's `valid_precision` before you use its date as an instant.
If it is `month` or coarser, ask with `overlap` over that month instead of
`at` its first day. If the event has no date at all, say so rather than
guessing one.

## What holds now

The examples below use a memory that holds the team's notes from May to
September 2026: the migration was planned for June, then moved to October
on 17 September.

```python
import remember

client = remember.Client.from_env()
now = client.facts_context("When does the billing migration go live?")
for fact in now.facts:
    print(fact.label, fact.validity.valid_from, fact.validity.valid_until)
```

This is the default; it returns the October plan.

## What held at a date

"What was the go-live date on 1 July?"

```python
july = client.facts_context(
    "When does the billing migration go live?",
    time={"mode": "at", "at": "2026-07-01T00:00:00Z"},
)
```

This returns the June plan, which held on 1 July, and not the October plan,
which only began to hold on 17 September.

CLI:

```bash
remember operations run facts_context \
  --arg query="When does the billing migration go live?" \
  --arg time='{"mode": "at", "at": "2026-07-01T00:00:00Z"}'
```

MCP:

```json
{
  "name": "facts_context",
  "arguments": {
    "query": "When does the billing migration go live?",
    "time": {"mode": "at", "at": "2026-07-01T00:00:00Z"}
  }
}
```

## What held during a period

"Who owned the invoice exporter during Q3?"

```python
q3 = client.facts_context(
    "Who owns the invoice exporter?",
    time={"mode": "overlap", "from": "2026-07-01T00:00:00Z", "to": "2026-09-30T23:59:59Z"},
)
```

Every fact that held at any moment in the window comes back, so a hand-over
in August returns both owners. Read their `validity` to order them.

## Everything that ever held

"Has the migration date ever changed?" "Which owners has the exporter had?"

```python
ever = client.facts_context(
    "billing migration go-live date",
    time={"mode": "history"},
)
for fact in sorted(ever.facts, key=lambda f: f.validity.valid_from or f.validity.ingested_at):
    v = fact.validity
    print(f"{fact.label}: {v.valid_from} → {v.valid_until or 'still holds'}")
```

Use history mode for "ever", "has … changed", biographies and timelines. It
includes facts whose validity has ended.

With `entity_ids`, the `overlap` and `history` modes search only the anchor
entities themselves. The `current` and `at` modes also search their graph
neighbours, because the neighbourhood is taken at a single instant.

## Read how sure the dates are

Each fact carries:

- `validity.valid_from` / `valid_until`: when it held in the world. A
  missing end with `valid_precision` `open` means it is ongoing; a missing
  value otherwise means unknown.
- `validity.valid_precision`: `instant`, `day`, `month`, `quarter`, `year`,
  `open` or `unknown`. "Planned for June" is `month` precision; do not
  answer with a day.
- `temporal_match`: `confirmed` when the fact's dates prove it matches your
  time window, `possible` when it is relevant but not dated well enough to
  be sure. Report possible matches separately; never count them as
  confirmed.
- `validity.ingested_at`: when the memory learned it. That is not when it
  happened.

## What sources said at the time

Facts are the memory's verdict. Sometimes you want the testimony: "what did
the June meetings say about the go-live?". Claims carry two source times:

- `asserted_at`: when the source made the statement, from the document's
  `source_modified_at`.
- `claim_valid_from` / `claim_valid_until`: when the claim says the thing
  happened or was true.

The shipped saved query `examples.claims_as_of` returns claims whose stated
time overlaps a window:

```python
june_claims = client.run_saved_query(
    namespace="examples",
    name="claims_as_of",
    parameters=["2026-06-01T00:00:00Z", "2026-06-30T23:59:59Z"],
)
columns = [column["name"] for column in june_claims["columns"]]
for row in june_claims["rows"]:
    print(dict(zip(columns, row)))
```

Claims whose time is `unknown` have no window and are left out; the
`unknown_precision_excluded` column counts them. To filter by when things
were said rather than when they happened, write a SQL query on
`claims_live` with `asserted_at`; see [Explore memory with SQL](https://remember.dev/docs/guides/sql).

## What the memory believed on an earlier day

The time modes answer "what was true then, as we know it now". To ask
"what did the memory believe on 1 August about 1 July?", before the
17 September correction arrived, use the `facts_as_of` function in a SQL
query. SQL queries run over the query space, a set of prepared read-only
views and functions; every statement is checked against it before it runs
([Explore memory with SQL](https://remember.dev/docs/guides/sql)). `facts_as_of` takes a world-time
instant and a belief instant:

```python
believed = client.open_query(
    "SELECT fact_label, valid_from, valid_until, temporal_match"
    " FROM facts_as_of($1::timestamptz, $2::timestamptz)"
    " WHERE fact_label ~~* $3"
    " ORDER BY valid_from",
    parameters=["2026-07-01T00:00:00Z", "2026-08-01T00:00:00Z", "%migration%"],
)
for row in believed.rows:
    print(row)
```

It returns at most 200 rows by default (its third argument, up to 1,000).
The graph calls `graph_neighborhood` and `graph_path` take the same two
clocks as `valid_at` and `believed_at`.

## What changed since a date

"What has the memory learned since Friday?"

```python
changes = client.run_saved_query(
    namespace="examples",
    name="changed_since",
    parameters=["2026-09-18T00:00:00Z"],
)
```

It lists up to 100 changes, newest first, with the kind of object, its ID,
when it changed and a label.

## Next

- [Cite the source of an answer](https://remember.dev/docs/guides/cite-sources)
- [Saved queries](https://remember.dev/docs/guides/saved-queries)
- [Time](https://remember.dev/docs/concepts/time)

---

Source: https://remember.dev/docs/guides/cite-sources

# Cite the source of an answer

An answer without a source asks the reader to trust the model. Every fact
RememberStack returns is linked to the claims that support it, each claim to
the document and passage it came from. This page shows how to turn those
links into a citation a person can check: "the migration moves to October
(stand-up, 17 September: *'The migration moves from June to October.'*)".

Setup is in the [Quickstart](https://remember.dev/docs/start/quickstart). The model behind this
page is in [Evidence and provenance](https://remember.dev/docs/concepts/evidence).

## The chain

```text
fact ──fact_evidence──▶ claim (evidence) ──doc_id──▶ document
                            │
                            └── source_span, char_start/char_end, chunk_id
```

In a `facts_context` result:

- `facts[]` are the facts. Each has a `fact_id` and a `kind` (`relation`
  or `observation`).
- `fact_evidence[]` links a fact to a claim: `fact_id`, `claim_id`,
  `stance` (`supports` or `contradicts`).
- `evidence[]` are the claims: `claim_text`, `source_span` (the passage the
  claim was cut from, word for word), `doc_id`, `document_title`,
  `source_kind`, `chunk_id`, `asserted_at`, and positions.
- `evidence_totals[]` say, per fact and stance, how many claims were
  `returned` and how many exist in `total`.

## Cite from a fact result

```python
import remember

client = remember.Client.from_env()
result = client.facts_context("When does the billing migration go live?")

claims = {claim.claim_id: claim for claim in result.evidence}
for fact in result.facts:
    print(fact.label)
    for link in result.fact_evidence:
        if link.fact_id != fact.fact_id or link.stance != "supports":
            continue
        claim = claims[link.claim_id]
        said = f"{claim.asserted_at:%d %B %Y}" if claim.asserted_at else "undated"
        print(f'  {claim.document_title}, {said}: "{claim.source_span}"')
    for total in result.evidence_totals:
        if total.fact_id == fact.fact_id and total.returned < total.total:
            print(f"  ({total.total - total.returned} more {total.stance} claims not shown)")
```

Quote `source_span`, not `claim_text`. `claim_text` is the claim as the
extractor stated it, which may resolve a relative date or add missing
context. It is the right thing to reason over, but it is not what the
source said; `source_span` is.

Show the `contradicts` links too when there are any. A fact with
contradicting testimony is still the memory's verdict, but a reader should
see the other side.

`combined_context` returns the same chain inside its `facts` half, and
`claims_and_sources_context` returns claims (`evidence[]`) and whole source
passages (`chunks[]`) with their `doc_id` and `document_title`.

## Positions and surrounding text

- `char_start` / `char_end` locate `source_span` in the text of the
  document version it was first extracted from, after conversion to
  Markdown.
- `evidence_spans[]` lists every range in that text that supports the
  claim, as half-open `char_start` / `char_end` pairs. A claim built from
  two sentences a paragraph apart has two spans.
- `chunk_id` is the passage the claim came from.

To show a passage with some context around it, fetch the neighbouring
chunks:

```python
around = client.adjacent_chunks(chunk_id=claim.chunk_id, window=1)
for chunk in around.chunks:
    print(chunk.chunk_text)
```

`window` is 1 or 2 chunks on each side. The CLI equivalent is `remember
query adjacent-chunks <chunk-id> --window 1`.

## From `doc_id` back to your file

`doc_id` identifies the document inside RememberStack. To link a citation
to your own system, look up the `source_ref` you ingested it with:

```python
docs = client.open_query(
    "SELECT doc_id, title, source_kind, source_ref, current_version_no"
    " FROM documents_live WHERE doc_id = $1::uuid",
    parameters=[str(claim.doc_id)],
)
doc_id, title, source_kind, source_ref, version_no = docs.rows[0]
print(f"{title} ({source_kind}:{source_ref}, version {version_no})")
```

This is a SQL query over the query space: prepared, read-only views such
as `documents_live`, with every statement checked before it runs. See
[Explore memory with SQL](https://remember.dev/docs/guides/sql).

## Every source for one relation: `hydrate_relation`

`facts_context` returns up to `evidence_per_fact` claims per fact (at most
5). To see all current supporting claims and the list of documents behind a
relation, hydrate it by ID:

```python
fact = result.facts[0]
if fact.kind == "relation":
    full = client.hydrate_relation(relation_id=fact.fact_id)
    for source in full.sources:
        print(source.doc_id, source.title, source.source_kind)
    for claim in full.evidence:
        print(f'  "{claim.source_span}"')
```

The result has the relation in `facts` (with its contradiction and support
state), its current supporting claims in `evidence`, and the documents in
`sources`. It works for relations that have ended, too, and says so in the
fact's `validity`. An unknown ID returns a `negative` of kind
`unknown_entity`.

There is no hydrate call for observations. For those, run the shipped
saved query `examples.explain` with the fact ID, which also works for
relations:

```python
why = client.run_saved_query(namespace="examples", name="explain", parameters=[str(fact.fact_id)])
```

It returns the fact's history, each supporting and contradicting claim, the
document it came from and when it was said, up to 100 rows.

## Why the memory decided: `transcript_relation`

A citation says where a fact came from. A transcript says how the memory
reached its verdict: which facts it replaced, contradicted or merged with,
by what method and when.

```python
history = client.transcript_relation(relation_id=fact.fact_id)
for entry in history.transcript:
    print(entry.decided_at, entry.outcome, entry.method, entry.related_id)
```

`outcome` is one of `add`, `noop`, `supersede`, `contradict`,
`same_as_merge_proposal`, `retracted_source_removal`. `related_id` is the
other relation in the decision. The transcript keeps the 40 most recent
decisions and sets `truncation` when there were more.

Hydration and transcripts have no CLI command or MCP tool; use the Python
client or `GET /hydrate/relation/{relation_id}` and `GET
/transcript/relation/{relation_id}`.

## A citation format for agents

Ask the model to cite in a form you can check mechanically, and give it the
IDs:

```text
- The billing migration goes live in October 2026.
  [fact 3f9e…; claim 81c2…; "Stand-up, 17 September 2026"]
```

Then verify each cited `claim_id` appears in the result you gave it before
you show the answer. A model that cites an ID you never sent has invented
it.

## Next

- [Handle unknowns and ambiguity](https://remember.dev/docs/guides/unknowns-and-ambiguity)
- [Build a memory-backed agent](https://remember.dev/docs/guides/build-an-agent)
- [Evidence and provenance](https://remember.dev/docs/concepts/evidence)

---

Source: https://remember.dev/docs/guides/unknowns-and-ambiguity

# Handle unknowns and ambiguity

When a model does not find the answer, it tends to make one up. A memory
that returns an empty list gives it no reason not to. RememberStack says
why a result is empty, when a name could mean two people, when a list was
cut short and when two sources disagree. This page shows how to read each
of those signals and how to tell your agent what to do with them.

Setup is in the [Quickstart](https://remember.dev/docs/start/quickstart). Every field used here
is described in [Reading a result](https://remember.dev/docs/concepts/reading-results).

## Three kinds of "nothing"

A result with nothing to return carries a `negative` with a `kind`, an
`explanation` and sometimes a `workaround`. `NegativeKind` has three
values:

| `kind` | Meaning | What to do |
|---|---|---|
| `unknown_entity` | The name or ID does not resolve to any current entity. | Check the spelling, try another name, or search claims and passages. Do not say "X has no owner"; say the memory does not know X. |
| `known_empty` | The entity or query is understood, and nothing matches within the result's stated freshness. | Report that the memory holds nothing matching. Broaden the query or look at testimony if the question allows. |
| `boundary` | The question could not be answered as asked: a dependency was unavailable, a budget ran out, or the request crossed a limit. | Follow `workaround`: retry, use fewer anchors or hops, or ask differently. Never report it as "nothing found". |

```python
import remember
from remember.models import NegativeKind

client = remember.Client.from_env()
result = client.facts_context("Who approved the refund policy?")

if result.negative is None:
    ...  # use result.facts
elif result.negative.kind is NegativeKind.UNKNOWN_ENTITY:
    print("The memory does not know that:", result.negative.explanation)
elif result.negative.kind is NegativeKind.KNOWN_EMPTY:
    print("Nothing is recorded:", result.negative.explanation)
else:  # NegativeKind.BOUNDARY
    print("Could not answer:", result.negative.explanation, "→", result.negative.workaround)
```

`NegativeKind` is imported from `remember.models`.

Forgotten material looks exactly like material that never existed. That is
deliberate: a hard-forgotten document leaves no trace in answers.

## Names that match more than one entity

`resolve_entity` never picks a winner for you. When a name matches several
entities, all of them come back in `entities`, ranked:

```python
resolved = client.resolve_entity("Dana")

if resolved.negative is not None:
    print("No one called Dana:", resolved.negative.explanation)
elif len(resolved.entities) == 1:
    dana = resolved.entities[0]
else:
    for candidate in resolved.entities:
        print(candidate.entity_id, candidate.canonical_name, candidate.tier)
```

Each candidate has `entity_id`, `canonical_name`, `tier` and
`context_hits`. The tier says how it matched:

| `tier` | Match |
|---|---|
| `T0` | Exact match on a known name or alias. Every entity with that exact name is returned. |
| `T1` | Close spelling. |
| `T2` | Similar sound. |
| `T3` | Similar meaning, by embedding, used only when nothing matched by name. |

Fuzzy tiers stop at a fixed number of candidates; when that cap hid more,
`truncation.truncated` is `True` with reason `resolve_candidate_limit`.

What to do with more than one candidate:

- **Ask.** "Do you mean Dana, the product lead, or Dana in finance?"
  is almost always right for an interactive agent.
- **Use the conversation.** If the question already names a project or a
  colleague, resolve those too and pass their IDs to `resolve`: candidates
  connected to them move up (`context_hits` counts the connections).

  ```python
  billing = client.resolve_entity("billing migration").entities[0]
  ranked = client.resolve(name="Dana", context_entity_ids=(billing.entity_id,))
  ```

  `resolve` takes up to 8 context entities. It reorders; it never drops a
  candidate.
- **Pass them all.** `facts_context(..., entity_ids=[...])` accepts every
  candidate, up to 19 anchors, and the facts show which one the answer is
  about.

Over MCP, `resolve_entity` is a tool with a single `name` argument. The
context-ranked `resolve` is available from the Python client and `GET
/resolve` only.

## Lists that were cut short

`truncation` tells you whether you have everything:

| Field | Meaning |
|---|---|
| `truncated` | `True` when more matched than was returned. |
| `returned` | How many items this result holds. |
| `estimated_total` | How many matched, as far as the operation knows. |
| `total_is_exact` | `False` when `estimated_total` is a lower bound. |
| `reason` | Why it stopped, when known. |

A truncated result is not an exhaustive answer. "Ravi owns three
components" from a truncated list means "at least three". Raise `k`
([Give an agent context](https://remember.dev/docs/guides/agent-context)), narrow the query, or say the
count is partial.

Evidence has its own totals: `evidence_totals[]` gives, per fact and
stance, `returned` and the exact `total` of claims.

## Candidates dropped on confirmation

Search nominates candidates quickly from an index; the database then
confirms each one against current state. `dropped_by_hydration` counts
candidates that were nominated but failed confirmation: deleted since the
index was built, no longer current, or outside your time window or
anchors. A non-zero value is normal. A large one on a fresh ingest usually
means processing is still settling; wait for
[readiness](https://remember.dev/docs/guides/wait-for-readiness) and ask again.

## Sources that disagree

When two facts conflict and both still stand, they share a
`contradiction_group`, and each carries `contradiction` with the other side:

```python
for fact in result.facts:
    if fact.contradiction is None:
        continue
    print("Disputed:", fact.label)
    for rival in fact.contradiction.co_members:
        print("  versus:", rival.label, f"({rival.evidence_count} sources)")
    if fact.contradiction.returned < fact.contradiction.total:
        print(f"  and {fact.contradiction.total - fact.contradiction.returned} more")
```

Report both sides with their sources. Do not pick the one with more
evidence and present it as settled; `evidence_count` counts distinct
documents, not truth. The SQL view `contradiction_members_current` lists
every member of every live group.

A fact can also stand while the newest statement linked to it contradicts
it. That is worth a second look even without a contradiction group.
[Explore memory with
SQL](https://remember.dev/docs/guides/sql#facts-whose-newest-testimony-disagrees) has a query that finds
those facts.

Two related signals:

- **`support: withdrawn`** means the fact lost all its current supporting
  testimony when a document was re-read, and is waiting for review. It
  still stands, but treat it as shaky: say so, and check its evidence
  before relying on it.
- **`temporal_match: possible`** means the fact is relevant but not dated
  well enough to be sure it falls in your time window. Report it apart
  from `confirmed` matches.

## SQL results have no negatives

A SQL query that returns no rows says nothing about why. It is not
`known_empty`. Check `truncated`, `truncation_reason` and, for graph
functions, `graph_invocations` before you treat an empty result as proof of
absence. See [Explore memory with SQL](https://remember.dev/docs/guides/sql).

## Brief your agent

Put a paragraph like this in the system prompt of any agent that answers
from RememberStack:

```text
You answer from a memory service. Its results are the only source of truth
about the team's work; do not fill gaps from general knowledge.

- Facts are what the memory holds true now (or at the time asked). Claims
  and passages are what sources said. Keep them apart: never present a
  quote as an established fact.
- If a result says unknown_entity, say you do not know that name. If it
  says known_empty, say nothing is recorded. If it says boundary, say you
  could not look it up and why. Never turn any of these into "no" or
  "none".
- If a name matches more than one entity, ask which one is meant, or
  answer for each separately and label them.
- If a list is marked truncated, say the answer may be incomplete. Say "at
  least N", never "exactly N".
- If facts contradict each other, give both sides with their sources.
  If a fact's support is withdrawn, say it is unconfirmed.
- Dates: a fact's validity is when it was true; asserted_at is when a
  source said it; ingested_at is when the memory learned it. Do not mix
  them, and do not give a day when the precision is a month.
- Cite the source passage for every factual statement.
```

Adapt the wording, not the rules.

## Why is my answer empty or wrong?

Before you suspect the memory, check how the question was asked. In this
order:

1. **Is the document processed?** A question sent before
   [readiness](https://remember.dev/docs/guides/wait-for-readiness) reports the version ready gets an
   answer without it, and nothing in that answer says so.
2. **What does `negative` say?** `unknown_entity`, `known_empty` and
   `boundary` each need a different next step; see
   [Three kinds of "nothing"](#three-kinds-of-nothing).
3. **Which time did you ask about?** `facts_context` returns facts true
   now unless you pass `time`. A fact that ended in June is not in a
   `current` answer; ask with `history` or `at`. See
   [Time](https://remember.dev/docs/concepts/time#asking-about-time).
4. **Facts or claims?** `claims_and_sources_context` returns what sources
   said, including statements that were later replaced. `facts_context`
   returns what holds.
5. **Which entity?** A name that matches two people needs resolving first;
   see [Names that match more than one entity](#names-that-match-more-than-one-entity).
6. **Is the list complete?** Check `truncation` before you count.
7. **SQL:** an empty result has no `negative`. Check
   `termination_reason` and `error_code` first: a rejected statement also
   comes back with no rows.

If the question is right and the answer is still wrong, the cause is on
the deployment side. On a self-hosted deployment, work through
[Troubleshooting](https://remember.dev/docs/self-hosting/troubleshooting).

## Next

- [Cite the source of an answer](https://remember.dev/docs/guides/cite-sources)
- [Build a memory-backed agent](https://remember.dev/docs/guides/build-an-agent)
- [Contradictions, corroboration, supersession](https://remember.dev/docs/concepts/contradictions)

---

Source: https://remember.dev/docs/guides/sql

# Explore memory with SQL

The assured operations answer the common questions in one call. Some
questions do not fit them: "which documents mention both Dana and the
invoice exporter?", "how many facts about the billing migration changed
this month?", "list every contradiction". For those you write SQL queries.

SQL queries run over **the query space**, `memory_v1`: a fixed set of
prepared, read-only views and functions over the memory. It is not access
to the database. Every statement is parsed and checked against the query
space before it runs: one `SELECT`, only the published views, functions,
operators and casts. Anything else is rejected with an error code and
never reaches the database.

Setup is in the [Quickstart](https://remember.dev/docs/start/quickstart). The full list of
views and columns is in [Query space memory_v1](https://remember.dev/docs/reference/query-space).

## 1. Discover the query space

Ask the deployment what it has:

```python
import remember

client = remember.Client.from_env()
space = client.describe_query_space(pattern="facts_*")

for view in space["views"]:
    print(view["name"], "—", view["comment"])
    for name, sql_type, nullable in view["columns"]:
        print(f"  {name} {sql_type}{'' if nullable else ' not null'}")
```

`describe_query_space(pattern=None, include_examples=False)` returns the
schema name and version, a hash of the published surface, the views (each
with `name`, `grain`, `row_key`, `comment`, `columns`), the function names,
the limits, the rules for reading results (`honesty_warnings`) and worked
examples. `pattern` is a shell-style filter over view names.
`include_examples=True` adds the names of the shipped [saved
queries](https://remember.dev/docs/guides/saved-queries).

To find where something lives, search the query space's descriptions:

```python
for hit in client.search_query_space(query="contradiction", k=5):
    print(hit["kind"], hit["name"], "—", hit["purpose"])
```

`k` is 1 to 25, default 10. Hits are views, functions, operations or
examples.

CLI:

```bash
remember query space --pattern 'facts_*'
remember query space --include-examples
remember query search-space "contradiction" --k 5
```

## 2. Your first query

`facts_current` holds every fact the memory holds true now, one row per
fact:

```python
result = client.open_query(
    "SELECT fact_kind, fact_label, valid_from, valid_until, evidence_count"
    " FROM facts_current"
    " WHERE fact_label ~~* $1"
    " ORDER BY evidence_count DESC"
    " LIMIT 20",
    parameters=["%invoice exporter%"],
)

names = [column["name"] for column in result.columns]
for row in result.rows:
    print(dict(zip(names, row)))
print("truncated:", result.truncated)
```

- Write parameters as `$1`, `$2`, … and pass them in `parameters`. Never
  paste values into the SQL text.
- Cast a parameter when its type matters: `$1::uuid`, `$2::timestamptz`.
- `~~*` is `ILIKE`; either spelling works.
- Every view is already scoped to your deployment. You do not filter on
  `deployment_id`.

`open_query(sql, *, parameters=(), max_rows=None)` returns a
`QueryResultDict`: a dict of the full result with `.rows`, `.columns` and
`.truncated` as shortcuts. Each row is a list of values in column order,
and each column is a dict with `name`, `type` and `nullable`.
`query_sql(sql=..., parameters=..., max_rows=...)` returns the same result
as a plain dict.

CLI (prints the result as JSON):

```bash
remember query sql \
  "SELECT fact_label, evidence_count FROM facts_current WHERE fact_label ~~* \$1 LIMIT 20" \
  --parameters '["%invoice exporter%"]'
```

MCP, for an agent:

```json
{
  "name": "query_sql",
  "arguments": {
    "sql": "SELECT fact_label, evidence_count FROM facts_current WHERE fact_label ~~* $1 LIMIT 20",
    "parameters": ["%invoice exporter%"]
  }
}
```

The MCP server offers `query_sql`, `explain_sql`, `describe_query_space`,
`search_query_space`, `list_saved_queries`, `describe_saved_query` and
`run_saved_query` when the deployment serves SQL queries.

## 3. The views you will use most

| View | One row per |
|---|---|
| `facts_current` | fact held true now |
| `facts_visible_history` | fact, including ended and no-longer-believed ones |
| `fact_claim_evidence_live` | link between a fact and a claim, with stance |
| `claims_live` | claim that is current testimony |
| `claims_visible_history` | claim, including superseded testimony |
| `documents_live` | document, with its `source_kind`, `source_ref` and current version |
| `document_versions_visible` | version of a document |
| `entities_current` | entity |
| `entity_aliases_current` | name an entity is known by |
| `entity_document_mentions` | entity and document it is mentioned in |
| `contradiction_members_current` | fact in a live contradiction group |
| `changes_visible` | change the memory recorded, with `occurred_at` |

Keep the layers apart in your joins. Claims are testimony: a claim that
says "the migration is in June" does not make it true now. Answer "is it
true" questions from `facts_current`, and join to
`fact_claim_evidence_live` and `claims_live` to show why.

### Wrong and right: "who owns the invoice exporter now?"

**Wrong.** This reads testimony and takes the newest statement as the
answer:

```sql
SELECT claim_text, source_handle, asserted_at
FROM claims_live
WHERE claim_text ~~* $1
ORDER BY asserted_at DESC
LIMIT 20
```

`claims_live` holds every statement that is still current testimony.
"Ravi owns the invoice exporter" from a May spec and "Dana took over the
exporter" from a September retro are both in it, and the newest one is not
necessarily what the memory holds true: it may be one side of a
disagreement, or a statement that did not change the facts at all. The
claims' `claim_valid_from` and `claim_valid_until` do not help either:
they are what the source said about time, not the memory's verdict.

**Right.** Start from the facts and join to the testimony behind each one:

```sql
SELECT f.fact_label, f.valid_from, f.evidence_count, f.contradiction_group,
       e.stance, e.source_handle, e.asserted_at
FROM facts_current AS f
JOIN fact_claim_evidence_live AS e
  ON e.fact_kind = f.fact_kind AND e.fact_id = f.fact_id
WHERE f.fact_label ~~* $1
ORDER BY f.evidence_count DESC, f.fact_id, e.stance, e.asserted_at DESC
LIMIT 50
```

Run both with `parameters=["%invoice exporter%"]`. The second answers with
what the memory holds true now, shows each fact's supporting and
contradicting claims, and a non-null `contradiction_group` tells you the
owner is disputed.

### Facts whose newest testimony disagrees

A fact can stand while the latest thing any source said about it
contradicts it: the October plan is still the fact, but yesterday's
standup note says the date is slipping again. This query finds those
facts, with the contradicting statement:

```sql
WITH ranked AS (
  SELECT e.fact_kind, e.fact_id, e.claim_id, e.stance,
         c.claim_text, c.source_handle, c.asserted_at,
         row_number() OVER (
           PARTITION BY e.fact_kind, e.fact_id
           ORDER BY c.asserted_at DESC NULLS LAST, c.claim_id
         ) AS testimony_rank
  FROM fact_claim_evidence_live AS e
  JOIN claims_live AS c ON c.claim_id = e.claim_id
)
SELECT f.fact_label, f.evidence_count, f.contradict_count,
       r.claim_text AS newest_claim, r.source_handle, r.asserted_at
FROM facts_current AS f
JOIN ranked AS r ON r.fact_kind = f.fact_kind AND r.fact_id = f.fact_id
WHERE r.testimony_rank = 1
  AND r.stance = 'contradicts'
ORDER BY r.asserted_at DESC
LIMIT 50
```

Each row is a fact worth a second look: report it together with the newer
statement, not as settled. `describe_query_space` returns this pattern and
the wrong/right pair above in its `worked_examples`.

## 4. Functions

Functions go in `FROM`, like a table:

| Function | What it returns |
|---|---|
| `semantic_facts(query, k, filters)` | facts ranked by meaning |
| `semantic_claims(query, k, filters)`, `lexical_claims(query, k, filters)` | claims ranked by meaning or by words |
| `semantic_chunks(query, k, filters)`, `lexical_chunks(query, k, filters)` | source passages ranked by meaning or by words |
| `semantic_entities(query, k, filters)` | entities ranked by meaning |
| `fetch_chunk_bodies(chunk_ids)` | the text of up to 50 chunks |
| `facts_as_of(valid_at, believed_at, max_rows)` | facts as held at one world time and one belief time |
| `canonical_bounds(valid_from, valid_until, valid_precision)` | a claim's time window made comparable |
| `graph_neighborhood(deployment_id, entity_id, …)` | relations within N hops |
| `graph_path(deployment_id, from_entity_id, to_entity_id, …)` | shortest routes between two entities |
| `graph_citation_path(deployment_id, from_doc_id, to_doc_id, …)` | citation routes between two documents |

A statement may call at most 3 functions of each category (search, graph,
body fetch, time). Search functions nominate candidates from the index; the database
confirms them against current state, and `semantic_invocations` in the
result says how many were nominated, confirmed and dropped.

Search joined to current state:

```python
result = client.open_query(
    "SELECT s.rank, c.claim_text, c.source_handle, c.asserted_at"
    " FROM semantic_claims($1, 20) AS s"
    " JOIN claims_live AS c ON c.claim_id = s.claim_id"
    " ORDER BY s.rank"
    " LIMIT 20",
    parameters=["why did the billing migration slip"],
)
```

The graph functions take your deployment ID as their first argument. Every
result carries it:

```python
deployment_id = client.open_query("SELECT count(*) FROM documents_live")["deployment_id"]
neighbours = client.open_query(
    "SELECT hops, relation_ids, node_ids FROM graph_neighborhood($1::uuid, $2::uuid, 2)"
    " ORDER BY hops",
    parameters=[deployment_id, str(ravi_id)],
)
```

The full argument lists are in [Query space
memory_v1](https://remember.dev/docs/reference/query-space).

## 5. Limits

Queries sent over the API run in the interactive tier:

| Limit | Value |
|---|---|
| Rows returned | 200 by default; `max_rows` raises it to at most 1,000 |
| Bytes returned | 1 MiB by default, 8 MiB at most |
| Statement time | 5 seconds |
| SQL text | 64 KiB |
| Parameters | 64, 256 KiB in total |
| Recursive CTEs | 1, depth at most 6 |
| Concurrent queries | 2 per caller, 8 per deployment |
| Statement time per minute | 30 seconds per caller, 120 per deployment |

A `max_rows` above the cap is lowered to the cap, not refused. When rows
were cut, `truncated` is `True` and `truncation_reason` says why. The
`limits` field of every result states the caps it ran under.

A second tier (analytical: 10,000 rows, 60 seconds) exists for operators;
the API does not select it.

## 6. Read the whole result

Every result is `QueryResult/v1`. Beyond `columns` and `rows`, check:

- `truncated`, `truncation_reason`: rows were cut.
- `warnings`: anything the query space wants you to know.
- `semantic_invocations`: per search function, how many candidates were
  nominated, confirmed and dropped.
- `graph_invocations`: per graph function, whether it stopped on a budget
  (`truncation_reason` such as `depth_budget` or `time_budget`).
- `referenced_views`, `referenced_functions`, `source_grain_tags`: what the
  query touched.

SQL results are `exploratory_tabular`: they carry no negatives, no
guaranteed order unless you `ORDER BY`, and no guarantee that a join kept
the meaning of the views it combined. An empty result is not proof that
nothing exists; see [Handle unknowns and
ambiguity](https://remember.dev/docs/guides/unknowns-and-ambiguity).

## 7. Check a query without running it

```python
plan = client.explain_query(
    "SELECT fact_label FROM facts_current WHERE fact_label ~~* $1",
    parameters=["%exporter%"],
)
```

`explain_query` (and `remember query explain-sql`) validates the statement
and returns the database plan without executing it.

## 8. Errors

A statement that is rejected or fails still returns a result (HTTP 200).
Check `termination_reason`: it is `completed`, `rejected` (the statement or
its limits were refused before running) or `failed` (it stopped while
running). When it is not `completed`, `error_code` and `error_message` say
why. Saved-query refusals and malformed requests are different: they raise
`remember.MemoryApiError`, with the HTTP status shown below.

The error codes:

| Code | HTTP status when raised | Meaning |
|---|---|---|
| `parse_error` | 422 | Not valid SQL. |
| `multiple_statements` | 422 | More than one statement. Send one. |
| `statement_not_allowed` | 422 | Not a `SELECT`. |
| `relation_not_allowed` | 422 | A table or view outside the query space. |
| `function_not_allowed` | 422 | A function outside the allowed list. |
| `function_placement_not_allowed` | 422 | A query-space function used outside `FROM`. |
| `operator_not_allowed` | 422 | An operator outside the allowed list. |
| `invalid_parameter` | 422 | A parameter is missing, of the wrong type or too large. |
| `unbounded_recursion` | 422 | A recursive query without a depth bound. |
| `schema_version_mismatch` | 409 | The query targets another query-space version. |
| `quota_exceeded`, `concurrency_exceeded` | 409 | Too many queries or too much statement time. Wait and retry. |
| `statement_timeout`, `lock_timeout`, `cancelled`, `resource_limit` | 500 | Hit a time or resource cap. Narrow the query. |
| `execution_error`, `confirmation_failed` | 500 | Failed while running. |
| `pg_unavailable`, `p1_unavailable`, `graph_unavailable`, `corpus_body_unavailable`, `generation_unavailable` | 503 | A store the query needs is not available. Retry later. |

```python
result = client.open_query("DELETE FROM facts_current")
if result["termination_reason"] != "completed":
    print(result["termination_reason"], result["error_code"], result["error_message"])
    # rejected statement_not_allowed …
```

## Next

- [Saved queries](https://remember.dev/docs/guides/saved-queries): the 18 shipped queries to start from.
- [Query space memory_v1](https://remember.dev/docs/reference/query-space)
- [SQL query routes in the HTTP API](https://remember.dev/docs/reference/http-api/query)

---

Source: https://remember.dev/docs/guides/saved-queries

# Saved queries

A saved query is a SQL query stored in the deployment under a name, with
its parameters described, so an agent or a script can run it without
writing SQL. Every deployment ships with 18 of them, covering the questions
people ask most: claims about an entity, documents that mention it, what
changed since a date, why a fact is held. They run over the same query
space as any [SQL query](https://remember.dev/docs/guides/sql): prepared, read-only views and functions,
with every statement checked before it runs.

Setup is in the [Quickstart](https://remember.dev/docs/start/quickstart).

## Names

A saved query is addressed by a `namespace` and a `name`, written
`examples.claims_about`. The shipped queries all have the `namespace`
`examples`. Each one also has integer versions; a run uses the newest
active version unless you ask for a specific one.

## List them

```python
import remember

client = remember.Client.from_env()

for query in client.list_saved_queries(namespace="examples"):
    print(f"{query['namespace']}.{query['name']} v{query['version']} — {query['description']}")
```

`list_saved_queries(namespace=None, status=None)` returns, for each saved
query: `query_id`, `namespace`, `name`, `version`, `status`,
`description`, `origin`, `assurance`, `query_hash` and the hash of the
query space it was validated against. Without `status`, only `active`
versions are listed.

CLI and MCP:

```bash
remember query list-saved --namespace examples
```

```json
{"name": "list_saved_queries", "arguments": {"namespace": "examples"}}
```

## The 18 shipped queries

Parameters are positional, in this order. IDs are UUID strings; instants
are ISO 8601 timestamps with a time zone.

| Name | Answers | Parameters | Rows at most |
|---|---|---|---|
| `claims_verbatim` | Claims as asserted, found by meaning. | search text | 20 |
| `claims_about` | Claims that mention an entity, newest first. | entity ID | 50 |
| `claims_as_of` | Claims whose stated time overlaps a window. | from, to | 50 |
| `claims_hybrid_rrf` | Claims found by meaning and by words, fused. | search text | 20 |
| `chunks_hybrid_rrf` | Source passages found by meaning and by words, fused. | search text | 20 |
| `chunk_neighbors` | The passages either side of one passage in its section. | chunk ID | 5 |
| `documents_about` | Documents that mention an entity, most mentions first. | entity ID | 50 |
| `pages_about` | Compiled pages that cite an entity. | entity ID | 50 |
| `relation_current` | Current relations of an entity. | entity ID | 50 |
| `observation_current` | Current observations about an entity. | entity ID | 50 |
| `identity_as_of` | How an entity's identity was decided, up to an instant. | entity ID, instant | 100 |
| `entity_timeline` | An entity's facts counted per day. | entity ID | 200 |
| `explain` | Why a fact is held: history, evidence, documents. | fact ID | 100 |
| `multi_hop_context` | Claims along a route between two entities that match a search. | deployment ID, from entity ID, to entity ID, search text | 100 |
| `changed_since` | What the memory learned after an instant. | instant | 100 |
| `graph_neighborhood` | Relations within two hops of an entity. | deployment ID, entity ID | graph budget |
| `graph_path` | Routes of up to four hops between two entities. | deployment ID, from entity ID, to entity ID | graph budget |
| `graph_citation_path` | Citation routes of up to six hops between two documents. | deployment ID, from document ID, to document ID | graph budget |

The graph queries take your deployment ID first. It is in every ingest
result (`deployment_id`) and every SQL result.

## Look at one

Before you rely on a saved query, read what it does:

```python
detail = client.describe_saved_query(namespace="examples", name="documents_about")
print(detail["status"], detail["version"])
print(detail["sql"])
print(detail["parameter_schema"])
print(detail["declared_interpretation"])
```

The description holds the SQL, the parameter and result schemas, the
declared interpretation, the default limits, the validation report, and who
wrote and approved the version. Pass `version=` to see an older one.

```bash
remember query describe-saved examples documents_about
```

## Run one

```python
dana = client.resolve_entity("Dana").entities[0]
result = client.run_saved_query(
    namespace="examples",
    name="documents_about",
    parameters=[str(dana.entity_id)],
    max_rows=20,
)
columns = [column["name"] for column in result["columns"]]
for row in result["rows"]:
    print(dict(zip(columns, row)))
```

`run_saved_query(namespace, name, parameters=(), version=None,
max_rows=None)` returns the same `QueryResult/v1` dict as a SQL query,
with a `saved_query` field naming the exact version that ran: `query_id`,
`namespace`, `name`, `version` and `query_hash`. Record it next to any
answer you keep.

The result is subject to the same [limits](https://remember.dev/docs/guides/sql#5-limits) as any SQL
query. A `max_rows` you pass overrides the saved query's own default; the
query's `LIMIT` still applies.

CLI and MCP:

```bash
remember query run-saved examples documents_about \
  --parameters '["0b6f2d8e-5c1a-4e3b-9d7f-1a2b3c4d5e6f"]' --max-rows 20
```

```json
{
  "name": "run_saved_query",
  "arguments": {
    "namespace": "examples",
    "name": "documents_about",
    "parameters": ["0b6f2d8e-5c1a-4e3b-9d7f-1a2b3c4d5e6f"],
    "max_rows": 20
  }
}
```

## Status: when a saved query runs

Every version has a status. Only `active` runs.

| Status | Meaning | Running it |
|---|---|---|
| `draft` | Written, not approved. Not listed by default. | Refused: `saved_query_disabled`. |
| `active` | Approved by an operator, validated against the current query space. | Runs. |
| `pending_revalidation` | The query space changed since it was validated. | Refused: `saved_query_revalidation_pending`. |
| `deprecated` | A newer version was activated. | Refused unless another version is active. |
| `disabled` | Switched off by an operator. | Refused: `saved_query_disabled`. |
| `broken` | Failed validation against the current query space. | Refused: `saved_query_disabled`. |

How a version moves:

- The author of a version cannot approve it. Activation is a separate act
  by someone with authority, and the version records both people.
- When the query space changes (an upgrade adds a column or a view), every
  active version moves to `pending_revalidation` in the same step. It runs
  again only after it is validated against the new query space.
- Activating a new version deprecates the one it replaces. Old versions are
  kept; a caller who pinned `version=2` either gets exactly version 2 or a
  refusal, never different SQL.

Errors when running:

| Code | HTTP | Meaning |
|---|---|---|
| `saved_query_not_found` | 404 | No such `namespace.name`, or no such version. |
| `saved_query_disabled` | 409 | The query or the requested version is not active. |
| `saved_query_revalidation_pending` | 409 | Validated against another version of the query space. |
| `saved_query_incompatible` | 409 | Written for a query space this deployment does not have. |

All the SQL error codes can also occur; see [Explore memory with
SQL](https://remember.dev/docs/guides/sql#8-errors).

## Change a shipped query

The shipped queries are starting points, not guarantees: the platform wrote
them honestly, but what they mean is up to you. To change one, copy its SQL
from `describe_saved_query`, edit the filters, and run your copy with
`open_query`. Your copy is yours; the shipped version does not change.

The HTTP API, the client and the CLI can list, describe and run saved
queries. They cannot create, approve or disable one yet.

## Next

- [Explore memory with SQL](https://remember.dev/docs/guides/sql)
- [Ask about the past](https://remember.dev/docs/guides/ask-about-the-past): `claims_as_of` and
  `changed_since` in use.
- [Cite the source of an answer](https://remember.dev/docs/guides/cite-sources): `explain` in use.

---

Source: https://remember.dev/docs/guides/build-an-agent

# Build a memory-backed agent

This page builds one small, complete program: a project assistant for the
billing migration team. It loads a folder of meeting notes into
RememberStack, waits until they are processed, and answers questions such
as "when does the migration go live, and who decided?" with citations it
checks before showing them. Run it again next week and it picks up only
the notes that changed.

It pulls together the other guides: [Ingest files](https://remember.dev/docs/guides/ingest-files), [Wait
until a document is queryable](https://remember.dev/docs/guides/wait-for-readiness), [Give an agent
context](https://remember.dev/docs/guides/agent-context), [Cite the source of an
answer](https://remember.dev/docs/guides/cite-sources) and [Handle unknowns and
ambiguity](https://remember.dev/docs/guides/unknowns-and-ambiguity).

## What you need

- A RememberStack endpoint and token in the environment
  (`REMEMBER_API_URL`, `REMEMBER_API_KEY`), from the
  [Quickstart](https://remember.dev/docs/start/quickstart).
- `pip install remember anthropic`, and `ANTHROPIC_API_KEY` set. Any
  model provider works; only the `ask_model` function below talks to it.
- A folder of Markdown meeting notes, one file per meeting, named by date:

```text
notes/
├── 2026-06-02-kickoff.md
├── 2026-06-16-planning.md
└── 2026-09-17-standup.md
```

## The program

Save as `assistant.py`:

```python
"""Project assistant: answers questions from meeting notes, with citations."""

from __future__ import annotations

import argparse
import re
import sys
from datetime import UTC, datetime
from pathlib import Path

import anthropic
import remember
from remember import ContextBundleV2, MemoryApiError

MODEL = "claude-sonnet-5"  # replace with the model you use
SOURCE_KIND = "meeting-notes"

SYSTEM_PROMPT = """\
You are the billing migration team's project assistant. You answer only
from the memory context in the user's message; do not fill gaps from
general knowledge.

- "Facts" are what the memory holds true. "Source passages" are what
  people said. Never present a passage as an established fact.
- Cite every statement with the IDs in square brackets, such as [C3].
  Use only IDs that appear in the context.
- If the context says nothing is known, say you do not know. If a list is
  marked incomplete, say the answer may be incomplete.
- If facts contradict each other, give both sides with their citations.
- A fact's validity is when it was true; "said on" is when someone said
  it. Do not mix them.
"""

# --- memory: ingest ---------------------------------------------------------

def meeting_time(path: Path) -> datetime:
    """Meeting date from a 'YYYY-MM-DD-*.md' name, else the file's mtime."""
    match = re.match(r"(\d{4})-(\d{2})-(\d{2})", path.name)
    if match:
        year, month, day = (int(part) for part in match.groups())
        return datetime(year, month, day, tzinfo=UTC)
    return datetime.fromtimestamp(path.stat().st_mtime, tz=UTC)

def load_notes(client: remember.Client, folder: Path) -> None:
    """Send every note; wait only for the ones that are new or changed."""
    pending = []
    for path in sorted(folder.glob("*.md")):
        version = client.ingest(
            path,
            source_kind=SOURCE_KIND,
            source_ref=path.name,
            source_modified_at=meeting_time(path),
        )
        state = "new version" if version.created else "unchanged"
        print(f"{path.name}: {state}")
        if version.created:
            pending.append(version.version_id)

    if not pending:
        print("Nothing new to process.")
        return
    print(f"Waiting for {len(pending)} version(s); this takes minutes...")
    for start in range(0, len(pending), 1000):
        client.wait_for_readiness(pending[start : start + 1000], timeout=3600)
    print("Ready.")

# --- memory: context --------------------------------------------------------

def build_context(bundle: ContextBundleV2) -> tuple[str, dict[str, object]]:
    """Render facts and passages with short citation IDs.

    Returns the text for the model and a map from citation ID to the claim
    it stands for.
    """
    citations: dict[str, object] = {}

    def cite(claim) -> str:
        for key, known in citations.items():
            if known.claim_id == claim.claim_id:
                return key
        key = f"C{len(citations) + 1}"
        citations[key] = claim
        return key

    facts = bundle.facts
    lines = ["## Facts the memory holds"]
    if facts.negative is not None:
        lines.append(f"(none: {facts.negative.kind}; {facts.negative.explanation})")
    claims_by_id = {claim.claim_id: claim for claim in facts.evidence}
    for fact in facts.facts:
        v = fact.validity
        when = f"valid from {v.valid_from:%Y-%m-%d}" if v.valid_from else "validity unknown"
        if v.valid_until:
            when += f" until {v.valid_until:%Y-%m-%d}"
        notes = []
        if fact.contradiction is not None:
            rivals = "; ".join(member.label for member in fact.contradiction.co_members)
            notes.append(f"contradicted by: {rivals}")
        if fact.support == "withdrawn":
            notes.append("support withdrawn, unconfirmed")
        refs = [
            cite(claims_by_id[link.claim_id])
            for link in facts.fact_evidence
            if link.fact_id == fact.fact_id and link.claim_id in claims_by_id
        ]
        extra = f"; {'; '.join(notes)}" if notes else ""
        lines.append(f"- {fact.label} ({when}{extra}) [{', '.join(refs)}]")
    if facts.truncation is not None and facts.truncation.truncated:
        lines.append("(This list of facts is incomplete.)")

    said = bundle.claims_and_sources
    lines += ["", "## Source passages"]
    if said.negative is not None:
        lines.append(f"(none: {said.negative.kind}; {said.negative.explanation})")
    for claim in said.evidence[:20]:
        when = f"{claim.asserted_at:%Y-%m-%d}" if claim.asserted_at else "undated"
        lines.append(
            f'- [{cite(claim)}] {claim.document_title or claim.doc_id}, said on {when}:'
            f' "{claim.source_span}"'
        )

    return "\n".join(lines), citations

# --- model -----------------------------------------------------------------

def ask_model(question: str, context: str) -> str:
    """The only provider-specific code: send context and question, get text."""
    llm = anthropic.Anthropic()
    response = llm.messages.create(
        model=MODEL,
        max_tokens=1024,
        system=SYSTEM_PROMPT,
        messages=[
            {
                "role": "user",
                "content": f"<memory>\n{context}\n</memory>\n\nQuestion: {question}",
            }
        ],
    )
    return "".join(block.text for block in response.content if block.type == "text")

# --- answer -----------------------------------------------------------------

def answer(client: remember.Client, question: str) -> str:
    bundle = client.combined_context(question)
    context, citations = build_context(bundle)
    reply = ask_model(question, context)

    used = sorted(set(re.findall(r"\[?(C\d+)\]?", reply)), key=lambda key: int(key[1:]))
    invented = [key for key in used if key not in citations]
    if invented:
        reply += f"\n\n(Warning: the model cited unknown sources {', '.join(invented)}.)"

    sources = ["", "Sources:"]
    for key in used:
        claim = citations.get(key)
        if claim is not None:
            title = claim.document_title or claim.doc_id
            sources.append(f'  [{key}] {title}: "{claim.source_span}"')
    return reply + ("\n" + "\n".join(sources) if len(sources) > 2 else "")

def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    commands = parser.add_subparsers(dest="command", required=True)
    load = commands.add_parser("load", help="send a folder of meeting notes")
    load.add_argument("folder", type=Path)
    ask = commands.add_parser("ask", help="ask a question")
    ask.add_argument("question")
    args = parser.parse_args()

    with remember.Client.from_env() as client:
        try:
            if args.command == "load":
                load_notes(client, args.folder)
            else:
                print(answer(client, args.question))
        except MemoryApiError as error:
            print(f"memory error: {error.status_code} {error.detail}", file=sys.stderr)
            return 1
        except TimeoutError as error:
            print(f"still processing: {error}", file=sys.stderr)
            return 1
    return 0

if __name__ == "__main__":
    sys.exit(main())
```

## Run it

```bash
python assistant.py load notes/
python assistant.py ask "When does the billing migration go live, and who decided?"
```

An answer looks like this:

```text
The billing migration now goes live in October 2026 [C1]. Ravi moved it
from June because the invoice exporter needs a rewrite, and Dana agreed
[C2]. The June date from the kickoff no longer holds [C3].

Sources:
  [C1] 2026-09-17-standup: "The migration moves from June to October."
  [C2] 2026-09-17-standup: "Dana agreed and will tell finance."
  [C3] 2026-06-02-kickoff: "We go live with the new billing system in June."
```

Edit a note and run `load` again: only that note becomes a new version and
only it is waited on.

## How it works

**Loading.** Each note is keyed by `source_kind="meeting-notes"` and its
file name, and dated by the meeting date in its name
(`source_modified_at`). Re-running the loader is safe: unchanged files
return `created=False` and are skipped; changed files become new versions
of the same document. The client sends `.md` files as `text/markdown`.
`wait_for_readiness` checks every 15 seconds and stops at once if a stage
dead-letters; its timeout is raised from the 30-minute default to an hour
because a batch of notes takes longer than one document.

**Context.** One `combined_context` call returns what the memory holds true
(`facts`) and what people said (`claims_and_sources`) side by side.
`build_context` keeps them under separate headings, turns every claim into
a short ID (`C1`, `C2`, …), and carries the signals the model must not
ignore: `negative`, contradictions, withdrawn support, truncation.

**Citations.** The model cites short IDs. `answer` checks every cited ID
against the ones it sent; an ID the model made up is flagged instead of
being shown as a source. The sources list quotes `source_span`, the exact
passage, rather than the model's paraphrase.

## Where to take it next

- **Ask about the past.** Pass `time={"mode": "history"}` or an `at`
  instant to `combined_context` for questions such as "what was the plan in
  July?" ([Ask about the past](https://remember.dev/docs/guides/ask-about-the-past)).
- **Resolve names first.** For questions about a person, call
  `resolve_entity` and ask the user which one when there are several; then
  use `facts_context(..., entity_ids=[...])`
  ([Handle unknowns and ambiguity](https://remember.dev/docs/guides/unknowns-and-ambiguity)).
- **Let the agent write memory.** Render each assistant session as a
  conversation document and ingest it
  ([Ingest conversations and transcripts](https://remember.dev/docs/guides/ingest-conversations)).
- **Give it tools instead of a fixed call.** Run `remember mcp` and let an
  MCP-capable agent choose among `facts_context`,
  `claims_and_sources_context`, `resolve_entity` and SQL queries itself
  ([Connect your coding agent](https://remember.dev/docs/start/connect-your-agent)).

---

Source: https://remember.dev/docs/self-hosting/requirements

# Requirements

A self-hosted RememberStack is a set of containers from one Compose file:
PostgreSQL, SeaweedFS object storage, an API process and twelve pipeline
workers. The Compose file builds or pulls everything it needs. This page
lists what has to be true of the machine and the accounts around it.

## Docker and Compose

You need Docker Engine with the Compose v2 plugin: the `docker compose`
command, not the older `docker-compose` script. `compose.yaml` uses a
top-level `name`, `depends_on` conditions such as
`service_completed_successfully`, profiles, and an optional `env_file`
(`required: false`), which needs Compose 2.24.0 or later.

Run the tokenless quickstart only on Docker Engine 28.0.0 or later
(`docker version --format '{{.Server.Version}}'`). Compose publishes the
API on `127.0.0.1` only, and before Engine 28.0.0 a port published on
`127.0.0.1` can still be reached by other hosts on the same local network.
On an older engine, turn on API authentication before the first start:
set `REMEMBERSTACK_SELFHOST_API_BEARER_TOKEN` and
`REMEMBERSTACK_SELFHOST_REQUIRE_API_AUTH=true` in `.env` (see
[A shared secret](https://remember.dev/docs/self-hosting/authentication#a-shared-secret)).

The project states no other minimum Docker or Compose version. Its own
continuous-integration check runs `docker compose up --build --detach --wait`
on a current GitHub Actions Ubuntu runner.

## Image architectures

Two images from this project make up the stack. Both are published for
`linux/amd64` and `linux/arm64`, so an arm64 machine (Apple Silicon,
Graviton, Ampere) runs them natively, without emulation:

| Image | Used by |
|---|---|
| `ghcr.io/writeitai/remember-stack:0.17.2` | The API, the workers and `setup` |
| `ghcr.io/writeitai/remember-stack-postgres:19beta3-0.17.2` | PostgreSQL |

Compose pulls both from GitHub Container Registry. It builds an image from
source (`Dockerfile` or `Dockerfile.postgres`) only when the pull fails, or
when you pass `--build`. Building PostgreSQL compiles one extension and
takes a few minutes. The third image, SeaweedFS, is also published for both
architectures.

## PostgreSQL 19 (beta)

RememberStack requires **PostgreSQL 19, which is a beta release**. The
Compose file pins `postgres:19beta3`.

It needs 19 because the memory's graph is a SQL/PGQ property graph
(`CREATE PROPERTY GRAPH`, queried with `GRAPH_TABLE`). PostgreSQL 19 is the
first release with SQL/PGQ, so entity neighbourhoods and paths run live
inside the same database that holds the facts, with no separate graph store
to build or keep in sync.

Running a beta has a consequence you should plan for: there is no promise
that a data directory created by one PostgreSQL 19 prerelease opens under a
later one. See [Upgrades and migrations](https://remember.dev/docs/self-hosting/upgrades).

### Extensions

The migrations create these extensions:

| Extension | Version | Used for |
|---|---|---|
| `vector` (pgvector) | 0.8.6 | Semantic search vectors and HNSW indexes |
| `pg_textsearch` | 1.3.1 | BM25 keyword search |
| `pg_partman` | 5.5.0 | Monthly partitioning of large tables |
| `pgcrypto` | contrib | UUID generation and digests |
| `pg_trgm` | contrib | Fuzzy name matching during entity resolution |
| `fuzzystrmatch` | contrib | Phonetic name matching (`daitch_mokotoff`) |
| `unaccent` | contrib | Accent folding of names |
| `btree_gist` | contrib | The time-range exclusion constraint on relations |

`pg_textsearch` has no PostgreSQL 19 package yet. `Dockerfile.postgres`
downloads a pinned source revision from `timescale/pg_textsearch`, applies
a small compatibility patch (`docker/pg_textsearch-pg19.patch`) and
compiles it. `pgvector` and `pg_partman` come from the PostgreSQL apt
archive at the exact versions above. `remember ops graph-catalog ensure`
checks these three versions (see [Operating the pipeline](https://remember.dev/docs/self-hosting/operating)).

### Server settings

Compose starts PostgreSQL with:

```text
shared_preload_libraries=pg_textsearch,pg_partman_bgw
pg_partman_bgw.dbname=<REMEMBERSTACK_POSTGRES_DB>
pg_partman_bgw.role=<REMEMBERSTACK_POSTGRES_USER>
max_connections=<REMEMBERSTACK_POSTGRES_MAX_CONNECTIONS, default 300>
```

Everything else stays at the image defaults.

Running the engine against a PostgreSQL server you manage yourself is not a
documented path. If you try it, the server must match everything on this
page: version 19, the extensions at those versions, and the preload
settings.

## S3-compatible object storage

Original files, converted Markdown and other artifacts, and filesystem
snapshots live in object storage, in three buckets (`remember-raw`,
`remember-artifacts`, `remember-corpusfs`). The `setup` step creates them.

Compose runs [SeaweedFS](https://github.com/seaweedfs/seaweedfs) for this
(image `chrislusf/seaweedfs`, pinned to `4.44` by digest). The engine talks
to it through the S3 API with path-style addressing and SigV4 signing. It
writes every object with a conditional create (`If-None-Match: *`), so an
object is never silently replaced. Only SeaweedFS is exercised by the
project's checks. Another S3-compatible store has to support path-style
requests, conditional writes and user metadata on objects.

SeaweedFS stores objects in volumes of at most 1 GiB. Compose allows 100
of them, so the bundled store holds up to about 100 GiB before writes fail
(each bucket also reserves 7 volumes on its first write). Volumes are not
preallocated, so unused capacity takes no disk. To store more, raise
`-volume.max` on the `object-store` service in `compose.yaml`. The engine
sets no S3 lifecycle (expiry) rules and does not need them: it deletes
objects itself, during a hard forget.

## Model provider keys

| Key | Required | Used for |
|---|---|---|
| OpenRouter API key (`REMEMBERSTACK_OPENROUTER_API_KEY`) | Yes | Every model call: structure, claim extraction, entity resolution, fact adjudication, and all embeddings, including query embeddings for search |
| Mistral API key (`REMEMBERSTACK_MISTRAL_OCR_API_KEY`) | Only if you route PDFs or images to OCR | The `mistral_ocr` and `image_ocr_description` converters |
| A second OpenRouter key (`REMEMBERSTACK_IMAGE_DESCRIPTION_API_KEY`) | Only if you route images to `image_ocr_description` | The vision-model description of an image |
| TypeSafe AI key (`REMEMBERSTACK_TYPESAFE_API_KEY`) | Only with `REMEMBERSTACK_FACT_ADJUDICATION_ENGINE=jev` | The alternative fact adjudicator |

The engine starts with the placeholder OpenRouter key from `.env.example`,
but the first document or search fails at the first model call. The model
calls cost money on your OpenRouter account; see
[Models and providers](https://remember.dev/docs/self-hosting/models).

## Sizing

No hardware minimum has been measured, and the project publishes no
throughput figures. What follows is what the configuration and schema fix,
so you can size a machine from your own corpus.

**Where it has run.** Continuous integration starts the full stack on a
standard GitHub-hosted Ubuntu runner, which for a public repository has
4 CPUs and 16 GB of memory. It ingests a document and waits for every
stage there; it does not measure speed.

**What uses CPU and memory.** Most pipeline work is waiting on model calls
to OpenRouter, so the engine's own processes are light. PostgreSQL does the
local heavy lifting: search, graph queries and SQL queries all run in it.
The query-side memory each of those may take is bounded (see the table
below and [Scaling](https://remember.dev/docs/self-hosting/scaling)).

**What uses disk.** Two volumes grow with the corpus:

- `postgres-data` holds every chunk, claim, fact, observation and entity.
  Each of these also carries a 1,536-dimension embedding, about 6 KB, plus
  its share of the vector index.
- `object-store-data` holds each uploaded file as sent, its converted
  Markdown and other artifacts, and filesystem snapshots. The bundled store
  is capped at about 100 GiB (see
  [S3-compatible object storage](#s3-compatible-object-storage)).

Nothing is deleted automatically; a hard forget removes a document's data.

**Database connections.** Compose starts PostgreSQL with
`max_connections` from `REMEMBERSTACK_POSTGRES_MAX_CONNECTIONS`, `300` by
default (the image default is 100). Each engine process opens
connections as it needs them, up to these ceilings:

| Process | General pool | Other | Ceiling |
|---|---|---|---|
| `api` | 15 (5 kept open, 10 more under load) | retrieval pool 4, graph pool 4 | 23 |
| Each `worker-…` | 15 (5 kept open, 10 more under load) | 1 held open to listen for new work | 16 |

The ceilings of the default stack add up to 23 + 12 × 16 = 215, which fits
within 300 with room to spare. They are ceilings, not what a stack holds:
pools open connections only on demand. Nothing caps the total, though, so
budget against the ceilings when you add replicas or API processes: each
worker replica can add 16 connections and each API process 23, plus any
increase in the retrieval and graph pool sizes. When the sum passes the limit,
raise it by setting `REMEMBERSTACK_POSTGRES_MAX_CONNECTIONS` in `.env`; see
[Scaling](https://remember.dev/docs/self-hosting/scaling#database-connections).

Other configured bounds:

| Bound | Default |
|---|---|
| Containers started by `docker compose up` | 15: `postgres`, `object-store`, `setup` (exits), `api`, 12 workers |
| Items one worker process runs at once | 1 |
| Claims per second per worker process | 20, burst 20 |
| Graph connection pool in the API | 4 connections, at most 2 graph queries at once |
| Graph query `work_mem` | 16,384 KiB |
| Retrieval connection pool in the API | 4 connections, at most 4 retrievals at once |
| SQL query sandbox | 5 s default and 15 s maximum statement time, 16 MiB `work_mem`, 8 statements at once per deployment |
| PostgreSQL `max_connections` | 300 (`REMEMBERSTACK_POSTGRES_MAX_CONNECTIONS`) |

[Scaling](https://remember.dev/docs/self-hosting/scaling) explains each bound and how to change it.

---

Source: https://remember.dev/docs/self-hosting/install

# Install with Docker Compose

This page takes you from an empty machine to a running RememberStack that
has read its first document. It uses the `compose.yaml` and `.env.example`
that ship in the repository. Check [Requirements](https://remember.dev/docs/self-hosting/requirements) first.

## 1. Get the files

```bash
git clone https://github.com/writeitai/remember-stack.git
cd remember-stack
git checkout v0.17.2
cp .env.example .env
printf 'REMEMBERSTACK_POSTGRES_PASSWORD=%s\nREMEMBERSTACK_MINIO_ACCESS_KEY=%s\nREMEMBERSTACK_MINIO_SECRET_KEY=%s\nREMEMBERSTACK_SELFHOST_DEPLOYMENT_ID=%s\n' \
  "$(openssl rand -hex 32)" "$(openssl rand -hex 12)" "$(openssl rand -hex 32)" \
  "$(openssl rand -hex 16 | sed -E 's/^(.{8})(.{4}).(.{3}).(.{3})(.{12})$/\1-\2-4\3-8\4-\5/')" >> .env
```

The last command generates the database password, the object-store
credentials and the deployment id (a random UUID); `.env.example` ships
none of them, and Compose refuses to start without them.

Checking out a release tag keeps `compose.yaml` in step with the image tag
it names (`ghcr.io/writeitai/remember-stack:0.17.2`). Each GitHub release
also attaches `compose.yaml` and `.env.example`. GitHub renames the second
one on download, so it appears as `default.env.example`.

## 2. Fill in `.env`

**Warning:**

The API needs no token by default. That is safe only on Docker Engine
28.0.0 or later; on an older engine, set a token first. See
[Before you expose it](#before-you-expose-it).

Docker Compose reads `.env` from the project directory, substitutes its
values into `compose.yaml` and passes every variable in it to the engine
containers. These variables have no default in `compose.yaml` and must be
set:

| Variable | What to put there |
|---|---|
| `REMEMBERSTACK_OPENROUTER_API_KEY` | Your OpenRouter API key. The placeholder lets the stack start, but every model call fails with it. |
| `REMEMBERSTACK_SELFHOST_DEPLOYMENT_ID` | This deployment's id, a random UUID generated in step 1. |
| `REMEMBERSTACK_SELFHOST_DEPLOYMENT_SLUG` | A short name, such as `billing-team`. |
| `REMEMBERSTACK_SELFHOST_DEPLOYMENT_NAME` | A display name, such as `Billing team memory`. |
| `REMEMBERSTACK_SELFHOST_API_PORT` | The host port for the API. `8000` in the example. Compose publishes it on `127.0.0.1` only; see [Before you expose it](#before-you-expose-it). |
| `REMEMBERSTACK_POSTGRES_USER`, `REMEMBERSTACK_POSTGRES_PASSWORD`, `REMEMBERSTACK_POSTGRES_DB` | Database credentials; the password is generated in step 1. PostgreSQL creates them on first start. |
| `REMEMBERSTACK_MINIO_ACCESS_KEY`, `REMEMBERSTACK_MINIO_SECRET_KEY` | Object-store credentials, generated in step 1. The bundled SeaweedFS store uses them as its only S3 identity; Compose refuses to start if either is empty. |

**Warning:**

Decide the slug and name before the first start, and keep the generated
deployment id. The first `setup` run records all three in the database,
and every later `setup` refuses to run if they differ. See
[Upgrades and migrations](https://remember.dev/docs/self-hosting/upgrades#the-deployment-id-is-permanent).

## 3. Start the stack

```bash
docker compose up -d --wait
```

On the first run Compose builds the PostgreSQL image (it compiles the
`pg_textsearch` extension, which takes a few minutes), pulls SeaweedFS and the
RememberStack image, and starts everything in dependency order:

1. `postgres` and `object-store` start and report healthy.
2. `setup` runs the database migrations, creates the three buckets,
   records the deployment, installs the assured operations and the example
   saved queries, and exits.
3. `api` and the twelve workers start once `setup` has exited successfully.

`--wait` returns when the services are running and healthy.

## What each service does

| Service | Command | Role |
|---|---|---|
| `postgres` | `postgres` | PostgreSQL 19 beta with the extensions RememberStack needs. It holds every document, version, claim, fact and entity, and the work queue. |
| `object-store` | `server -s3` | [SeaweedFS](https://github.com/seaweedfs/seaweedfs), S3-compatible storage for original files, converted Markdown and snapshots. Reachable only by the other containers; no host port is published. |
| `setup` | `setup` | One-shot: migrations, buckets, the deployment row, seed data. Runs again, harmlessly, on every `up`. |
| `api` | `api` | The HTTP API on port 8000 inside the container, published on `127.0.0.1:REMEMBERSTACK_SELFHOST_API_PORT`. |
| `worker-convert` | `worker --stage convert` | Turns an uploaded file into Markdown using the configured [converter](https://remember.dev/docs/self-hosting/converters). |
| `worker-structure` | `worker --stage structure` | Finds the document's sections and writes section summaries. |
| `worker-chunk` | `worker --stage chunk` | Splits the document into chunks. |
| `worker-embed-chunk` | `worker --stage embed_chunk` | Embeds chunks for semantic search. |
| `worker-extract-claims` | `worker --stage extract_claims` | Reads each chunk and selects the sentences that state something. |
| `worker-ground-claims` | `worker --stage ground_claims` | Turns those sentences into claims (what the source said), each tied to its passage. |
| `worker-normalize-relations` | `worker --stage normalize_relations` | Resolves entities and turns claims into relations and facts. |
| `worker-adjudicate-observations` | `worker --stage adjudicate_observations` | Decides, entity by entity, whether a new statement confirms, contradicts or replaces a fact the memory holds. |
| `worker-adjudicate-supersession` | `worker --stage adjudicate_supersession` | Records the follow-up of those decisions for the version and refreshes affected entity profiles. |
| `worker-embed-claim` | `worker --stage embed_claim` | Embeds claims for semantic search. |
| `worker-reconcile` | `worker --stage reconcile` | Settles the version's lifecycle once the other stages are done. |
| `worker-label-relation` | `worker --stage label_relation` | Writes the searchable label of each relation and embeds facts for semantic search. |
| `projections` | `project --plane p3` | Profile `operations` only. Builds a [filesystem view](https://remember.dev/docs/self-hosting/filesystem-views) snapshot on demand. |

Each worker handles one stage and wakes when new work for that stage is
committed. [The pipeline and readiness](https://remember.dev/docs/concepts/pipeline) explains
what the stages produce.

## 4. Check that it is healthy

```bash
docker compose ps
curl http://localhost:8000/healthz
```

`/healthz` answers `{"status":"ok"}` when the API can reach PostgreSQL.
It is the check Compose itself uses, and it never needs a token.

```bash
curl http://localhost:8000/deployment
```

`/deployment` reports the source revision the image was built from
(`build_revision`, empty for a locally built image) and the model bound to
each pipeline seat (`model_bindings`).

## 5. Send a first document

Install the client on the machine you work from. It needs Python 3.12 or
later.

```bash
pip install remember
export REMEMBER_API_URL=http://localhost:8000
```

Save this as `standup.md`:

```markdown
# Stand-up, 17 September 2026

Ravi said the billing migration moves from June to October, because the
invoice exporter needs a rewrite. Dana agreed and will tell finance.
Ravi owns the invoice exporter.
```

Send it and ask about it:

```bash
remember ingest standup.md \
  --source-kind file --source-ref notes/standup.md \
  --source-modified-at 2026-09-17T09:30:00+00:00

remember query "Who owns the invoice exporter?"
```

`remember ingest` prints the new `version_id`. Processing takes minutes, so
a query sent straight away may find nothing yet.
[Wait until a document is queryable](https://remember.dev/docs/guides/wait-for-readiness) shows
how to wait for a version. To watch the work happen:

```bash
docker compose logs -f worker-extract-claims worker-normalize-relations
```

**Note:**

Set `REMEMBER_API_URL` explicitly, or run `remember setup --self-hosted`,
which stores the address. With no address anywhere, the CLI falls back to
`http://127.0.0.1:8000`.

## Stop, start and reset

| To | Run | Data |
|---|---|---|
| Stop the containers | `docker compose stop` | Kept |
| Start them again | `docker compose start` or `docker compose up -d` | Kept |
| Remove the containers | `docker compose down` | Kept in the volumes |
| Remove everything, including all memory | `docker compose down -v` | **Deleted** |

## Volumes

Compose names volumes after the project, `rememberstack`:

| Volume | Mounted at | Holds |
|---|---|---|
| `rememberstack_postgres-data` | `/var/lib/postgresql` in `postgres` | The database: every document, version, claim, fact and entity, the work queue and the cost ledger |
| `rememberstack_object-store-data` | `/data` in `object-store` | Original files, converted Markdown, derived artifacts and filesystem snapshots |
| `rememberstack_app-state` | `/var/lib/rememberstack` in the app containers | Working directories; the optional debug capture of invalid model output |
| `rememberstack_forget-manifests` | `/var/lib/rememberstack/forget-manifests` in the app containers | Hard-forget manifests. Empty in a Compose deployment. |

The first two hold the memory. Back them up together; see
[Upgrades and migrations](https://remember.dev/docs/self-hosting/upgrades#back-up).

## Before you expose it

By default the API needs no token, so Compose publishes its port on
`127.0.0.1` only: only this machine can reach it. That holds on Docker
Engine 28.0.0 or later. On an older engine, other hosts on the same local
network can reach a port published on `127.0.0.1`, so set
`REMEMBERSTACK_SELFHOST_API_BEARER_TOKEN` and
`REMEMBERSTACK_SELFHOST_REQUIRE_API_AUTH=true` before the first start (see
[Requirements](https://remember.dev/docs/self-hosting/requirements#docker-and-compose)). To serve other machines,
set a token and then the address to publish on
(`REMEMBERSTACK_SELFHOST_API_PUBLISH_ADDRESS`); see
[Opening the API to other machines](https://remember.dev/docs/self-hosting/authentication#opening-the-api-to-other-machines).

---

Source: https://remember.dev/docs/self-hosting/configuration

# Configuration

RememberStack is configured entirely through environment variables. There
is no configuration file inside the engine. This page explains how the
variables reach the containers, which ones matter first, and which ones
cannot change later. [Configuration variables](https://remember.dev/docs/reference/configuration) lists
every variable with its default.

## How the engine reads settings

Each part of the engine reads its own group of variables when the process
starts, through typed settings classes (pydantic-settings). Each group has
a prefix:

| Prefix | Group |
|---|---|
| `REMEMBERSTACK_SELFHOST_` | The deployment, the API, authentication, pools and workers, converter routes |
| `REMEMBERSTACK_OPENROUTER_` | The model provider |
| `REMEMBERSTACK_MINIO_` | Object storage (any S3-compatible store; Compose runs SeaweedFS) |
| `REMEMBERSTACK_STRUCTURER_`, `_E2_`, `_E3_`, `_OBS_`, `_FACT_`, `_P1_` … | One pipeline model seat each ([Models and providers](https://remember.dev/docs/self-hosting/models)) |
| `REMEMBERSTACK_MISTRAL_OCR_`, `REMEMBERSTACK_IMAGE_DESCRIPTION_` | [Converters](https://remember.dev/docs/self-hosting/converters) |
| `REMEMBERSTACK_WORK_` | Retries and spend budgets |
| `REMEMBERSTACK_SENTRY_` | [Error tracking](https://remember.dev/docs/self-hosting/observability) |

Three rules follow from this:

- **The engine reads only the process environment.** It does not open a
  `.env` file itself. In the Compose setup, Docker Compose reads `.env`
  and passes values into the containers.
- **Values are checked at start.** A malformed value (a non-UUID deployment
  id, a pool concurrency larger than its pool, a converter route naming an
  unknown converter) stops the process with an error that names the
  variable. It never starts half-configured.
- **Blank means unset.** Compose turns an unset `${VAR:-}` into an empty
  string; the engine treats an empty value as "use the default" for the
  optional settings.

## What Compose passes through

`compose.yaml` loads your `.env` into every engine container (`env_file`),
so any engine variable you put in `.env` takes effect: for example
`REMEMBERSTACK_IMAGE_DESCRIPTION_API_KEY`, `REMEMBERSTACK_COST_EXPORT_BIND`,
the worker rate settings, `REMEMBERSTACK_OPENROUTER_ZDR` or
`REMEMBERSTACK_WORK_BUDGETS`.

Each engine service also has an `environment:` list. It supplies defaults
and three values Compose sets itself, which win over `.env`:

| Variable | Value inside the containers |
|---|---|
| `REMEMBERSTACK_DATABASE_URL` | Built from `REMEMBERSTACK_POSTGRES_USER`, `_PASSWORD` and `_DB`, pointing at the `postgres` service. |
| `REMEMBERSTACK_MINIO_ENDPOINT_URL` | `http://object-store:8333`, the bundled SeaweedFS store. |
| `REMEMBERSTACK_SELFHOST_API_PORT` | `8000`. In `.env` the same variable is the host port Compose publishes. |

A variable exported in your shell overrides `.env` only if it is on the
`environment:` list, because Compose substitutes shell values into that
list but reads `env_file` from the file alone. Put settings in `.env`.

## Apply a change

```bash
docker compose up -d
```

Compose recreates every container whose configuration changed. `setup`
runs again first, then the API and workers restart with the new values.
A running process never picks up a changed variable on its own.

## What to change first

For a machine only you can reach, these settings matter:

| Variable | Why |
|---|---|
| `REMEMBERSTACK_OPENROUTER_API_KEY` | Nothing is processed without it. |
| `REMEMBERSTACK_SELFHOST_DEPLOYMENT_ID`, `REMEMBERSTACK_POSTGRES_PASSWORD`, `REMEMBERSTACK_MINIO_ACCESS_KEY`, `REMEMBERSTACK_MINIO_SECRET_KEY` | `.env.example` ships none; generate them as in [Install](https://remember.dev/docs/self-hosting/install#1-get-the-files). Compose refuses to start without them. |

Compose publishes the API on `127.0.0.1` only. To let other machines reach
it, set `REMEMBERSTACK_SELFHOST_API_BEARER_TOKEN` and
`REMEMBERSTACK_SELFHOST_REQUIRE_API_AUTH=true`, then
`REMEMBERSTACK_SELFHOST_API_PUBLISH_ADDRESS`
([Opening the API to other machines](https://remember.dev/docs/self-hosting/authentication#opening-the-api-to-other-machines)).

To accept more than Markdown and plain text, set
`REMEMBERSTACK_SELFHOST_CONVERSION_ROUTES` ([File formats and converters](https://remember.dev/docs/self-hosting/converters)).

## Settings fixed after the first start

The first `setup` records these in the database, and later runs refuse to
start if they change. A database holds one deployment, so a new deployment
id is refused too; it does not start a second, empty deployment.

- `REMEMBERSTACK_SELFHOST_DEPLOYMENT_ID`
- `REMEMBERSTACK_SELFHOST_DEPLOYMENT_SLUG`
- `REMEMBERSTACK_SELFHOST_DEPLOYMENT_NAME`
- `REMEMBERSTACK_SELFHOST_DEFAULT_LANGUAGE` (default `en`)
- the three bucket names, `REMEMBERSTACK_SELFHOST_RAW_BUCKET_NAME`,
  `…_ARTIFACTS_BUCKET_NAME` and `…_CORPUSFS_BUCKET_NAME`

`setup` also refuses a new `REMEMBERSTACK_P1_EMBEDDING_MODEL` once
anything has been embedded; see
[Models and providers](https://remember.dev/docs/self-hosting/models#changing-the-embedding-model).

## The client side

The `remember` CLI and Python client read a separate, small set of
variables on your own machine, most importantly `REMEMBER_API_URL` and
`REMEMBER_API_KEY`. They are listed at the top of
[Configuration variables](https://remember.dev/docs/reference/configuration#client).

---

Source: https://remember.dev/docs/self-hosting/models

# Models and providers

RememberStack calls language models to read your documents and an
embedding model to make them searchable. A self-hosted deployment sends
every one of those calls through [OpenRouter](https://openrouter.ai) with
the key in `REMEMBERSTACK_OPENROUTER_API_KEY`, and pays for them on that
account. This page lists each place a model is used (a *seat*), what it
does, and the settings that shape the calls.

Retrieval itself calls no language model. A search embeds your query text
once; the operations, graph and SQL queries then read the database.

## Model seats

Each seat is one environment variable holding an OpenRouter model id. The
defaults are the values `compose.yaml` passes when `.env` leaves them
unset.

| Seat | Variable | Default | What it does |
|---|---|---|---|
| Structure fallback | `REMEMBERSTACK_STRUCTURER_MODEL` | `openai/gpt-5.6-luna` | Proposes section anchors when a document's own headings do not give a usable outline. A document whose headings pass the structure checks never reaches this seat. |
| Skeleton check | `REMEMBERSTACK_SKELETON_CHECK_MODEL` | `z-ai/glm-4.7-flash` | Checks a proposed section outline, independently of the model that proposed it. |
| Section role | `REMEMBERSTACK_ROLE_MODEL` | `z-ai/glm-4.7-flash` | Classifies what each section is for, from its title. |
| Section summary | `REMEMBERSTACK_SUMMARY_MODEL` | `z-ai/glm-4.7-flash` | Writes the short summary of each section. |
| Claim extraction | `REMEMBERSTACK_E2_EXTRACT_MODEL` | `openai/gpt-5.6-luna` | Selects the sentences that state something and turns them into claims. Used by both the `extract_claims` and `ground_claims` workers. |
| Relation normalisation | `REMEMBERSTACK_E3_NORMALIZE_MODEL` | `openai/gpt-5.6-luna` | Turns claims into relations between entities. |
| Entity resolution | `REMEMBERSTACK_OBS_SMALL_MODEL` | `openai/gpt-5.6-luna` | Decides between candidate entities when the deterministic name matching cannot. |
| Fact adjudication | `REMEMBERSTACK_FACT_MODEL` | `openai/gpt-5.6-luna` | Decides whether a new statement confirms, contradicts or replaces a fact the memory holds. |
| Embeddings | `REMEMBERSTACK_P1_EMBEDDING_MODEL` | `qwen/qwen3-embedding-8b` | Every vector: chunks, claims, facts, entity profiles, and the query text of each search. |

`GET /deployment` reports the models the running deployment is bound to
under `model_bindings`, so you can check what is serving:

```bash
curl http://localhost:8000/deployment
```

## Fact adjudication

Fact adjudication decides what the memory holds true, so it has two extra
settings:

| Variable | Default | Meaning |
|---|---|---|
| `REMEMBERSTACK_FACT_ADJUDICATION_ENGINE` | `prompt` | `prompt` asks `REMEMBERSTACK_FACT_MODEL` through OpenRouter. `jev` asks a TypeSafe AI System One model instead. |
| `REMEMBERSTACK_FACT_CONFIDENCE_FLOOR` | `0.75` | Below this confidence the adjudicator does not replace or contradict anything: the new statement and the existing fact are both kept. |

The `jev` engine needs its own key and settings:

| Variable | Default |
|---|---|
| `REMEMBERSTACK_TYPESAFE_API_KEY` | none (required with `jev`; the fact workers refuse to start without it) |
| `REMEMBERSTACK_TYPESAFE_MODEL` | `jev-latest` |
| `REMEMBERSTACK_TYPESAFE_BASE_URL` | `https://api.typesafe.ai/v1` |
| `REMEMBERSTACK_TYPESAFE_TIMEOUT_S` | `30.0` |
| `REMEMBERSTACK_FACT_FALLBACK_TO_PROMPT` | `false`. When `true`, a failed TypeSafe call falls back to the `prompt` engine instead of failing the work item. |

## Change a model

Put the new model id in `.env` and apply it:

```bash
# .env
REMEMBERSTACK_E2_EXTRACT_MODEL=openai/gpt-5.6-sol
```

```bash
docker compose up -d
```

Use exact model ids. A rotating router id such as `openrouter/free` makes
results impossible to reproduce and to attribute. A chat seat needs a model
that supports structured (JSON schema) output, because every seat asks for
a typed answer and rejects one that does not match.

A new model applies to work done from then on. Documents already processed
keep what the old model produced; RememberStack does not re-run them on
its own.

## Changing the embedding model

Every vector in RememberStack has **1,536 dimensions**. The column types,
the search channels and every request are fixed at that size: the engine
asks the provider for 1,536 dimensions and rejects a response of any other
length. An embedding model can replace `qwen/qwen3-embedding-8b` only if it
can return 1,536-dimension vectors on request.

Each stored vector is stamped with the model that produced it, and search
compares a query only with vectors of the same model. Nothing re-embeds
stored chunks, claims and facts, so the embedding model is fixed once
anything has been embedded:

- Before the first document is embedded, you can change
  `REMEMBERSTACK_P1_EMBEDDING_MODEL` freely.
- After that, `setup` refuses to run with a different model. It exits with
  an error that names the configured model and the stored one, and the API
  and workers do not start. Set the variable back to the stored model.

To change it before anything is embedded, stop the workers first
(`docker compose stop`), then change the variable and run
`docker compose up -d`. A worker still running on the old model could
otherwise embed with it after the switch; the next `setup` would then
refuse the new model.

To use a different embedding model, start a new deployment and send the
documents again.

## OpenRouter routing and limits

These settings apply to every call through OpenRouter.

| Variable | Default | Meaning |
|---|---|---|
| `REMEMBERSTACK_OPENROUTER_CHAT_PROVIDER_ORDER` | unset | Comma-separated provider slugs to try first for chat calls. Other providers remain a fallback. |
| `REMEMBERSTACK_OPENROUTER_CHAT_PROVIDER_ONLY` | unset | Comma-separated provider slugs that are the only ones allowed. A call they cannot serve fails. Cannot be combined with the order. |
| `REMEMBERSTACK_OPENROUTER_EMBEDDING_PROVIDER_ORDER` | unset | Provider slugs to try first for embeddings, with fallback, for example `nebius,deepinfra,siliconflow`. |
| `REMEMBERSTACK_OPENROUTER_EMBEDDING_PROVIDER` | unset | One provider slug that embeddings must use, with no fallback. The order wins if both are set. |
| `REMEMBERSTACK_OPENROUTER_ZDR` | `false` | Restricts chat calls to zero-data-retention endpoints. |
| `REMEMBERSTACK_OPENROUTER_REASONING_EFFORT` | unset (model default) | One of `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, applied to every chat call. |
| `REMEMBERSTACK_OPENROUTER_REASONING_EFFORT_MAP` | unset | A JSON object of model id to effort. An entry wins over the global value for that model. |
| `REMEMBERSTACK_OPENROUTER_MAX_COMPLETION_TOKENS` | `32000` | Output allowance per chat call, reasoning included. |
| `REMEMBERSTACK_OPENROUTER_TIMEOUT_S` | `120` | Per-request timeout in seconds. |
| `REMEMBERSTACK_OPENROUTER_CHAT_THROTTLE_RETRIES` | `3` | How many times a chat call that gets HTTP 429 is retried, moving to the next listed provider. These retries do not count against the work item's attempts. |
| `REMEMBERSTACK_OPENROUTER_CHAT_UPSTREAM_OVERLOAD_MAX_RETRY_AFTER_S` | `30` | The longest single wait before retrying an overloaded provider. |
| `REMEMBERSTACK_OPENROUTER_BASE_URL` | `https://openrouter.ai/api/v1` | The OpenRouter API address. |

When you set a provider list or ZDR, each chat call also tells OpenRouter
not to route to providers that collect data (`data_collection: deny`). With
none of them set, OpenRouter applies your account's own routing settings.

Lowering reasoning effort makes extraction faster and cheaper; the comment
in `.env.example` notes that `none` can reduce adjudication quality and
that some models reject it. A map lets you turn it off for the small models
only:

```bash
REMEMBERSTACK_OPENROUTER_REASONING_EFFORT_MAP={"z-ai/glm-4.7-flash":"none","openai/gpt-5.6-luna":"high"}
```

For debugging, `REMEMBERSTACK_OPENROUTER_INVALID_COMPLETION_CAPTURE_DIR`
(an absolute path, for example
`/var/lib/rememberstack/openrouter-invalid-completions`) keeps every model
answer that failed schema validation, one file each, readable only by the
engine user. Those files can repeat text from your documents; treat the
directory as customer data and leave the setting off otherwise.

## Cost

Every document costs model calls: section work during structuring, claim
extraction for every chunk, relation normalisation, entity resolution and
fact adjudication for what it says, and embeddings for its chunks, claims
and facts. Each search costs one query embedding. The size of the bill
depends on your documents and the models you choose; RememberStack has
published no per-document cost figures.

Every billed call is written to the cost ledger with its model, tokens,
cost in US dollars and the pipeline stage that made it. Read it with the
[cost export](https://remember.dev/docs/self-hosting/observability#cost-export), and cap it per stage with
[spend budgets](https://remember.dev/docs/self-hosting/operating#spend-budgets). The provider account's own
limit is the final monetary boundary.

---

Source: https://remember.dev/docs/self-hosting/converters

# File formats and converters

Before RememberStack can read a file, the `convert` worker turns it into
Markdown. Which converter handles which file is a table you configure: a
map from MIME type to converter name. A stock deployment converts
Markdown, plain text, HTML, Word (`.docx`), PowerPoint (`.pptx`) and Excel
(`.xlsx`) with no API key. Everything else, including PDFs and images, is
stored and waits until you add a route for it; the ingest response says so
at once with `"parked": "no_route"`.

## The default table

With `REMEMBERSTACK_SELFHOST_CONVERSION_ROUTES` unset, the table is:

| MIME type | Converter |
|---|---|
| `text/markdown` | `passthrough` |
| `text/plain` | `passthrough` |
| `text/html` | `markitdown` |
| `application/vnd.openxmlformats-officedocument.wordprocessingml.document` (`.docx`) | `markitdown` |
| `application/vnd.openxmlformats-officedocument.presentationml.presentation` (`.pptx`) | `markitdown` |
| `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` (`.xlsx`) | `markitdown` |

`passthrough` keeps the text as it is. It requires valid UTF-8; a file in
another encoding fails conversion. `markitdown` runs in the worker with no
network call (see [markitdown](#markitdown)).

PDFs and images are not in the default table: the converters that read
them call a paid provider API with your own key. Add a route for them as
shown below.

The MIME type is the one sent with the upload. The `remember` CLI, Python
client and MCP `ingest` tool take it from the file extension, the same way
on every Python installation for the formats a route can name (the table is
in [Ingest files](https://remember.dev/docs/guides/ingest-files#the-mime-type)), and fall back
to `application/octet-stream`. Pass `--mime` (CLI) or `mime=` (Python) when
the extension does not say what the file is. The match is exact:
`text/markdown` and `text/x-markdown` are different keys.

## Add routes

Set `REMEMBERSTACK_SELFHOST_CONVERSION_ROUTES` to a JSON object. It
**replaces** the default table, so include the default routes again. This
example adds PDFs and images:

```bash
# .env
REMEMBERSTACK_SELFHOST_CONVERSION_ROUTES={"text/markdown": "passthrough", "text/plain": "passthrough", "text/html": "markitdown", "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "markitdown", "application/vnd.openxmlformats-officedocument.presentationml.presentation": "markitdown", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "markitdown", "application/pdf": "mistral_ocr", "image/png": "image_ocr_description", "image/jpeg": "image_ocr_description"}
```

Then apply it with `docker compose up -d`. The API and the `convert` worker
both read this variable. A route that names an unknown converter stops the
`convert` worker at start with an error listing the four known names.

| Converter | Runs | Accepts | Needs |
|---|---|---|---|
| `passthrough` | In the worker | Markdown and plain text | Nothing |
| `markitdown` | In the worker | HTML, Word (`.docx`), PowerPoint (`.pptx`) and Excel (`.xlsx`) | Nothing |
| `mistral_ocr` | Mistral's OCR API | PDFs and scanned images | `REMEMBERSTACK_MISTRAL_OCR_API_KEY` |
| `image_ocr_description` | Mistral OCR plus a vision model on OpenRouter | PNG and JPEG only | `REMEMBERSTACK_MISTRAL_OCR_API_KEY` and `REMEMBERSTACK_IMAGE_DESCRIPTION_API_KEY` |

## markitdown

`markitdown` converts in the worker process, with no network call and no
cost. The image installs the markitdown library with the packages it needs
for Word (`.docx`), PowerPoint (`.pptx`) and Excel (`.xlsx`); HTML needs
none. It reads the text, headings, lists and tables of these files; it does
not read text inside embedded images.

The image does not contain markitdown's PDF, audio or legacy Office
(`.doc`, `.ppt`, `.xls`) packages, so a file of those types routed to
`markitdown` fails conversion. Route PDFs to `mistral_ocr` instead.

## mistral_ocr

`mistral_ocr` sends the whole file, base64-encoded, in one request to
Mistral's `/v1/ocr` endpoint and turns the per-page result into Markdown.
It keeps page structure, tables, headers and footers, embedded images and
the provider's confidence scores as artifacts beside the Markdown. You
bring the key; the call is billed to your Mistral account and recorded in
the cost ledger.

| Variable | Default | Meaning |
|---|---|---|
| `REMEMBERSTACK_MISTRAL_OCR_API_KEY` | none | Your Mistral API key. Required when any route names `mistral_ocr` or `image_ocr_description`. |
| `REMEMBERSTACK_MISTRAL_OCR_BASE_URL` | `https://api.mistral.ai` | API address. |
| `REMEMBERSTACK_MISTRAL_OCR_MODEL` | `mistral-ocr-latest` | OCR model. |
| `REMEMBERSTACK_MISTRAL_OCR_TIMEOUT_S` | `300` | Request timeout in seconds. |
| `REMEMBERSTACK_MISTRAL_OCR_MAX_DOCUMENT_BYTES` | `50000000` | Larger files fail without a call. |
| `REMEMBERSTACK_MISTRAL_OCR_INCLUDE_IMAGES` | `true` | Keep images embedded in the pages. |
| `REMEMBERSTACK_MISTRAL_OCR_TABLE_FORMAT` | `markdown` | `markdown` or `html` for tables. |
| `REMEMBERSTACK_MISTRAL_OCR_EXTRACT_HEADERS_AND_FOOTERS` | `true` | Extract page headers and footers separately. |
| `REMEMBERSTACK_MISTRAL_OCR_CONFIDENCE_GRANULARITY` | `word` | `word` or `page` confidence scores. |
| `REMEMBERSTACK_MISTRAL_OCR_KEEP_PROVIDER_RESPONSE` | `true` | Keep the provider's response (without image data) as an artifact. |
| `REMEMBERSTACK_MISTRAL_OCR_PRICE_USD_PER_1000_PAGES` | `1` | The price used to record OCR cost in the cost ledger. Set it to your actual price. |

Mistral rejecting a file (HTTP 400, 413 or 422) fails conversion at once.
Other provider errors are retried.

## image_ocr_description

`image_ocr_description` runs two calls on every PNG or JPEG: Mistral OCR
reads the visible text, and a vision model on OpenRouter describes what
the image shows. The Markdown has two sections, `## Visible text (OCR)` and
`## Visual description`. Both calls must succeed. A finished call is saved,
so a retry does not repeat it.

| Variable | Default | Meaning |
|---|---|---|
| `REMEMBERSTACK_IMAGE_DESCRIPTION_API_KEY` | none | OpenRouter key for the description call. Required when any route names this converter. |
| `REMEMBERSTACK_IMAGE_DESCRIPTION_MODEL` | `google/gemini-2.5-flash` | Must accept image input; a text-only model fails. |
| `REMEMBERSTACK_IMAGE_DESCRIPTION_BASE_URL` | `https://openrouter.ai/api/v1` | API address. |
| `REMEMBERSTACK_IMAGE_DESCRIPTION_TIMEOUT_S` | `120` | Request timeout in seconds. |
| `REMEMBERSTACK_IMAGE_DESCRIPTION_MAX_IMAGE_BYTES` | `10000000` | Larger images fail before any call. |
| `REMEMBERSTACK_IMAGE_DESCRIPTION_MAX_IMAGE_PIXELS` | `40000000` | Width × height ceiling. |
| `REMEMBERSTACK_IMAGE_DESCRIPTION_MAX_IMAGE_WIDTH` | `16000` | Pixel width ceiling. |
| `REMEMBERSTACK_IMAGE_DESCRIPTION_MAX_IMAGE_HEIGHT` | `16000` | Pixel height ceiling. |
| `REMEMBERSTACK_IMAGE_DESCRIPTION_MAX_DESCRIPTION_CHARS` | `16000` | Ceiling on the description text. |
| `REMEMBERSTACK_IMAGE_DESCRIPTION_MAX_TOKENS` | `4096` | Output allowance of the description call. |
| `REMEMBERSTACK_IMAGE_DESCRIPTION_LANE_CONCURRENCY` | `2` | Run the two calls in parallel (`2`) or one after the other (`1`). |

Set them in `.env`. Without the key, the `convert` worker refuses to start
once a route names this converter.

## Files no route accepts

An upload whose MIME type has no route is still accepted. The API stores
the original bytes and creates the version, and its `convert` work is
parked with the reason `no_route`. It uses no attempts and makes no model
calls.

The ingest response says so immediately: its `parked` field is
`"no_route"` (it is `null` when the version is not parked). `remember ingest`
also prints a warning, and the MCP `ingest` tool tells the agent not to
wait for readiness. [Readiness](https://remember.dev/docs/guides/wait-for-readiness) shows the
version's `convert` stage as `pending`.

After you add a route for that type and restart with `docker compose up -d`,
release the parked work:

```bash
docker compose exec -T api \
  sh -c 'remember ops resume-no-route --deployment "$REMEMBERSTACK_SELFHOST_DEPLOYMENT_ID"'
```

The command prints `{"released": [...]}` with the processing ids it
released. It releases only work whose stored MIME type the current table
covers; the rest stays parked. See [Operating the pipeline](https://remember.dev/docs/self-hosting/operating).

If the file was simply sent with the wrong type, send the same bytes again
with a type that has a route (`--mime text/markdown` on the CLI,
`mime="text/markdown"` in Python). The new type replaces the unrouted one
and the parked conversion is released without an operator step.

## Not supported yet

RememberStack does not ingest audio, video or web addresses yet. To add a
web page, download it and send the HTML.

---

Source: https://remember.dev/docs/self-hosting/authentication

# Authentication and scopes

A fresh self-hosted deployment accepts every request without a token.
That is convenient on your own laptop and unsafe anywhere else. This page
shows how to require a token, what each kind of token may do, and how to
let a browser app call the API.

## Open by default, on this machine only

With no authentication variable set, the API has no perimeter: any caller
that reaches port 8000 can read the whole memory, send documents, and run
every operation. Nothing is logged about who they were.

So `compose.yaml` publishes the API on the loopback interface only
(`127.0.0.1:${REMEMBERSTACK_SELFHOST_API_PORT}:8000`): other machines
cannot connect. (The object store publishes no host port at all.)

## Opening the API to other machines

Set a token first, then choose the address to publish on:

1. Set a shared secret as described in [A shared secret](#a-shared-secret),
   and make the API refuse to start without one:

    ```bash
    # .env
    REMEMBERSTACK_SELFHOST_API_BEARER_TOKEN=<the secret>
    REMEMBERSTACK_SELFHOST_REQUIRE_API_AUTH=true
    ```

2. Publish the port on every interface, or on one interface's address:

    ```bash
    # .env
    REMEMBERSTACK_SELFHOST_API_PUBLISH_ADDRESS=0.0.0.0
    ```

3. Apply it:

    ```bash
    docker compose up -d
    ```

The API speaks plain HTTP. Across an untrusted network, put a TLS-terminating
reverse proxy in front of it, keep the publish address on loopback, and let
the proxy reach it there.

## A shared secret

The simplest perimeter is one secret that every client presents.

1. Generate a secret:

    ```bash
    openssl rand -hex 32
    ```

2. Put it in `.env` and apply it:

    ```bash
    # .env
    REMEMBERSTACK_SELFHOST_API_BEARER_TOKEN=<the secret>
    ```

    ```bash
    docker compose up -d
    ```

3. Give it to the client:

    ```bash
    export REMEMBER_API_URL=http://localhost:8000
    export REMEMBER_API_KEY=<the secret>   # the Python client and the CLI
    ```

The client sends it as `Authorization: Bearer <secret>`. The API compares a
SHA-256 digest of what it receives with the digest of the configured
secret, bound to this deployment's id. The shared secret has full `write`
scope. A request without it gets `401`.

### Keep only the digest on the server

`REMEMBERSTACK_SELFHOST_API_BEARER_BIND` configures the same check without
the secret itself in the container's environment. Its value is the
deployment id and the hex SHA-256 of the secret, joined by a colon:

```bash
SECRET=<the secret>
DEPLOYMENT_ID=<your REMEMBERSTACK_SELFHOST_DEPLOYMENT_ID>
DIGEST=$(printf '%s' "$SECRET" | shasum -a 256 | cut -d' ' -f1)   # sha256sum on Linux
echo "REMEMBERSTACK_SELFHOST_API_BEARER_BIND=$DEPLOYMENT_ID:$DIGEST"
```

Use `printf '%s'`, not `echo`: a trailing newline changes the digest.

The id in the bind must be this deployment's id; a bind made for another
deployment rejects every request with `403` ("credential is for another
deployment"). If you set both the token and the bind, they must describe
the same secret, or the API refuses to start.

## Signed keys

For more than one credential, or for credentials that expire and carry
narrower permissions, the API verifies signed keys (JWTs) minted by a
**key issuer**: a service of your own, or any other, that follows the
contract below. The deployment holds only the issuer's public keys, so it
can check keys but never mint them. Signed keys and the shared secret can
be configured together.

| Variable | Meaning |
|---|---|
| `REMEMBERSTACK_SELFHOST_API_KEY_ISSUER` | The issuer's URL, compared exactly with the `iss` claim. Setting it turns signed keys on; the tenant id and both URLs below are then required. |
| `REMEMBERSTACK_SELFHOST_API_KEY_TENANT_ID` | The issuer's id for the group of deployments this one belongs to. |
| `REMEMBERSTACK_SELFHOST_API_KEY_PROJECT_ID` | The issuer's id for this deployment's project. Default: the deployment id. |
| `REMEMBERSTACK_SELFHOST_API_SIGNING_KEYS_URL` | Where the issuer's public keys (a JWKS, `{"keys": [...]}`) are fetched. |
| `REMEMBERSTACK_SELFHOST_API_REVOCATION_URL` | Where the issuer's signed revocation document for this deployment is fetched. |
| `REMEMBERSTACK_SELFHOST_API_KEY_REFRESH_S` | How often both are fetched, in seconds. Default `60`. |
| `REMEMBERSTACK_SELFHOST_API_REVOCATION_MAX_AGE_S` | How old the accepted revocation document may get before signed keys stop working, in seconds. Default `3600`. |

`compose.yaml` passes all seven to the API.

### The public keys

The API fetches the JWKS every refresh interval. EdDSA over Ed25519 only:
every key must have `"kty": "OKP"`, `"crv": "Ed25519"`, a string `kid`, no
private part (`d`), and, if present, `"use": "sig"` and a `key_ops` list
containing `verify`. A key set with one bad key is refused as a whole, and
a failed fetch keeps the last good set. `{"keys": []}` is valid and refuses
every signed key.

### The revocation document

A signed key cannot be un-signed, so the issuer publishes a **revocation
document**: a JWT signed with one of its keys, header `typ` set to
`revocation+jwt`, with these claims:

| Claim | Meaning |
|---|---|
| `iss` | The issuer, equal to `REMEMBERSTACK_SELFHOST_API_KEY_ISSUER`. |
| `aud` | This deployment's id. A document for another deployment is rejected. |
| `seq` | An integer the issuer increases on every document it issues. |
| `iat`, `exp` | When it was issued and when it expires (`iat` + the maximum age). |
| `revoked` | `jti` values refused despite a valid signature. |
| `active_kids` | The `kid`s whose keys are still valid. |

The API accepts the first document whose signature, `iss` and `aud` check
out. After that, a new document must have a higher `seq` and be signed by a
`kid` listed in `active_kids` of the document it replaces; anything else is
rejected and logged, and the last accepted document stays. The accepted
document is stored in the database, so a restart does not lose it.

**No fresh document, no signed keys.** Until a document is accepted, and
whenever the accepted one is older than the maximum age (one hour by
default) or past its `exp`, every signed key gets `401`. The
shared secret keeps working. An issuer re-issues the document every refresh
interval; a key revoked at time *r* stops working by *r* plus the maximum
age plus 30 seconds, even if the issuer is unreachable.

To retire a signing key, remove its `kid` from `active_kids`. Keys signed
with it stop working from the next accepted document, even while the
public key is still in the JWKS.

### What a key must carry

- **Form:** `<prefix>_<JWT>`, where the prefix is letters only (for example
  `rmb_`), so secret scanners can recognise a leaked key. A bare JWT is
  also accepted. The header's `kid` must name a fetched key that is in
  `active_kids`.
- **Every key:** `iss`, `aud` (one string), `sub`, `kind`, `permissions` (a
  list of strings), `iat`, `nbf`, `exp`, and a non-empty `jti` not in
  `revoked`. 30 seconds of leeway on `exp` and `nbf`.
- **Per `kind`:**

| `kind` | `aud` | `org` | `projects` | `sub` |
|---|---|---|---|---|
| `key` (a long-lived key a person created) | `org:<tenant id>` | the tenant id | `"org:*"` (every project), or a list of 1 to 20 project ids that includes this deployment's project id | the person |
| `session` (a short-lived key for this one deployment) | this deployment's id | the tenant id | exactly `[<project id>]` | the person |
| `service` (a machine credential) | this deployment's id | — | — | exactly `dpcred:<jti>` |

A `session` may also carry `src`, naming the service that derived it; it
is recorded, never used to decide access. Any other `aud`, such as an OAuth
token meant for a hosted MCP server, is refused.

- **Permissions:** `memory:read` gives the `read` scope, `memory:write` the
  `write` scope, and `memory:ingest` the `ingest` scope (see
  [Scopes](#scopes)). `memory:write` wins when present; `memory:read` and
  `memory:ingest` together without it are refused. `memory:ingest` is
  accepted only on a `session` key. Permissions without the
  `memory:` prefix (such as `account:read`) are ignored. An unknown
  `memory:` permission is refused. A key with no `memory:` permission gets
  `403` on every route.

This example creates a key pair, prints the JWKS, and signs a revocation
document and a one-hour read key. It needs `pip install "pyjwt[crypto]"`.
Serve the JWKS and the document at the two URLs, and sign a new document
(with a higher `seq`) at least every refresh interval:

```python
import json
import time
import uuid

import jwt
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from jwt.algorithms import OKPAlgorithm

ISSUER = "https://issuer.example.com"  # REMEMBERSTACK_SELFHOST_API_KEY_ISSUER
TENANT = "my-team"                      # REMEMBERSTACK_SELFHOST_API_KEY_TENANT_ID
DEPLOYMENT_ID = "<your REMEMBERSTACK_SELFHOST_DEPLOYMENT_ID>"

private_key = Ed25519PrivateKey.generate()
public_jwk = json.loads(OKPAlgorithm.to_jwk(private_key.public_key()))
public_jwk.update({"kid": "k1", "use": "sig"})
print(json.dumps({"keys": [public_jwk]}))  # serve at API_SIGNING_KEYS_URL

now = int(time.time())
revocation = jwt.encode(
    {
        "iss": ISSUER,
        "aud": DEPLOYMENT_ID,
        "seq": now,  # any integer that grows with every document
        "iat": now,
        "exp": now + 3600,
        "revoked": [],
        "active_kids": ["k1"],
    },
    private_key,
    algorithm="EdDSA",
    headers={"kid": "k1", "typ": "revocation+jwt"},
)
print(revocation)  # serve at API_REVOCATION_URL

key = jwt.encode(
    {
        "iss": ISSUER,
        "aud": f"org:{TENANT}",
        "org": TENANT,
        "projects": [DEPLOYMENT_ID],
        "sub": "alice",
        "kind": "key",
        "permissions": ["memory:read"],
        "iat": now,
        "nbf": now,
        "exp": now + 3600,
        "jti": str(uuid.uuid4()),
    },
    private_key,
    algorithm="EdDSA",
    headers={"kid": "k1"},
)
print("mykey_" + key)
```

In real use, store the private key somewhere safe; this script discards it
when it exits.

## Scopes

Every request that passes the perimeter is checked against the scope of its
credential. The route decides the scope it needs, not the HTTP method:
several reads use `POST` because their arguments do not fit in a URL.

| Scope | May call |
|---|---|
| `read` | `GET /resolve`, `/lookup/relations`, `/lookup/observations`, `/transcript/relation/{id}`, `/hydrate/relation/{id}`, `/search/claims`, `/search/chunks`, `/chunks/{id}/adjacent`, `/query/space`, `/query/space/search`, `/query/saved`, `/query/saved/{namespace}/{name}`, `/operations`, `/connectors`, `/connectors/{id}`, `/documents`; `POST /search/claims`, `/search/chunks`, `/chunks/adjacent`, `/graph/neighborhood`, `/graph/path`, `/graph/citation-path`, `/query/sql`, `/query/sql/explain`, `/query/saved/{namespace}/{name}/run`, `/readiness` |
| `ingest` | `POST /ingest` only. An ingest credential cannot read. |
| `write` | Everything, including every route not listed above |

Routes that are not in the `read` or `ingest` list need `write`. Today that
includes `POST /connectors` and `POST /connectors/{id}/pause`.

`POST /operations/{name}` (the [assured operations](https://remember.dev/docs/reference/assured-operations))
decides per operation: an operation that declares itself read-only needs
`read`, any other needs `write`. All four shipped operations only read and
declare it (`mutates: false`), so a `read` token can run them.

`GET /healthz` never needs a credential.

| Status | Meaning |
|---|---|
| `401` | No `Authorization` header, the credential did not verify, or (for signed keys) no fresh revocation document has been accepted |
| `403` | The credential is for another deployment, or its scope does not cover the route |

## `REMEMBERSTACK_SELFHOST_REQUIRE_API_AUTH`

The perimeter is enforced as soon as a token, a bind or a key issuer is
configured. `REMEMBERSTACK_SELFHOST_REQUIRE_API_AUTH=true` makes that a
start condition: the API refuses to start unless at least one of
`REMEMBERSTACK_SELFHOST_API_BEARER_TOKEN`,
`REMEMBERSTACK_SELFHOST_API_BEARER_BIND` or
`REMEMBERSTACK_SELFHOST_API_KEY_ISSUER` is set. Set it on any deployment
other machines can reach, so a lost `.env` line cannot leave it open.

## Browser origins (CORS)

A web app served from another origin cannot call the API unless the API
names that origin. `REMEMBERSTACK_SELFHOST_BROWSER_ORIGINS` is a
comma-separated list of exact origins:

```bash
REMEMBERSTACK_SELFHOST_BROWSER_ORIGINS=https://app.example.com,https://admin.example.com
```

- Each entry must be an origin exactly as a browser sends it: lowercase
  scheme and host, an optional port, nothing else. A malformed entry, an
  empty entry or a trailing comma stops the API at start.
- The scheme must be `https`, except for an app on the same machine:
  `http://localhost`, `http://127.0.0.1` and `http://[::1]`, with any port,
  are accepted, so `http://localhost:3000` works during development.
- Only `GET`, `POST` and `DELETE` with the `Authorization` and `Content-Type`
  headers are allowed. `DELETE` (deleting a document) still needs a token with
  `write` scope. Cookies are not allowed; the browser must send a token.
- Browsers may cache the permission for up to 600 seconds after you remove
  an origin. Revoking the token is what stops access immediately.

Empty, the default, advertises nothing: no browser origin can call the API.

## Trusted uploader headers

`POST /ingest` accepts two headers that name who uploaded a document:
`X-Ingest-Principal-Kind` (`user`, `api_credential` or `service`) and
`X-Ingest-Principal-Ref` (1 to 255 printable ASCII characters). By default
the API ignores them, because any client can send any value.

`REMEMBERSTACK_SELFHOST_TRUSTED_PRINCIPAL_SOURCE=true` makes the API record
them. Enable it only when every request reaches the API through a proxy or
gateway that authenticates the actor, sets these headers itself and strips
any a client sent. Even then, when authentication is configured, the
headers count only on requests with a `write` credential; with a narrower
credential they are ignored and the upload still succeeds. A malformed
pair on a trusted request is rejected with `422`.

---

Source: https://remember.dev/docs/self-hosting/scaling

# Scaling

RememberStack publishes no throughput figures: how many documents per hour,
or queries per second, a machine handles depends mostly on your model
provider and corpus. What exists are explicit bounds on every process, so a
busy deployment slows down or refuses work. The one budget you keep
yourself is PostgreSQL's connection limit, once you add replicas. This
page lists those bounds and how to move them.

The model provider is usually the first limit you meet. Most pipeline
stages spend their time waiting on OpenRouter calls, and the provider's
own rate limits apply to your account.

## Workers

Each pipeline stage runs in its own container. A worker process claims one
item of work at a time from PostgreSQL, runs it, and claims the next. It
wakes on a notification when new work is committed and also polls in case a
notification was missed.

| Variable | Default | Meaning |
|---|---|---|
| `REMEMBERSTACK_SELFHOST_WORKER_RATE_PER_S` | `20` | Claims per second one worker process may make. |
| `REMEMBERSTACK_SELFHOST_WORKER_BURST` | `20` | How many claims it may make at once before the rate applies. |
| `REMEMBERSTACK_SELFHOST_WORKER_FALLBACK_POLL_S` | `5` | Seconds between polls when no notification arrives. |
| `REMEMBERSTACK_SELFHOST_WORKER_SESSION_S` | `3600` | Length of one worker session in seconds; the worker then starts the next one. |

Set them in `.env`. Lowering the rate is the way to stay under a provider's rate limit; the
rate applies per process, so it multiplies with replicas.

### More replicas of a stage

Items are claimed with `SELECT … FOR UPDATE SKIP LOCKED`, so several
processes can serve the same stage without taking the same item. Add
replicas of the stage that is behind:

```bash
docker compose up -d --scale worker-extract-claims=3 --scale worker-normalize-relations=2
```

To find the stage that is behind, look at the pending counts per stage in
`remember ops inspect` ([Operating the pipeline](https://remember.dev/docs/self-hosting/operating#inspect-the-pipeline)).
The claim-heavy stages (`extract_claims`, `ground_claims`,
`normalize_relations`, `adjudicate_observations`) make the most model calls
per document.

Every replica holds its own database connections; see
[Database connections](#database-connections) before adding many.

## Database connections

Each engine process has its own connection pools. Compose starts
PostgreSQL with `max_connections` set from
`REMEMBERSTACK_POSTGRES_MAX_CONNECTIONS` (default `300`); the engine does not
size itself against that limit. The ceilings per process are:

| Process | Ceiling | Made of |
|---|---|---|
| `api` | 23 | General pool 15 (5 kept open, 10 more under load), retrieval pool 4, graph pool 4 |
| Each `worker-…` replica | 16 | General pool 15, plus 1 connection that listens for new work |

The general pool's size is fixed. The retrieval and graph pools follow
`REMEMBERSTACK_SELFHOST_RETRIEVAL_POOL_SIZE` and
`REMEMBERSTACK_SELFHOST_GRAPH_POOL_SIZE` (see
[Retrieval and graph queries](#retrieval-and-graph-queries)).

Pools open connections only on demand, so a running stack holds fewer than
the sum of its ceilings. The default stack's ceilings add up to 215 (23 +
12 × 16), within the default limit of 300. When you add replicas or API
processes, keep the sum of their ceilings under the limit, and when
PostgreSQL refuses connections (`too many clients already` in the logs),
raise it in `.env` and restart PostgreSQL:

```bash
echo 'REMEMBERSTACK_POSTGRES_MAX_CONNECTIONS=600' >> .env
docker compose up -d postgres
```

Every PostgreSQL connection takes memory on the database host, so raise the
limit only as far as that machine allows.

### Embedding batches

| Variable | Default | Range |
|---|---|---|
| `REMEMBERSTACK_E1_EMBED_BATCH_SIZE` | `64` | 1–512 chunks per embedding request |
| `REMEMBERSTACK_P1_EMBED_BATCH_SIZE` | `64` | 1–1,024 claims or facts per embedding request |

## Retrieval and graph queries

The API process keeps two dedicated connection pools, apart from its main
one, so a burst of searches or graph traversals cannot take every
connection.

| Variable | Default | Range | Meaning |
|---|---|---|---|
| `REMEMBERSTACK_SELFHOST_RETRIEVAL_POOL_SIZE` | `4` | 1–32 | Connections for searches and fact reads |
| `REMEMBERSTACK_SELFHOST_RETRIEVAL_MAX_CONCURRENCY` | `4` | 1–32, at most the pool size | Retrievals running at once |
| `REMEMBERSTACK_SELFHOST_RETRIEVAL_POOL_TIMEOUT_S` | `1` | up to 30 | Seconds a retrieval waits for a connection before failing |
| `REMEMBERSTACK_SELFHOST_GRAPH_POOL_SIZE` | `4` | 1–32 | Connections for graph neighbourhoods and paths |
| `REMEMBERSTACK_SELFHOST_GRAPH_MAX_CONCURRENCY` | `2` | 1–32, at most the pool size | Graph queries running at once |
| `REMEMBERSTACK_SELFHOST_GRAPH_POOL_TIMEOUT_S` | `1` | up to 30 | Seconds a graph query waits for a slot before failing |
| `REMEMBERSTACK_SELFHOST_GRAPH_WORK_MEM_KIB` | `16384` | 64–65,536 | PostgreSQL `work_mem` for each graph query |

`compose.yaml` passes all seven. A concurrency larger than its pool stops
the API at start.

When the graph limit is reached, graph routes answer `503` with
`live graph is busy`. When a retrieval waits longer than its timeout, the
request fails; that failure is not yet mapped to a specific status code.

Raise the pool, the concurrency and `work_mem` together, and only as far as
PostgreSQL's memory allows: each running graph query may use up to
`GRAPH_WORK_MEM_KIB` for each sort or hash step it performs.

## API admission limits

Optional, and off by default: the API does not limit the request rate or the
number of requests running at once until you set one of these in `.env`.
Each set limit counts every request (except `GET /healthz`) per credential or
for the whole deployment; over it the API answers `429` with `Retry-After`.

| Variable | Default | Meaning |
|---|---|---|
| `REMEMBERSTACK_SELFHOST_API_ADMISSION_KEY_PER_MINUTE` | unset (no limit) | Requests per minute per signed credential, in bursts of up to a quarter of it |
| `REMEMBERSTACK_SELFHOST_API_ADMISSION_KEY_IN_FLIGHT` | unset (no limit) | Requests running at once per signed credential |
| `REMEMBERSTACK_SELFHOST_API_ADMISSION_DEPLOYMENT_PER_MINUTE` | unset (no limit) | Requests per minute for the deployment, in bursts of up to a quarter of it |
| `REMEMBERSTACK_SELFHOST_API_ADMISSION_DEPLOYMENT_IN_FLIGHT` | unset (no limit) | Requests running at once for the deployment |

`0` also means no limit. Set them when a deployment is shared by callers you
do not control and one of them could crowd out the rest. The shared secret,
and every caller when authentication is off, meets the deployment limits
only. With limits set, a script that ingests many files in parallel should
keep its concurrency under the in-flight limit and wait `Retry-After` on a
`429`; the `remember` client does not retry by itself. The counters live in
the API process, so running more API processes multiplies the effective
limits. See
[Admission limits](https://remember.dev/docs/reference/http-api#admission-limits).

## SQL queries

[SQL queries](https://remember.dev/docs/guides/sql) run in a sandbox over the query space
(`memory_v1`, the prepared read-only views and functions); every statement
is validated against it before it runs. The sandbox limits are fixed in
code for a self-hosted deployment:

| Limit | Default | Maximum a caller can request |
|---|---|---|
| Statement time | 5 s | 15 s |
| Rows returned | 200 | 1,000 |
| Bytes returned | 1 MiB | 8 MiB |
| `work_mem` | 16 MiB | — |
| Temporary files | 64 MiB | — |
| Statements at once, per caller | 2 | — |
| Statements at once, per deployment | 8 | — |
| Statement seconds per minute, per caller | 30 | — |
| Statement seconds per minute, per deployment | 120 | — |

The code also defines a larger analytical tier (60 s statements, 10,000
rows). A self-hosted deployment does not enable it.

## Ingest size

`REMEMBERSTACK_SELFHOST_INGEST_BODY_MAX_BYTES` caps the size of one upload
to `POST /ingest`. Unset, the default, means the engine imposes no cap.
Converters have their own ceilings (50,000,000 bytes for Mistral OCR,
10,000,000 for images).

---

Source: https://remember.dev/docs/self-hosting/operating

# Operating the pipeline

Every piece of pipeline work is a row in PostgreSQL: which version, which
stage, how many attempts, and what went wrong last. Workers claim these
rows, retry failures, and set aside work that keeps failing. This page shows
how to see that state and act on it.

## Running operator commands

The operator commands are part of the `remember` CLI, under
`remember ops`. They talk to PostgreSQL directly rather than to the API, so
they run inside the engine image, where the database settings already are.
Run them with `docker compose exec api remember ops …`; the image enables
them, so no extra setting is needed.

Read the deployment id from `.env` once per shell:

```bash
DEPLOYMENT_ID=$(grep '^REMEMBERSTACK_SELFHOST_DEPLOYMENT_ID=' .env | cut -d= -f2)
```

Then run each command in the `api` container:

```bash
docker compose exec -T api \
  remember ops inspect --deployment "$DEPLOYMENT_ID"
```

Every `remember ops` command prints one JSON document on standard output.
Pipe it through `python3 -m json.tool` to read it.

| Command | What it does |
|---|---|
| `remember ops inspect --deployment ID` | Report pipeline, dead-letter, projection and consistency state |
| `remember ops replay PROCESSING_ID --deployment ID` | Give one dead-lettered item more attempts |
| `remember ops resume-no-route --deployment ID` | Release conversions parked for lack of a route |
| `remember ops rebuild --deployment ID --snapshot-root DIR --version V` | Build a filesystem snapshot into a local directory |
| `remember ops graph-catalog ensure` | Check and repair the PostgreSQL graph definitions |
| `remember ops cost-export --deployment ID` | Print one page of the cost ledger ([Observability](https://remember.dev/docs/self-hosting/observability#cost-export)) |

## Inspect the pipeline

```bash
docker compose exec -T api \
  remember ops inspect --deployment "$DEPLOYMENT_ID" | python3 -m json.tool
```

The report has five parts:

| Field | Contents |
|---|---|
| `routes` | For each stage and lane, how many items are in each status (`pending`, `running`, `succeeded`, `failed` while waiting to retry, `dead_letter`, `skipped`), split by `defer_reason`: `no_route` or `budget` for parked work, `scheduled` for work due later, `retry_backoff` for a failure waiting to retry, `null` otherwise. A large `pending` count with no reason on one stage shows where the pipeline is behind. |
| `dead_letters` | Items that ran out of attempts: a total, groups by stage and error class, and individual items with their `processing_id`, attempts and `last_error`. |
| `poison_targets` | Items that dead-lettered under two or more component versions: an upgrade did not fix them. |
| `latest_projections` | The latest filesystem snapshot, if any. |
| `currency` | A consistency check between each claim's cached "current" flag and the ledger it is derived from. `mismatch_total` should be 0. |

Lists in the report are capped at `REMEMBERSTACK_OPERATIONAL_SAMPLE_LIMIT`
entries (default 20).

## Retries and dead letters

When a stage fails on an item:

- A **retryable** failure, such as a provider timeout or a `5xx`, is retried
  after a back-off: 2 seconds after the first failure, doubling each time,
  never more than 60 seconds
  (`REMEMBERSTACK_WORK_RETRY_BACKOFF_BASE_S`, `REMEMBERSTACK_WORK_RETRY_BACKOFF_MAX_S`).
- An item gets **3 attempts** in total: the first try and two retries.
- A **non-retryable** failure, such as a file that cannot be converted,
  goes straight to the dead letters.
- An item that uses its last attempt goes to the dead letters.

HTTP 429 answers from OpenRouter are handled inside the provider call and do
not use attempts ([Models and providers](https://remember.dev/docs/self-hosting/models#openrouter-routing-and-limits)).

A dead-lettered item stays where it is. Its version is not ready, and the
work after it in the pipeline waits. Nothing retries it on its own.

### Replay a dead letter

Find the `processing_id` in the `dead_letters.items` of the inspect report,
fix the cause (a key, a model, a converter route), then replay it:

```bash
docker compose exec -T api \
  remember ops replay 3f2a9c1e-8b7d-4e21-9a0f-5c6d7e8f9a0b --deployment "$DEPLOYMENT_ID"
```

| Option | Default | Meaning |
|---|---|---|
| `--attempts N` | `1` | How many more attempts to grant |
| `--lane steady\|backfill` | the item's lane | Run it in another lane |
| `--not-before 2026-09-24T08:00:00+00:00` | now | Do not run it before this time |

The command prints the item's new state: its route, `attempts`,
`max_attempts` and `not_before`. The worker for that stage picks it up.

## Parked conversions

An uploaded file whose MIME type has no conversion route is parked with the
reason `no_route` rather than failed. It uses no attempts. After you add a
route and restart with `docker compose up -d`, release it:

```bash
docker compose exec -T api \
  remember ops resume-no-route --deployment "$DEPLOYMENT_ID"
```

The output lists the released `processing_id` values. Items whose MIME type
is still unrouted stay parked. See [File formats and converters](https://remember.dev/docs/self-hosting/converters).

## Spend budgets

A budget caps what one pipeline stage may spend on model calls, in US
dollars, within a fixed time window. Before a worker runs an item, it
checks the budget for that stage and lane. If the window's spend has
reached the ceiling, the item is parked until the window ends, and the
worker moves on. Nothing is dropped.

`REMEMBERSTACK_WORK_BUDGETS` is a JSON list; each entry names the
deployment, the stage, the lane (`steady` or `backfill`), the window in
seconds, and the ceiling:

```bash
# .env
REMEMBERSTACK_WORK_BUDGETS=[{"deployment_id":"<your deployment id>","stage":"extract_claims","lane":"steady","window_seconds":86400,"ceiling_usd":"5.00"},{"deployment_id":"<your deployment id>","stage":"normalize_relations","lane":"steady","window_seconds":86400,"ceiling_usd":"5.00"}]
```

Only one budget may exist per deployment, stage and lane. Stages without a
budget are not capped.

Put it in `.env` and run `docker compose up -d`; the workers enforce it.

Work a budget parked shows as `pending` with `defer_reason` `budget`, both
in `remember ops inspect` and in readiness.
The spend itself is in the cost ledger; read it with `remember ops
cost-export` ([Observability](https://remember.dev/docs/self-hosting/observability#cost-export)).

## The graph catalog

The memory's graph is a set of PostgreSQL property-graph definitions over
the stored relations. Migrations create them. If a manual database change
or a restore leaves them missing or different,
`remember ops graph-catalog ensure` compares them with what this release
expects, replays the definitions if needed, and reports the result:

```bash
docker compose exec -T api \
  remember ops graph-catalog ensure
```

The output has `ready`, `changed`, `problems_before`, `problems_after` and
the list of `definitions`. It also checks the versions of `pgvector`,
`pg_textsearch` and `pg_partman`.

## When a hard forget is in progress

While a hard forget is running, the API answers `503` with
`{"code": "forget_in_progress"}`, and `replay`, `resume-no-route`,
`rebuild`, the projection build and `mounts` refuse to run. This protects
data that is being removed from being served or copied mid-removal.

A deployment started from `compose.yaml` has no command that starts a hard
forget, so it does not enter this state on its own. The API also refuses to
start if the `forget-manifests` volume holds hard-forget manifests for this
deployment, as it would after restoring data from an installation that ran
a hard forget; see [Upgrades and migrations](https://remember.dev/docs/self-hosting/upgrades#back-up).

---

Source: https://remember.dev/docs/self-hosting/troubleshooting

# Troubleshooting

Most problems with a self-hosted RememberStack fall into four groups: the
stack did not start, the client talks to the wrong place or with the wrong
credential, a document is stuck somewhere in the pipeline, or the answer is
correct but not what you expected. This page lists each symptom with the
first thing to check and the fix.

Run the commands on this page from the directory that holds your
`compose.yaml` and `.env`.

## Start here

| Symptom | First check | Section |
|---|---|---|
| `curl` gets no answer, or `api` is not healthy | `docker compose ps -a` | [The API does not answer](#the-api-does-not-answer) |
| `Connection refused`, or documents land somewhere else | `echo $REMEMBER_API_URL` | [Wrong port or wrong address](#wrong-port-or-wrong-address) |
| `401` or `403` | The `detail` string | [401 and 403](#401-and-403) |
| A browser app reports a network error | The browser console | [A browser app cannot reach the API](#a-browser-app-cannot-reach-the-api) |
| The ingest result has `"parked": "no_route"` | The file's MIME type | [Parked conversions](#parked-conversions) |
| Ingest succeeded, but questions never find the document | Readiness for the `version_id` | [A document never becomes queryable](#a-document-never-becomes-queryable) |
| Documents take a long time | `remember ops inspect` | [Processing is slow](#processing-is-slow) |
| Stages fail within seconds of each other | Worker logs | [Model key and provider errors](#model-key-and-provider-errors) |
| Work waits and nothing fails | `defer_reason` in [See what is waiting and why](#see-what-is-waiting-and-why) | [Work parked by a spend budget](#work-parked-by-a-spend-budget) |
| The answer is empty or not what you expected | `negative`, `temporal_scope`, the operation you called | [Empty or surprising answers](#empty-or-surprising-answers) |
| A SQL query returns `200` with no rows and an `error_code` | `termination_reason` | [A SQL query is rejected](#a-sql-query-is-rejected) |
| `503` with `forget_in_progress` | `docker compose logs api` | [503 forget_in_progress](#503-forget_in_progress) |
| `setup` stops after an upgrade | `docker compose logs setup` | [setup refuses an existing database](#setup-refuses-an-existing-database) |

## Running operator commands

Several fixes below use `remember ops`. These commands read PostgreSQL
directly, so they run inside the `api` container
(`docker compose exec api remember ops …`). Run anywhere else, `remember ops`
exits with "'remember ops' runs inside the engine container".

Read the deployment id once per shell:

```bash
DEPLOYMENT_ID=$(grep '^REMEMBERSTACK_SELFHOST_DEPLOYMENT_ID=' .env | cut -d= -f2)
```

Then pass it to each command:

```bash
docker compose exec -T api \
  remember ops inspect --deployment "$DEPLOYMENT_ID" | python3 -m json.tool
```

[Operating the pipeline](https://remember.dev/docs/self-hosting/operating) describes every field of the report.

## The API does not answer

`/healthz` answers `{"status":"ok"}` when the API process is up and can run
a query on PostgreSQL. It needs no token.

```bash
docker compose ps -a
curl http://localhost:8000/healthz
```

`docker compose ps -a` also lists containers that have stopped. Read it
this way:

| What you see | Meaning | Next |
|---|---|---|
| `setup` exited with code 0 | Normal. `setup` runs once on every `up` and exits. | Nothing. |
| `setup` exited with a non-zero code, `api` and the workers are not running | `setup` failed, and nothing that depends on it starts. | `docker compose logs setup` |
| `api` is `health: starting` | The API is still starting. Compose checks it every 10 seconds, up to 30 times. | Wait, then check again. |
| `api` keeps restarting | The API refused its configuration at start. | `docker compose logs api` |
| A `worker-…` container keeps restarting | The worker fails at start or crashes. Its stage's work waits as `pending` meanwhile. | `docker compose logs worker-…` |

The API, the workers, PostgreSQL and the object store have the restart
policy `unless-stopped`: Docker starts a container that exits again, until
you stop it yourself. A container that fails at start therefore shows as
restarting rather than exited. `/healthz` keeps answering `ok` while a
worker is down: it checks the API and the database, not the pipeline.

Configuration mistakes that stop a container at start:

| In the logs | Cause | Fix |
|---|---|---|
| A validation error that names `api_key` | `REMEMBERSTACK_OPENROUTER_API_KEY` is empty. | Set it in `.env`, then `docker compose up -d`. |
| `setup` refuses with "deployment identity or mapped profile values conflict" or "this database already holds deployment …" | `.env` no longer matches what the first `setup` recorded; the message names the changed values or the recorded id. | Restore the original values. See [The deployment id is permanent](https://remember.dev/docs/self-hosting/upgrades#the-deployment-id-is-permanent). |
| Compose says `set REMEMBERSTACK_SELFHOST_DEPLOYMENT_ID` | `.env` has no deployment id. | Generate one as in [Install](https://remember.dev/docs/self-hosting/install#1-get-the-files). |
| `setup` refuses because `REMEMBERSTACK_P1_EMBEDDING_MODEL` differs from the stored vectors | The embedding model changed after documents were embedded. | Set it back to the model the message names. See [Changing the embedding model](https://remember.dev/docs/self-hosting/models#changing-the-embedding-model). |
| "browser origins must each be an https origin…" | An entry in `REMEMBERSTACK_SELFHOST_BROWSER_ORIGINS` is malformed, empty, or `http` on a host other than `localhost`, `127.0.0.1` or `[::1]`. | Fix the list; see [A browser app cannot reach the API](#a-browser-app-cannot-reach-the-api). |
| The shared secret and the bind disagree | `REMEMBERSTACK_SELFHOST_API_BEARER_TOKEN` and `REMEMBERSTACK_SELFHOST_API_BEARER_BIND` describe different secrets. | See [Authentication and scopes](https://remember.dev/docs/self-hosting/authentication). |
| A validation error that names `API_KEY_ISSUER`, `API_KEY_TENANT_ID`, `API_SIGNING_KEYS_URL` or `API_REVOCATION_URL` | Signed keys are half configured: an issuer without its tenant id or URLs, or those without an issuer. | Set all four, or none. See [Signed keys](https://remember.dev/docs/self-hosting/authentication#signed-keys). |
| "no fresh revocation document; every signed credential is refused" | The API has not accepted a revocation document yet, or the accepted one is older than `REMEMBERSTACK_SELFHOST_API_REVOCATION_MAX_AGE_S`. Every signed key gets `401`; the shared secret still works. | Check that the API can fetch both issuer URLs and that the issuer re-signs the document with a growing `seq`. The warnings just before it name the reason (a failed fetch, or a rejected document). |
| `worker-convert` names an unknown converter | A route in `REMEMBERSTACK_SELFHOST_CONVERSION_ROUTES` names a converter that does not exist, or needs a key that is not set. | See [File formats and converters](https://remember.dev/docs/self-hosting/converters). |
| A migration error in `setup` that says the store already holds claims | The new release cannot convert the existing data. | See [setup refuses an existing database](#setup-refuses-an-existing-database). |

## Wrong port or wrong address

Compose publishes the API on the host port in
`REMEMBERSTACK_SELFHOST_API_PORT`, on `127.0.0.1` only unless
`REMEMBERSTACK_SELFHOST_API_PUBLISH_ADDRESS` says otherwise, so another
machine gets "Connection refused" until you
[open it](https://remember.dev/docs/self-hosting/authentication#opening-the-api-to-other-machines). The `remember` client and CLI do not
read that variable. Without `REMEMBER_API_URL` they use
`http://127.0.0.1:8000`, whatever port the API is published on.

If you changed the port, tell the client:

```bash
export REMEMBER_API_URL=http://localhost:8080
```

`remember doctor` prints the address it checks and whether the API
answered:

```bash
remember doctor
```

It asks `GET /deployment`, which needs a `read` or `write` credential. With
an `ingest` token it reports an authentication failure even though the
address is right.

## 401 and 403

The API sends one of four `detail` strings:

| Status and `detail` | Cause | Fix |
|---|---|---|
| `401` `a perimeter credential is required` | The deployment has a token configured, and the request had no `Authorization` header. | Export `REMEMBER_API_KEY` in the shell or agent configuration that makes the call. |
| `401` `perimeter authentication failed` | The token is wrong, expired, revoked, or signed with a key the deployment does not know. | Compare it with the configured secret. If you changed `.env`, apply it with `docker compose up -d`. If you use a bind, compute its digest with `printf '%s'`, not `echo`: a trailing newline changes it. |
| `403` `credential is for another deployment` | The shared-secret bind or the token's `aud` names a different deployment id. | Bind the secret, or mint the token, for the id in your `.env`. |
| `403` `credential may not perform this operation` | The token's scope does not cover the route. | See below. |

A `read` token can call every route that only reads, including the four
assured operations and `GET /deployment`. It cannot ingest or change
anything; that needs `ingest` or `write`. The full table is in
[Authentication and scopes](https://remember.dev/docs/self-hosting/authentication#scopes).

## A browser app cannot reach the API

A web page on another origin that calls the API gets a network error in
the browser, and the request may not show up in the API logs at all. The
API is running; the browser refused the response because the API did not
name the page's origin.

Check `REMEMBERSTACK_SELFHOST_BROWSER_ORIGINS`. It is empty by default,
which allows no origin. Each entry must be written exactly as the browser
sends it: lowercase, no path, no trailing slash. It must be `https`, unless
the app runs on the same machine: `http://localhost:3000`,
`http://127.0.0.1:5173` and `http://[::1]:8080` are accepted, and any other
`http` origin stops the API at start.

Only `GET`, `POST` and `DELETE` with the `Authorization` and `Content-Type`
headers are allowed, and cookies are not. See
[Browser origins](https://remember.dev/docs/self-hosting/authentication#browser-origins-cors).

## A document never becomes queryable

Ingest returns as soon as the bytes are stored. Everything after that runs
in the workers and takes minutes. Before you look for a fault, ask
readiness about the `version_id` that ingest returned:

```python
import uuid

import remember
from remember import ReadinessRequirements

client = remember.Client.from_env()
report = client.pipeline_readiness(
    version_ids=(uuid.UUID("6f1c2d0e-8a3b-4d5e-9f10-2a3b4c5d6e7f"),),
    require=ReadinessRequirements(pipeline=True, p1=True, live_graph=True, p3=False),
)
for version in report.versions:
    for stage in version.stages:
        print(stage.stage, stage.status)
```

| What readiness shows | Meaning | Next |
|---|---|---|
| Stages `running` or `succeeded`, later ones `missing` | Still processing. | Wait. See [Processing is slow](#processing-is-slow). |
| `convert` is `pending` with `defer_reason` `no_route` | No converter for the file's type. | [Parked conversions](#parked-conversions) |
| A stage is `pending` with `defer_reason` `budget` | A spend budget parked it. | [Work parked by a spend budget](#work-parked-by-a-spend-budget) |
| A stage stays `pending` with no `defer_reason` | Its worker is not running. | `docker compose ps -a` |
| `failed` | The last attempt failed and a retry is scheduled. | Watch the worker logs; a repeated failure becomes `dead_letter`. |
| `dead_letter` | Out of attempts. Nothing retries it on its own. | [Dead letters](#dead-letters) |
| Every stage done, `p1` or `live_graph` not ready | A deployment-wide index or graph check is failing, not your document. | [Operating the pipeline](https://remember.dev/docs/self-hosting/operating#the-graph-catalog) |

The document status in `GET /documents` is not readiness: a document is
`ready` there once it is converted and structured, before its claims and
facts exist.

### See what is waiting and why

Readiness gives each waiting stage a `defer_reason`, and the `routes` in
`remember ops inspect` count work by stage, status and `defer_reason`, for
the whole deployment:

```bash
docker compose exec -T api \
  remember ops inspect --deployment "$DEPLOYMENT_ID"
```

`defer_reason` is `no_route` for a file no converter accepts, `budget` for
work a spend budget parked, `scheduled` for work due later, and
`retry_backoff` for a failure waiting to be retried. Pending work with no
reason is simply queued for its worker.

### Parked conversions

A file whose MIME type has no conversion route is stored and its `convert`
work is parked with the reason `no_route`. It uses no attempts and makes no
model calls. The ingest result reports it at once with
`"parked": "no_route"`, and `remember ingest` prints a warning.

A stock deployment routes Markdown, plain text, HTML, `.docx`, `.pptx` and
`.xlsx`. PDFs and images need an OCR route with your own provider key. Add
a route for the type as described in
[File formats and converters](https://remember.dev/docs/self-hosting/converters#add-routes), apply it with
`docker compose up -d`, then release the parked work:

```bash
docker compose exec -T api \
  remember ops resume-no-route --deployment "$DEPLOYMENT_ID"
```

It prints `{"released": [...]}` with the processing ids it released. Work
whose MIME type is still unrouted stays parked.

### A Markdown file parked as `no_route`

The MIME type is the one the upload declared, not something the engine
detects. These uploads arrive with a type the default routes do not cover:

- a file or `filename` with no extension, or an extension the client does
  not map and the sending machine's MIME table does not know:
  `application/octet-stream`;
- an older `remember` client on a Python installation that does not know
  `.md`: `application/octet-stream`;
- a `curl` call with a type such as `text/x-markdown`. The match is exact.

Send the same file again with the right type:
`remember ingest notes --mime text/markdown`, or `mime="text/markdown"` in
Python. RememberStack stores identical bytes once, with the type of their
first upload, but a type with no route is replaced by a later upload's
type that has one, and every parked conversion of those bytes is released.
The ingest result's `mime` shows the type now recorded.

Alternatively, add a route for the type that was recorded, for example
`"application/octet-stream": "passthrough"`, then run `resume-no-route`.
`passthrough` needs valid UTF-8, so a binary file sent under that type
fails conversion instead of waiting. To see which types were recorded:

```bash
docker compose exec -T postgres sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"' <<'SQL'
SELECT mime, count(*) FROM content_objects GROUP BY mime;
SQL
```

The current client sends `.md` and `.markdown` as `text/markdown` on
every Python installation, and takes the type of bytes from their
`filename`. For a file whose extension does not say what it is, pass the
type: `remember ingest notes --mime text/markdown`, or `mime="text/markdown"`
in Python.

### Dead letters

Find what failed and why:

```bash
docker compose exec -T api \
  remember ops inspect --deployment "$DEPLOYMENT_ID" | python3 -m json.tool
```

`dead_letters.groups` shows the stage and error class; each entry in
`dead_letters.items` has its `processing_id` and `last_error`, the full
traceback of the last attempt. Fix the cause first: a key, a model id, a
converter route, a file that is not UTF-8. Then give the item more
attempts:

```bash
docker compose exec -T api \
  remember ops replay 3f2a9c1e-8b7d-4e21-9a0f-5c6d7e8f9a0b --deployment "$DEPLOYMENT_ID"
```

`replay` handles one item. If `poison_targets` lists the item, it failed
under two releases of the same stage, so an upgrade did not fix it; look
at the input itself. See [Operating the pipeline](https://remember.dev/docs/self-hosting/operating#retries-and-dead-letters).

## Processing is slow

Minutes per document is normal: most stages wait on model calls, and some
cannot start until every chunk or claim of the version has finished the
stage before. Check where work is piling up:

```bash
docker compose exec -T api \
  remember ops inspect --deployment "$DEPLOYMENT_ID" | python3 -m json.tool
docker compose logs -f --since 10m worker-extract-claims worker-normalize-relations
```

| Sign | Cause | Fix |
|---|---|---|
| A large `pending` count on one stage in `routes` | That stage is the bottleneck. | Add replicas: `docker compose up -d --scale worker-extract-claims=3`. See [Scaling](https://remember.dev/docs/self-hosting/scaling#more-replicas-of-a-stage). |
| Log lines with `overloaded (429)` | OpenRouter or a provider behind it is rate-limiting you. More replicas make it worse. | Lower the worker rate, or set a provider order; see [Scaling](https://remember.dev/docs/self-hosting/scaling) and [Models and providers](https://remember.dev/docs/self-hosting/models#openrouter-routing-and-limits). |
| `pending` counts that do not move, no log activity | The worker for that stage is not running, or a budget parked the work. | `docker compose ps -a`; [Work parked by a spend budget](#work-parked-by-a-spend-budget). |
| Every call slow | Reasoning models spend time and tokens thinking. | Lower `REMEMBERSTACK_OPENROUTER_REASONING_EFFORT_MAP` for the small models; see [Models and providers](https://remember.dev/docs/self-hosting/models#openrouter-routing-and-limits). |

## Model key and provider errors

A missing key stops the containers at start (see
[The API does not answer](#the-api-does-not-answer)). A wrong key does not:
the stack starts, and every model call fails. `.env.example` ships the
placeholder `replace-before-real-use`, which fails the same way.

What you see: `retryable failure in stage …` lines in the worker logs, with
`OpenRouter /chat/completions returned 401` (or `402` when the account is
out of credit) in the traceback. After three attempts, a few seconds apart,
the items dead-letter.

```bash
docker compose logs --since 30m worker-structure worker-extract-claims | grep -i openrouter
```

Fix: put a valid key in `.env`, apply it with `docker compose up -d`, then
[replay the dead letters](#dead-letters). Other provider errors follow the
same path:

| In `last_error` | Cause | Fix |
|---|---|---|
| `returned 401` | The key is wrong or revoked. | Replace the key. |
| `returned 402` | The OpenRouter account has no credit left. | Add credit on OpenRouter. |
| `returned 400` or `returned 404`, with the provider's message about the model | A model id in `.env` does not exist, or the model does not support structured output. | Use an exact model id of a model with structured output; see [Models and providers](https://remember.dev/docs/self-hosting/models#change-a-model). |
| `provider returned an embedding dimension that differs from the request` | The embedding model does not return 1,536 dimensions. | See [Changing the embedding model](https://remember.dev/docs/self-hosting/models#changing-the-embedding-model). |
| `mistral ocr` | An OCR route's Mistral call failed, or Mistral rejected the file. | Check `REMEMBERSTACK_MISTRAL_OCR_API_KEY`; see [File formats and converters](https://remember.dev/docs/self-hosting/converters#mistral_ocr). |

A search that cannot embed its query answers `503` `model provider
unavailable`. The same key fixes it.

## Work parked by a spend budget

If you set `REMEMBERSTACK_WORK_BUDGETS`, a stage that reaches its ceiling
parks its work until the budget window ends. Nothing fails and nothing is
dropped; readiness shows the stage as `pending`.

A `defer_reason` of `budget` (see
[See what is waiting and why](#see-what-is-waiting-and-why)) shows it.
Wait for the window to end, or raise `ceiling_usd` in `.env` and run
`docker compose up -d`. See [Spend budgets](https://remember.dev/docs/self-hosting/operating#spend-budgets).

## Empty or surprising answers

First make sure the documents are processed: an answer from before
readiness does not include them, and nothing in it says so. Then read the
result, not only its list of facts.

- **`negative`** says why a result is empty. `unknown_entity` means the name
  did not resolve; `known_empty` means nothing matches; `boundary` means
  the question could not be answered as asked, with a `workaround`.
- **`truncation.truncated`** means there is more than was returned.
- **`dropped_by_hydration`** counts candidates the search found that no
  longer hold. A high value right after ingest usually means processing is
  still settling.

Most surprising answers are the result of how the question was asked:

| What happened | Why it is not a bug | What to do instead |
|---|---|---|
| "Who works on the billing migration?" leaves out Ravi, who worked on it until June. | `facts_context` defaults to the `current` time mode, which returns only facts true now. | Ask with `time={"mode": "history"}`, or `at` a date. See [Time](https://remember.dev/docs/concepts/time#asking-about-time). |
| A fact dated "sometime" appears in an answer about March. | Its window is missing or partial, so it comes back with `temporal_match: "possible"`. | Treat `possible` as a lead, not an answer. |
| The old June deadline still appears after the notes moved it to October. | `claims_and_sources_context` returns what sources said, including the earlier statement. Claims never change. | Use `facts_context` for what holds now; use claims to show who said what. See [Facts](https://remember.dev/docs/concepts/facts). |
| "Dana" returns facts about the wrong Dana, or nothing. | `resolve_entity` returns every entity that matches and never picks one for you. | Resolve first; ask which one, or pass the right `entity_ids`. See [Handle unknowns and ambiguity](https://remember.dev/docs/guides/unknowns-and-ambiguity#names-that-match-more-than-one-entity). |
| The agent says "Ravi has no tasks" after a `boundary` negative. | `boundary` means the lookup could not run as asked, not that nothing exists. | Follow `workaround`. Brief your agent as in [Handle unknowns and ambiguity](https://remember.dev/docs/guides/unknowns-and-ambiguity#brief-your-agent). |
| "Ravi owns three components" when he owns more. | The list was truncated. | Check `truncation`; raise `k` or narrow the question. |
| A SQL query returns no rows, and the agent concludes nothing exists. | SQL results carry no `negative`; an empty result can come from a rejected statement or a truncated one. | Check `termination_reason`, `error_code` and `truncated` first. |
| The answer picks one side of a disagreement. | Both facts stand, in one `contradiction_group`. | Report both sides with their sources. See [Contradictions](https://remember.dev/docs/concepts/contradictions). |
| `422` `invalid_parameter` "unknown argument(s)" from an operation. | Each operation accepts a fixed set of arguments. | See [Assured operations](https://remember.dev/docs/reference/assured-operations). |

## A SQL query is rejected

SQL queries run against the query space, `memory_v1`: prepared, read-only
views and functions. Every statement is parsed and checked against that
query space before it runs, and anything outside it is rejected.

A statement the API refuses or that fails while running does **not** come
back as an HTTP error. `POST /query/sql` answers `200` with
`termination_reason` set to `rejected` or `failed`, an `error_code`, an
`error_message` and no rows. The Python client returns that result as it
is; it does not raise. Check `termination_reason` on every result before
you read `rows`:

```python
result = client.open_query("SELECT fact_label FROM facts_current LIMIT 5")
if result["termination_reason"] != "completed":
    print(result["error_code"], result["error_message"])
```

The codes you meet most often:

| `error_code` | Usual cause | Fix |
|---|---|---|
| `relation_not_allowed` | A table outside `memory_v1`, such as the internal `processing_state`. | Use the [views](https://remember.dev/docs/reference/query-space#views). |
| `function_not_allowed` | `now()` or another function not on the allowlist. | Pass times as parameters (`$1`). |
| `statement_not_allowed` | Anything but a read-only `SELECT`, `VALUES` or `WITH`. | Rewrite as a read. |
| `function_placement_not_allowed` | A query-space function used outside a top-level `FROM` item. | Follow the [placement rules](https://remember.dev/docs/reference/query-space#public-function-placement). |
| `quota_exceeded`, `concurrency_exceeded` | Too many statements, or too much statement time, in the last minute. | Wait and retry. |
| `statement_timeout` | The statement ran past 5 seconds. | Add filters or a `LIMIT`. |

Every code is in [Errors and status codes](https://remember.dev/docs/reference/errors#sql-query-codes).

## 503 forget_in_progress

The API answers `503` with `{"code": "forget_in_progress"}` while a hard
forget is running, and the operator commands that copy or replay data
refuse to run. A deployment started from `compose.yaml` has no command that
starts a hard forget, so it should not enter this state on its own. If
you restored data from an installation that ran one, see
[Hard-forget manifests](https://remember.dev/docs/self-hosting/upgrades#hard-forget-manifests) and
[When a hard forget is in progress](https://remember.dev/docs/self-hosting/operating#when-a-hard-forget-is-in-progress).

## setup refuses an existing database

Some releases change what stored data means in a way that cannot be
converted. Their migrations stop instead of guessing. On a database that
already holds claims or chunks, `setup` stops with an error that ends in
"recreate the deployment and ingest its sources again", and the API and
workers do not start.

`docker compose logs setup` shows the message. To continue:

1. If you may want to go back, take a backup of the old volumes first, as
   described in [Back up](https://remember.dev/docs/self-hosting/upgrades#back-up), while the old release is
   still checked out.
2. Remove the old data: `docker compose down -v`. This deletes the memory.
3. Start the new release: `docker compose up -d --wait`.
4. Send your source documents again.

To stay on the earlier release instead, check out its tag again and start
it with `docker compose up -d`. `setup` applies all pending migrations in
one database transaction, so the failure rolled back the ones before it
too. See [Upgrades and migrations](https://remember.dev/docs/self-hosting/upgrades).

## Before you report a problem

Collect:

- `build_revision` and `model_bindings` from `curl http://localhost:8000/deployment`;
- the `remember ops inspect` output;
- `docker compose logs --since 1h` for the services involved, with
  secrets removed;
- the `version_id` and its readiness stages, for a stuck document.

`last_error` tracebacks and logs can quote text from your documents.
Read them before you share them. See [Contributing](https://remember.dev/docs/project/contributing).

---

Source: https://remember.dev/docs/self-hosting/observability

# Observability

A self-hosted deployment tells you what it is doing in four ways: container
logs, an optional error tracker, the pipeline report from
`remember ops inspect`, and a cost export of every billed model call. None
of them sends anything off the machine unless you configure it to.

## Logs

Every container writes to standard output, which Docker keeps:

```bash
docker compose logs -f api
docker compose logs -f worker-extract-claims
docker compose logs --since 1h
```

- The **API** writes an access log line per request.
- Each **worker** writes one JSON line per item it runs: an event named
  `worker.run` with an `occurred_at` time and an `attributes` list holding
  `deployment_id`, `processing_id`, `stage`, `lane`, `attempt`, `outcome`
  and `duration_ms`. The outcome is one of
  `succeeded`, `retry_scheduled`, `dead_lettered`, `budget_parked`,
  `no_route_parked` or `no_work`. A failure adds an `exception` object with
  the error type, message and full traceback.

An error message can quote the input that caused it, so treat worker logs
as containing document text. Log retention and rotation are Docker's; set
them with a Docker logging driver.

For the state of the pipeline as a whole (what is pending, what has failed
for good), use `remember ops inspect`; see
[Operating the pipeline](https://remember.dev/docs/self-hosting/operating#inspect-the-pipeline).

## Error tracking

The engine can send errors to any service that speaks the Sentry protocol:
Sentry, GlitchTip or Bugsink. It is off unless you set a DSN.

| Variable | Default | Meaning |
|---|---|---|
| `REMEMBERSTACK_SENTRY_DSN` | unset | The project DSN. Unset or empty keeps error tracking off. |
| `REMEMBERSTACK_SENTRY_ENVIRONMENT` | the deployment slug | The environment name on each event. |
| `REMEMBERSTACK_SENTRY_SAMPLE_RATE` | `1.0` | Share of error events sent, from 0.0 to 1.0. |

`compose.yaml` passes all three. Set them in `.env` and run
`docker compose up -d`. The `setup`, `api` and worker
processes report errors; the projection and mount commands do not.

Events are stripped to metadata before they leave the process: the
exception type and stack trace stay; the exception message is replaced by
`[redacted]`; request bodies, breadcrumbs, local variables, user data and
extras are removed. A worker failure carries the tags `stage`, `lane` and
`processing_id`, which you can look up with `remember ops inspect`.
Performance tracing is off.

## Model-call tracing

RememberStack does not trace model calls from a self-hosted deployment yet.
`compose.yaml` passes `LANGFUSE_*` and `REMEMBERSTACK_LANGFUSE_*` variables,
but only the project's benchmark harness reads them; the API and workers
ignore them. Setting them changes nothing.

What you can see of each model call is its cost receipt, below.

## Cost export

Every billed provider call, by a worker or by the API (for example the
embedding of a search query), is written to a cost ledger with its model,
tokens, cost in US dollars and where it came from. The cost export reads
that ledger. It contains no document text and no prompts.

### From the command line

```bash
DEPLOYMENT_ID=$(grep '^REMEMBERSTACK_SELFHOST_DEPLOYMENT_ID=' .env | cut -d= -f2)

docker compose exec -T api \
  remember ops cost-export --deployment "$DEPLOYMENT_ID" --limit 100
```

Pass the page's `next_cursor` as `--cursor` to read the next page. The
command exits with status 2 if `--deployment` is not this deployment's id or
the cursor is malformed.

### Over HTTP

The API process can serve the same export on a second address, separate
from the memory API. It is off unless you give it an address and a token:

| Variable | Meaning |
|---|---|
| `REMEMBERSTACK_COST_EXPORT_BIND` | Where to listen: `host:port`, `[ipv6]:port` or `unix:/path/to/socket`. Unset means no HTTP export. |
| `REMEMBERSTACK_COST_EXPORT_TOKEN` | The bearer token callers must send. At least 32 bytes; the API refuses to start with a shorter one once the bind is set. |

Set both in `.env`, and publish the port with a `compose.override.yaml`
next to `compose.yaml`, which Compose merges automatically:

```bash
# .env
REMEMBERSTACK_COST_EXPORT_BIND=0.0.0.0:8001
REMEMBERSTACK_COST_EXPORT_TOKEN=<output of: openssl rand -hex 32>
```

```yaml
# compose.override.yaml
services:
  api:
    ports:
      - "127.0.0.1:8001:8001"
```

The extra port is added to the API's existing one.

The bind is an address inside the container, so it must be `0.0.0.0` for
the published port to reach it.

```bash
curl -H "Authorization: Bearer $COST_EXPORT_TOKEN" \
  "http://127.0.0.1:8001/ops/cost-export/v1?limit=100"
```

| Parameter | Default | Range |
|---|---|---|
| `limit` | `100` | 1–500 |
| `cursor` | none (start from the beginning) | a `next_cursor` from an earlier page |

| Status | Meaning |
|---|---|
| `401` | Missing or wrong token |
| `422` | Malformed cursor |
| `429` | More than one request per second |

### The page

```json
{
  "contract": "rememberstack.cost_export.v1",
  "deployment_id": "…",
  "server_time": "2026-09-23T10:15:00Z",
  "horizon": "2026-09-23T10:14:00Z",
  "cursor": "…",
  "next_cursor": "…",
  "persist_failures": 0,
  "scope_missing": 0,
  "receipts": [
    {
      "cost_id": "…",
      "deployment_id": "…",
      "source": "worker",
      "work_id": "…",
      "stage": "extract_claims",
      "lane": "steady",
      "attempt": 1,
      "surface": null,
      "call_key": "…",
      "outcome": "…",
      "model_name": "openai/gpt-5.6-luna",
      "tokens_in": 5120,
      "tokens_out": 830,
      "cost_usd": "0.0041",
      "latency_ms": 2300,
      "occurred_at": "2026-09-23T10:02:11Z"
    }
  ]
}
```

- `source` is `worker` for pipeline calls, which carry `stage`, `lane` and
  `attempt`, or `surface` for calls made while answering a request, which
  carry `surface` instead.
- `cost_usd` is a decimal string, or `null` when the provider reported no
  cost.
- A page includes only receipts older than `horizon`, which is 60 seconds
  before `server_time`, so a receipt still being written never appears
  and then changes.
- Keep the last `next_cursor` and poll with it later to read only new
  receipts.
- `persist_failures` and `scope_missing` count request-time costs that could
  not be written to the ledger. Non-zero means the export under-reports.

## Health and provenance

`GET /healthz` answers `{"status":"ok"}` when the API reaches PostgreSQL.
It needs no token, and Compose uses it as the API's health check.

`GET /deployment` reports `build_revision` (the source commit the image
was built from, empty for a local build), `model_bindings` (the model on
each pipeline seat and the OpenRouter routing settings) and
`document_binding_generation`, and the MCP memory tools it serves
(`tools`). It needs a `read` or `write` credential when authentication is
on.

---

Source: https://remember.dev/docs/self-hosting/upgrades

# Upgrades and migrations

A self-hosted deployment is yours to upgrade and to back up. This page
covers how releases are published, what happens to the database on an
upgrade, the order to do it in, and how to take a backup you can restore.

## Releases and image tags

Each release has a Git tag (`v0.17.0`) and an image with the same version
number and no `v`: `ghcr.io/writeitai/remember-stack:0.17.0`. There is no
`latest` tag. The release on GitHub attaches the `compose.yaml` and
`.env.example` for that version (the second downloads as
`default.env.example`) and `openapi.json`.

`compose.yaml` names the image tag, so the version you run is the version
of the `compose.yaml` you start. The client package on PyPI, `remember`,
is released from the same tag with the same version number.

## What `setup` does on an upgrade

The `setup` service runs before the API and workers every time you run
`docker compose up`. It:

1. applies every database migration the new release brings
   (`alembic upgrade head`);
2. checks that the deployment id, slug and name still match the database;
3. installs any assured operations and example saved queries the release adds;
4. checks that the embedding model still matches the stored vectors, then
   re-publishes the search channels, rebuilding entity profile vectors
   when needed.

The API and the workers start only after `setup` exits successfully. If a
migration fails, `setup` exits with an error, nothing else starts, and
`docker compose logs setup` shows why.

Migrations only go forward. Some refuse to convert data in a way that would
lose it, and there is no supported downgrade. To go back to an earlier
release, restore a backup taken before the upgrade.

Upgrading does not reprocess documents already in the memory. They keep
what the earlier release extracted from them; new documents are processed
by the new code.

**Some releases cannot convert existing data:**

When a release changes what stored data means in a way that cannot be
converted, its migrations stop instead of guessing, and `setup` exits
with an error that ends in "recreate the deployment and ingest its
sources again". Start from an empty deployment and send your source
documents again. Keep your source files for this reason. See
[setup refuses an existing database](https://remember.dev/docs/self-hosting/troubleshooting#setup-refuses-an-existing-database).

## Upgrade step by step

1. **Read the release notes** on GitHub for the version you are moving to.
2. **Back up**, as described [below](#back-up).
3. **Get the new files:**

    ```bash
    git fetch --tags
    git checkout v<new-version>
    diff .env .env.example
    ```

    Add any new required variable to your `.env`. Keep your deployment id,
    slug and name as they are.

4. **Stop the old processes**, so no old worker runs against a database
   that is being migrated:

    ```bash
    docker compose down
    ```

    `down` removes the containers and keeps the volumes, and with them the
    memory.

5. **Get the new images:**

    ```bash
    docker compose pull api postgres
    ```

    All app services share the image `pull api` fetches. Each release also
    publishes its own PostgreSQL image, so `postgres` is pulled too.

6. **Start:**

    ```bash
    docker compose up -d --wait
    docker compose logs setup
    curl http://localhost:8000/deployment
    ```

    `build_revision` in `/deployment` names the source commit of the image
    now serving.

## PostgreSQL prerelease versions

RememberStack runs on a PostgreSQL 19 beta (`19beta3` at v0.17.0). A
PostgreSQL prerelease does not promise that its data directory opens under
the next prerelease or the final release. The project has not published a
procedure for moving a deployment's data across such a change.

If a release changes the PostgreSQL image, its release notes are the place
to look. Without instructions there, move the data with a logical dump and
restore (below) into a new PostgreSQL volume, or start a new deployment and
send your documents again.

## The deployment id is permanent

`REMEMBERSTACK_SELFHOST_DEPLOYMENT_ID` names the deployment: its trust
domain. Every token is bound to it, and every stored row belongs to it.
The install step generates it once, and the first `setup` records it.

A database holds exactly one deployment. If the id in `.env` differs from
the recorded one, `setup` refuses to start and names the recorded id, so
the API and workers do not start either. Restore the recorded value. The
same check covers the slug, the name, the default language and the bucket
names: `setup` names the ones that changed. Change none of them after the
first start.

To start over under a new id, remove the volumes
(`docker compose down --volumes`) and send your documents again.

## Back up

Backups are your responsibility. RememberStack does not schedule them, and
the project does not test a restore procedure. What follows is grounded in
how the Compose deployment stores its data.

A deployment's state lives in four Docker volumes:

| Volume | Must be backed up |
|---|---|
| `rememberstack_postgres-data` | Yes: the database |
| `rememberstack_object-store-data` | Yes: original files and derived artifacts |
| `rememberstack_forget-manifests` | Yes, with the others: hard-forget manifests |
| `rememberstack_app-state` | Optional: working directories and debug captures |

Filesystem-view snapshots in the object store can be rebuilt; everything else in the
first three volumes cannot. The database and the object store refer to
each other, so back them up at the same moment.

### A cold backup of the volumes

The simplest consistent backup stops the stack and copies the volumes:

```bash
docker compose stop
mkdir -p backup
for volume in postgres-data object-store-data forget-manifests app-state; do
  docker run --rm \
    -v "rememberstack_${volume}:/source:ro" \
    -v "$PWD/backup:/backup" \
    alpine tar -czf "/backup/${volume}.tar.gz" -C /source .
done
docker compose start
```

Keep the `backup/` directory and your `.env` together: a restore needs the
same deployment id and the same database and object-store credentials.

To restore it into an empty stack on the same or another machine, with the
same `.env`:

```bash
docker compose down -v          # deletes the current volumes
docker compose create           # creates empty volumes and containers
for volume in postgres-data object-store-data forget-manifests app-state; do
  docker run --rm \
    -v "rememberstack_${volume}:/target" \
    -v "$PWD/backup:/backup:ro" \
    alpine tar -xzf "/backup/${volume}.tar.gz" -C /target
done
docker compose up -d --wait
docker compose exec -T api remember ops graph-catalog ensure
```

The last command checks that the graph definitions in the restored database
match this release ([Operating the pipeline](https://remember.dev/docs/self-hosting/operating#the-graph-catalog)).

### A logical backup

`pg_dump` backs up the database while it runs:

```bash
docker compose exec -T postgres \
  pg_dump -U rememberstack -d rememberstack --format=custom > rememberstack.dump
docker compose exec -T postgres \
  pg_dumpall -U rememberstack --roles-only > roles.sql
```

Use your own user and database names from `.env`. The second file matters:
the migrations create a separate, restricted PostgreSQL role for SQL
queries, and roles are not part of `pg_dump`. Copy the object-store buckets
(`remember-raw`, `remember-artifacts`, `remember-corpusfs`) at the same
time with an S3 tool such as `rclone sync`. Restoring a logical backup means
creating the roles, restoring the dump into an empty database with
`pg_restore`, copying the buckets back, and running `setup`; the project
has not tested this path.

### Hard-forget manifests

The `forget-manifests` volume holds the list of documents that were
permanently removed, so a restore can remove them again from an older
backup. A deployment started from `compose.yaml` has no command that
creates such manifests. If the volume does hold manifests for your
deployment, the API refuses to start: honouring them needs a recovery
procedure the Compose deployment does not include.

---

Source: https://remember.dev/docs/self-hosting/filesystem-views

# Filesystem views

Some agents work best with files. A filesystem view gives them one: a
directory tree with a page per document and per entity, grouped by source,
by month and by topic, with an index in every directory. An agent can
`cat` the index, follow a path, and `grep` the tree without calling the
API.

The tree is generated from the database. It holds no information the
memory does not, and it is a snapshot: it shows the memory as it was when
you built it, until you build it again.

## Build a snapshot

```bash
docker compose --profile operations run --rm projections
```

The `projections` service runs `project --plane p3`. It renders the whole
tree, writes it to the `remember-corpusfs` bucket in the object store under a new
version, and marks that version as the latest. It prints a short report
with the `snapshot_id`, the `version` and the number of `files`. Each run
builds the tree from scratch.

Nothing builds snapshots on a schedule. Run the command again, from cron or
by hand, when you want the view to catch up.

## Publish it to a directory

`mounts` copies the latest snapshot out of the object store into a directory:

```bash
mkdir -p "$PWD/memory-views"
docker compose run --rm --no-deps \
  --user "$(id -u):$(id -g)" \
  -v "$PWD/memory-views:$PWD/memory-views" \
  api mounts --root "$PWD/memory-views"
```

Mount the host directory at the **same path** inside the container, as
above. `mounts` points the view at the snapshot with an absolute symbolic
link, and a link created under a different path inside the container is
broken on the host. `--user` makes the files yours rather than the
container user's.

The command prints where each view is:

```json
{"deployment_id": "…", "p3": "…/memory-views/<deployment-id>/p3", "artifacts": "…/artifacts", "raw": "…/raw", "knowledge": "…/knowledge", "read_only": true}
```

Publishing is atomic: `p3` is a link that switches from one complete
snapshot directory to the next, so an agent reading the tree never sees a
half-copied one. Old snapshot directories stay in place; delete them when
you no longer need them.

## The four views

| View | Contents |
|---|---|
| `p3` | The corpus tree described below. |
| `artifacts` | The converted Markdown and other artifacts, **if** you pass `--artifacts-root` pointing at a directory where the `remember-artifacts` bucket is mounted. Otherwise an empty directory. |
| `raw` | The original files, **if** you pass `--raw-root` pointing at a directory where the `remember-raw` bucket is mounted. Otherwise an empty directory. Nothing in the corpus tree links into it: reaching an original means following the `raw_uri` in a document's page on purpose. |
| `knowledge` | Reserved. Always an empty directory in this release. |

`--raw-root` and `--artifacts-root` must be existing directories. They
default to `REMEMBERSTACK_SELFHOST_RAW_MOUNT_ROOT` and
`REMEMBERSTACK_SELFHOST_ARTIFACTS_MOUNT_ROOT`. RememberStack does not mount
buckets itself; use an S3 filesystem tool of your choice.

## Layout of the corpus tree

```text
p3/
├── _index.md              how to navigate; which paths are stable
├── llms.txt               the facets and where things live
├── documents/
│   ├── _index.md          one row per document
│   └── <doc_id>/_index.md one page per document
├── entities/
│   ├── _index.md          one row per entity
│   └── <entity_id>/_index.md one page per entity, listing the documents that mention it
├── by-source/<source_kind>/<title>-<doc_id>.md
├── by-time/<yyyy>/<mm>/<title>-<doc_id>.md
└── by-topic/<topic path>/<title>-<doc_id>.md
```

Every directory, including the intermediate ones, carries an `_index.md`
listing what is in it with a one-line summary per entry, and an
`llms.txt` for orientation. An agent reads one index to learn what a
directory holds instead of opening every file.

Paths come in two kinds:

- **Stable paths:** `documents/<doc_id>/` and `entities/<entity_id>/`.
  They are addressed by id and do not move between snapshots, including
  when a document gets a new version. Store these.
- **View paths:** everything under `by-source/`, `by-time/` and
  `by-topic/`. They may be reorganised as the memory grows. Every page
  there names its stable path in its front matter.

A document appears in `by-source/` under its `source_kind`, in `by-time/`
under the month of its `source_modified_at` (or its publication time), and
in `by-topic/` when structuring proposed a topic path for it. A directory
with more than 150 entries is split into sub-directories by name prefix
(`REMEMBERSTACK_P3_SHARD_THRESHOLD`).

A document page holds front matter (`doc_id`, `canonical_path`,
`version_id`, `content_hash`, `artifact_uri`, `raw_uri`, `mime`,
`source_kind`, `source_ref` and the state of the latest stored version),
the title and the document's summary. It does not hold the document's
text; `artifact_uri` points to the converted Markdown.

## Read-only is your job

The views are meant to be read, never written: changes go through the API
and the pipeline, and PostgreSQL is the authority. `mounts` does not set
file permissions. If agents or users should not be able to change the tree,
make the directory read-only for them, for example by mounting it read-only
into the agent's container. The same applies to bucket mounts you pass as
`--raw-root` and `--artifacts-root`, and reads of the raw originals are
logged only if your mount tool logs them.

## Rebuilding into a local directory

`remember ops rebuild --deployment ID --snapshot-root DIR --version V`
writes a snapshot tree straight into a directory inside the container
instead of the object store. It also records that snapshot as the latest, and `mounts`
then fails to find it in the object store. Run the `projections` service again before
the next `mounts`.

## When it refuses

Building a snapshot and publishing the views both refuse to run while a
hard forget is in progress ([Operating the pipeline](https://remember.dev/docs/self-hosting/operating#when-a-hard-forget-is-in-progress)).

---

Source: https://remember.dev/docs/reference/http-api

# HTTP API conventions

Every way into RememberStack — the `remember` Python package, the `remember`
CLI, the MCP server — ends in the same HTTP API. This page covers the rules
that hold for all of its routes: where to send requests, how to authenticate,
which credential may call what, how errors look, and which limits apply. The
group pages then describe each route.

The API has 28 documented operations:

| Group | Page | Routes |
|---|---|---|
| Ingest, readiness, documents | [Ingest](https://remember.dev/docs/reference/http-api/ingest) | `POST /ingest`, `POST /readiness`, `GET /documents`, `DELETE /documents/{doc_id}` |
| Assured operations | [Operations](https://remember.dev/docs/reference/http-api/operations) | `GET /operations`, `POST /operations/{name}` |
| Entities and facts | [Entities and facts](https://remember.dev/docs/reference/http-api/entities-and-facts) | `GET /resolve`, `GET /lookup/relations`, `GET /lookup/observations`, `GET /hydrate/relation/{relation_id}`, `GET /transcript/relation/{relation_id}` |
| Search | [Search](https://remember.dev/docs/reference/http-api/search) | `GET` and `POST /search/claims`, `GET` and `POST /search/chunks`, `GET /chunks/{chunk_id}/adjacent`, `POST /chunks/adjacent` |
| Graph | [Graph](https://remember.dev/docs/reference/http-api/graph) | `POST /graph/neighborhood`, `POST /graph/path`, `POST /graph/citation-path` |
| SQL queries | [SQL queries](https://remember.dev/docs/reference/http-api/query) | `POST /query/sql`, `POST /query/sql/explain`, `GET /query/space`, `GET /query/space/search`, `GET /query/saved`, `GET /query/saved/{namespace}/{name}`, `POST /query/saved/{namespace}/{name}/run` |
| Deployment | [Deployment](https://remember.dev/docs/reference/http-api/deployment) | `GET /deployment` |

Every deployment serves all of them, including the SQL query routes (`/query/*`). SQL queries run over the query
space, `memory_v1`: a fixed set of prepared, read-only views and functions.
Every statement is parsed and validated against it before it runs, and
anything outside it is rejected.

The machine-readable schema is attached to every GitHub release:
[openapi.json](https://github.com/writeitai/remember-stack/releases/download/v0.17.2/openapi.json).

## Base URL

Each deployment answers on its own address. There is no shared gateway in
front of the memory routes: requests go straight to the deployment.

```bash
export REMEMBER_API_URL=http://localhost:8000
```

The engine listens on port 8000 inside its container
(`REMEMBERSTACK_SELFHOST_API_PORT`, default `8000`). The Compose file
publishes it on the host's loopback address, `127.0.0.1`.

Paths in this reference are relative to that base URL. The `remember` client
reads the same variable (`REMEMBER_API_URL`) and falls back to
`http://127.0.0.1:8000`.

## Authentication

Send the credential in the `Authorization` header with the `Bearer` scheme:

```bash
curl -s "$REMEMBER_API_URL/operations" \
  -H "Authorization: Bearer $REMEMBER_API_KEY"
```

The `remember` client adds the `Bearer ` prefix for you when you pass a bare
secret (`Client(api_key=...)` or `REMEMBER_API_KEY`).

A self-hosted deployment requires it only when you configure a credential
(`REMEMBERSTACK_SELFHOST_API_BEARER_BIND`,
`REMEMBERSTACK_SELFHOST_API_BEARER_TOKEN` or
`REMEMBERSTACK_SELFHOST_API_KEY_ISSUER`). With none of them set, the API is
open and ignores the header; protecting it is then the job of your network.
See [Authentication and scopes](https://remember.dev/docs/self-hosting/authentication).

When authentication is on, a request without the header is refused with
`401` and `{"detail": "a perimeter credential is required"}`. A credential the
deployment does not accept is `401` with `{"detail": "perimeter authentication failed"}`.
A valid credential issued for a different deployment is `403` with
`{"detail": "credential is for another deployment"}`.

`GET /healthz` is the only route that never asks for a credential.

### Credential kinds

| Credential | Where it comes from | Scope it carries |
|---|---|---|
| Shared secret (self-hosted) | You choose it; the engine keeps only its SHA-256 digest (`REMEMBERSTACK_SELFHOST_API_BEARER_BIND` is `{deployment-uuid}:{sha256-hex}`). | `write` (unrestricted) |
| Signed key, `kind: key` | A long-lived key a person created at a key issuer. It can cover several projects. | From its permissions |
| Signed key, `kind: session` | A short-lived key an issuer derives for this one deployment, for example for a browser. | From its permissions |
| Signed key, `kind: service` | A short-lived machine credential for this one deployment. | From its permissions |

A deployment verifies signed keys when it is given a key issuer
(`REMEMBERSTACK_SELFHOST_API_KEY_ISSUER` and the settings beside it). It
fetches the issuer's public keys and a signed revocation document, and
refuses every signed key while it has no fresh revocation document. A
signed key's `memory:read`, `memory:write` or `memory:ingest` permission
becomes the `read`, `write` or `ingest` scope. The claims each kind must
carry are in [Signed keys](https://remember.dev/docs/self-hosting/authentication#signed-keys).

## Scopes

A credential carries exactly one of three scopes:

| Scope | May call |
|---|---|
| `read` | Every route marked `read` below. Nothing that changes memory. |
| `ingest` | `POST /ingest` only. It cannot read. |
| `write` | Everything. |

A signed key with no `memory:` permission carries none of them and gets `403`
on every route.

`read` and `ingest` do not overlap. A credential with too narrow a scope gets
`403` with `{"detail": "credential may not perform this operation"}`. Scopes
apply only when authentication is on; an open self-hosted deployment serves
every route to every caller.

The HTTP method does not tell you the scope. Several reads use `POST` because
their arguments do not fit in a query string.

| Method | Path | Scope |
|---|---|---|
| `GET` | `/healthz` | none (no credential needed) |
| `GET` | `/resolve` | `read` |
| `GET` | `/lookup/relations` | `read` |
| `GET` | `/lookup/observations` | `read` |
| `GET` | `/transcript/relation/{relation_id}` | `read` |
| `GET` | `/hydrate/relation/{relation_id}` | `read` |
| `GET` | `/search/claims` | `read` |
| `POST` | `/search/claims` | `read` |
| `GET` | `/search/chunks` | `read` |
| `POST` | `/search/chunks` | `read` |
| `GET` | `/chunks/{chunk_id}/adjacent` | `read` |
| `POST` | `/chunks/adjacent` | `read` |
| `POST` | `/graph/neighborhood` | `read` |
| `POST` | `/graph/path` | `read` |
| `POST` | `/graph/citation-path` | `read` |
| `POST` | `/query/sql` | `read` |
| `POST` | `/query/sql/explain` | `read` |
| `GET` | `/query/space` | `read` |
| `GET` | `/query/space/search` | `read` |
| `GET` | `/query/saved` | `read` |
| `GET` | `/query/saved/{namespace}/{name}` | `read` |
| `POST` | `/query/saved/{namespace}/{name}/run` | `read` |
| `POST` | `/readiness` | `read` |
| `GET` | `/documents` | `read` |
| `DELETE` | `/documents/{doc_id}` | `write` |
| `GET` | `/operations` | `read` |
| `POST` | `/operations/{name}` | `read` for the four shipped operations (see below) |
| `POST` | `/ingest` | `ingest` (or `write`) |
| `GET` | `/deployment` | `read` |
| any other | any other | `write` |

One entry needs a note: **`POST /operations/{name}` decides per
operation.** The route asks the operation's descriptor whether it changes
memory (`mutates`). All four shipped operations only read and declare
`mutates: false`, so a `read` credential can run them. An operation that
did not declare itself read-only would need `write`.

Any route added later without a classification also requires `write`.

## Content types

Requests with a body send JSON (`Content-Type: application/json`), except
`POST /ingest`, which sends the raw file bytes
(`Content-Type: application/octet-stream`). Every response body is JSON.

Timestamps are ISO 8601 strings. Wherever a timestamp is part of a result, it
is UTC. Identifiers are UUID strings.

## Errors

A failed request returns a non-2xx status and a JSON body with a single
`detail` key. `detail` takes one of four forms:

| Form | Example | Where |
|---|---|---|
| Short string | `{"detail": "body_too_large"}` | Most refusals: authentication, scope, limits, ingest checks, graph. |
| Object with `code` | `{"detail": {"code": "forget_in_progress"}}` | `503` while a deletion (hard forget) runs. |
| Object with `code` and `message` | `{"detail": {"code": "saved_query_not_found", "message": "no saved query named examples.nope"}}` | SQL query routes (`/query/*`), argument errors on `POST /operations/{name}`, and `429` admission refusals. |
| List of validation errors | `{"detail": [{"type": "missing", "loc": ["query", "name"], "msg": "Field required", "input": null}]}` | `422` when a parameter or body fails its declared type or bounds. |

Branch on the status and on `code` (or the short string), never on `message`.
Messages are written for people and can change.

The `remember` client raises `remember.MemoryApiError` for every failure, with
`status_code`, `detail` and, on the `/query/*` routes, `code`. A `429` raises
its subclass `remember.RateLimited`, with `code` and `retry_after`. A network
failure has `status_code` `0`.

[Errors and status codes](https://remember.dev/docs/reference/errors) lists every status, code and what to
do about it.

## Status codes that can come from any route

| Status | Body | When |
|---|---|---|
| `401` | `a perimeter credential is required` / `perimeter authentication failed` | Missing or unaccepted credential (authentication on). |
| `403` | `credential is for another deployment` / `credential may not perform this operation` | Wrong deployment, or scope too narrow. |
| `404` | `Not Found` | Unknown path. The credential is checked first, so an unauthenticated request gets `401`. |
| `405` | `Method Not Allowed` | Known path, wrong method. |
| `422` | validation list | A parameter or body is missing, has the wrong type, or is out of bounds. |
| `429` | `{"code": "rate_limited", …}` / `{"code": "concurrency_limited", …}` | An [admission limit](#admission-limits) was reached. Wait the `Retry-After` seconds, then retry. |
| `500` | `Internal Server Error` | An unhandled failure: a defect. Report it. |
| `503` | `{"code": "forget_in_progress"}` | A hard forget is running. Every route except the credential check is closed until it finishes. Retry later. |

## Admission limits

Admission limits are optional and **off by default**: a self-hosted
deployment refuses no request for its rate or concurrency until you set one of
the settings below. Each limit
applies only when its setting is a positive number; unset or `0` means no
limit.

When a limit is set, every request except `GET /healthz` is counted against it
after the credential check and before routing,
for its credential or for the whole deployment. A request to an unknown path
counts too.

| Limit | Setting |
|---|---|
| Requests per minute, per credential | `REMEMBERSTACK_SELFHOST_API_ADMISSION_KEY_PER_MINUTE` |
| Requests running at once, per credential | `REMEMBERSTACK_SELFHOST_API_ADMISSION_KEY_IN_FLIGHT` |
| Requests per minute, per deployment | `REMEMBERSTACK_SELFHOST_API_ADMISSION_DEPLOYMENT_PER_MINUTE` |
| Requests running at once, per deployment | `REMEMBERSTACK_SELFHOST_API_ADMISSION_DEPLOYMENT_IN_FLIGHT` |

The request rate works as a bucket of tokens that refills continuously at the
per-minute rate, up to a burst of a quarter of it (30 at 120 per minute); each
request takes one. A
signed credential is counted by its id (`jti`). The shared secret, and every
caller when authentication is off, is counted against the deployment limits
only. A refused request counts against nothing.

Over a limit the deployment answers `429` with a `Retry-After` header in whole
seconds:

| `code` | Meaning | `Retry-After` |
|---|---|---|
| `rate_limited` | No request token is left for this credential or the deployment. | Seconds until the next token. |
| `concurrency_limited` | Too many requests of this credential, or of the deployment, are still running. | `1` |

```json
{"detail": {"code": "rate_limited", "message": "request rate limit reached; retry after Retry-After seconds"}}
```

The counters live in the memory of each API process: they start empty when
the process starts, and with several API processes each has its own, so the
effective limits are that many times larger. A request whose client
disconnects stays counted as running until its handler has actually stopped.

## Idempotency

There is no `Idempotency-Key` header. Ingest is idempotent by content: the
engine hashes the bytes (SHA-256). Sending the same bytes again returns the
existing version with `"created": false` and starts no new work. For a
document with a stable source identity (`source_kind` and `source_ref`), bytes
identical to its latest version are the same no-op; changed bytes become a new
version of the same document, and so do bytes that match only an older
version (a revert). See [Ingest](https://remember.dev/docs/reference/http-api/ingest#post-ingest).

Every other route only reads, so repeating it is safe.

## Pagination and truncation

Results are bounded everywhere, and a bound is never silent.

- **`GET /documents`** pages with an opaque `cursor`. Pass the `cursor` from
  one page to get the next; `null` means there is no next page.
- **Envelope results** carry a `truncation` block when a cap applied:
  `truncated`, `returned`, `estimated_total`, `total_is_exact`, an optional
  `continuation` and an optional `reason`. Only `POST /graph/neighborhood`
  accepts a `continuation` back today.
- **SQL query results** (`QueryResult/v1`) carry `truncated`,
  `truncation_reason` (`row_cap`, `byte_cap`, or a graph budget) and the
  `limits` the statement ran under.

See [Result types](https://remember.dev/docs/reference/result-types).

## Size limits

`POST /ingest` bodies can be capped per deployment. A self-hosted deployment
has no cap by default; set `REMEMBERSTACK_SELFHOST_INGEST_BODY_MAX_BYTES` to
add one. With a cap set, a body over it gets `413` `body_too_large` and a
request without `Content-Length` gets `411` `length_required`.

Other bounds are per route: search and lookup `k` up to 400, search query up to 4,096
characters in the `POST` form, operation queries up to 8,192 characters, SQL
text up to 65,536 bytes, and so on. Each group page lists them.

## CORS

A deployment sends no CORS headers unless it is told which browser origins may
call it (`REMEMBERSTACK_SELFHOST_BROWSER_ORIGINS`, a comma-separated list of
exact `https` origins, or `http` origins on `localhost`, `127.0.0.1` or
`[::1]`; empty by default). For those origins it allows:

- methods `GET`, `POST` and `DELETE` (`DELETE` still needs a `write` credential),
- request headers `Authorization` and `Content-Type`,
- no credentialed (cookie) mode,
- preflight caching for 600 seconds.

Wildcards are refused at startup, and so is an origin with a path, a user,
the default port (`443`, or `80` for `http`) written out, or upper-case
letters.

## Routes outside the documented API

The published OpenAPI document (`openapi.json` in the repository) describes
the 27 operations above. A few routes exist in the code but are not part of
it:

| Route | Status |
|---|---|
| `GET /healthz` | Served by the self-hosted profile as the container's liveness probe. Returns `{"status": "ok"}` after a `SELECT 1` against PostgreSQL. No credential needed. Not in the OpenAPI document. See [Deployment](https://remember.dev/docs/reference/http-api/deployment#get-healthz). |
| `GET /connectors`, `POST /connectors`, `GET /connectors/{connector_id}`, `POST /connectors/{connector_id}/pause` | Defined in the engine, but the shipped profile does not mount them, so they answer `404`. The `remember` client's `connectors()`, `add_connector()`, `pause_connector()` and `connector_status()` methods therefore fail against a stock deployment. |
| `GET /ops/cost-export/v1` | Served on a separate listener, only when `REMEMBERSTACK_COST_EXPORT_BIND` is set. Self-hosted operators only. See [Deployment](https://remember.dev/docs/reference/http-api/deployment#get-opscost-exportv1). |

The running server does not serve its own schema: `/openapi.json`, `/docs`
and `/redoc` answer `404`. Those pages would answer without a credential, and
the API is for programs, not browsers, so they are switched off. Use the
`openapi.json` file checked into the repository for the release you run.

---

Source: https://remember.dev/docs/reference/http-api/ingest

# Ingest, readiness and documents

Four routes cover the write side of memory. `POST /ingest` adds one file.
`POST /readiness` tells you whether the versions you added are processed far
enough to query. `GET /documents` lists what the deployment holds, newest
document first. `DELETE /documents/{doc_id}` removes a document from the
memory.

Base URL, authentication and error shapes are described in
[HTTP API conventions](https://remember.dev/docs/reference/http-api).

## POST /ingest

Add one file as a new document, or as a new version of an existing one.

**Scope:** `ingest` or `write`.

The body is the raw file. Everything else travels in the query string, except
attribution, which travels in headers so that it never lands in access logs.

### Parameters

| Name | In | Type | Required | Default | Constraints |
|---|---|---|---|---|---|
| `filename` | query | string | yes | | At least 1 character. Its suffix is kept on the stored original; its stem is the title when `title` is absent. |
| `mime` | query | string | yes | | At least 1 character. Chooses the converter. |
| `title` | query | string | no | stem of `filename` | |
| `source_kind` | query | string | no | | At least 1 character. Must be sent together with `source_ref`. |
| `source_ref` | query | string | no | | At least 1 character. Must be sent together with `source_kind`. |
| `source_modified_at` | query | date-time | no | | UTC only (`Z` or `+00:00`). Requires `source_kind` and `source_ref`. |
| `versioning_mode` | query | `snapshot` \| `living` | no | `snapshot` | `living` requires `source_kind` and `source_ref`. |
| `source_version_ref` | query | string | no | | The source system's own revision id. Requires `source_kind` and `source_ref`. |
| `X-Ingest-Principal-Kind` | header | `user` \| `api_credential` \| `service` | no | | Sent with `X-Ingest-Principal-Ref`. See [Attribution](#attribution). |
| `X-Ingest-Principal-Ref` | header | string | no | | 1–255 printable ASCII characters. Sent with `X-Ingest-Principal-Kind`. |
| `Content-Type` | header | | yes | | `application/octet-stream`. |
| `Content-Length` | header | integer | when the deployment caps bodies | | |

**Body:** the file's bytes, `application/octet-stream`.

### What identifies a document

Without `source_kind` and `source_ref`, the file's content is its identity:
the document id is derived from the SHA-256 of the bytes. Sending the same
bytes again is a no-op, and different bytes are a different document. After
you [delete](#delete-documentsdoc_id) a document, sending its bytes again adds
it back as a new version (`"created": true`), processed from the start.

With `source_kind` and `source_ref`, the pair is the identity. Use it for
anything that changes over time — a spec that gets edited, a page in another
system — so that each change becomes a new version of the same document:

- bytes identical to the document's latest version: no-op, `"created": false`;
- changed bytes: a new version, `"created": true`;
- bytes that match only an older version (a revert): a new version.

`versioning_mode` says what a new version means for the old one. `snapshot`
(the default) keeps what earlier versions said as testimony. `living` treats
the newest version as the source's current state: claims the new version no
longer makes stop being current testimony. See
[Updating a source](https://remember.dev/docs/concepts/updating-sources).

A MIME type the deployment has no converter for is still accepted. Its
conversion waits until a converter route for that type exists, and the
response says so with `"parked": "no_route"`. See
[File formats and converters](https://remember.dev/docs/self-hosting/converters).

### What the first ingest fixes

Some settings are taken from the first ingest and kept:

- **`title` and `versioning_mode`** belong to the document and are set when
  it is first ingested. Later ingests of the same document do not change
  them.
- **The MIME type** belongs to the bytes: identical bytes are stored once,
  with the type they were first sent with, and conversion uses that type.
  One exception: if that type has no converter route, so conversion is
  parked, sending the same bytes with a type that has a route replaces it
  and releases every parked conversion of those bytes. A file first sent as
  `application/octet-stream` is fixed by sending it again as `text/markdown`.

The response reports the values in force (`mime`, `title`,
`versioning_mode`). Compare them with what you sent to see whether yours
were taken.

### Attribution

`X-Ingest-Principal-Kind` and `X-Ingest-Principal-Ref` record who added the
version. The engine believes them only when both hold:

- the deployment declares its network trusted
  (`REMEMBERSTACK_SELFHOST_TRUSTED_PRINCIPAL_SOURCE=true`, off by default), and
- when authentication is on, the credential has `write` scope.

Otherwise the headers are ignored, not rejected: the upload succeeds and no
attribution is recorded. A browser `ingest` credential can never set
attribution. Attribution belongs to the new version only; a no-op re-ingest
does not change it.

### Response

`200` with an [`IngestedVersion`](https://remember.dev/docs/reference/result-types#ingestedversion):

```json
{
  "deployment_id": "5d0c7a52-3b1e-4f55-9a8e-0e6c1f2b7a10",
  "doc_id": "a1f3e0b4-1c2d-5e6f-8a9b-0c1d2e3f4a5b",
  "version_id": "0f9e8d7c-6b5a-4c3d-2e1f-0a9b8c7d6e5f",
  "content_hash": "3b7c0e2d9f1a…",
  "created": true,
  "mime": "text/markdown",
  "title": "Q3 plan",
  "versioning_mode": "snapshot",
  "parked": null
}
```

`200` means the version is stored. `"parked": null` means only
that it is not parked for `no_route`; poll [`POST /readiness`](#post-readiness)
with the `version_id` for its processing state. `"parked": "no_route"` means
its conversion is parked waiting for a conversion route for the `mime`: the
bytes are kept, but nothing reads them until an operator adds a route if
needed and runs `remember ops resume-no-route`. Do not wait on readiness
for it; it will not become ready on its own.

### Errors

| Status | `detail` | Cause |
|---|---|---|
| `411` | `length_required` | The deployment caps bodies and the request has no `Content-Length`. |
| `413` | `body_too_large` | The body is over the deployment's cap. |
| `409` | `source_forgotten` | A hard forget removed these bytes, or the document with this `source_kind` and `source_ref`. A forget is permanent; do not retry. |
| `422` | `source_kind and source_ref must be supplied together` | Only one of the pair was sent. |
| `422` | `source timestamps, revisions, and living mode require source_kind/source_ref` | `source_modified_at`, `source_version_ref` or `versioning_mode=living` without a source identity. |
| `422` | `source_modified_at must be timezone-aware UTC` | The timestamp has no offset or a non-zero one. |
| `422` | `X-Ingest-Principal-Kind and X-Ingest-Principal-Ref must be supplied together` | Trusted attribution with only one header. |
| `422` | `invalid_ingest_principal` | Trusted attribution with an unknown kind or a malformed reference. |
| `422` | validation list | `filename` or `mime` missing or empty, or another parameter malformed. |
| `503` | `{"code": "forget_in_progress"}` | A hard forget is running. |

A self-hosted deployment has no body cap unless you set
`REMEMBERSTACK_SELFHOST_INGEST_BODY_MAX_BYTES`, and accepts any file its
converters handle.

### Example

```bash
curl -s -X POST "$REMEMBER_API_URL/ingest?filename=billing-migration.md&mime=text/markdown&source_kind=notes&source_ref=specs/billing-migration.md&versioning_mode=living" \
  -H "Authorization: Bearer $REMEMBER_API_KEY" \
  -H "Content-Type: application/octet-stream" \
  --data-binary @billing-migration.md
```

```python
from remember import Client

memory = Client()
version = memory.ingest(
    "billing-migration.md",
    source_kind="notes",
    source_ref="specs/billing-migration.md",
    versioning_mode="living",
)
print(version.version_id, version.created)
```

`Client.ingest` accepts a path (string or `Path`) or bytes; with bytes, pass
`filename`. It takes `mime` from the extension of the file's name (for
bytes, of `filename`): `.md` is `text/markdown` on every Python
installation; see [the full table](https://remember.dev/docs/guides/ingest-files#the-mime-type).
An extension outside that table takes the type your Python installation's MIME database gives it, or `application/octet-stream` if it has none. `Client.ingest_file(path, ...)` is the same call.

## POST /readiness

Report, for up to 1,000 versions, which pipeline stages have finished and
whether the capabilities you need are ready. It only reads; it never waits.

**Scope:** `read`.

### Request body

| Field | Type | Required | Constraints |
|---|---|---|---|
| `version_ids` | array of UUID | yes | 1 to 1,000 items. Duplicates are collapsed. |
| `require` | object | yes | All four fields below are required. |
| `require.pipeline` | boolean | yes | Every expected stage has finished for every version. |
| `require.p1` | boolean | yes | The search index (claims, chunks, facts) is published. |
| `require.live_graph` | boolean | yes | The live graph passes its health checks. |
| `require.p3` | boolean | yes | A filesystem snapshot built after the versions finished has been published. |

Unknown fields are rejected (`422`).

### Response

`200` with a [`PipelineReadinessReport`](https://remember.dev/docs/reference/result-types#pipelinereadinessreport).
`ready` is `true` when every capability you marked as required is ready. Each
version lists one entry per expected stage with its `status`. A version is
ready when every stage is `succeeded` or `skipped` and has a `finished_at`.

The stages a self-hosted deployment reports, in order: `convert`, `structure`,
`chunk`, `embed_chunk`, `extract_claims`, `ground_claims`,
`normalize_relations`, `adjudicate_observations`, `adjudicate_supersession`,
`embed_claim`, `reconcile`, `label_relation`.

A `failed` stage has a retry scheduled and can still succeed; keep polling.
If a stage is `dead_letter`, stop polling: it used all its attempts, and
that version will not become ready without intervention.

Capability `reason` values:

| Capability | `reason` when ready | `reason` when not ready |
|---|---|---|
| `pipeline` | `ready` | `stage_incomplete` |
| `p1` | `ready` | `search_channel_incomplete` |
| `p3` | `ready` | `corpus_snapshot_incomplete` |
| `live_graph` | `ready` | `graph_server_version_mismatch`, `graph_extension_version_mismatch`, `graph_role_contract_mismatch`, `graph_helper_contract_mismatch`, `graph_catalog_mismatch`, `graph_role_runtime_limits_mismatch`, `graph_smoke_identifier_collision`, `graph_pgq_guard_smoke_failed`, `graph_pgq_smoke_failed`, `graph_neighborhood_smoke_failed`, `graph_path_smoke_failed`, `graph_citation_smoke_failed`, `graph_database_or_permission_failed`, `graph_smoke_contract_failed` |

A version id the deployment does not know is not an error: every stage reads
`missing` and the version is not ready.

### Errors

| Status | `detail` | Cause |
|---|---|---|
| `422` | validation list | Empty or oversized `version_ids`, a malformed UUID, a missing `require` field, or an unknown field. |

### Example

```bash
curl -s -X POST "$REMEMBER_API_URL/readiness" \
  -H "Authorization: Bearer $REMEMBER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"version_ids": ["0f9e8d7c-6b5a-4c3d-2e1f-0a9b8c7d6e5f"],
       "require": {"pipeline": true, "p1": true, "live_graph": true, "p3": false}}'
```

```python
from remember import Client, ReadinessRequirements

memory = Client()
report = memory.pipeline_readiness(
    version_ids=(version.version_id,),
    require=ReadinessRequirements(pipeline=True, p1=True, live_graph=True, p3=False),
)

# Or poll until ready (pipeline, p1 and live_graph required; p3 optional):
report = memory.wait_for_readiness([version.version_id])
```

`wait_for_readiness` polls every `poll_interval` seconds (default 15) and
raises `TimeoutError` after `timeout` seconds (default 1800). It keeps
polling through a `failed` stage and raises `PipelineDeadLettered` at once
on a `dead_letter` stage. See [Wait until a document is queryable](https://remember.dev/docs/guides/wait-for-readiness).

## GET /documents

List the documents the deployment holds, one page at a time, newest document
first.

**Scope:** `read`.

### Parameters

| Name | In | Type | Required | Default | Constraints |
|---|---|---|---|---|---|
| `limit` | query | integer | no | `50` | 1 to 200. |
| `cursor` | query | string | no | | The `cursor` from the previous page. Opaque. |
| `status` | query | `ingesting` \| `converting` \| `structuring` \| `ready` \| `failed` | no | | Filters on the newest version's status. |

Documents are ordered by when each was first seen, then by id. Re-ingesting a
document does not move it: it is the same document, first seen when it was
first seen. Deleted documents and deleted versions are not listed.

The order is fixed on purpose. "Most recently changed first" would move a
document every time a new version arrived, and a client paging through the
list would skip it or see it twice. `first_seen_at` never changes, so the
cursor stays exact while documents are being added. A new document still
appears at the top of the first page.

Each row reports the newest surviving version (`latest`) and, separately,
whether any version is being served (`serving`). A document whose newest
upload failed can still be served from an older, working version.

A document status of `ready` means conversion and structuring finished. It
does not mean the document is searchable yet; use
[`POST /readiness`](#post-readiness) for that.

### Response

`200` with a [`DocumentPage`](https://remember.dev/docs/reference/result-types#documentpage):

```json
{
  "documents": [
    {
      "doc_id": "a1f3e0b4-1c2d-5e6f-8a9b-0c1d2e3f4a5b",
      "title": "billing-migration",
      "source_kind": "notes",
      "source_uri": "specs/billing-migration.md",
      "first_seen_at": "2026-09-21T09:14:03.201Z",
      "latest": {
        "version_id": "0f9e8d7c-6b5a-4c3d-2e1f-0a9b8c7d6e5f",
        "version_no": 3,
        "status": "ready",
        "ingested_at": "2026-09-23T08:02:11.540Z",
        "error": null
      },
      "serving": true
    }
  ],
  "cursor": null
}
```

`cursor` is `null` on the last page.

### Errors

| Status | `detail` | Cause |
|---|---|---|
| `400` | `cursor is malformed` | The cursor does not parse. |
| `422` | validation list | `limit` out of range or an unknown `status`. |

### Example

```bash
curl -s "$REMEMBER_API_URL/documents?status=failed&limit=20" \
  -H "Authorization: Bearer $REMEMBER_API_KEY"
```

```python
from remember import Client

memory = Client()
page = memory.list_documents(status="failed", limit=20)
for document in page.documents:
    print(document.doc_id, document.title, document.latest.error)
# page.cursor is None on the last page; pass it back to read the next one.
```

`remember documents list` prints the same page as JSON.

## DELETE /documents/{doc_id}

Remove one document from the memory: every version of it, all at once.

**Scope:** `write`.

### Parameters

| Name | In | Type | Required | Constraints |
|---|---|---|---|---|
| `doc_id` | path | UUID | yes | The `doc_id` that `POST /ingest` returned, or that a claim, chunk or `GET /documents` names. |

There is no body.

### What deleting does

When the call returns, the document is out of the memory:

- it no longer appears in `GET /documents`, search, facts, the graph or SQL
  queries;
- its claims stop counting as evidence (their reason becomes
  `version_deleted`);
- a fact that only this document supported is closed, with a recorded
  retraction (`retracted_source_removal`); a fact other documents also
  support stays, with one supporter fewer.

Deleting is not erasing. The claims and the stored original stay in the
deployment as history, and the retraction is recorded. Erasing a document's
bytes and every trace of it is a separate operator operation that is not
offered through the API.

Processing that was still running for the document stops publishing new
claims. Anything it already produced is never visible and is retired when
that version reaches the `reconcile` stage.

A pending review of whether the document's claims were extracted correctly is
closed as moot, because the document is gone.

If you ingest the same file again later, it is added back as a new version
and processed from the start, like any new document. The facts that closed
stay closed; the new claims support facts as usual.

The route starts no pipeline work. Its one possible
model call re-embeds the profile text of entities whose facts changed; that
call is recorded with the deployment's other request-time model calls. If
the model provider is down, the deletion still succeeds and those profiles
catch up the next time the entity's evidence changes.

### Response

`200` with a [`DocumentDeletion`](https://remember.dev/docs/reference/result-types#documentdeletion):

```json
{
  "doc_id": "a1f3e0b4-1c2d-5e6f-8a9b-0c1d2e3f4a5b",
  "deleted_at": "2026-09-23T10:41:07.318Z",
  "claims_retired": 12,
  "relations_closed": 2,
  "observations_closed": 1
}
```

The counts describe what this call changed.

### Deleting twice

A deletion is all or nothing: the document is hidden and its evidence
updated in one step, or nothing changes. A second `DELETE` of the same
document answers `404` `document_not_found`, the same as an id the deployment
never held, so retrying after a timeout is safe. The one exception is a
document that was hidden some other way but whose evidence was never updated;
deleting it finishes the job and answers `200` with what it finished.

If you ingest the document again while a `DELETE` of it is running, the
ingest waits for the delete to finish and then adds the document back as a
new version.

### Errors

| Status | `detail` | Cause |
|---|---|---|
| `404` | `document_not_found` | No such document, or it is already deleted. Do not retry. |
| `403` | `credential may not perform this operation` | The credential has `read` or `ingest` scope. |
| `422` | validation list | `doc_id` is not a UUID. |
| `503` | `{"code": "forget_in_progress"}` | A hard forget is running, or started preparing just before the delete. Nothing was deleted; retry later. |

### Example

```bash
curl -s -X DELETE "$REMEMBER_API_URL/documents/a1f3e0b4-1c2d-5e6f-8a9b-0c1d2e3f4a5b" \
  -H "Authorization: Bearer $REMEMBER_API_KEY"
```

```python
from remember import Client, MemoryApiError

memory = Client()
try:
    deletion = memory.delete_document(doc_id="a1f3e0b4-1c2d-5e6f-8a9b-0c1d2e3f4a5b")
    print(deletion.claims_retired, deletion.relations_closed)
except MemoryApiError as error:
    if error.status_code != 404:
        raise
```

---

Source: https://remember.dev/docs/reference/http-api/operations

# Assured operation routes

Assured operations are the four fixed retrievals RememberStack guarantees:
`resolve_entity`, `claims_and_sources_context`, `facts_context` and
`combined_context`. Two routes expose them. The CLI and the MCP server read
the same registry, so the tools they offer always match these routes.

What each operation takes and returns is described in
[Assured operations](https://remember.dev/docs/reference/assured-operations). This page covers the HTTP
side.

## GET /operations

Return the descriptors of the four operations this deployment serves.

**Scope:** `read`.

### Parameters

None.

### Response

`200` with an array of [`ToolDescriptor`](https://remember.dev/docs/reference/result-types#tooldescriptor),
ordered by the registry. Each descriptor carries the operation's `name`,
`description`, closed `input_schema` (JSON Schema, `additionalProperties:
false`), `result_schema`, `result_contract` (`envelope` or
`context_bundle_v2`), `output_grain`, `answer_intent`, `version` and
`implementation_plan_hash`. `mutates` is `false` on all four.

```json
[
  {
    "name": "resolve_entity",
    "description": "Resolve a name to ranked current survivor candidates; never silently guess.",
    "input_schema": {
      "type": "object",
      "properties": {"name": {"type": "string", "minLength": 1}},
      "additionalProperties": false,
      "required": ["name"]
    },
    "result_schema": {"...": "the Envelope JSON Schema"},
    "result_contract": "envelope",
    "output_grain": "fact",
    "answer_intent": "identity",
    "mutates": false,
    "version": 1,
    "implementation_plan_hash": "fcf8f8bd08efe28d62af572dbd4f81601b03f1744d993021225454b55748059a"
  }
]
```

### Example

```bash
curl -s "$REMEMBER_API_URL/operations" \
  -H "Authorization: Bearer $REMEMBER_API_KEY"
```

```python
from remember import Client

memory = Client()
for descriptor in memory.list_operations():
    print(descriptor.name, descriptor.version, descriptor.result_contract)
```

## POST /operations/{name}

Run one assured operation over a JSON object of arguments.

**Scope:** `read`. The route checks the operation's descriptor, and all
four declare themselves read-only (`mutates: false`). See
[HTTP API conventions](https://remember.dev/docs/reference/http-api#scopes).

### Parameters

| Name | In | Type | Required | Constraints |
|---|---|---|---|---|
| `name` | path | string | yes | One of `resolve_entity`, `claims_and_sources_context`, `facts_context`, `combined_context`. |

### Request body

A JSON object whose keys are the operation's parameters. An empty body is the
empty object. Arguments are checked against the descriptor before anything
runs:

- unknown keys are refused;
- a required key that is missing is refused;
- strings must be JSON strings; integers must be JSON integers (a whole-number
  float such as `3.0` is accepted, `true` is not);
- `entity_ids` must be an array of UUID strings, with no duplicates;
- `time` must be an object in one of the four modes;
- length, item-count and range bounds from the descriptor apply;
- for `claims_and_sources_context`, `candidate_k` may not be smaller than `k`.

The parameters of each operation are listed in
[Assured operations](https://remember.dev/docs/reference/assured-operations).

### Response

`200` with the operation's result:

| Operation | Result |
|---|---|
| `resolve_entity` | [`Envelope`](https://remember.dev/docs/reference/result-types#envelope), grain `fact` |
| `claims_and_sources_context` | `Envelope`, grain `evidence` |
| `facts_context` | `Envelope`, grain `fact` |
| `combined_context` | [`ContextBundle/v2`](https://remember.dev/docs/reference/result-types#contextbundlev2) |

An answer of "nothing found" is still `200`. The envelope's `negative` field
says which kind of nothing: `unknown_entity`, `known_empty` or `boundary`.
See [Reading a result](https://remember.dev/docs/concepts/reading-results).

### Errors

| Status | `detail` | Cause |
|---|---|---|
| `403` | `credential may not perform this operation` | The credential's scope is not `write`. |
| `404` | the operation name, as a string | No active operation has that name. |
| `422` | `{"code": "invalid_parameter", "message": "<name>"}` | A required argument is missing; the message is its name. |
| `422` | `{"code": "invalid_parameter", "message": "unknown argument(s): …"}` | An argument the operation does not take. |
| `422` | `{"code": "invalid_parameter", "message": "invalid <name>: …"}` | Wrong type, a malformed UUID, or a bad `time` object. |
| `422` | `{"code": "invalid_parameter", "message": "<name> violates maxLength=8192"}` (and similar) | A length, item-count, uniqueness or range bound. |
| `422` | `{"code": "invalid_parameter", "message": "candidate_k cannot be smaller than k"}` | `claims_and_sources_context` only. |
| `422` | validation list | The body is not a JSON object. |
| `503` | `model provider unavailable` | Embedding the query failed at the model provider. Retry later. |

### Example

```bash
curl -s -X POST "$REMEMBER_API_URL/operations/facts_context" \
  -H "Authorization: Bearer $REMEMBER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": "Who owns the billing migration?",
       "time": {"mode": "current"}}'
```

```python
from remember import Client

memory = Client()

# The generic form:
result = memory.run_operation(
    name="facts_context",
    arguments={"query": "Who owns the billing migration?"},
)

# The named helpers:
people = memory.resolve_entity("Dana")
said = memory.claims_and_sources_context("billing migration cutover date")
held = memory.facts_context("Who owns the billing migration?", hops=1)
both = memory.combined_context("billing migration status", time={"mode": "current"})
```

`run_operation` returns an `Envelope`, or a `ContextBundleV2` when the body
says `"contract": "ContextBundle/v2"`. The helpers pass only some parameters:
`facts_context` takes `query`, `time`, `hops`, `predicate` and `entity_ids`;
`combined_context` takes `query` and `time`; `claims_and_sources_context`
takes `query`; `resolve_entity` takes `name`. Use `run_operation` for the rest
(`k`, `candidate_k`, `evidence_per_fact`, and `entity_ids` on the other
operations).

---

Source: https://remember.dev/docs/reference/http-api/entities-and-facts

# Entities and facts

These five routes are the direct, single-purpose reads behind the assured
operations. Use them when you already know what you want: the entity ids for
a name, the relations that match a pattern, or the evidence behind one fact.
Each returns an [`Envelope`](https://remember.dev/docs/reference/result-types#envelope).

All five need the `read` scope. Base URL,
authentication and error shapes are described in
[HTTP API conventions](https://remember.dev/docs/reference/http-api).

## GET /resolve

Resolve a name to the current entities it can mean, ranked. It never guesses:
if several entities match, you get all of them.

### Parameters

| Name | In | Type | Required | Default | Constraints |
|---|---|---|---|---|---|
| `name` | query | string | yes | | |
| `context_entity_ids` | query | array of UUID | no | none | Repeat the parameter for each id. At most 8. Duplicates are collapsed. |

Matching runs in tiers and stops at the first that finds anything:

| Tier | `tier` value | How it matches |
|---|---|---|
| Exact | `T0` | The normalized name equals a current alias. Every entity with that alias is returned, uncapped. |
| Trigram | `T1` | Fuzzy match against current aliases. |
| Phonetic | `T2` | Sound-alike match against current aliases. |
| Embedding | `T3` | The name is embedded and compared with entity profiles. |

`T1`, `T2` and `T3` stop at the same candidate width the ingest pipeline uses
and say so in `truncation` (`reason: "resolve_candidate_limit"`,
`total_is_exact: false`). `context_entity_ids` does not add or remove
candidates; it reorders them by how many current relations connect each
candidate to those entities (`context_hits`).

### Response

`200` with an `Envelope` of grain `fact`. `entities` holds the candidates,
best first. When nothing matches, `entities` is empty and `negative.kind` is
`unknown_entity`. If the embedding tier was needed but the entity search index
is not published, `negative.kind` is `boundary`.

```json
{
  "grain": "fact",
  "temporal_scope": {"mode": "current", "evaluated_at": "2026-09-23T10:00:00Z", "believed_at": "2026-09-23T10:00:00Z", "identity_regime": "current"},
  "entities": [
    {"entity_id": "7c1e…", "canonical_name": "Dana Whitfield", "tier": "T0", "context_hits": 0},
    {"entity_id": "91ab…", "canonical_name": "Dana", "tier": "T0", "context_hits": 0}
  ],
  "freshness": {"pg_live_ts": "2026-09-23T10:00:00Z", "p1_written_inline": true, "p1_believed_at_horizon": null, "k": null},
  "truncation": null,
  "negative": null
}
```

Array fields that are empty (`facts`, `evidence` and so on) are present in
the real response and omitted here.

### Errors

| Status | `detail` | Cause |
|---|---|---|
| `422` | validation list | `name` missing, a malformed UUID, or more than 8 `context_entity_ids`. |
| `503` | `model provider unavailable` | The embedding tier was needed and the embedding call failed. Retry with back-off. |

### Example

```bash
curl -s -G "$REMEMBER_API_URL/resolve" \
  -H "Authorization: Bearer $REMEMBER_API_KEY" \
  --data-urlencode "name=Dana"
```

```python
from remember import Client

memory = Client()
envelope = memory.resolve(name="Dana")
for candidate in envelope.entities:
    print(candidate.entity_id, candidate.canonical_name, candidate.tier)
```

The assured operation [`resolve_entity`](https://remember.dev/docs/reference/assured-operations#resolve_entity)
runs the same resolution without `context_entity_ids`.

## GET /lookup/relations

Return the relations that match a subject–predicate–object pattern, as held
now or at a past instant.

### Parameters

| Name | In | Type | Required | Default | Constraints |
|---|---|---|---|---|---|
| `subject_entity_id` | query | UUID | no | any | |
| `predicate` | query | string | no | any | Exact predicate name. |
| `object_entity_id` | query | UUID | no | any | |
| `valid_at` | query | date-time | no | now | Must be UTC (`Z` or `+00:00`). |
| `k` | query | integer | no | `50` | 1–400. The most relations to return. |

Every filter is optional; the ones you send are combined with AND. A
relation matches when it has not been invalidated and its validity window
covers `valid_at` (or now). A relation with no known start or end is treated
as open on that side.

At most `k` relations come back. When more match, `truncation` says so
(`truncated: true`, `reason: "lookup_k_limit"`, `total_is_exact: false`):
narrow the pattern or raise `k`.

### Response

`200` with an `Envelope` of grain `fact`. `facts` holds one
[`FactResult`](https://remember.dev/docs/reference/result-types#factresult) per relation (`kind:
"relation"`), ordered by evidence count, highest first, then by when the
memory learned it; `truncation` is set when the `k` cap left matches out.
Each result carries its `validity`, its
`temporal_match`, its `support` and, when the relation is part of a
contradiction, the other sides in `contradiction`. With `valid_at`,
`temporal_scope.mode` is `at`; without it, `current`. No match gives
`negative.kind` `known_empty`.

### Errors

| Status | `detail` | Cause |
|---|---|---|
| `422` | validation list | A malformed UUID or timestamp, a `valid_at` without a zero UTC offset, or `k` outside 1–400. |

### Example

```bash
curl -s -G "$REMEMBER_API_URL/lookup/relations" \
  -H "Authorization: Bearer $REMEMBER_API_KEY" \
  --data-urlencode "subject_entity_id=$RAVI_ID" \
  --data-urlencode "valid_at=2026-06-01T00:00:00Z"
```

```python
from datetime import datetime, UTC
from remember import Client

memory = Client()
envelope = memory.lookup_relations(
    subject_entity_id=ravi_id,
    valid_at=datetime(2026, 6, 1, tzinfo=UTC),
)
```

## GET /lookup/observations

Return the live observations (single-entity facts, such as "the billing
migration is behind schedule") about one entity, optionally ranked by how
well they match a phrase.

### Parameters

| Name | In | Type | Required | Default | Constraints |
|---|---|---|---|---|---|
| `entity_id` | query | UUID | yes | | |
| `property_query` | query | string | no | | When present, observations are found by semantic similarity to this text. |
| `k` | query | integer | no | `10` | 1–400. The most observations to return. |

Without `property_query`, the live observations on the entity whose validity
covers now are returned, strongest evidence first, at most `k`; when more
exist, `truncation` says so (`reason: "lookup_k_limit"`). With
`property_query`, up to `k` candidates are nominated by similarity and each is
re-checked against the database before it is returned; candidates that no
longer hold are dropped and counted in `dropped_by_hydration`.

### Response

`200` with an `Envelope` of grain `fact`. `facts` holds one `FactResult` per
observation (`kind: "observation"`). No match gives `negative.kind`
`known_empty`.

### Errors

| Status | `detail` | Cause |
|---|---|---|
| `422` | validation list | `entity_id` missing or malformed, or `k` not an integer in 1–400. |
| `503` | `model provider unavailable` | The embedding call for `property_query` failed. Retry with back-off. |

### Example

```bash
curl -s -G "$REMEMBER_API_URL/lookup/observations" \
  -H "Authorization: Bearer $REMEMBER_API_KEY" \
  --data-urlencode "entity_id=$BILLING_MIGRATION_ID" \
  --data-urlencode "property_query=schedule" \
  --data-urlencode "k=10"
```

```python
envelope = memory.lookup_observations(
    entity_id=billing_migration_id, property_query="schedule", k=10
)
```

## GET /hydrate/relation/{relation_id}

Follow one relation down to the claims that support it and the documents
those claims came from. This is the audit hop: it answers "why does the memory
hold this?".

### Parameters

| Name | In | Type | Required | Constraints |
|---|---|---|---|---|
| `relation_id` | path | UUID | yes | |

### Response

`200` with an `Envelope` of grain `composite`:

- `facts`: the relation itself, with its contradiction block and support
  state. An invalidated relation is still returned, with `invalidated_at` set
  in its `validity`: this route does not hide history.
- `evidence`: the supporting claims
  ([`EvidenceResult`](https://remember.dev/docs/reference/result-types#evidenceresult)), each with its source
  span and character offsets.
- `sources`: the documents
  ([`SourceRecord`](https://remember.dev/docs/reference/result-types#sourcerecord)).

An unknown relation id gives `negative.kind` `unknown_entity`.

### Errors

| Status | `detail` | Cause |
|---|---|---|
| `422` | validation list | `relation_id` is not a UUID. |

### Example

```bash
curl -s "$REMEMBER_API_URL/hydrate/relation/$RELATION_ID" \
  -H "Authorization: Bearer $REMEMBER_API_KEY"
```

```python
envelope = memory.hydrate_relation(relation_id=relation_id)
```

## GET /transcript/relation/{relation_id}

Return the decisions the memory made about one relation: when it was
superseded, by what, how, and on what grounds.

### Parameters

| Name | In | Type | Required | Constraints |
|---|---|---|---|---|
| `relation_id` | path | UUID | yes | |

### Response

`200` with an `Envelope` of grain `composite`. `transcript` holds up to the 40
most recent decisions ([`TranscriptEntry`](https://remember.dev/docs/reference/result-types#transcriptentry)),
oldest first. `truncation` is always present and says whether older decisions
were left out (`truncated`, `returned`, `estimated_total`). A relation with no
recorded decisions, or an unknown id, gives `negative.kind` `known_empty`.

### Errors

| Status | `detail` | Cause |
|---|---|---|
| `422` | validation list | `relation_id` is not a UUID. |

### Example

```bash
curl -s "$REMEMBER_API_URL/transcript/relation/$RELATION_ID" \
  -H "Authorization: Bearer $REMEMBER_API_KEY"
```

```python
envelope = memory.transcript_relation(relation_id=relation_id)
for decision in envelope.transcript:
    print(decision.decided_at, decision.outcome, decision.method)
```

---

Source: https://remember.dev/docs/reference/http-api/search

# Search and adjacent chunks

Search returns evidence, not facts. A claim is what one source said; a chunk
is a passage of a source document. Neither is a statement of what the memory
holds true. For that, use [`facts_context`](https://remember.dev/docs/reference/assured-operations#facts_context)
or the [lookup routes](https://remember.dev/docs/reference/http-api/entities-and-facts).

Each search exists in two forms. The `GET` form puts the query in the URL; the
`POST` form puts it in the body. A query is often a person's own words, and a
URL is written to access logs, kept by proxies and saved in browser history.
The `POST` form keeps the query text out of all three, so prefer it for
anything a person typed. The `GET` form stays for existing clients; both
return the same result and cost the same.

All six routes need the `read` scope. Base URL, authentication and error shapes are described in
[HTTP API conventions](https://remember.dev/docs/reference/http-api).

## How search works

1. **Nominate.** The chosen channel ranks candidate ids: `semantic` embeds the
   query and compares vectors; `bm25` ranks by keyword. Only current testimony
   is nominated for claims.
2. **Confirm.** Each candidate is re-read from the database. Anything that no
   longer holds — a deleted version, a forgotten source — is dropped, and the
   number dropped is reported in `dropped_by_hydration`.

A search that finds nothing returns `200` with `negative.kind` `known_empty`.

## GET /search/claims

Search claims.

### Parameters

| Name | In | Type | Required | Default | Constraints |
|---|---|---|---|---|---|
| `query` | query | string | yes | | |
| `k` | query | integer | no | `10` | 1 to 400. |
| `channel` | query | `semantic` \| `bm25` | no | `semantic` | |

### Response

`200` with an [`Envelope`](https://remember.dev/docs/reference/result-types#envelope) of grain `evidence`.
`evidence` holds up to `k` [`EvidenceResult`](https://remember.dev/docs/reference/result-types#evidenceresult)
entries in rank order.

### Errors

| Status | `detail` | Cause |
|---|---|---|
| `422` | validation list | `query` missing, `k` out of range, or an unknown `channel`. |
| `503` | `model provider unavailable` | The embedding call failed (semantic channel). Retry with back-off. |

### Example

```bash
curl -s -G "$REMEMBER_API_URL/search/claims" \
  -H "Authorization: Bearer $REMEMBER_API_KEY" \
  --data-urlencode "query=billing migration cutover date" \
  --data-urlencode "k=20"
```

```python
from remember import Client

memory = Client()
envelope = memory.search_claims(query="billing migration cutover date", k=20)
for claim in envelope.evidence:
    print(claim.claim_text, claim.document_title)
```

## POST /search/claims

Search claims, with the query in the body.

### Request body

[`SearchRequest`](https://remember.dev/docs/reference/result-types#searchrequest):

| Field | Type | Required | Default | Constraints |
|---|---|---|---|---|
| `query` | string | yes | | 1 to 4,096 characters. |
| `k` | integer | no | `10` | 1 to 400. |
| `channel` | `semantic` \| `bm25` | no | `semantic` | |

Unknown fields are rejected.

### Response

As for [`GET /search/claims`](#get-searchclaims).

### Errors

As for `GET /search/claims`, plus `422` for an empty or over-long `query`
or an unknown field.

### Example

```bash
curl -s -X POST "$REMEMBER_API_URL/search/claims" \
  -H "Authorization: Bearer $REMEMBER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": "billing migration cutover date", "k": 20, "channel": "bm25"}'
```

The `remember` Python client has no method for the `POST` form; its
`search_claims` uses `GET`.

## GET /search/chunks

Search source chunks: passages of the documents themselves.

### Parameters

| Name | In | Type | Required | Default | Constraints |
|---|---|---|---|---|---|
| `query` | query | string | yes | | |
| `k` | query | integer | no | `10` | 1 to 400. |
| `channel` | query | `semantic` \| `bm25` | no | `semantic` | |

### Response

`200` with an `Envelope` of grain `evidence`. `chunks` holds up to `k`
[`ChunkEvidenceResult`](https://remember.dev/docs/reference/result-types#chunkevidenceresult) entries in
rank order, each with the chunk text, its offsets in the document's converted
Markdown, and the document it belongs to.

### Errors

As for [`GET /search/claims`](#get-searchclaims).

### Example

```bash
curl -s -G "$REMEMBER_API_URL/search/chunks" \
  -H "Authorization: Bearer $REMEMBER_API_KEY" \
  --data-urlencode "query=rollback plan" \
  --data-urlencode "channel=bm25"
```

```python
envelope = memory.search_chunks(query="rollback plan", channel="bm25")
for chunk in envelope.chunks:
    print(chunk.document_title, chunk.char_start, chunk.chunk_text[:80])
```

## POST /search/chunks

Search source chunks, with the query in the body.

### Request body

[`SearchRequest`](https://remember.dev/docs/reference/result-types#searchrequest), as for
[`POST /search/claims`](#post-searchclaims).

### Response

As for [`GET /search/chunks`](#get-searchchunks).

### Errors

As for [`POST /search/claims`](#post-searchclaims).

### Example

```bash
curl -s -X POST "$REMEMBER_API_URL/search/chunks" \
  -H "Authorization: Bearer $REMEMBER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": "rollback plan", "k": 10}'
```

The `remember` Python client has no method for the `POST` form.

## GET /chunks/{chunk_id}/adjacent

Return a chunk together with its neighbours in document order, so you can
read a search hit in context.

### Parameters

| Name | In | Type | Required | Default | Constraints |
|---|---|---|---|---|---|
| `chunk_id` | path | UUID | yes | | |
| `window` | query | integer | no | `1` | 1 or 2. How many chunks on each side. |

The neighbours come from the same version of the same document as the target
chunk.

### Response

`200` with an `Envelope` of grain `evidence`. `chunks` holds up to
`2 × window + 1` chunks in document order, the target included. An unknown or
no longer visible `chunk_id` gives `negative.kind` `unknown_entity`. If the
target exists but none of the chunks can be confirmed, `negative.kind` is
`known_empty`.

### Errors

| Status | `detail` | Cause |
|---|---|---|
| `422` | validation list | `chunk_id` is not a UUID, or `window` is not 1 or 2. |

### Example

```bash
curl -s "$REMEMBER_API_URL/chunks/$CHUNK_ID/adjacent?window=2" \
  -H "Authorization: Bearer $REMEMBER_API_KEY"
```

```python
envelope = memory.adjacent_chunks(chunk_id=chunk_id, window=2)
```

## POST /chunks/adjacent

Return a chunk and its neighbours, with the chunk id in the body.

### Request body

| Field | Type | Required | Default | Constraints |
|---|---|---|---|---|
| `chunk_id` | UUID | yes | | |
| `window` | integer | no | `1` | 1 or 2. |

Unknown fields are rejected.

### Response

As for [`GET /chunks/{chunk_id}/adjacent`](#get-chunkschunk_idadjacent).

### Errors

`422` for a missing or malformed `chunk_id`, a `window` other than 1 or 2, or
an unknown field.

### Example

```bash
curl -s -X POST "$REMEMBER_API_URL/chunks/adjacent" \
  -H "Authorization: Bearer $REMEMBER_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"chunk_id\": \"$CHUNK_ID\", \"window\": 1}"
```

The `remember` Python client's `adjacent_chunks` uses the `GET` form.

---

Source: https://remember.dev/docs/reference/http-api/graph

# Graph

The live graph is the set of current relations seen as edges between
entities (the nodes). Three routes walk it: the neighbourhood of one entity,
the shortest paths between two entities, and citation chains between two
documents. They read PostgreSQL directly, inside one read-only snapshot, so
every answer is consistent with itself.

All three use `POST` because their arguments do not fit a query string. They
only read and need the `read` scope. Base URL,
authentication and error shapes are described in
[HTTP API conventions](https://remember.dev/docs/reference/http-api).

## Bounds every traversal shares

Each traversal runs under fixed budgets. When one is reached, the result says
so in `truncation.reason` instead of stopping silently.

| Budget | Value | `truncation.reason` when reached |
|---|---|---|
| Edges examined | 2,000 | `expansion_budget` |
| Frontier size | 1,000 | `frontier_budget` |
| Traversal time | 1,000 ms | `time_budget` |
| Results | the route's limit | `result_budget` |
| Statement timeout | 5 s | none: the request fails with `503` `live graph timed out` |

Two traversals run at a time by default
(`REMEMBERSTACK_SELFHOST_GRAPH_MAX_CONCURRENCY`, default `2`). A request that
cannot get a slot within the pool wait (default 1 second) is refused with
`503` `live graph is busy`.

### Time

`valid_at` and `believed_at` are the two clocks of a bitemporal read: the
instant in the world you ask about, and the instant of the memory's knowledge
you ask from. Send both or neither. With neither, both are the time the
request runs. The result's `temporal_scope` always has mode `as_of` and names
both clocks. Send them in UTC (`Z` or `+00:00`); a timestamp with no offset
or another offset is refused with `422`. See
[Time](https://remember.dev/docs/concepts/time).

## POST /graph/neighborhood

Return the entities within a number of hops of one entity, nearest first,
optionally with the path to each.

### Request body

| Field | Type | Required | Default | Constraints |
|---|---|---|---|---|
| `entity_id` | UUID | yes | | |
| `hops` | integer | no | `2` | 1 to 4. |
| `predicates` | array of string | no | `[]` (all) | At most 100 items, each 1 to 200 characters. Only edges with these predicates are followed. |
| `valid_at` | date-time | no | now | Send with `believed_at`. |
| `believed_at` | date-time | no | now | Send with `valid_at`. |
| `limit` | integer | no | `500` | 1 to 500 entities per page. |
| `continuation` | string | no | | The `truncation.continuation` from the previous page. At most 200 characters. |
| `include_paths` | boolean | no | `false` | Also return one path to each entity. |

Unknown fields are rejected.

### Response

`200` with an [`Envelope`](https://remember.dev/docs/reference/result-types#envelope) of grain `fact`:

- `nodes`: the entities reached ([`GraphNode`](https://remember.dev/docs/reference/result-types#graphnode)),
  each with its hop distance.
- `paths` and `edges`: with `include_paths`, one
  [`GraphPath`](https://remember.dev/docs/reference/result-types#graphpath) per entity and the distinct
  [`GraphEdge`](https://remember.dev/docs/reference/result-types#graphedge) entries they use. Empty
  otherwise.
- `truncation`: always present. When more entities exist, `truncated` is
  `true` and `continuation` holds the cursor for the next page.

| Situation | Result |
|---|---|
| `entity_id` not in the live graph | `negative.kind` `unknown_entity` |
| The entity exists but no neighbour matches | `negative.kind` `known_empty` |
| `continuation` is not a cursor this route issued | `negative.kind` `boundary` |

### Errors

| Status | `detail` | Cause |
|---|---|---|
| `422` | validation list | Out-of-range `hops` or `limit`, too many or too long `predicates`, only one of `valid_at` and `believed_at`, a clock without a zero UTC offset, or an unknown field. |
| `503` | `live graph is busy` | No traversal slot was free in time. Retry. |
| `503` | `live graph result unavailable` | The traversal and the rows it pointed at disagreed. Retry. |
| `503` | `live graph timed out` | The traversal ran past its statement timeout, or its database connection failed. Retry with back-off; narrow the request if it persists. |

### Example

```bash
curl -s -X POST "$REMEMBER_API_URL/graph/neighborhood" \
  -H "Authorization: Bearer $REMEMBER_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"entity_id\": \"$BILLING_MIGRATION_ID\", \"hops\": 2, \"limit\": 100, \"include_paths\": true}"
```

```python
from remember import Client

memory = Client()
page = memory.graph_neighborhood(entity_id=billing_migration_id, hops=2, limit=100)
while True:
    for node in page.nodes:
        print(node.hops, node.name)
    if page.truncation is None or page.truncation.continuation is None:
        break
    page = memory.graph_neighborhood(
        entity_id=billing_migration_id,
        hops=2,
        limit=100,
        continuation=page.truncation.continuation,
    )
```

## POST /graph/path

Return the shortest paths between two entities. All returned paths have the
same, shortest length.

### Request body

| Field | Type | Required | Default | Constraints |
|---|---|---|---|---|
| `from_entity_id` | UUID | yes | | |
| `to_entity_id` | UUID | yes | | |
| `max_hops` | integer | no | `4` | 1 to 6. |
| `predicates` | array of string | no | `[]` (all) | At most 100 items, each 1 to 200 characters. |
| `valid_at` | date-time | no | now | Send with `believed_at`. |
| `believed_at` | date-time | no | now | Send with `valid_at`. |

Unknown fields are rejected. At most 10 paths are returned.

### Response

`200` with an `Envelope` of grain `fact`. `paths` holds the paths, each whole:
if any edge of a path no longer holds, the whole path is dropped rather than
shortened. `nodes` and `edges` list the distinct entities and relations the
paths use. `truncation` is always present.

| Situation | Result |
|---|---|
| Either entity not in the live graph | `negative.kind` `unknown_entity` |
| No path within `max_hops` | `negative.kind` `known_empty` |

### Errors

As for [`POST /graph/neighborhood`](#post-graphneighborhood).

### Example

```bash
curl -s -X POST "$REMEMBER_API_URL/graph/path" \
  -H "Authorization: Bearer $REMEMBER_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"from_entity_id\": \"$DANA_ID\", \"to_entity_id\": \"$RAVI_ID\", \"max_hops\": 3}"
```

```python
envelope = memory.graph_path(from_entity_id=dana_id, to_entity_id=ravi_id, max_hops=3)
```

## POST /graph/citation-path

Return directed citation chains from one document to another: document A
cites B, B cites C, and so on.

### Request body

| Field | Type | Required | Default | Constraints |
|---|---|---|---|---|
| `from_doc_id` | UUID | yes | | |
| `to_doc_id` | UUID | yes | | |
| `max_hops` | integer | no | `6` | 1 to 6. |

Unknown fields are rejected. At most 10 paths are returned. This route takes
no clocks; it reads the current document graph.

### Response

`200` with an `Envelope` of grain `fact`, shaped like a path result but over
documents:

- each `GraphNode` is a document: `entity_id` holds the document id and
  `name` its title;
- each `GraphEdge` is a citation: `relation_id` holds the cross-reference id,
  `subject_id` and `object_id` the citing and cited documents, `predicate` the
  kind of reference and `fact` its context text. `evidence_count` is `0` and
  the validity fields are `null`.

`temporal_scope.mode` is `current` when chains are found.

| Situation | Result |
|---|---|
| Either document not live | `negative.kind` `unknown_entity` |
| No chain within `max_hops` | `negative.kind` `known_empty` |

### Errors

As for [`POST /graph/neighborhood`](#post-graphneighborhood).

### Example

```bash
curl -s -X POST "$REMEMBER_API_URL/graph/citation-path" \
  -H "Authorization: Bearer $REMEMBER_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"from_doc_id\": \"$SPEC_DOC_ID\", \"to_doc_id\": \"$RFC_DOC_ID\"}"
```

```python
envelope = memory.graph_citation_path(from_doc_id=spec_doc_id, to_doc_id=rfc_doc_id)
```

---

Source: https://remember.dev/docs/reference/http-api/query

# SQL queries

SQL queries let you ask questions the assured operations do not cover: counts,
joins across claims and documents, timelines, anything you can express as one
read-only `SELECT`. They run over **the query space**, `memory_v1`: a fixed
set of prepared, read-only views and functions. Every statement is parsed and
validated against the query space before it runs, and anything outside it —
another schema, a function not on the list, a write — is rejected.

The query space itself (every view, column, function and limit) is described
in [Query space memory_v1](https://remember.dev/docs/reference/query-space). This page covers the seven
routes.

All seven need the `read` scope. Base URL, authentication and error shapes are
described in [HTTP API conventions](https://remember.dev/docs/reference/http-api).

## How results and errors come back

Statement routes (`POST /query/sql`, `POST /query/sql/explain`,
`POST /query/saved/{namespace}/{name}/run`) return a
[`QueryResult/v1`](https://remember.dev/docs/reference/result-types#queryresultv1) **in every outcome**,
including a rejected or failed statement. A statement problem is not an HTTP
error:

| `termination_reason` | HTTP status | Meaning |
|---|---|---|
| `completed` | `200` | The statement ran. `rows` holds the result. |
| `rejected` | `200` | The statement was refused before it ran: it did not parse, used something outside the query space, had the wrong parameters, or hit an admission limit. `error_code` and `error_message` say which. |
| `failed` | `200` | The statement started and then failed: a timeout, a resource cap, an unavailable store. `error_code` and `error_message` say which. |

So check `termination_reason` (or `error_code`) on every `200`.

HTTP errors come from outside the statement: a saved query that cannot run,
a malformed request body, an argument the server refuses before it builds a
statement. Those use the object form of `detail`:

```json
{"detail": {"code": "saved_query_not_found", "message": "no saved query named examples.nope"}}
```

The full list of `error_code` values, with the status each has when it is an
HTTP error, is in [Errors and status codes](https://remember.dev/docs/reference/errors#sql-query-codes).

Every statement runs under the interactive limits:

| Limit | Default | Hard cap |
|---|---|---|
| Rows returned | 200 | 1,000 |
| Bytes returned | 1,048,576 | 8,388,608 |
| Statement timeout | 5,000 ms | 15,000 ms (5,000 ms when a graph function is used) |
| SQL text | 65,536 bytes | |
| Parameters | 64, at most 262,144 bytes encoded | |
| Concurrent statements | 2 per caller, 8 per deployment | |
| Statement time | 30 s per caller per minute, 120 s per deployment per minute | |

A caller here is the kind of credential, not the person: every signed
credential counts as one caller, the self-hosted shared secret as another, and
an open self-hosted deployment as a third. The analytical tier listed in
[Query space memory_v1](https://remember.dev/docs/reference/query-space#limits) is not reachable over HTTP.

## POST /query/sql

Run one read-only SQL statement.

### Request body

| Field | Type | Required | Default | Constraints |
|---|---|---|---|---|
| `sql` | string | yes | | One statement: `SELECT`, `VALUES` or `WITH … SELECT`. At most 65,536 bytes. |
| `parameters` | array | no | `[]` | Positional values for `$1`, `$2`, …. The count must equal the highest placeholder, and placeholders must be contiguous from `$1`. |
| `max_rows` | integer | no | 200 | At least 0; values above 1,000 are clamped to 1,000. `0` returns no rows. |

Unknown fields are rejected. Cast parameters in the SQL when the type matters
(`$1::uuid`, `$2::timestamptz`). Graph functions must take `$1` as their first
argument, and `$1` must be this deployment's id; the `deployment_id` field of
any earlier `QueryResult` or `IngestedVersion` gives it to you.

### Response

`200` with a `QueryResult/v1`. `columns` names each column with its SQL type;
`rows` is an array of arrays, one value per column, in column order.

```json
{
  "contract": "QueryResult/v1",
  "grade": "exploratory_tabular",
  "request_id": "2b0f…",
  "deployment_id": "5d0c7a52-3b1e-4f55-9a8e-0e6c1f2b7a10",
  "surface_manifest_hash": "d8be43966d90048ce3fc8ffe6dfdfc7943999fbf4f018ac2eb7998f2c995aae2",
  "query_space_schema": "memory_v1",
  "query_hash": "91c4…",
  "query_language": "sql",
  "saved_query": null,
  "referenced_views": ["documents_live"],
  "referenced_functions": ["count"],
  "source_grain_tags": ["document_lineage_live"],
  "columns": [
    {"name": "source_kind", "type": "text", "nullable": true},
    {"name": "documents", "type": "bigint", "nullable": true}
  ],
  "rows": [["notes", 42], ["transcripts", 17]],
  "returned_row_count": 2,
  "returned_byte_count": 34,
  "limits": {"row_cap": 200, "byte_cap": 1048576, "statement_timeout_ms": 5000, "analytical_tier": false},
  "truncated": false,
  "truncation_reason": null,
  "exact_total_known": false,
  "exact_total": null,
  "ordered_result": true,
  "empty_result": false,
  "negative_kind": null,
  "execution_started_at": "2026-09-23T10:00:00.120000+00:00",
  "evaluated_at": null,
  "pg_snapshot_at": "2026-09-23T10:00:00.121000+00:00",
  "elapsed_ms": 18.4,
  "termination_reason": "completed",
  "error_code": null,
  "error_message": null,
  "warnings": [],
  "semantic_invocations": [],
  "graph_invocations": []
}
```

### Errors

| Status | `detail` | Cause |
|---|---|---|
| `422` | validation list | `sql` missing, `parameters` not an array, `max_rows` negative, or an unknown field. |

Everything else about the statement is reported inside the `200` result.

### Example

```bash
curl -s -X POST "$REMEMBER_API_URL/query/sql" \
  -H "Authorization: Bearer $REMEMBER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"sql": "SELECT source_kind, count(*) AS documents FROM documents_live GROUP BY source_kind ORDER BY documents DESC",
       "max_rows": 50}'
```

```python
from remember import Client

memory = Client()
result = memory.open_query(
    "SELECT claim_text, asserted_at FROM claims_live"
    " WHERE claim_text ILIKE $1 ORDER BY asserted_at DESC",
    parameters=["%cutover%"],
    max_rows=20,
)
if result["termination_reason"] != "completed":
    raise RuntimeError(f"{result['error_code']}: {result['error_message']}")
for row in result.rows:
    print(row)
```

`open_query` returns a dictionary with `.rows`, `.columns` and `.truncated`
shortcuts; `query_sql(sql=..., parameters=..., max_rows=...)` returns the
plain dictionary. Each row is a list of values in column order.

## POST /query/sql/explain

Validate one statement and return PostgreSQL's plan for it without running it.

### Request body

| Field | Type | Required | Default | Constraints |
|---|---|---|---|---|
| `sql` | string | yes | | As for `POST /query/sql`. |
| `parameters` | array | no | `[]` | As for `POST /query/sql`. |

Unknown fields (including `max_rows`) are rejected.

### Response

`200` with a `QueryResult/v1` whose one row holds the plan as JSON
(`EXPLAIN (FORMAT JSON)`). The same validation, parameter checks and
admission limits as `POST /query/sql` apply. Semantic and lexical search
functions are not called: the planner sees an empty relation of the same
shape in their place.

### Errors

As for `POST /query/sql`.

### Example

```bash
curl -s -X POST "$REMEMBER_API_URL/query/sql/explain" \
  -H "Authorization: Bearer $REMEMBER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"sql": "SELECT count(*) FROM facts_current WHERE subject_entity_id = $1::uuid",
       "parameters": ["7c1e5a0e-2f4b-4d8a-9c3e-1b2a3c4d5e6f"]}'
```

```python
plan = memory.explain_query(
    "SELECT count(*) FROM facts_current WHERE subject_entity_id = $1::uuid",
    parameters=[str(dana_id)],
)
```

## GET /query/space

Describe the query space: its views and columns, functions, limits, grammar
allowlists and, optionally, the shipped example queries. The answer comes from
the checked-in manifest, never from your data, so it is identical for every
deployment on the same release.

### Parameters

| Name | In | Type | Required | Default | Constraints |
|---|---|---|---|---|---|
| `pattern` | query | string | no | all views | A shell-style glob matched against view names (`facts_*`). |
| `include_examples` | query | boolean | no | `false` | Also list the shipped example query names. |

### Response

`200` with a JSON object:

| Field | Type | Contents |
|---|---|---|
| `schema` | string | `memory_v1` |
| `schema_major` | integer | `1` |
| `surface_manifest_hash` | string | The manifest hash every `QueryResult` also reports. |
| `headline` | string | A short orientation for agents. |
| `retrieval_choices` | array of string | Guidance on choosing between operations, search and SQL. |
| `honesty_warnings` | array of string | What SQL results do not guarantee. |
| `worked_examples` | array of object | Worked examples. |
| `views` | array of object | One per matching view: `name`, `grain`, `row_key` (array), `comment`, `columns` (array of `[name, type, nullable]`). |
| `functions` | array of string | The 12 public function names. |
| `limits` | object | `interactive` and `analytical` limit sets. |
| `core_operation_descriptors` | object | The assured operations as the manifest records them. |
| `function_signatures` | object | Every function's arguments, result columns and caps. |
| `sql_grammar` | object | The allowlists: `functions`, `operators`, `cast_types`, `statement_node_classes`, `public_functions`, `srf_categories`, `srf_invocations_max_per_category`, `recursion_depth_max`. |
| `examples` | array of string | `examples.<name>` for each shipped example when `include_examples=true`; empty otherwise. |

### Errors

| Status | `detail` | Cause |
|---|---|---|
| `422` | validation list | `include_examples` is not a boolean. |

### Example

```bash
curl -s "$REMEMBER_API_URL/query/space?pattern=facts_*" \
  -H "Authorization: Bearer $REMEMBER_API_KEY"
```

```python
space = memory.describe_query_space(pattern="facts_*", include_examples=True)
```

## GET /query/space/search

Search the query space's own text — view and column comments, function
descriptions, operation descriptions, example purposes — for a phrase. It
never searches your data.

### Parameters

| Name | In | Type | Required | Default | Constraints |
|---|---|---|---|---|---|
| `query` | query | string | yes | | At least 1 character, and at least one word. |
| `k` | query | integer | no | `10` | 1 to 25. |

### Response

`200` with an array of hits, best first:

| Field | Type | Contents |
|---|---|---|
| `kind` | `view` \| `function` \| `core_operation` \| `example` | What the hit is. |
| `name` | string | Its name (`facts_current`, `semantic_claims`, `examples.claims_about`). |
| `score` | number | Term-overlap score. A word in the name counts more than a word in the text. |
| `purpose` | string | The comment or description that matched. |
| `tags` | array of string | Grain and key names for views; channel and target for functions. |

### Errors

| Status | `detail` | Cause |
|---|---|---|
| `422` | validation list | `query` missing or empty, or `k` out of range. |
| `422` | `{"code": "invalid_parameter", "message": "query must be non-empty"}` | `query` is only whitespace. |

### Example

```bash
curl -s -G "$REMEMBER_API_URL/query/space/search" \
  -H "Authorization: Bearer $REMEMBER_API_KEY" \
  --data-urlencode "query=current facts" \
  --data-urlencode "k=5"
```

```python
hits = memory.search_query_space(query="current facts", k=5)
```

## GET /query/saved

List saved queries. A saved query is a named, versioned SQL statement stored in
the deployment. Every deployment ships with 18 in the `examples` namespace;
see [Query space memory_v1](https://remember.dev/docs/reference/query-space#shipped-saved-queries).

### Parameters

| Name | In | Type | Required | Default | Constraints |
|---|---|---|---|---|---|
| `namespace` | query | string | no | all | |
| `status` | query | string | no | `active` | One of `draft`, `pending_revalidation`, `active`, `deprecated`, `disabled`, `broken`. |

Without `status`, only `active` versions of queries that are not disabled are
listed. With `status=draft`, only the latest draft of each query is listed.

### Response

`200` with an array, ordered by namespace, name and version:

| Field | Type |
|---|---|
| `query_id` | UUID |
| `namespace` | string |
| `name` | string |
| `version` | integer |
| `status` | string |
| `description` | string or null |
| `origin` | `human` \| `agent` \| `import` \| `shipped_example` |
| `assurance` | `customer_authored` \| `customer_reviewed` \| `shipped_example` \| null |
| `query_hash` | string |
| `validated_surface_manifest_hash` | string |

### Errors

| Status | `detail` | Cause |
|---|---|---|
| `422` | validation list | `status` is not one of the six values. |

### Example

```bash
curl -s "$REMEMBER_API_URL/query/saved?namespace=examples" \
  -H "Authorization: Bearer $REMEMBER_API_KEY"
```

```python
for saved in memory.list_saved_queries(namespace="examples"):
    print(saved["name"], saved["version"], saved["description"])
```

## GET /query/saved/{namespace}/{name}

Describe one version of a saved query, including its SQL.

### Parameters

| Name | In | Type | Required | Default | Constraints |
|---|---|---|---|---|---|
| `namespace` | path | string | yes | | `^[a-z][a-z0-9_]*$` (enforced by the `remember` client). |
| `name` | path | string | yes | | `^[a-z][a-z0-9_]*$` (enforced by the `remember` client). |
| `version` | query | integer | no | the active version | |

### Response

`200` with an object: every field of the list above plus `sql`,
`parameter_schema`, `declared_result_schema`, `declared_interpretation`,
`query_space_major` (`memory_v1`), `default_limits` (any of `max_rows`,
`statement_timeout_ms`, `max_bytes`), `validation_report`, `author_principal`
and `approver_principal`.

The shipped examples have an empty `parameter_schema`. Their parameters are
the positional placeholders in their SQL, listed in
[Query space memory_v1](https://remember.dev/docs/reference/query-space#shipped-saved-queries).

### Errors

| Status | `detail` code | Cause |
|---|---|---|
| `404` | `saved_query_not_found` | No such query or version. |
| `422` | validation list | `version` is not an integer. |

### Example

```bash
curl -s "$REMEMBER_API_URL/query/saved/examples/claims_about" \
  -H "Authorization: Bearer $REMEMBER_API_KEY"
```

```python
detail = memory.describe_saved_query(namespace="examples", name="claims_about")
print(detail["sql"])
```

## POST /query/saved/{namespace}/{name}/run

Run the active version of a saved query, or a named version, through the same
validation and limits as `POST /query/sql`.

### Parameters

| Name | In | Type | Required | Constraints |
|---|---|---|---|---|
| `namespace` | path | string | yes | `^[a-z][a-z0-9_]*$` (enforced by the `remember` client). |
| `name` | path | string | yes | `^[a-z][a-z0-9_]*$` (enforced by the `remember` client). |

### Request body

| Field | Type | Required | Default | Constraints |
|---|---|---|---|---|
| `version` | integer | no | the active version | At least 1. |
| `parameters` | array | no | `[]` | Positional values for the query's placeholders. |
| `max_rows` | integer | no | the query's stored default, else 200 | At least 0; clamped to 1,000. |

Unknown fields are rejected. The query's stored `default_limits` apply
(clamped to the interactive caps); your `max_rows` takes precedence over the
stored one. Only a version whose status is exactly `active` runs.

### Response

`200` with a `QueryResult/v1` whose `saved_query` field is
`{"query_id", "namespace", "name", "version", "query_hash"}`, all strings.
Statement problems are reported inside the result, as for `POST /query/sql`.

### Errors

| Status | `detail` code | Cause |
|---|---|---|
| `404` | `saved_query_not_found` | No such query or version. |
| `409` | `saved_query_disabled` | The query is disabled, or the version is not `active` (a draft, deprecated or broken version). |
| `409` | `saved_query_revalidation_pending` | The query space changed since the version was validated, and it has not been revalidated. |
| `422` | validation list | Malformed body, `version` below 1, `max_rows` negative, or an unknown field. |
| `500` | `execution_error` | The deployment could not read its saved-query registry state. |

### Example

```bash
curl -s -X POST "$REMEMBER_API_URL/query/saved/examples/claims_about/run" \
  -H "Authorization: Bearer $REMEMBER_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"parameters\": [\"$DANA_ID\"], \"max_rows\": 20}"
```

```python
result = memory.run_saved_query(
    namespace="examples", name="claims_about", parameters=[str(dana_id)], max_rows=20
)
```

Saved queries can only be created, activated or disabled through the
deployment's own tooling; there is no HTTP route for it. See
[Saved queries](https://remember.dev/docs/guides/saved-queries).

---

Source: https://remember.dev/docs/reference/http-api/deployment

# Deployment info and health

One documented route tells you what a deployment is running before you send
it work. Two more exist for operators of a self-hosted deployment: the
liveness probe and the cost export. Base URL, authentication and error shapes
are described in [HTTP API conventions](https://remember.dev/docs/reference/http-api).

## GET /deployment

Report the code revision, the model and document bindings, and the MCP
memory tools the deployment is serving. It touches no submitted work, so you
can call it first to confirm you are talking to the build you expect.

**Scope:** `read`.

### Parameters

None.

### Response

`200` with a [`DeploymentBuildInfo`](https://remember.dev/docs/reference/result-types#deploymentbuildinfo):

```json
{
  "build_revision": "4e1d0c9a7b2f…",
  "model_bindings": {
    "claim_extraction": "…",
    "p1_embedding": "…",
    "fact_adjudication": "…"
  },
  "document_binding_generation": "…",
  "tools": {
    "ingest": 1,
    "pipeline_readiness": 1,
    "delete_document": 1,
    "resolve_entity": 1,
    "claims_and_sources_context": 2,
    "facts_context": 3,
    "combined_context": 4,
    "query_sql": 1,
    "…": 1
  }
}
```

| Field | Meaning |
|---|---|
| `build_revision` | The source revision stamped into the image when it was built (`REMEMBERSTACK_BUILD_REVISION`). Empty when the image was built without one. |
| `model_bindings` | The model each pipeline role is bound to, by role name. A self-hosted deployment reports `structure_fallback`, `skeleton_check`, `section_role`, `section_summary`, `claim_extraction`, `relation_normalization`, `entity_resolution`, `fact_adjudication`, `p1_embedding` (the one embedding model, used for every vector), and the OpenRouter settings `openrouter_embedding_provider`, `openrouter_embedding_provider_order`, `openrouter_max_completion_tokens`, `openrouter_reasoning_effort` and `openrouter_reasoning_effort_map`. No secrets. |
| `document_binding_generation` | The deployment's current document binding generation, or `null`. |
| `tools` | Each MCP memory tool this deployment serves, by tool name, with its tool version. A tool appears only when the route it calls is served: the seven query tools (`query_sql`, `explain_sql`, `describe_query_space`, `search_query_space`, `list_saved_queries`, `describe_saved_query`, `run_saved_query`) only when the open query space is enabled, and `delete_document` only when document deletion is. A tool's version rises whenever a call it accepts would fail, or mean something else, on a deployment serving the previous version. |

The first three fields also appear on every
[`PipelineReadinessReport`](https://remember.dev/docs/reference/result-types#pipelinereadinessreport).

### Errors

| Status | `detail` | Cause |
|---|---|---|
| `403` | `credential may not perform this operation` | The credential has the `ingest` scope, which cannot read. |

### Example

```bash
curl -s "$REMEMBER_API_URL/deployment" \
  -H "Authorization: Bearer $REMEMBER_API_KEY"
```

```python
from remember import Client

memory = Client()
info = memory.deployment_build_info()
print(info.build_revision, info.model_bindings)
```

## GET /healthz

**Note:**

Self-hosted only, and not part of the documented API: it is absent from
`openapi.json`.

Prove the engine process is up and can reach its PostgreSQL database. The
Compose file uses it as the API container's health check.

**Scope:** none. This is the one route that never asks for a credential.

### Response

`200` with `{"status": "ok"}` after a `SELECT 1` succeeds. If the database
cannot be reached, the request fails with `500`. While a hard forget runs, it
answers `503` with `{"detail": {"code": "forget_in_progress"}}` like every
other route.

### Example

```bash
curl -s http://localhost:8000/healthz
```

## GET /ops/cost-export/v1

**Note:**

Self-hosted operators only. This route is served by a second, separate
listener, not on the API's address, and only when you configure it.

Page through the deployment's cost receipts: one row per model call made by
the pipeline workers or by a query route, with tokens, cost and latency. It
never carries memory content.

The listener starts only when `REMEMBERSTACK_COST_EXPORT_BIND` is set
(`host:port`, `[ipv6]:port` or `unix:/path/to.sock`). It then requires
`REMEMBERSTACK_COST_EXPORT_TOKEN`, at least 32 bytes long; the process refuses
to start without it. See [Observability](https://remember.dev/docs/self-hosting/observability).

**Authentication:** `Authorization: Bearer <REMEMBERSTACK_COST_EXPORT_TOKEN>`.
This token is separate from the API credential.

**Rate limit:** one request per second, per process.

### Parameters

| Name | In | Type | Required | Default | Constraints |
|---|---|---|---|---|---|
| `cursor` | query | string | no | start | The `next_cursor` from the previous page. |
| `limit` | query | integer | no | `100` | 1 to 500. |

### Response

`200` with a page:

| Field | Type | Meaning |
|---|---|---|
| `contract` | string | Always `rememberstack.cost_export.v1`. |
| `deployment_id` | UUID | |
| `server_time` | date-time | When the page was read. |
| `horizon` | date-time | The page covers receipts up to this instant. |
| `cursor` | string | The cursor this page started from. |
| `next_cursor` | string | Pass it back to continue. |
| `persist_failures` | integer | Receipts the engine failed to record. |
| `scope_missing` | integer | Receipts recorded without an attribution scope. |
| `receipts` | array | The receipts, below. |

Each receipt:

| Field | Type |
|---|---|
| `cost_id` | UUID |
| `deployment_id` | UUID |
| `source` | `worker` \| `surface` |
| `work_id` | UUID |
| `stage` | string or null |
| `lane` | string or null |
| `attempt` | integer or null |
| `surface` | string or null |
| `call_key` | string |
| `outcome` | string |
| `model_name` | string or null |
| `tokens_in` | integer or null |
| `tokens_out` | integer or null |
| `cost_usd` | decimal string or null |
| `latency_ms` | integer or null |
| `occurred_at` | date-time (UTC) |

### Errors

| Status | `detail` | Cause |
|---|---|---|
| `401` | `unauthorized` | Missing or wrong token. |
| `422` | `malformed cursor` | The cursor does not parse. |
| `429` | `rate limited` | More than one request per second. |

### Example

```bash
curl -s "http://127.0.0.1:8001/ops/cost-export/v1?limit=500" \
  -H "Authorization: Bearer $REMEMBERSTACK_COST_EXPORT_TOKEN"
```

---

Source: https://remember.dev/docs/reference/assured-operations

# Assured operations

An assured operation is a retrieval whose plan, parameters and result shape
are fixed by RememberStack and checked on every deployment. There are exactly
four:

| Operation | Version | Answers from | Result | Grain |
|---|---|---|---|---|
| [`resolve_entity`](#resolve_entity) | 1 | identity | `Envelope` | `fact` |
| [`claims_and_sources_context`](#claims_and_sources_context) | 2 | what sources said | `Envelope` | `evidence` |
| [`facts_context`](#facts_context) | 3 | what is held true | `Envelope` | `fact` |
| [`combined_context`](#combined_context) | 4 | both, side by side | `ContextBundle/v2` | none (two envelopes) |

None of them calls a language model. `claims_and_sources_context`,
`facts_context` and `combined_context` embed the query text to search by
meaning, and `resolve_entity` embeds the name only when no alias matches;
embedding is not generation.

The same four are offered everywhere: `POST /operations/{name}` over HTTP,
`Client.run_operation` and the named helpers in the `remember` package, and
one MCP tool each. Their descriptors (`GET /operations`) carry the exact JSON
Schema of the inputs, the result schema and an `implementation_plan_hash`, so
an agent can check it is calling the version it expects. The route is
described in [Assured operation routes](https://remember.dev/docs/reference/http-api/operations). For anything
these four do not cover, use the [direct routes](https://remember.dev/docs/reference/http-api/entities-and-facts),
[search](https://remember.dev/docs/reference/http-api/search), the [graph](https://remember.dev/docs/reference/http-api/graph) or
[SQL queries](https://remember.dev/docs/reference/http-api/query).

## Choosing one

| You want | Use |
|---|---|
| The entity ids behind a name ("Dana", "billing migration") | `resolve_entity` |
| Everything the sources said about a topic, with the passages, for high recall | `claims_and_sources_context` |
| What the memory holds true now, or at a time, with its evidence and contradictions | `facts_context` |
| Context for an agent turn: both of the above in one call | `combined_context` |

Resolve names first. The context operations take `entity_ids`, not names, so
the usual flow is `resolve_entity`, pick the candidates you mean, then pass
their ids. See [Give an agent context](https://remember.dev/docs/guides/agent-context).

## Arguments

Arguments are one JSON object. They are validated against the descriptor
before anything runs:

- unknown keys are refused;
- a missing required key is refused;
- strings must be strings; integers must be integers (`3.0` is accepted,
  `true` and `"3"` are not);
- `entity_ids` must be an array of UUID strings with no duplicates, and at
  least one item if present;
- lengths, item counts and ranges below are enforced.

A refused argument is HTTP `422` with
`{"detail": {"code": "invalid_parameter", "message": "…"}}`.

## The time object

`facts_context` and `combined_context` take an optional `time` object that
fixes which facts count. The default is `{"mode": "current"}`.

| Mode | Shape | Selects | Result `temporal_scope.mode` |
|---|---|---|---|
| `current` | `{"mode": "current"}` | Facts valid at the moment the call runs. | `current` |
| `at` | `{"mode": "at", "at": "2026-06-01T00:00:00Z"}` | Facts whose validity covers that instant. | `at` |
| `overlap` | `{"mode": "overlap", "from": "2026-04-01T00:00:00Z", "to": "2026-06-30T23:59:59Z"}` | Facts whose validity overlaps the interval, both ends included. `to` must not be before `from`. | `overlap` |
| `history` | `{"mode": "history"}` | Every currently believed validity interval that began by the moment the call runs. | `history` |

Rules for the timestamps:

- they must be full ISO 8601 date-times (`2026-06-01T00:00:00Z`), not dates;
- they must carry a time zone (`Z` or an offset); the engine converts them to
  UTC;
- the object takes no other keys.

In every mode the memory's knowledge is taken as it stands now: the result's
`believed_at` is the call's evaluation time. To read what the memory believed
at a past instant, use the [graph routes](https://remember.dev/docs/reference/http-api/graph) or the
[`facts_as_of`](https://remember.dev/docs/reference/query-space#facts_as_of) SQL function.

Each returned fact carries `temporal_match`: `confirmed` when its validity
certainly matches the requested time, `possible` when it may (for example, a
fact with no known start). See [Time](https://remember.dev/docs/concepts/time).

## resolve_entity

Resolve a name to the current entities it can mean, ranked, without guessing.

**Version** 1. **Answer intent** `identity`. **Result contract** `envelope`,
grain `fact`. **Plan** one step: `resolve_entity`.

### Parameters

| Name | Type | Required | Default | Constraints |
|---|---|---|---|---|
| `name` | string | yes | | At least 1 character. |

### What it does

Matching runs in tiers and stops at the first that finds anything: exact
alias (`T0`, every entity with that alias, uncapped), trigram (`T1`),
phonetic (`T2`), then embedding similarity against entity profiles (`T3`).
The fuzzy and embedding tiers stop at the ingest pipeline's candidate width and
say so in `truncation` (`reason: "resolve_candidate_limit"`). Several
candidates mean the name is ambiguous; the operation never picks one for you.

### Result

`entities` holds the candidates, best first, each with `entity_id`,
`canonical_name` and `tier`. No match: `negative.kind` `unknown_entity`. The
embedding tier was needed but the entity index is not published:
`negative.kind` `boundary`.

The HTTP route [`GET /resolve`](https://remember.dev/docs/reference/http-api/entities-and-facts#get-resolve)
does the same and also accepts `context_entity_ids` to reorder candidates.

### Example

```bash
curl -s -X POST "$REMEMBER_API_URL/operations/resolve_entity" \
  -H "Authorization: Bearer $REMEMBER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "billing migration"}'
```

```python
from remember import Client

memory = Client()
envelope = memory.resolve_entity("billing migration")
ids = [candidate.entity_id for candidate in envelope.entities]
```

## claims_and_sources_context

Return what the sources said about a query: claims and the source passages
around them, with high recall. This is testimony, not the memory's settled
view.

**Version** 2. **Answer intent** `claims_and_sources`. **Result contract**
`envelope`, grain `evidence`. **Plan** one step: `claims_and_sources_context`.

### Parameters

| Name | Type | Required | Default | Constraints |
|---|---|---|---|---|
| `query` | string | yes | | 1 to 8,192 characters. |
| `entity_ids` | array of UUID | no | none | 1 to 20 unique ids. Restricts the search to claims and chunks about these entities. |
| `k` | integer | no | `50` | 1 to 100. The most claims, and separately the most chunks, returned. |
| `candidate_k` | integer | no | `200` | 1 to 400, and at least `k`. How many candidates each search channel nominates. |

### What it does

1. Claims and chunks are searched separately. Each is searched twice — by
   meaning (semantic) and by keyword (BM25) — with up to `candidate_k`
   candidates per channel. Only current testimony is searched.
2. The two rankings are fused by reciprocal rank (constant 60).
3. Every candidate is re-read from the database; what no longer holds is
   dropped and counted in `dropped_by_hydration`.
4. Claims with identical text are grouped: the first carries
   `corroboration_count` and the ids of the others in `grouped_claim_ids`.
5. The top `k` claims and the top `k` chunks are returned.

If any id in `entity_ids` is not a current entity, nothing is searched and
the result is `negative.kind` `unknown_entity`.

### Result

`evidence` holds up to `k` [`EvidenceResult`](https://remember.dev/docs/reference/result-types#evidenceresult)
entries and `chunks` up to `k`
[`ChunkEvidenceResult`](https://remember.dev/docs/reference/result-types#chunkevidenceresult) entries.
`temporal_scope.mode` is `current`. `truncation` is set when more results
existed than `k`, or when a channel returned its full `candidate_k` (then
`total_is_exact` is `false`). Nothing found: `negative.kind` `known_empty`.

### Example

```bash
curl -s -X POST "$REMEMBER_API_URL/operations/claims_and_sources_context" \
  -H "Authorization: Bearer $REMEMBER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": "When is the billing migration cutover?", "k": 20}'
```

```python
envelope = memory.run_operation(
    name="claims_and_sources_context",
    arguments={"query": "When is the billing migration cutover?", "k": 20},
)
for claim in envelope.evidence:
    print(claim.asserted_at, claim.document_title, claim.claim_text)
```

## facts_context

Return the facts the memory holds true — relations and observations it has
adjudicated — for a query, under an explicit time scope, each with its
supporting and contradicting evidence.

**Version** 3. **Answer intent** `facts`. **Result contract** `envelope`,
grain `fact`. **Plan** two steps: `graph_neighborhood`, then `facts_context`.

### Parameters

| Name | Type | Required | Default | Constraints |
|---|---|---|---|---|
| `query` | string | yes | | 1 to 8,192 characters. |
| `entity_ids` | array of UUID | no | none | 1 to 19 unique ids. The anchors. |
| `k` | integer | no | `15` | 1 to 30. The most facts returned. |
| `evidence_per_fact` | integer | no | `3` | 1 to 5. The most claims returned per fact and per stance (supporting, contradicting). |
| `hops` | integer | no | `1` | 1 to 2. How far the live graph expands from each anchor. |
| `predicate` | string | no | none | 1 to 200 characters. Only relations with this predicate are expanded and returned. |
| `time` | object | no | `{"mode": "current"}` | See [The time object](#the-time-object). |

### What it does

Without `entity_ids`, facts are searched across the whole deployment by
meaning. Entity profiles are searched too, and facts about the 20 best
matching entities are fused in, which helps list-shaped questions.

With `entity_ids`:

1. Every anchor must be a current entity. If one is not, the result is
   `negative.kind` `unknown_entity`.
2. For `current` and `at`, the live graph expands each anchor by `hops`
   (following only `predicate` edges when given), filling at most 20 entities
   in total, anchors included. For `overlap` and `history` there is no single
   instant to expand at, so only the anchors are used.
3. Facts are searched inside that set of entities.

Then, in every case: up to 200 candidates are nominated, confirmed against
the database in batches until `k` facts (plus one, to know whether there are
more) have been confirmed, and their evidence is attached — up to
`evidence_per_fact` claims per fact and stance, and at most 60 claims in the
whole result.

The operation has a 25-second database budget. If it runs out, or the live
graph or search index cannot answer, the result is `negative.kind`
`boundary` with an explanation, never a silently smaller answer.

### Result

| Field | Contents |
|---|---|
| `facts` | Up to `k` [`FactResult`](https://remember.dev/docs/reference/result-types#factresult) entries: `kind` (`relation` or `observation`), `label`, `validity`, `temporal_match`, `support`, and the other sides of any contradiction. |
| `evidence` | The claims attached to those facts, each once. |
| `fact_evidence` | Which claim supports or contradicts which fact (`stance`). |
| `evidence_totals` | For each fact and each stance, how many claims were `returned` and how many exist in `total`. |
| `nodes` | The neighbours the graph expansion added, when `entity_ids` were given. |
| `temporal_scope` | The time mode used, with `evaluated_at` and `believed_at`. |
| `truncation` | Whether more facts (or neighbours) existed. |
| `dropped_by_hydration` | Candidates that failed confirmation, plus neighbours that were no longer current. |
| `negative` | `known_empty` when no fact matches; `unknown_entity` or `boundary` as above. |

### Example

```bash
curl -s -X POST "$REMEMBER_API_URL/operations/facts_context" \
  -H "Authorization: Bearer $REMEMBER_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"query\": \"who owns what\", \"entity_ids\": [\"$BILLING_MIGRATION_ID\"],
       \"time\": {\"mode\": \"at\", \"at\": \"2026-06-01T00:00:00Z\"}}"
```

A trimmed response (empty arrays and some fields left out):

```json
{
  "grain": "fact",
  "temporal_scope": {
    "mode": "at",
    "at": "2026-06-01T00:00:00Z",
    "evaluated_at": "2026-09-23T10:00:00Z",
    "believed_at": "2026-09-23T10:00:00Z",
    "identity_regime": "current"
  },
  "facts": [
    {
      "fact_id": "c3d2…",
      "kind": "relation",
      "label": "Ravi owns the billing migration",
      "evidence_count": 4,
      "validity": {
        "valid_from": "2026-03-02T00:00:00Z",
        "valid_until": null,
        "valid_precision": "day",
        "ingested_at": "2026-03-03T08:12:40Z",
        "invalidated_at": null
      },
      "temporal_match": "confirmed",
      "contradiction_group": null,
      "contradiction": null,
      "support": "current"
    }
  ],
  "evidence": [
    {
      "claim_id": "e8f1…",
      "doc_id": "a1f3…",
      "chunk_id": "0b7d…",
      "claim_text": "Ravi is taking over the billing migration from 2 March 2026.",
      "source_span": "Ravi takes over billing migration from Monday",
      "char_start": 1204,
      "char_end": 1249,
      "is_attributed": true,
      "is_current_testimony": true,
      "asserted_at": "2026-02-27T15:00:00Z",
      "document_title": "Weekly sync 2026-02-27",
      "source_kind": "transcripts"
    }
  ],
  "fact_evidence": [
    {"fact_kind": "relation", "fact_id": "c3d2…", "claim_id": "e8f1…", "stance": "supports"}
  ],
  "evidence_totals": [
    {"fact_kind": "relation", "fact_id": "c3d2…", "stance": "supports", "returned": 1, "total": 4},
    {"fact_kind": "relation", "fact_id": "c3d2…", "stance": "contradicts", "returned": 0, "total": 0}
  ],
  "freshness": {"pg_live_ts": "2026-09-23T10:00:00Z", "p1_written_inline": true, "p1_believed_at_horizon": null, "k": null},
  "truncation": {"truncated": false, "returned": 3, "estimated_total": 3, "total_is_exact": true, "continuation": null, "reason": null},
  "dropped_by_hydration": 0,
  "excluded_unstamped": 0,
  "negative": null
}
```

```python
envelope = memory.facts_context(
    "who owns what",
    entity_ids=[billing_migration_id],
    time={"mode": "at", "at": "2026-06-01T00:00:00Z"},
)
```

## combined_context

Return `claims_and_sources_context` and `facts_context` for the same query in
one response, side by side and never blended.

**Version** 4. **Answer intent** `combined_context`. **Result contract**
`context_bundle_v2`; no single grain. **Plan** an operation bundle whose
children are `claims_and_sources_context` then `facts_context`; its
`implementation_plan_hash` covers both children's plan hashes.

### Parameters

| Name | Type | Required | Default | Constraints |
|---|---|---|---|---|
| `query` | string | yes | | 1 to 8,192 characters. |
| `entity_ids` | array of UUID | no | none | 1 to 19 unique ids. Passed to both children. |
| `hops` | integer | no | `1` | 1 to 2. Passed to `facts_context`. |
| `predicate` | string | no | none | 1 to 200 characters. Passed to `facts_context`. |
| `time` | object | no | `{"mode": "current"}` | Passed to `facts_context`. |

The children's other parameters are fixed: `claims_and_sources_context` runs
with `k` 50 and `candidate_k` 200; `facts_context` with `k` 15 and
`evidence_per_fact` 3. Both run at the same evaluation instant.

### Result

A [`ContextBundle/v2`](https://remember.dev/docs/reference/result-types#contextbundlev2):

```json
{
  "contract": "ContextBundle/v2",
  "claims_and_sources": {"grain": "evidence", "...": "an Envelope"},
  "facts": {"grain": "fact", "...": "an Envelope"}
}
```

Each child is a complete envelope with its own `negative`, `truncation` and
`temporal_scope`. `claims_and_sources` is always current; `facts` follows
`time`. Read them separately: a claim in the first is testimony, a fact in the
second is the memory's view. See [Reading a result](https://remember.dev/docs/concepts/reading-results).

### Example

```bash
curl -s -X POST "$REMEMBER_API_URL/operations/combined_context" \
  -H "Authorization: Bearer $REMEMBER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": "billing migration status and risks"}'
```

```python
bundle = memory.combined_context("billing migration status and risks")
said = bundle.claims_and_sources.evidence
held = bundle.facts.facts
```

## Descriptor reference

`GET /operations` returns one [`ToolDescriptor`](https://remember.dev/docs/reference/result-types#tooldescriptor)
per operation. The values that identify the shipped build:

| Operation | `version` | `result_contract` | `output_grain` | `answer_intent` | `implementation_plan_hash` |
|---|---|---|---|---|---|
| `resolve_entity` | 1 | `envelope` | `fact` | `identity` | `fcf8f8bd08efe28d62af572dbd4f81601b03f1744d993021225454b55748059a` |
| `claims_and_sources_context` | 2 | `envelope` | `evidence` | `claims_and_sources` | `cb897d871de908ee47b4bd80b313cf3b1df86c6e6d2a83882c3c3c3320ce4278` |
| `facts_context` | 3 | `envelope` | `fact` | `facts` | `88264ee8ce445d7145b6138e8ea97e9c91d441512816186d5d064519499e339f` |
| `combined_context` | 4 | `context_bundle_v2` | `null` | `combined_context` | `d9e9b28cfdf398658459e17839e8f732437c5514aa9927d4f622d9d5175d0f10` |

The hashes are those recorded in the `memory_v1` manifest for this release.
The registry refuses any stored operation that does not match its canonical
definition byte for byte.

---

Source: https://remember.dev/docs/reference/result-types

# Result types

This page lists every shape the HTTP API returns, and the request bodies that
have a name. Field names are exactly as they appear on the wire. The same
models exist in Python as `remember.models.<Name>` (the most used are also
exported from `remember`); the `remember` client validates every response
against them.

The shapes group into five families:

| Family | Types | Returned by |
|---|---|---|
| Envelope | [`Envelope`](#envelope) and its parts | Every read route except SQL queries, readiness, documents and deployment info; three of the four assured operations. |
| Context bundle | [`ContextBundle/v2`](#contextbundlev2) | `combined_context`. |
| Query result | [`QueryResult/v1`](#queryresultv1) | `POST /query/sql`, `POST /query/sql/explain`, `POST /query/saved/{namespace}/{name}/run`. |
| Write and readiness | [`IngestedVersion`](#ingestedversion), [`PipelineReadinessReport`](#pipelinereadinessreport), [`DocumentPage`](#documentpage) | `POST /ingest`, `POST /readiness`, `GET /documents`. |
| Deployment | [`ToolDescriptor`](#tooldescriptor), [`DeploymentBuildInfo`](#deploymentbuildinfo) | `GET /operations`, `GET /deployment`. |

## Conventions

- **Required and nullable.** "Nullable" means the field is present and may be
  `null`. Fields with a default are always present in responses.
- **Arrays.** Array fields are always present; an empty result is `[]`.
- **Unknown fields.** Every model forbids fields it does not declare, so the
  `remember` client rejects a response with an unexpected key.

### Timestamps

Timestamps in results are ISO 8601 strings in UTC. Fields typed
"UTC date-time" below are checked to have an offset of exactly zero.

**Warning:**

Send timestamps to `GET /lookup/relations` (`valid_at`) and to the graph
routes (`valid_at`, `believed_at`) in UTC, with `Z` or `+00:00`. A
timestamp with no offset or another offset is refused with `422` and a
validation list naming the field; it is not converted. The assured
operations' `time` object converts any offset to UTC for you.

## Envelope

The result of every read that is not a SQL query: the answer plus its own
account of what kind of answer it is, when it was true, how fresh it is,
whether it was capped, and, when the answer is "no", which kind of no.

Which fields a route fills depends on the route; the rest stay empty. No HTTP
route in this release fills `ranking`, `changes`, `aggregate` or `pages`.

| Field | Type | Contents |
|---|---|---|
| `grain` | [`Grain`](#grain) | What kind of truth the result is. |
| `temporal_scope` | [`TemporalScope`](#temporalscope) | The time the answer applies to. |
| `entities` | array of [`EntityCandidate`](#entitycandidate) | Resolve candidates. |
| `facts` | array of [`FactResult`](#factresult) | Relations and observations. |
| `evidence` | array of [`EvidenceResult`](#evidenceresult) | Claims. |
| `fact_evidence` | array of [`FactEvidence`](#factevidence) | Which claim supports or contradicts which fact. |
| `evidence_totals` | array of [`EvidenceTotal`](#evidencetotal) | Exact evidence counts per fact and stance. |
| `chunks` | array of [`ChunkEvidenceResult`](#chunkevidenceresult) | Source passages. |
| `sources` | array of [`SourceRecord`](#sourcerecord) | Documents. |
| `transcript` | array of [`TranscriptEntry`](#transcriptentry) | Decision history. |
| `nodes` | array of [`GraphNode`](#graphnode) | Entities (or documents) a traversal reached. |
| `paths` | array of [`GraphPath`](#graphpath) | Whole paths. |
| `edges` | array of [`GraphEdge`](#graphedge) | Relations (or citations) traversed. |
| `ranking` | array of [`RankedItem`](#rankeditem) | A fused or reranked order. |
| `changes` | array of [`ChangeRecord`](#changerecord) | A change feed. |
| `aggregate` | [`AggregateReport`](#aggregatereport), nullable | An enumerated aggregate. |
| `pages` | array of [`PageRef`](#pageref) | Compiled pages. |
| `freshness` | [`Freshness`](#freshness) | How current the answer's sources are. |
| `truncation` | [`Truncation`](#truncation), nullable | Present when a cap applied or the route always discloses one. |
| `dropped_by_hydration` | integer | Candidates that were nominated but no longer held when re-read. Default `0`. |
| `excluded_unstamped` | integer ≥ 0 | Items left out because they carry no usable time. Default `0`. |
| `negative` | [`Negative`](#negative), nullable | Set when the answer is a typed "no". |

Which fields each route fills:

| Route or operation | Grain | Fields |
|---|---|---|
| `GET /resolve`, `resolve_entity` | `fact` | `entities`, `truncation`, `negative` |
| `GET /lookup/relations`, `GET /lookup/observations` | `fact` | `facts`, `truncation`, `dropped_by_hydration`, `negative` |
| `GET /hydrate/relation/{relation_id}` | `composite` | `facts`, `evidence`, `sources`, `negative` |
| `GET /transcript/relation/{relation_id}` | `composite` | `transcript`, `truncation`, `negative` |
| Search routes | `evidence` | `evidence` or `chunks`, `dropped_by_hydration`, `negative` |
| Adjacent chunks | `evidence` | `chunks`, `dropped_by_hydration`, `negative` |
| Graph routes | `fact` | `nodes`, `paths`, `edges`, `truncation`, `negative` |
| `claims_and_sources_context` | `evidence` | `evidence`, `chunks`, `truncation`, `dropped_by_hydration`, `negative` |
| `facts_context` | `fact` | `facts`, `evidence`, `fact_evidence`, `evidence_totals`, `nodes`, `truncation`, `dropped_by_hydration`, `negative` |

### Grain

| Value | Meaning |
|---|---|
| `fact` | What the memory holds true (relations, observations, entities, the graph). |
| `evidence` | What sources said (claims, chunks). Not a statement of truth. |
| `compiled` | A compiled page. Not returned by any HTTP route in this release. |
| `composite` | A fact together with the evidence and sources behind it, or a decision history. |

### TemporalScope

One of five shapes, told apart by `mode`. Every shape carries
`evaluated_at` (when the read ran), `believed_at` (the memory's knowledge it
read from) and `identity_regime`.

| `mode` | Extra fields | Used by |
|---|---|---|
| `current` | none | Most reads; `time: {"mode": "current"}`. |
| `at` | `at` (UTC date-time) | `time: {"mode": "at"}`; lookups with `valid_at`. |
| `overlap` | `from`, `to` (UTC date-times, `to` ≥ `from`) | `time: {"mode": "overlap"}`. |
| `history` | none | `time: {"mode": "history"}`. |
| `as_of` | `valid_at` (UTC date-time) | Graph routes; names both clocks. |

| Field | Type |
|---|---|
| `mode` | string, as above |
| `evaluated_at` | UTC date-time |
| `believed_at` | UTC date-time |
| `identity_regime` | [`IdentityRegime`](#identityregime), default `current` |

### IdentityRegime

| Value | Meaning |
|---|---|
| `current` | Entities are identified as they are today (today's aliases and merges), even for a past instant. Every HTTP route uses this. |
| `as_of` | Entities are identified as they stood at the queried instant. |

### EntityCandidate

| Field | Type | Contents |
|---|---|---|
| `entity_id` | UUID | |
| `canonical_name` | string | |
| `tier` | string | `T0` exact alias, `T1` trigram, `T2` phonetic, `T3` embedding. |
| `context_hits` | integer | Current relations linking this candidate to the `context_entity_ids` you sent. Default `0`. |

### FactResult

One relation or observation.

| Field | Type | Contents |
|---|---|---|
| `fact_id` | UUID | The relation or observation id. |
| `kind` | string | `relation` or `observation`. |
| `label` | string | Human-readable statement of the fact. |
| `evidence_count` | integer | Claims supporting it. |
| `validity` | [`Validity`](#validity) | |
| `temporal_match` | [`TemporalMatch`](#temporalmatch) | Default `possible`. |
| `contradiction_group` | UUID, nullable | The contradiction group it belongs to. |
| `contradiction` | [`Contradiction`](#contradiction), nullable | The other sides, when the group is live. |
| `support` | [`FactSupport`](#factsupport) | Default `current`. |

### Validity

| Field | Type | Contents |
|---|---|---|
| `valid_from` | UTC date-time, nullable | When it became true; `null` when unknown. |
| `valid_until` | UTC date-time, nullable | When it stopped being true; `null` when open or unknown. |
| `valid_precision` | [`ClaimValidPrecision`](#claimvalidprecision) | Default `unknown`. |
| `ingested_at` | UTC date-time | When the memory learned it. |
| `invalidated_at` | UTC date-time, nullable | When the memory stopped believing it. |

### ClaimValidPrecision

How exact a validity window is: `unknown`, `instant`, `day`, `month`,
`quarter`, `year`, `open` (known start, still ongoing).

### TemporalMatch

| Value | Meaning |
|---|---|
| `confirmed` | The fact's validity certainly matches the requested time. |
| `possible` | It may match; for example, a bound is unknown. |

### FactSupport

| Value | Meaning |
|---|---|
| `current` | At least one source still asserts it. |
| `withdrawn` | Every source that asserted it has stopped. The fact is still returned, flagged. |

### Contradiction

A fact's live contradiction, returned with all its sides up to a cap.

| Field | Type | Contents |
|---|---|---|
| `group_id` | UUID | |
| `co_members` | array of [`CoMember`](#comember) | The other sides, up to 25 inline. |
| `returned` | integer ≥ 0 | Sides returned. |
| `total` | integer ≥ 0 | Sides that exist. |
| `continuation` | string, nullable | Set when more sides exist than were returned. |

### CoMember

| Field | Type |
|---|---|
| `fact_id` | UUID |
| `label` | string |
| `evidence_count` | integer |
| `validity` | [`Validity`](#validity) |

### EvidenceResult

One claim with its provenance.

| Field | Type | Contents |
|---|---|---|
| `claim_id` | UUID | |
| `doc_id` | UUID | The document it came from. |
| `chunk_id` | UUID | The chunk it was extracted from. |
| `claim_text` | string | The claim, rewritten to stand alone. |
| `source_span` | string | The source text it came from. |
| `char_start` | integer | Start offset of the span in the converted Markdown of its representation. |
| `char_end` | integer | End offset. |
| `evidence_spans` | array of [`EvidenceSpan`](#evidencespan) | Every range of that chunk's representation that supports the claim. |
| `is_attributed` | boolean | The source reports someone else's statement ("Dana said …"). |
| `is_current_testimony` | boolean | The claim still comes from a current version of its source. |
| `asserted_at` | UTC date-time, nullable | When the source made the statement. Relative phrases in `claim_text` are relative to this. |
| `claim_valid_from` | UTC date-time, nullable | Start of when the claim says it was true, as the source put it. `null` when precision is `unknown`. |
| `claim_valid_until` | UTC date-time, nullable | End of that window; `null` when open or unknown. |
| `claim_valid_precision` | string | `instant`, `day`, `month`, `quarter`, `year`, `open` or `unknown`. Default `unknown`. |
| `claim_valid_kind` | string, nullable | What the window describes: `event_time` (when an event happened), `effective_period` or `proposition_validity` (when a state was true), `measurement_period` (the period a figure covers). |
| `document_title` | string, nullable | |
| `source_kind` | string, nullable | |
| `corroboration_count` | integer ≥ 1, nullable | Claims with identical text grouped under this one (`claims_and_sources_context`). |
| `grouped_claim_ids` | array of UUID | The ids of the grouped claims. |

### EvidenceSpan

| Field | Type | Contents |
|---|---|---|
| `char_start` | integer ≥ 0 | Start of a half-open range. |
| `char_end` | integer ≥ 0 | End; always greater than `char_start`. |

### FactEvidence

| Field | Type |
|---|---|
| `fact_kind` | `relation` \| `observation` |
| `fact_id` | UUID |
| `claim_id` | UUID |
| `stance` | `supports` \| `contradicts` |

### EvidenceTotal

| Field | Type | Contents |
|---|---|---|
| `fact_kind` | `relation` \| `observation` | |
| `fact_id` | UUID | |
| `stance` | `supports` \| `contradicts` | |
| `returned` | integer ≥ 0 | Claims of this stance in the result. |
| `total` | integer ≥ 0 | Claims of this stance that exist. Never less than `returned`. |

### ChunkEvidenceResult

One source passage.

| Field | Type | Contents |
|---|---|---|
| `chunk_id` | UUID | |
| `doc_id` | UUID | |
| `version_id` | UUID | |
| `representation_id` | UUID | The converted reading of the version the offsets refer to. |
| `chunk_text` | string | |
| `context_prefix` | string, nullable | Generated orientation text for the chunk, when one exists. Not evidence. |
| `char_start` | integer | Offsets in the representation's Markdown. |
| `char_end` | integer | |
| `section_role` | string, nullable | The role of the section it sits in (`body`, `abstract`, `references`, …). |
| `document_title` | string, nullable | |
| `source_kind` | string | |
| `source_modified_at` | UTC date-time, nullable | |
| `published_at` | UTC date-time, nullable | |

### SourceRecord

One document.

| Field | Type | Contents |
|---|---|---|
| `doc_id` | UUID | |
| `title` | string, nullable | |
| `source_kind` | string | |
| `markdown_uri` | string, nullable | Where the converted Markdown is stored. |
| `mention_count` | integer ≥ 0, nullable | |
| `first_mentioned_at` | UTC date-time, nullable | |
| `last_mentioned_at` | UTC date-time, nullable | |

### TranscriptEntry

One recorded decision.

| Field | Type | Contents |
|---|---|---|
| `subject_kind` | string | `relation`, `observation`, `entity` or `k_page`. |
| `outcome` | string | What was decided. |
| `method` | string | How it was decided. |
| `confidence` | number, nullable | |
| `related_id` | UUID, nullable | The counterpart: the other fact in a supersession, the entity absorbed in a merge. |
| `decided_by` | string | |
| `decided_at` | UTC date-time | |
| `features` | object, nullable | The inputs the decision recorded. |

### GraphNode

| Field | Type | Contents |
|---|---|---|
| `entity_id` | UUID | The entity, or on citation paths the document. |
| `name` | string | Canonical name, or document title. |
| `hops` | integer ≥ 0 | Distance from the start. |

### GraphEdge

| Field | Type | Contents |
|---|---|---|
| `relation_id` | UUID | The relation, or on citation paths the cross-reference. |
| `subject_id` | UUID | |
| `object_id` | UUID | |
| `predicate` | string | On citation paths, the kind of reference. |
| `fact` | string, nullable | The relation's label, or the citation's context. |
| `evidence_count` | integer | `0` on citation paths. |
| `valid_from` | UTC date-time, nullable | |
| `valid_until` | UTC date-time, nullable | |
| `valid_precision` | [`ClaimValidPrecision`](#claimvalidprecision) | Default `unknown`. |
| `ingested_at` | UTC date-time, nullable | |
| `invalidated_at` | UTC date-time, nullable | |
| `support` | [`FactSupport`](#factsupport) | Default `current`. |

### GraphPath

A path is returned whole or not at all.

| Field | Type | Contents |
|---|---|---|
| `length` | integer ≥ 1 | Number of edges. |
| `nodes` | array of [`GraphNode`](#graphnode), at least 2 | In order. |
| `edges` | array of [`GraphEdge`](#graphedge), at least 1 | In order. |

### RankedItem

| Field | Type |
|---|---|
| `item_id` | UUID |
| `score` | number |
| `signals` | object of string to number |

### ChangeRecord

| Field | Type | Contents |
|---|---|---|
| `kind` | string | `relation`, `observation`, `claim` or `page`. |
| `change` | string | `new`, `invalidated`, `capped` or `recompiled`. |
| `id` | UUID | |
| `label` | string, nullable | |
| `at` | UTC date-time | |

### AggregateReport

| Field | Type |
|---|---|
| `form` | string |
| `buckets` | array of [`AggregateBucket`](#aggregatebucket) |
| `total` | integer ≥ 0 |
| `possible_total` | integer ≥ 0, default `0` |
| `bounded_by` | string, nullable |

### AggregateBucket

| Field | Type |
|---|---|
| `key` | string, nullable |
| `count` | integer ≥ 0 |
| `possible_count` | integer ≥ 0, default `0` |
| `entity_id` | UUID, nullable |

### PageRef

| Field | Type |
|---|---|
| `artifact_id` | UUID |
| `page_kind` | string |
| `git_path` | string, nullable |
| `page_summary` | string, nullable |
| `last_compiled_at` | UTC date-time, nullable |
| `status` | string |
| `stale` | boolean, default `false` |
| `open_review_flags` | integer ≥ 0, default `0` |
| `redaction_required` | boolean, default `false` |

### Freshness

| Field | Type | Contents |
|---|---|---|
| `pg_live_ts` | UTC date-time | The database time the answer was read at. |
| `p1_written_inline` | boolean | The search index is written in the same step as the database. Default `true`. |
| `p1_believed_at_horizon` | UTC date-time, nullable | The oldest `believed_at` the search index can answer; `null` means unbounded. |
| `k` | [`KFreshness`](#kfreshness), nullable | Present only when the answer used a compiled page. |

### KFreshness

| Field | Type |
|---|---|
| `compiled_at` | UTC date-time, nullable |
| `stale` | boolean, default `false` |
| `open_flags` | integer ≥ 0, default `0` |

### Truncation

A cap is never silent: when one applies, this block says so.

| Field | Type | Contents |
|---|---|---|
| `truncated` | boolean | More existed than was returned. |
| `returned` | integer ≥ 0 | Items returned. |
| `estimated_total` | integer ≥ 0 | Items seen before the cap. |
| `total_is_exact` | boolean | `false` when the count itself hit a cap. Default `true`. |
| `continuation` | string, nullable | A cursor to pass back. Only `POST /graph/neighborhood` accepts one. |
| `reason` | string, nullable | Which cap applied, when known: `resolve_candidate_limit`, `lookup_k_limit`, `result_budget`, `expansion_budget`, `frontier_budget`, `time_budget`, `depth_budget`. |

### Negative

A typed "no". Each kind asks for a different reaction.

| Field | Type |
|---|---|
| `kind` | [`NegativeKind`](#negativekind) |
| `explanation` | string, at least 1 character |
| `workaround` | string, nullable |

### NegativeKind

| Value | Meaning | What to do |
|---|---|---|
| `unknown_entity` | The name or id is not known (or no longer exists). | Resolve the name again, check spelling, or search claims and chunks. |
| `known_empty` | The thing exists, and nothing matches. | Treat it as a real "none"; broaden the query if you expected results. |
| `boundary` | The memory could not answer within a limit: an index not published, a graph not ready, a time budget spent. | Do not treat it as "none". Retry, or use a direct route. |

Forgotten content is indistinguishable from content that never existed: it
shows up as `unknown_entity` or `known_empty`.

## ContextBundle/v2

The result of `combined_context`: two complete envelopes side by side.

| Field | Type | Contents |
|---|---|---|
| `contract` | string | Always `ContextBundle/v2`. |
| `claims_and_sources` | [`Envelope`](#envelope) | Always grain `evidence`. |
| `facts` | [`Envelope`](#envelope) | Always grain `fact`. |

## QueryResult/v1

The result of every SQL statement route, whatever the outcome. It describes
where the rows came from before giving them.

| Field | Type | Contents |
|---|---|---|
| `contract` | string | Always `QueryResult/v1`. |
| `grade` | string | Always `exploratory_tabular`: no grain, negatives, contradiction completeness, exact totals or order are guaranteed beyond what the fields below state. |
| `request_id` | UUID | |
| `deployment_id` | UUID | |
| `surface_manifest_hash` | string | The query space version the statement ran against. |
| `query_space_schema` | string | Always `memory_v1`. |
| `query_hash` | string | Hash of the normalized statement and the types (never the values) of its parameters. Empty when the statement did not parse. |
| `query_language` | string | Always `sql`. |
| `saved_query` | object of string, nullable | For a saved query: `query_id`, `namespace`, `name`, `version`, `query_hash`. |
| `referenced_views` | array of string | Views the statement reads. |
| `referenced_functions` | array of string | Functions it calls. |
| `source_grain_tags` | array of string | The grain tags of the referenced views. |
| `columns` | array of [`ResultColumn`](#resultcolumn) | |
| `rows` | array of arrays | One array per row, values in column order. |
| `returned_row_count` | integer ≥ 0 | |
| `returned_byte_count` | integer ≥ 0 | JSON-encoded size of the returned rows. |
| `limits` | [`ResultLimits`](#resultlimits) | The caps this statement ran under. |
| `truncated` | boolean | |
| `truncation_reason` | string, nullable | `row_cap`, `byte_cap`, or a graph budget (`depth_budget`, `expansion_budget`, `frontier_budget`, `result_budget`, `time_budget`). |
| `exact_total_known` | boolean | Always `false` in this release. |
| `exact_total` | integer, nullable | Always `null` in this release. |
| `ordered_result` | boolean | The statement has a top-level `ORDER BY`. |
| `empty_result` | boolean | No rows were returned (also `true` on every rejection or failure). |
| `negative_kind` | null | Always `null`: SQL results never carry a typed negative. |
| `execution_started_at` | date-time | |
| `evaluated_at` | date-time, nullable | Set only when every referenced view and function answers at one instant (`facts_current`, `graph_edges_current`, `contradiction_members_current`, `facts_as_of`). |
| `pg_snapshot_at` | date-time, nullable | The database snapshot time. |
| `elapsed_ms` | number ≥ 0 | |
| `termination_reason` | string | `completed`, `rejected` or `failed`. |
| `error_code` | string, nullable | One of the [SQL query codes](https://remember.dev/docs/reference/errors#sql-query-codes). |
| `error_message` | string, nullable | Safe to show; never raw database detail. |
| `warnings` | array of string | For example one entry per graph function that hit a budget. |
| `semantic_invocations` | array of [`SemanticInvocation`](#semanticinvocation) | One per search function call. |
| `graph_invocations` | array of [`GraphInvocation`](#graphinvocation) | One per graph function call. |

### ResultColumn

| Field | Type | Contents |
|---|---|---|
| `name` | string | |
| `type` | string | The PostgreSQL type name (`text`, `uuid`, `bigint`, `timestamp with time zone`, …). |
| `nullable` | boolean | Always `true`: PostgreSQL does not report nullability for computed columns. |

### ResultLimits

| Field | Type |
|---|---|
| `row_cap` | integer |
| `byte_cap` | integer |
| `statement_timeout_ms` | integer |
| `analytical_tier` | boolean (always `false` over HTTP) |

### SemanticInvocation

What one search function (`semantic_*`, `lexical_*`, `fetch_chunk_bodies`)
did.

| Field | Type | Contents |
|---|---|---|
| `function` | string | |
| `nominated` | integer | Candidates the index returned. |
| `confirmed` | integer | Candidates that held when re-read. |
| `dropped_stale` | integer | |
| `dropped_filtered` | integer, default `0` | |
| `dropped_ambiguous` | integer, default `0` | |
| `dropped_absent` | integer, default `0` | |
| `dropped_body_mismatch` | integer, default `0` | |
| `dropped_absent_current` | integer, default `0` | |
| `dropped_absent_projection` | integer, default `0` | |
| `dropped_hash_mismatch` | integer, default `0` | |
| `policy_generation` | string, nullable | |
| `embedder_generation` | string, nullable | |
| `generation` | string, nullable | |
| `pg_confirmed_at` | date-time, nullable | |
| `termination_reason` | string, nullable | |

### GraphInvocation

What one graph function did.

| Field | Type |
|---|---|
| `ordinal` | integer ≥ 0 |
| `function` | `graph_neighborhood` \| `graph_path` \| `graph_citation_path` |
| `truncated` | boolean |
| `truncation_reason` | `depth_budget` \| `expansion_budget` \| `frontier_budget` \| `result_budget` \| `time_budget`, nullable |
| `examined_edges` | integer ≥ 0 |
| `returned_paths` | integer ≥ 0 |
| `effective_depth` | integer ≥ 1 |
| `effective_expansion_budget` | integer ≥ 1 |
| `effective_frontier_budget` | integer ≥ 1 |
| `effective_result_budget` | integer ≥ 1 |
| `effective_time_budget_ms` | integer ≥ 1 |
| `applied_valid_at` | date-time, nullable |
| `applied_believed_at` | date-time, nullable |
| `evaluated_at` | date-time, nullable |

## IngestedVersion

Returned by `POST /ingest`.

| Field | Type | Contents |
|---|---|---|
| `deployment_id` | UUID | |
| `doc_id` | UUID | The document. |
| `version_id` | UUID | The version; pass it to `POST /readiness`. |
| `content_hash` | string | SHA-256 of the bytes, hex. |
| `created` | boolean | `true` for a new version; `false` when the bytes matched the latest version and nothing new was stored. |
| `mime` | string, nullable | The MIME type recorded for these bytes, which conversion uses. Set by the first upload of the bytes, unless that type had no converter route and a later upload sent one that has. |
| `title` | string, nullable | The document's title, set by its first ingest. |
| `versioning_mode` | `snapshot` \| `living`, nullable | The document's versioning mode, set by its first ingest. |
| `parked` | `"no_route"` or `null` | `"no_route"` when the upload's conversion is parked waiting for a conversion route for its MIME type: the bytes are stored, and conversion waits until an operator adds a route if needed and runs `remember ops resume-no-route`, or the same bytes are sent again with a MIME type that has a route. `null` means only that it is not parked for `no_route`; read processing state from `POST /readiness`. |

This release's engine always sets `mime`, `title` and `versioning_mode`; they
are `null` only from an engine release that predates them.

## Readiness

### ReadinessRequirements

The `require` object of `POST /readiness`. All four fields are required.

| Field | Type |
|---|---|
| `pipeline` | boolean |
| `p1` | boolean |
| `live_graph` | boolean |
| `p3` | boolean |

### PipelineReadinessReport

| Field | Type | Contents |
|---|---|---|
| `ready` | boolean | Every required capability is ready. |
| `versions` | array of [`VersionPipelineReadiness`](#versionpipelinereadiness) | One per requested version. |
| `capabilities` | object: `pipeline`, `p1`, `live_graph`, `p3` → [`CapabilityReadiness`](#capabilityreadiness) | All four, required or not. |
| `document_binding_generation` | string, nullable | As in [`DeploymentBuildInfo`](#deploymentbuildinfo). |
| `model_bindings` | object of string to string | As in `DeploymentBuildInfo`. |
| `build_revision` | string | As in `DeploymentBuildInfo`. |

### VersionPipelineReadiness

| Field | Type | Contents |
|---|---|---|
| `version_id` | UUID | |
| `ready` | boolean | Every stage is `succeeded` or `skipped` and finished. |
| `stages` | array of [`PipelineStageReadiness`](#pipelinestagereadiness) | One per expected stage. |

### PipelineStageReadiness

| Field | Type | Contents |
|---|---|---|
| `stage` | string | The pipeline stage. |
| `component_version` | string | The version of that stage's component the deployment expects. |
| `status` | string | `missing`, `pending`, `running`, `succeeded`, `failed`, `dead_letter`, `skipped`. |
| `finished_at` | date-time, nullable | |
| `defer_reason` | string, nullable | Why a `pending` or `failed` stage waits: `no_route` (no converter for the file's type), `budget` (a spend budget is used up), `scheduled` (due later), `retry_backoff` (a `failed` stage waiting for its next attempt). `null` for any other status, and for pending work that is simply queued. |

`missing` means no work for that stage and component version exists yet.
`failed` may be retried by the pipeline; `dead_letter` will not be.

### CapabilityReadiness

| Field | Type | Contents |
|---|---|---|
| `required` | boolean | You asked for it. |
| `ready` | boolean | |
| `checked_at` | date-time | |
| `reason` | string | `ready`, or why not (see [Ingest](https://remember.dev/docs/reference/http-api/ingest#post-readiness)). |
| `version` | string, nullable | `p3` only: the snapshot version. |
| `built_at` | date-time, nullable | `p3` only. |
| `published_at` | date-time, nullable | `p3` only. |

## Documents

### DocumentPage

| Field | Type | Contents |
|---|---|---|
| `documents` | array of [`DocumentSummary`](#documentsummary) | |
| `cursor` | string, nullable | Pass to the next request; `null` on the last page. |

### DocumentSummary

| Field | Type | Contents |
|---|---|---|
| `doc_id` | UUID | |
| `title` | string, nullable | |
| `source_kind` | string | `upload` for a file sent without a source identity. |
| `source_uri` | string, nullable | The `source_ref`, for documents sent with one. |
| `first_seen_at` | date-time | Never changes. |
| `latest` | [`DocumentVersionSummary`](#documentversionsummary) | The newest version that has not been deleted. |
| `serving` | boolean | Some version of this document is `ready` and not deleted. |

### DocumentVersionSummary

| Field | Type | Contents |
|---|---|---|
| `version_id` | UUID | |
| `version_no` | integer | |
| `status` | string | `ingesting`, `converting`, `structuring`, `ready`, `failed`, `deleted`. |
| `ingested_at` | date-time | |
| `error` | string, nullable | Why it failed. |

A version `status` of `ready` means conversion and structuring are done. It
does not mean claims and facts have been extracted; check
[readiness](#pipelinereadinessreport) for that.

### DocumentDeletion

What [`DELETE /documents/{doc_id}`](https://remember.dev/docs/reference/http-api/ingest#delete-documentsdoc_id)
changed. The counts describe this call only.

| Field | Type | Contents |
|---|---|---|
| `doc_id` | UUID | The deleted document. |
| `deleted_at` | date-time | When the document was deleted. For a call that finished a deletion another path started, when the document was first hidden. |
| `claims_retired` | integer | Claims that stopped counting as evidence. |
| `relations_closed` | integer | Relations closed because this document was their only support. |
| `observations_closed` | integer | Observations closed because this document was their only support. |

## ToolDescriptor

One assured operation, as `GET /operations` returns it.

| Field | Type | Contents |
|---|---|---|
| `name` | string | |
| `description` | string | |
| `input_schema` | object | JSON Schema of the arguments. Closed (`additionalProperties: false`). |
| `result_schema` | object | JSON Schema of the result. |
| `result_contract` | string | `envelope` or `context_bundle_v2`. |
| `output_grain` | string, nullable | `fact`, `evidence`, or `null` for `combined_context`. |
| `answer_intent` | string | `identity`, `claims_and_sources`, `facts` or `combined_context`. |
| `mutates` | boolean, nullable | Whether the operation changes memory. `false` on all four shipped operations. |
| `version` | integer ≥ 1, nullable | |
| `implementation_plan_hash` | string of 64 hex characters, nullable | Identifies the exact plan. |

## DeploymentBuildInfo

| Field | Type | Contents |
|---|---|---|
| `build_revision` | string | Source revision the image was built from; empty when unknown. Default `""`. |
| `model_bindings` | object of string to string | Model per pipeline role. Default `{}`. |
| `document_binding_generation` | string, nullable | |
| `tools` | object of string to integer | MCP memory tool name to tool version, for every tool the deployment serves. Default `{}`. |

## Request bodies

### SearchRequest

Body of `POST /search/claims` and `POST /search/chunks`.

| Field | Type | Required | Default | Constraints |
|---|---|---|---|---|
| `query` | string | yes | | 1 to 4,096 characters. |
| `k` | integer | no | `10` | 1 to 400. |
| `channel` | `semantic` \| `bm25` | no | `semantic` | |

### AdjacentChunksRequest

Body of `POST /chunks/adjacent`.

| Field | Type | Required | Default | Constraints |
|---|---|---|---|---|
| `chunk_id` | UUID | yes | | |
| `window` | integer | no | `1` | 1 or 2. |

The graph, readiness, SQL and saved-query bodies are described with their
routes: [Graph](https://remember.dev/docs/reference/http-api/graph), [Ingest](https://remember.dev/docs/reference/http-api/ingest#post-readiness),
[SQL queries](https://remember.dev/docs/reference/http-api/query).

---

Source: https://remember.dev/docs/reference/query-space

# Query space memory_v1

SQL queries run over **the query space**, `memory_v1`: a fixed set of
prepared, read-only views and functions over your memory. Every statement is
parsed and validated against it before it runs, and anything outside it is
rejected. The views already apply the memory's rules — deleted versions,
forgotten sources and superseded readings are absent — so a statement can only
see what the memory itself would return.

This page is the reference for the query space: [identity and
versioning](#version-and-manifest-hash), [the grammar a statement must
follow](#what-a-statement-may-contain), [limits](#limits), the
[12 functions](#functions), the [25 views](#views) and the
[18 shipped saved queries](#shipped-saved-queries). The routes that run
statements are in [SQL queries](https://remember.dev/docs/reference/http-api/query); a walkthrough is in
[Explore memory with SQL](https://remember.dev/docs/guides/sql).

## What `describe_query_space` tells an agent

Every call to `describe_query_space` (and `GET /query/space`) opens with
this headline, word for word. It is the rule the rest of the query space
is built around:

RememberStack has two deliberately separate truth layers. Claims are immutable source testimony (“what was asserted, by whom, when”); facts—relations and observations—are the adjudicated worldview (“what the system holds or held true”): supersession-adjudicated, clocked on two time axes (when a fact held in the world, and when the system learned it), evidence-counted per distinct source—repetition is not corroboration—and contradiction-tracked. The `fact_claim_evidence` association is the auditable bridge between the layers, recording which claims support or contradict each fact. Query claims to inspect testimony; query facts to answer current or historical truth questions, then follow the bridge to see why the system believes or believed the fact.

(Internally these guarantees are decisions D41 and D54.)

## Version and manifest hash

| Property | Value |
|---|---|
| Schema | `memory_v1` |
| Schema major version | `1` |
| Manifest contract | `memory_v1.manifest/2` |
| Surface manifest hash (this release) | `d8be43966d90048ce3fc8ffe6dfdfc7943999fbf4f018ac2eb7998f2c995aae2` |
| PostgreSQL major | 19 |

The query space is described by a manifest checked into the engine. Its hash
covers the views (definitions, columns, comments), the function signatures,
the assured operation descriptors and the limits. Every `QueryResult` reports
it in `surface_manifest_hash`, and `GET /query/space` returns the whole
manifest content.

When a release changes the query space, the hash changes. The deployment then
moves every active saved query to `pending_revalidation`, and a saved query
refuses to run (`saved_query_revalidation_pending`) until it is revalidated
against the new hash. If the database's live views do not match the manifest
the server was built with, every statement fails with
`schema_version_mismatch`.

## What a statement may contain

A statement is checked in this order. The first rule it breaks decides the
error code.

### One read-only statement

- Exactly one statement (`multiple_statements` otherwise).
- It must be a `SELECT`, `VALUES` or `WITH … SELECT` (`statement_not_allowed`).
  No `SELECT INTO`, no `FOR UPDATE` or other row locks, no `TABLESAMPLE`.
- It must parse as PostgreSQL (`parse_error`) and contain no NUL byte.
- Names beginning with `__rememberstack_` are reserved
  (`statement_not_allowed`).
- Placeholders must be contiguous from `$1` (`invalid_parameter`), and the
  request must supply exactly as many parameters as the highest placeholder.

### Relations

A statement may read only the 25 views below, unqualified or as
`memory_v1.<view>`, and its own CTEs. Any other table, view or schema is
`relation_not_allowed`.

### Built-in functions

Besides the [12 public functions](#functions), these built-in functions are
allowed (`function_not_allowed` otherwise). A `pg_catalog.` prefix is allowed;
any other schema prefix is not.

| Kind | Functions |
|---|---|
| Aggregates | `count`, `sum`, `avg`, `min`, `max`, `bool_and`, `bool_or`, `array_agg`, `string_agg`, `jsonb_agg`, `jsonb_object_agg` |
| Conditionals | `coalesce`, `nullif`, `greatest`, `least` |
| Text | `lower`, `upper`, `trim`, `btrim`, `length`, `octet_length`, `substring`, `replace`, `regexp_replace` |
| Numbers | `abs`, `ceil`, `floor`, `round` |
| Time | `date_trunc`, `extract`, `make_interval` |
| Arrays and JSON | `array_length`, `cardinality`, `jsonb_typeof`, `jsonb_array_length`, `jsonb_build_object` |
| Window | `row_number`, `rank`, `dense_rank`, `lag`, `lead`, `first_value`, `last_value` |

Also refused: ordered-set aggregates (`WITHIN GROUP`), table functions
(`XMLTABLE`, `JSON_TABLE`), XML expressions, and session keywords such as
`CURRENT_USER` and `CURRENT_SCHEMA`.

### Operators

`=`, `<>`, `!=`, `<`, `<=`, `>`, `>=`, `+`, `-`, `*`, `/`, `%`, `||`, `@>`,
`<@`, `&&`, `->`, `->>`, `#>`, `#>>`, `~`, `~*`, `!~`, `!~*`, `LIKE` (`~~`),
`NOT LIKE` (`!~~`), `ILIKE` (`~~*`), `NOT ILIKE` (`!~~*`), `= ANY`,
`BETWEEN`, `NOT BETWEEN`. `AND`, `OR`, `NOT`, `IS NULL` and `IS [NOT]
TRUE/FALSE` are allowed too. Any other operator, including one in
`ORDER BY … USING`, is `operator_not_allowed`.

### Casts

Casts are allowed only to `uuid`, `text`, `varchar`, `bpchar`, `bool`,
`boolean`, `int2`, `int4`, `int8`, `integer`, `bigint`, `smallint`, `numeric`,
`float4`, `float8`, `timestamptz`, `timestamp`, `date`, `interval`, `jsonb`
(`operator_not_allowed` otherwise).

### Syntax

The statement is built from a fixed set of syntax elements; anything else is
`statement_not_allowed`. Allowed: `SELECT` with `FROM`, joins, subqueries in
`FROM` and in expressions (`IN`, `EXISTS`, scalar), `WITH`, `WHERE`,
`GROUP BY` (including grouping sets), `HAVING`, `ORDER BY`, `LIMIT`, window
definitions, `CASE`, `COALESCE`, `GREATEST`/`LEAST`, `NULL` and boolean tests,
array constructors and subscripts, row constructors, `COLLATE`, named
function arguments, and literals of every kind. The exact parser classes are
listed in `GET /query/space` under `sql_grammar.statement_node_classes`.

### Recursion

At most one recursive CTE per statement, and it must follow one template
(`unbounded_recursion` otherwise):

- the `WITH RECURSIVE` clause holds exactly one CTE, with no `CYCLE` or
  `SEARCH` clause;
- its body is `anchor UNION [ALL] recursive-term`;
- the anchor sets an integer column named `depth` to the literal `0`;
- the recursive term references the CTE exactly once, joins only views (no
  subqueries in its `FROM`), and emits `depth + 1` in the `depth` column;
- the recursive term has a top-level condition `depth < N` with `N` at most
  6, not inside an `OR`.

```sql
WITH RECURSIVE reach AS (
  SELECT object_entity_id AS entity_id, 0 AS depth
  FROM graph_edges_current
  WHERE subject_entity_id = $1::uuid
  UNION
  SELECT e.object_entity_id, r.depth + 1
  FROM reach AS r
  JOIN graph_edges_current AS e ON e.subject_entity_id = r.entity_id
  WHERE r.depth < 3
)
SELECT DISTINCT entity_id FROM reach
```

### Public function placement

The 12 public functions return rows, and they have extra rules
(`function_placement_not_allowed` otherwise):

- a call must be a `FROM` item of the top-level statement, or of a top-level
  CTE body — not inside a subquery, a `UNION` arm, a `LATERAL` join or an
  `IN`/`EXISTS` test;
- each call is its own `FROM` item (no `ROWS FROM` with several functions);
- every argument is a literal or a parameter (`$1`), optionally cast;
- at most 3 calls per category per statement (`quota_exceeded`): nomination
  (`semantic_*`, `lexical_*`), body fetch (`fetch_chunk_bodies`), bitemporal
  (`facts_as_of`), temporal (`canonical_bounds`), graph (`graph_*`).

## Limits

Two limit tiers exist. Over HTTP, every statement runs in the **interactive**
tier. The analytical tier needs an operator entitlement and a separate pool
that the shipped profile does not configure, so it is listed for completeness.

A caller may lower a limit, or raise it up to the hard cap. Over HTTP only
`max_rows` can be set by the caller; a saved query can also carry its own
`statement_timeout_ms` and `max_bytes` defaults.

| Limit | Interactive | Analytical |
|---|---|---|
| Statement timeout, default | 5,000 ms | 60,000 ms |
| Statement timeout, hard cap | 15,000 ms | 60,000 ms |
| Statement timeout with a graph function | 5,000 ms | 5,000 ms |
| Lock timeout | 250 ms | 2,000 ms |
| Idle-in-transaction timeout | 5,000 ms | 15,000 ms |
| Rows returned, default | 200 | 10,000 |
| Rows returned, hard cap | 1,000 | 10,000 |
| Bytes returned, default | 1,048,576 | 67,108,864 |
| Bytes returned, hard cap | 8,388,608 | 67,108,864 |
| Work memory | 16,384 KiB | 65,536 KiB |
| Temporary files | 65,536 KiB | 65,536 KiB |
| SQL text | 65,536 bytes | 65,536 bytes |
| Parameters, count | 64 | 256 |
| Parameters, encoded size | 262,144 bytes | 1,048,576 bytes |
| Recursive CTEs per statement | 1 | 1 |
| Recursion depth | 6 | 6 |
| Concurrent statements per caller | 2 | 1 |
| Concurrent statements per deployment | 8 | 4 |
| Statement seconds per caller per minute | 30 | 60 |
| Statement seconds per deployment per minute | 120 | 240 |

Rows beyond the row cap, or beyond the byte cap, are cut and the result says
`truncated: true` with `truncation_reason` `row_cap` or `byte_cap`. Exceeding
a concurrency or per-minute budget refuses the statement with
`concurrency_exceeded` or `quota_exceeded`.

Every statement runs in one read-only, repeatable-read transaction with
parallel query off, so all its parts see one snapshot of the memory.

Per-function caps, in addition:

| Function | Cap |
|---|---|
| `semantic_*`, `lexical_*` | `k` defaults to 20 and is lowered to 100 if larger; 200 candidates in total per statement across all search calls (`quota_exceeded` beyond). |
| `fetch_chunk_bodies` | 50 chunk ids per call; 512 KiB of chunk text per call; 4 MiB per statement. |
| `facts_as_of` | `max_rows` defaults to 200, hard cap 1,000. |
| `graph_*` | See each function. |

## Functions

The 12 public functions are set-returning: call them in `FROM`, following the
[placement rules](#public-function-placement). The search functions nominate
candidates from the search index and then confirm each one against the
database inside the same transaction, so a result never includes something the
views would hide. What they did is reported in the result's
`semantic_invocations` or `graph_invocations`.

`now()` and other clock functions are not allowed. Pass the time you mean as a
parameter.

### semantic_claims

```sql
semantic_claims(query text, k integer [, filters jsonb [, embedding_input_policy_version text [, embedder_generation text]]])
```

Claims ranked by meaning. Category: nomination.

| Column | Type |
|---|---|
| `rank` | integer |
| `score` | double precision |
| `channel` | text |
| `claim_id` | uuid |
| `doc_id` | uuid |
| `claim_text` | text |
| `source_handle` | text |
| `source_kind` | text |
| `asserted_at` | timestamptz |
| `claim_valid_from` | timestamptz |
| `claim_valid_until` | timestamptz |

Filters: `asserted_from`, `asserted_to` (ISO 8601 instants), `doc_id`,
`entity_id` (UUIDs), `source_kind` (string).

### lexical_claims

```sql
lexical_claims(query text, k integer [, filters jsonb])
```

Claims ranked by keyword (BM25). Same columns and filters as
`semantic_claims`. Category: nomination.

### semantic_chunks

```sql
semantic_chunks(query text, k integer [, filters jsonb [, embedding_input_policy_version text [, embedder_generation text]]])
```

Source chunks ranked by meaning. Category: nomination.

| Column | Type |
|---|---|
| `rank` | integer |
| `score` | double precision |
| `channel` | text |
| `chunk_id` | uuid |
| `doc_id` | uuid |
| `version_id` | uuid |
| `representation_id` | uuid |
| `section_id` | uuid |
| `chunk_content_hash` | text |
| `embedding_text_hash` | text |
| `source_text` | text |
| `location_header` | text |
| `embedding_input_policy_version` | text |
| `policy_generation` | text |
| `embedder_generation` | text |
| `created_at` | timestamptz |

Filters: `doc_id` (UUID), `language`, `section_role`, `source_kind`,
`source_shape` (strings). `section_role` must be one of `body`, `abstract`,
`introduction`, `results`, `methods`, `discussion`, `conclusion`,
`references`, `appendix`, `table`, `figure_caption`, `nav`, `boilerplate`,
`legal`.

### lexical_chunks

```sql
lexical_chunks(query text, k integer [, filters jsonb])
```

Source chunks ranked by keyword (BM25). Same columns and filters as
`semantic_chunks`. Category: nomination.

### semantic_facts

```sql
semantic_facts(query text, k integer [, filters jsonb [, embedding_input_policy_version text [, embedder_generation text]]])
```

Facts (relations and observations) ranked by meaning. Category: nomination.

| Column | Type |
|---|---|
| `rank` | integer |
| `score` | double precision |
| `channel` | text |
| `fact_id` | uuid |
| `fact_kind` | text |
| `fact_label` | text |
| `predicate` | text |
| `subject_entity_id` | uuid |
| `object_entity_id` | uuid |
| `evidence_count` | bigint |
| `contradict_count` | bigint |
| `support_state` | text |
| `evaluated_at` | timestamptz |

Filters: `fact_kind` (`relation` or `observation`), `support_state`
(`current` or `withdrawn`), `predicate` (string), `subject_entity_id`,
`object_entity_id` (UUIDs).

### semantic_entities

```sql
semantic_entities(query text, k integer [, filters jsonb])
```

Entities ranked by how well their profile matches. No filters. Category:
nomination.

| Column | Type |
|---|---|
| `rank` | integer |
| `score` | double precision |
| `channel` | text |
| `entity_id` | uuid |
| `entity_type` | text |
| `canonical_name` | text |
| `profile_summary` | text |
| `live_mention_count` | bigint |
| `live_document_count` | bigint |

For all six search functions: `filters` is a JSON object with scalar values
from the function's filter list; any other key is `invalid_parameter`. The
two generation arguments pin a specific embedding generation and are
normally left out.

### fetch_chunk_bodies

```sql
fetch_chunk_bodies(chunk_ids uuid[])
```

The text of up to 50 chunks, re-verified against their content and embedding
hashes before it is returned. All requested chunks must belong to one
embedding generation. Category: body fetch.

| Column | Type |
|---|---|
| `input_ordinal` | integer |
| `chunk_id` | uuid |
| `doc_id` | uuid |
| `version_id` | uuid |
| `representation_id` | uuid |
| `section_id` | uuid |
| `chunk_content_hash` | text |
| `embedding_text_hash` | text |
| `source_text` | text |
| `location_header` | text |
| `embedding_input_policy_version` | text |
| `policy_generation` | text |
| `embedder_generation` | text |
| `created_at` | timestamptz |

`chunks_live` has no text column; this function is the only way to read chunk
text in SQL.

### facts_as_of

```sql
facts_as_of(valid_at timestamptz, believed_at timestamptz [, max_rows integer])
```

The facts that were valid at `valid_at`, as the memory believed at
`believed_at`. `max_rows` defaults to 200, hard cap 1,000. Category:
bitemporal.

| Column | Type |
|---|---|
| `deployment_id` | uuid |
| `fact_kind` | text |
| `fact_id` | uuid |
| `subject_entity_id` | uuid |
| `predicate` | text |
| `object_entity_id` | uuid |
| `statement` | text |
| `fact_label` | text |
| `valid_from` | timestamptz |
| `valid_until` | timestamptz |
| `ingested_at` | timestamptz |
| `invalidated_at` | timestamptz |
| `contradiction_group` | uuid |
| `confidence` | real |
| `evidence_count_current` | bigint |
| `contradict_count_current` | bigint |
| `support_state_current` | text |
| `applied_valid_at` | timestamptz |
| `applied_believed_at` | timestamptz |
| `identity_regime` | text |
| `valid_precision` | text |
| `temporal_match` | text |

### canonical_bounds

```sql
canonical_bounds(valid_from timestamptz, valid_until timestamptz, valid_precision text)
```

Turn a stored validity window and its precision into the half-open interval
it means: a `day` covers the whole UTC day, a `year` the whole year, an
`instant` a one-microsecond point; `open` has no end and `unknown` has no
bounds. Category: temporal.

| Column | Type |
|---|---|
| `canon_start` | timestamptz |
| `canon_end` | timestamptz |

Because arguments must be literals or parameters, use it for one window you
supply. For every claim at once, read `claims_canonical`, which carries the
same bounds.

### graph_neighborhood

```sql
graph_neighborhood(deployment_id uuid, start_entity_id uuid
  [, max_depth integer [, predicates text[] [, valid_at timestamptz [, believed_at timestamptz
  [, max_results integer [, expansion_budget integer [, frontier_budget integer [, time_budget_ms integer]]]]]]]])
```

Paths to the entities within `max_depth` hops, and one terminal status row.
Category: graph.

| Argument | Default | Range |
|---|---|---|
| `max_depth` | 2 | 1 to 4 (above 4 is capped and reported as `depth_budget`) |
| `predicates` | all | |
| `valid_at`, `believed_at` | now | both or neither |
| `max_results` | 100 | 1 to 500 |
| `expansion_budget` | 2,000 | 1 to 2,000 |
| `frontier_budget` | 1,000 | 1 to 1,000 |
| `time_budget_ms` | 1,000 | 1 to 5,000 |

| Column | Type |
|---|---|
| `row_kind` | text |
| `hops` | integer |
| `relation_ids` | uuid[] |
| `node_ids` | uuid[] |
| `truncated` | boolean |
| `truncation_reason` | text |
| `examined_edges` | bigint |
| `returned_paths` | bigint |
| `effective_depth` | integer |
| `effective_expansion_budget` | integer |
| `effective_frontier_budget` | integer |
| `effective_result_budget` | integer |
| `effective_time_budget_ms` | integer |
| `applied_valid_at` | timestamptz |
| `applied_believed_at` | timestamptz |

### graph_path

```sql
graph_path(deployment_id uuid, from_entity_id uuid, to_entity_id uuid
  [, max_depth integer [, predicates text[] [, valid_at timestamptz [, believed_at timestamptz
  [, max_results integer [, expansion_budget integer [, frontier_budget integer [, time_budget_ms integer]]]]]]]])
```

Equal-length shortest paths between two entities, and one terminal status
row. Same columns as `graph_neighborhood`. Category: graph.

| Argument | Default | Range |
|---|---|---|
| `max_depth` | 4 | 1 to 6 |
| `max_results` (paths) | 3 | 1 to 10 |
| Budgets, clocks, predicates | as for `graph_neighborhood` | |

### graph_citation_path

```sql
graph_citation_path(deployment_id uuid, from_doc_id uuid, to_doc_id uuid
  [, max_depth integer [, max_paths integer [, expansion_budget integer [, frontier_budget integer [, time_budget_ms integer]]]]])
```

Directed citation chains between two live documents, and one terminal status
row. Category: graph.

| Argument | Default | Range |
|---|---|---|
| `max_depth` | 6 | 1 to 6 |
| `max_paths` | 3 | 1 to 10 |
| Budgets | as for `graph_neighborhood` | |

| Column | Type |
|---|---|
| `row_kind` | text |
| `hops` | integer |
| `crossref_ids` | uuid[] |
| `document_ids` | uuid[] |
| `truncated` | boolean |
| `truncation_reason` | text |
| `examined_edges` | bigint |
| `returned_paths` | bigint |
| `effective_depth` | integer |
| `effective_expansion_budget` | integer |
| `effective_frontier_budget` | integer |
| `effective_result_budget` | integer |
| `effective_time_budget_ms` | integer |
| `evaluated_at` | timestamptz |

For all three graph functions:

- the first argument must be the parameter `$1`, bound to this deployment's
  id (`invalid_parameter` otherwise). Every `QueryResult` reports that id in
  `deployment_id`;
- the statement timeout is 5 seconds;
- the terminal status row is removed from `rows` and reported in
  `graph_invocations`; a budget that was reached also appears in `warnings`
  and `truncation_reason`.

```sql
SELECT hops, relation_ids, node_ids
FROM graph_neighborhood($1::uuid, $2::uuid, 2)
ORDER BY hops, relation_ids
```

## Views

The 25 views, with every column. Descriptions are the comments in the
manifest, as `GET /query/space` returns them. Every view has a
`deployment_id` column; a deployment only ever sees its own rows.

| View | Grain | Row key |
|---|---|---|
| [`changes_visible`](#changes_visible) | one externally visible change event | `object_kind`, `event_id` |
| [`chunks_live`](#chunks_live) | one chunk coordinate in a current ready representation | `chunk_id` |
| [`claim_occurrences_live`](#claim_occurrences_live) | one current claim occurrence | `claim_id`, `chunk_id`, `derivation_kind` |
| [`claims_canonical`](#claims_canonical) | one historically visible claim with surviving lineage and its half-open canonical window | `claim_id` |
| [`claims_live`](#claims_live) | one current-testimony claim | `claim_id` |
| [`claims_visible_history`](#claims_visible_history) | one historically visible claim with surviving lineage | `claim_id` |
| [`contradiction_members_current`](#contradiction_members_current) | one current contradiction-group member | `contradiction_group`, `fact_kind`, `fact_id` |
| [`document_crossrefs_live`](#document_crossrefs_live) | one live document cross-reference | `crossref_id` |
| [`document_versions_visible`](#document_versions_visible) | one visible version of a live lineage | `version_id` |
| [`documents_live`](#documents_live) | one live document lineage | `doc_id` |
| [`entities_current`](#entities_current) | one externally visible survivor entity | `entity_id` |
| [`entity_aliases_current`](#entity_aliases_current) | one current alias-to-survivor mapping | `alias_id` |
| [`entity_document_mentions`](#entity_document_mentions) | one survivor entity × live document | `entity_id`, `doc_id` |
| [`evidence_lineage`](#evidence_lineage) | one fact × current-testimony document lineage × stance | `fact_kind`, `fact_id`, `doc_id`, `stance` |
| [`fact_claim_evidence_live`](#fact_claim_evidence_live) | one current claim-to-fact association | `fact_kind`, `fact_id`, `claim_id`, `stance` |
| [`facts_current`](#facts_current) | one currently valid relation or observation | `fact_kind`, `fact_id` |
| [`facts_visible_history`](#facts_visible_history) | one historically visible relation or observation | `fact_kind`, `fact_id` |
| [`graph_edges_current`](#graph_edges_current) | one current relation edge | `relation_id` |
| [`graph_edges_visible_history`](#graph_edges_visible_history) | one historically visible relation edge | `relation_id` |
| [`identity_events_visible`](#identity_events_visible) | one visible resolution/merge/split event | `object_kind`, `event_id` |
| [`mentions_live`](#mentions_live) | one mention in current content | `mention_id` |
| [`page_evidence_visible`](#page_evidence_visible) | one visible K artifact-to-target association | `artifact_id`, `role`, `target_kind`, `target_id` |
| [`pages_live`](#pages_live) | one visible K artifact | `artifact_id` |
| [`sections_live`](#sections_live) | one section in a current ready representation | `section_id` |
| [`testimony_currency_events_visible`](#testimony_currency_events_visible) | one visible D54 transition | `event_id` |

### changes_visible

One row per externally visible change event, keyed by the synthetic pair (deployment_id, object_kind, event_id): every union arm names its own transition in object_kind and supplies an identifier from its own source table, so the underlying identifier spaces cannot collide. Each arm reads an already invariant-bearing relation or joins one, so a change event appears only while its object is still visible. THERE IS DELIBERATELY NO DELETION ARM: forgetting a lineage, tombstoning a version, or retiring a page removes the affected events instead of announcing the removal, and labels are drawn only from visible objects, so neither the event set nor the label text can become a side channel for what was forgotten. The occurrence clock is transaction time and never world validity, the feed is uncapped at the relation level so a caller bounds it with an ordinary predicate, and the view carries no counts and asserts no facts.

- **Grain:** one externally visible change event (`change_event_visible`)
- **Row key:** `deployment_id`, `object_kind`, `event_id`
- **Clock semantics:** `transaction_time_event`
- **Joins to:** `facts_visible_history` on `deployment_id`, `object_id`; `claims_visible_history` on `deployment_id`, `object_id`; `pages_live` on `deployment_id`, `object_id`

| Column | Type | Null | Meaning |
|---|---|---|---|
| `deployment_id` | uuid | no | The deployment that owns the change event. |
| `object_kind` | text | no | Which kind of change event this is, naming both the changed object and the transition. Values: `relation_ingest`, `relation_invalidation`, `relation_supersession`, `observation_ingest`, `observation_invalidation`, `observation_supersession`, `claim_ingest`, `knowledge_page_compilation`. |
| `event_id` | uuid | no | Identity of the event within its own source, unique together with object_kind. |
| `object_id` | uuid | no | The object that changed, joinable to the relation named by object_kind. |
| `occurred_at` | timestamp with time zone | no | When the change occurred, which is a transaction-time instant and never world validity. |
| `label` | text | no | Short human-readable label for the changed object, drawn only from objects that are themselves visible. |

### chunks_live

One row per chunk coordinate in the current ready representation of a live lineage, keyed by (deployment_id, chunk_id) and joined to documents_live on (deployment_id, doc_id) and to sections_live on (deployment_id, section_id). This relation is metadata only and deliberately carries no authoritative body column: chunk text is returned solely by the confirmed body-fetch path, which re-verifies the coordinate and the content and embedding hashes before any bytes leave the system. Chunks of superseded versions, non-ready readings, and forgotten lineages are absent, and a section_id is exposed only when that section belongs to the representation's current structure generation. The location header is generated orientation text, never evidence; the view carries no counts and no validity clocks.

- **Grain:** one chunk coordinate in a current ready representation (`chunk_current_content`)
- **Row key:** `deployment_id`, `chunk_id`
- **Clock semantics:** `none`
- **Joins to:** `documents_live` on `deployment_id`, `doc_id`; `document_versions_visible` on `deployment_id`, `version_id`; `sections_live` on `deployment_id`, `section_id`

| Column | Type | Null | Meaning |
|---|---|---|---|
| `deployment_id` | uuid | no | The deployment that owns the chunk. |
| `chunk_id` | uuid | no | Stable identity of this retrieval unit within the current reading. |
| `doc_id` | uuid | no | The live lineage the chunk belongs to. |
| `version_id` | uuid | no | The lineage's current version the chunk was cut from. |
| `representation_id` | uuid | no | The current ready reading whose block grid and offsets the chunk uses. |
| `section_id` | uuid | yes | The section containing the chunk, null when the chunk has no section in the current structure generation. |
| `ordinal` | integer | no | Position of the chunk within the document. |
| `block_start` | integer | no | First block ordinal packed into the chunk. |
| `block_end` | integer | no | Last block ordinal packed into the chunk, inclusive. |
| `char_start` | integer | no | Start character offset of the chunk within this representation's markdown. |
| `char_end` | integer | no | End character offset of the chunk within this representation's markdown. |
| `token_count` | integer | yes | Token length of the chunk, null when it was never measured. |
| `chunk_content_hash` | text | no | Hash of the chunk's ordered block hashes, which is its content identity. |
| `extraction_input_hash` | text | no | Hash of the stable extraction inputs, which is the reuse key that avoids re-extracting unchanged content. |
| `embedding_text_hash` | text | yes | Hash of the exact text that was embedded under the D80 policy, null when the chunk has not been embedded. |
| `location_facts` | jsonb | yes | The deterministic D80 location facts as structured data, null when no policy generation has stamped the chunk. |
| `location_header` | text | yes | The deterministic D80 location header prepended to the embedded text; it is generated orientation text and is never asserted evidence. |
| `embedding_input_policy_version` | text | yes | The D80 embedding-input policy in force for this chunk, null when unstamped. |
| `policy_generation` | text | yes | The generation label of that policy application, null when unstamped. |
| `embedder_generation` | text | yes | The embedder generation that produced the chunk vector, null when the chunk has not been embedded. |
| `chunker_version` | text | yes | The chunker configuration that produced this cut, null on rows written before the stamp existed. |
| `prefixer_version` | text | yes | The context-prefixer generation for this chunk, null when no prefix was generated. |
| `created_at` | timestamp with time zone | no | When the chunk row was written, which is a processing instant rather than a world-time clock. |

### claim_occurrences_live

One row per current claim occurrence, keyed by (deployment_id, claim_id, chunk_id, derivation_kind) with null derivation kinds treated as equal, and joined to claims_visible_history on (deployment_id, claim_id) and to chunks_live on (deployment_id, chunk_id). It is the explicit association answering which current chunk, version, representation, and section carry a claim, and evidence_spans is the complete body support for that occurrence. Repeated attachments collapse to the earliest, so attached_at is the first time the occurrence was recorded. Occurrences in superseded versions, non-ready readings, and forgotten lineages are absent. Claim char_start/char_end remain the immutable origin; they are not the complete evidence list. The view carries no counts and no validity clocks.

- **Grain:** one current claim occurrence (`claim_occurrence_current_content`)
- **Row key:** `deployment_id`, `claim_id`, `chunk_id`, `derivation_kind`
- **Clock semantics:** `none`
- **Joins to:** `claims_visible_history` on `deployment_id`, `claim_id`; `chunks_live` on `deployment_id`, `chunk_id`

| Column | Type | Null | Meaning |
|---|---|---|---|
| `deployment_id` | uuid | no | The deployment that owns the occurrence. |
| `claim_id` | uuid | no | The claim carried by this chunk occurrence. |
| `chunk_id` | uuid | no | The current-content chunk that carries the claim. |
| `derivation_kind` | text | yes | How this occurrence was derived from the source, such as passthrough, asr, or ocr; null when the reading recorded no label. |
| `doc_id` | uuid | no | The live lineage carrying the occurrence. |
| `version_id` | uuid | no | The lineage's current version carrying the occurrence. |
| `representation_id` | uuid | no | The current ready reading carrying the occurrence. |
| `section_id` | uuid | yes | The section containing the carrying chunk, null when the chunk has no section in the current structure generation. |
| `evidence_mode` | text | yes | How mediated this occurrence is, such as source_expression or model_observation; null when the reading recorded no mode. |
| `source_locators` | jsonb | yes | The resolved source locator set for this occurrence, null when the reading resolved none. |
| `attached_at` | timestamp with time zone | no | When this occurrence was first recorded, which is a processing instant rather than a world-time clock. |
| `evidence_spans` | jsonb | no | The complete ordered list of supporting body ranges for this occurrence, origin first, as {char_start, char_end} objects in the carrying representation. |

### claims_canonical

One row per historically visible claim with surviving lineage, keyed by (deployment_id, claim_id), carrying the stored inclusive D41 window beside the half-open canonical bounds that every overlap predicate must use (D107 §5). canon_start is inclusive and canon_end exclusive; both are null when precision is unknown, and canon_end is also null for an open window. Overlap is a.start < b.end AND b.start < a.end with a null end as unbounded. This relation is IMMUTABLE SOURCE TESTIMONY: it never answers what currently holds. Claims of forgotten lineages and tombstoned versions are absent.

- **Grain:** one historically visible claim with surviving lineage and its half-open canonical window (`claim_visible_history_canonical`)
- **Row key:** `deployment_id`, `claim_id`
- **Clock semantics:** `claim_validity_immutable`
- **Joins to:** `documents_live` on `deployment_id`, `doc_id`; `document_versions_visible` on `deployment_id`, `version_id`

| Column | Type | Null | Meaning |
|---|---|---|---|
| `deployment_id` | uuid | no | The deployment that owns the claim. |
| `claim_id` | uuid | no | Stable identity of this immutable claim. |
| `doc_id` | uuid | no | The live lineage that asserted the claim. |
| `version_id` | uuid | no | The non-tombstoned version the claim was extracted from. |
| `representation_id` | uuid | no | The reading whose character offsets the claim's anchors use. |
| `chunk_id` | uuid | no | The chunk the claim was extracted from. |
| `claim_text` | text | no | The standalone assertion as extracted, which is source testimony rather than adjudicated truth. |
| `source_span` | text | no | The verbatim slice of the source the claim derives from. |
| `char_start` | integer | no | Start character offset of source_span within the named representation's markdown. |
| `char_end` | integer | no | End character offset of source_span within the named representation's markdown. |
| `added_context` | jsonb | no | The substrings decontextualization added, each with the bundle source it came from. |
| `temporal_class` | text | yes | How the claim behaves over time, either static, dynamic, or atemporal; null when unclassified. Values: `static`, `dynamic`, `atemporal`. |
| `is_attributed` | boolean | no | True when the claim preserves an attribution, so it entails that someone said it rather than that it holds. |
| `audit_status` | text | no | Result of the sampled independent grounding audit, defaulting to unaudited. Values: `unaudited`, `sampled_pass`, `sampled_fail`, `escalated`. |
| `kept_flagged` | boolean | no | True when selection kept the claim but marked it for review. |
| `extractor_version` | text | no | The extractor generation that produced the claim, which is part of the D54 extraction basis. |
| `asserted_at` | timestamp with time zone | yes | Assertion-event time: when the source asserted this, null when the source carries no date. |
| `claim_valid_from` | timestamp with time zone | yes | Immutable inclusive start of the world-time interval the SOURCE asserted, null for unbounded-before or unknown. |
| `claim_valid_until` | timestamp with time zone | yes | Immutable inclusive end of that interval, null for open-per-source or unknown as disambiguated by claim_valid_precision. |
| `claim_valid_precision` | text | no | Granularity of the asserted interval, from unknown through instant, day, month, quarter, and year to open. Values: `unknown`, `instant`, `day`, `month`, `quarter`, `year`, `open`. |
| `claim_valid_kind` | text | yes | Which world-interval was asserted, such as event_time or measurement_period; null when unclassified. Values: `proposition_validity`, `event_time`, `measurement_period`, `effective_period`. |
| `ingested_at` | timestamp with time zone | no | Transaction-time: when this deployment extracted the claim. |
| `source_kind` | text | no | The connector family of the asserting lineage. |
| `source_handle` | text | no | Stable human-usable handle for the asserting lineage, formed from its connector-native identity. |
| `is_current_testimony` | boolean | no | True while this claim is the current transcription of its chunk under D54; false once a newer extraction generation or a living-mode version move superseded it. |
| `canon_start` | timestamp with time zone | yes | Inclusive start of the half-open canonical window (D107 §5); null when precision is unknown. |
| `canon_end` | timestamp with time zone | yes | Exclusive end of the half-open canonical window; null when the window is open or unknown. |

### claims_live

One row per current-testimony claim, keyed by (deployment_id, claim_id): the subset of claims_visible_history whose D54 currency flag is still set. Like every claim relation this is IMMUTABLE SOURCE TESTIMONY, and "live" here means current transcription of a live source, never current truth: a claim in this relation can be contradicted by the adjudicated worldview, and querying its validity window to answer what holds now is the wrong query — start from facts_current and follow fact_claim_evidence_live back to here. The stored claim_valid_* window is inclusive D41 storage; world-time overlap belongs on claims_canonical. Claims of forgotten lineages and tombstoned versions are absent, and this relation is the sole claim input to the D54 counting path. The view carries no counts.

- **Grain:** one current-testimony claim (`claim_current_testimony`)
- **Row key:** `deployment_id`, `claim_id`
- **Clock semantics:** `claim_validity_immutable`
- **Joins to:** `documents_live` on `deployment_id`, `doc_id`; `document_versions_visible` on `deployment_id`, `version_id`

| Column | Type | Null | Meaning |
|---|---|---|---|
| `deployment_id` | uuid | no | The deployment that owns the claim. |
| `claim_id` | uuid | no | Stable identity of this immutable claim. |
| `doc_id` | uuid | no | The live lineage that asserted the claim. |
| `version_id` | uuid | no | The non-tombstoned version the claim was extracted from. |
| `representation_id` | uuid | no | The reading whose character offsets the claim's anchors use. |
| `chunk_id` | uuid | no | The chunk the claim was extracted from. |
| `claim_text` | text | no | The standalone assertion as extracted, which is source testimony rather than adjudicated truth. |
| `source_span` | text | no | The verbatim slice of the source the claim derives from. |
| `char_start` | integer | no | Start character offset of source_span within the named representation's markdown. |
| `char_end` | integer | no | End character offset of source_span within the named representation's markdown. |
| `added_context` | jsonb | no | The substrings decontextualization added, each with the bundle source it came from. |
| `temporal_class` | text | yes | How the claim behaves over time, either static, dynamic, or atemporal; null when unclassified. Values: `static`, `dynamic`, `atemporal`. |
| `is_attributed` | boolean | no | True when the claim preserves an attribution, so it entails that someone said it rather than that it holds. |
| `audit_status` | text | no | Result of the sampled independent grounding audit, defaulting to unaudited. Values: `unaudited`, `sampled_pass`, `sampled_fail`, `escalated`. |
| `kept_flagged` | boolean | no | True when selection kept the claim but marked it for review. |
| `extractor_version` | text | no | The extractor generation that produced the claim, which is part of the D54 extraction basis. |
| `asserted_at` | timestamp with time zone | yes | Assertion-event time: when the source asserted this, null when the source carries no date. |
| `claim_valid_from` | timestamp with time zone | yes | Immutable start of the world-time interval the SOURCE asserted, null for unbounded-before or unknown. |
| `claim_valid_until` | timestamp with time zone | yes | Immutable end of that interval, null for open-per-source or unknown as disambiguated by claim_valid_precision. |
| `claim_valid_precision` | text | no | Granularity of the asserted interval, from unknown through instant, day, month, quarter, and year to open. Values: `unknown`, `instant`, `day`, `month`, `quarter`, `year`, `open`. |
| `claim_valid_kind` | text | yes | Which world-interval was asserted, such as event_time or measurement_period; null when unclassified. Values: `proposition_validity`, `event_time`, `measurement_period`, `effective_period`. |
| `ingested_at` | timestamp with time zone | no | Transaction-time: when this deployment extracted the claim. |
| `source_kind` | text | no | The connector family of the asserting lineage. |
| `source_handle` | text | no | Stable human-usable handle for the asserting lineage, formed from its connector-native identity. |

### claims_visible_history

One row per claim whose source lineage is live and whose source version is not tombstoned, keyed by (deployment_id, claim_id) and joined to documents_live on (deployment_id, doc_id) and to document_versions_visible on (deployment_id, version_id). This relation is IMMUTABLE SOURCE TESTIMONY and never answers what currently holds: claim_valid_from and claim_valid_until are the inclusive stored D41 window (an instant has equal endpoints). World-time overlap MUST use claims_canonical.canon_start / canon_end, which are the half-open canonical bounds; filtering these raw columns as a current-truth or overlap predicate is the wrong query — use facts_current for what holds now, and claims_canonical for as-of testimony. A null claim_valid_from means unbounded-before or unknown and a null claim_valid_until means open-per-source or unknown, disambiguated by claim_valid_precision. Claims of forgotten lineages and tombstoned versions are absent; is_current_testimony is D54 bookkeeping and never validity. The view carries no counts.

- **Grain:** one historically visible claim with surviving lineage (`claim_visible_history`)
- **Row key:** `deployment_id`, `claim_id`
- **Clock semantics:** `claim_validity_immutable`
- **Joins to:** `documents_live` on `deployment_id`, `doc_id`; `document_versions_visible` on `deployment_id`, `version_id`

| Column | Type | Null | Meaning |
|---|---|---|---|
| `deployment_id` | uuid | no | The deployment that owns the claim. |
| `claim_id` | uuid | no | Stable identity of this immutable claim. |
| `doc_id` | uuid | no | The live lineage that asserted the claim. |
| `version_id` | uuid | no | The non-tombstoned version the claim was extracted from. |
| `representation_id` | uuid | no | The reading whose character offsets the claim's anchors use. |
| `chunk_id` | uuid | no | The chunk the claim was extracted from. |
| `claim_text` | text | no | The standalone assertion as extracted, which is source testimony rather than adjudicated truth. |
| `source_span` | text | no | The verbatim slice of the source the claim derives from. |
| `char_start` | integer | no | Start character offset of source_span within the named representation's markdown. |
| `char_end` | integer | no | End character offset of source_span within the named representation's markdown. |
| `added_context` | jsonb | no | The substrings decontextualization added, each with the bundle source it came from. |
| `temporal_class` | text | yes | How the claim behaves over time, either static, dynamic, or atemporal; null when unclassified. Values: `static`, `dynamic`, `atemporal`. |
| `is_attributed` | boolean | no | True when the claim preserves an attribution, so it entails that someone said it rather than that it holds. |
| `audit_status` | text | no | Result of the sampled independent grounding audit, defaulting to unaudited. Values: `unaudited`, `sampled_pass`, `sampled_fail`, `escalated`. |
| `kept_flagged` | boolean | no | True when selection kept the claim but marked it for review. |
| `extractor_version` | text | no | The extractor generation that produced the claim, which is part of the D54 extraction basis. |
| `asserted_at` | timestamp with time zone | yes | Assertion-event time: when the source asserted this, null when the source carries no date. |
| `claim_valid_from` | timestamp with time zone | yes | Immutable start of the world-time interval the SOURCE asserted, null for unbounded-before or unknown. |
| `claim_valid_until` | timestamp with time zone | yes | Immutable end of that interval, null for open-per-source or unknown as disambiguated by claim_valid_precision. |
| `claim_valid_precision` | text | no | Granularity of the asserted interval, from unknown through instant, day, month, quarter, and year to open. Values: `unknown`, `instant`, `day`, `month`, `quarter`, `year`, `open`. |
| `claim_valid_kind` | text | yes | Which world-interval was asserted, such as event_time or measurement_period; null when unclassified. Values: `proposition_validity`, `event_time`, `measurement_period`, `effective_period`. |
| `ingested_at` | timestamp with time zone | no | Transaction-time: when this deployment extracted the claim. |
| `source_kind` | text | no | The connector family of the asserting lineage. |
| `source_handle` | text | no | Stable human-usable handle for the asserting lineage, formed from its connector-native identity. |
| `is_current_testimony` | boolean | no | True while this claim is the current transcription of its chunk under D54; false once a newer extraction generation or a living-mode version move superseded it. |

### contradiction_members_current

One row per current member of a contradiction group, keyed by (deployment_id, contradiction_group, fact_kind, fact_id) and joined to facts_current on (deployment_id, fact_kind, fact_id). A contradiction group is the system declining to silently pick a winner, so both sides stand and are visible here with their own clocks, counts, and support state. Membership, clocks, and the shared evaluation instant are inherited unchanged from facts_current, including the half-open world-time interval and the surviving-provenance requirement. Because arbitrary SQL can still filter this relation down to one side, a result built from it carries no platform guarantee that co-members are complete: that guarantee belongs to the assured operations. The counts are exact counts of distinct current-testimony lineages, and support state is derived at read time.

- **Grain:** one current contradiction-group member (`contradiction_member_current`)
- **Row key:** `deployment_id`, `contradiction_group`, `fact_kind`, `fact_id`
- **Clock semantics:** `bitemporal_current_at_evaluated_at`
- **Joins to:** `facts_current` on `deployment_id`, `fact_kind`, `fact_id`

| Column | Type | Null | Meaning |
|---|---|---|---|
| `deployment_id` | uuid | no | The deployment that owns the fact. |
| `contradiction_group` | uuid | no | The shared identifier binding the members of one unadjudicated contradiction. |
| `fact_kind` | text | no | Which fact layer this member belongs to, either relation or observation. Values: `relation`, `observation`. |
| `fact_id` | uuid | no | Stable identity of the member fact. |
| `fact_label` | text | yes | Human-readable sentence for the member, null when no label has been generated. |
| `valid_from` | timestamp with time zone | yes | World-time start of the member, null for unknown or always. |
| `valid_until` | timestamp with time zone | yes | World-time end of the member, null while the member is open. |
| `ingested_at` | timestamp with time zone | no | Transaction-time start: when the system first believed the member. |
| `evidence_count` | bigint | no | Exact count of distinct current-testimony lineages supporting the member. |
| `contradict_count` | bigint | no | Exact count of distinct current-testimony lineages contradicting the member. |
| `support_state` | text | no | Exactly current or withdrawn, derived at read time from the open review queue. Values: `current`, `withdrawn`. |
| `evaluated_at` | timestamp with time zone | no | The single statement instant at which both clocks were applied, shared with every other current relation in the statement. |

### document_crossrefs_live

One row per resolved cross-reference whose BOTH endpoint lineages are live, keyed by (deployment_id, crossref_id) and joined to documents_live on (deployment_id, from_doc_id) and (deployment_id, to_doc_id). An unresolved reference, a reference whose target was never ingested, or one whose source or target lineage has been forgotten is absent rather than half-resolved, so this relation never reveals that a document once existed. The raw citation text is deliberately not exposed, because it is retained even after a target is forgotten; the bounded context is truncated to 500 characters. The creation clock is a processing instant, and the view carries no counts and asserts no facts.

- **Grain:** one live document cross-reference (`document_crossref_live`)
- **Row key:** `deployment_id`, `crossref_id`
- **Clock semantics:** `none`
- **Joins to:** `documents_live` on `deployment_id`, `from_doc_id`; `documents_live` on `deployment_id`, `to_doc_id`

| Column | Type | Null | Meaning |
|---|---|---|---|
| `deployment_id` | uuid | no | The deployment that owns both endpoint lineages. |
| `crossref_id` | uuid | no | Stable identity of this cross-reference. |
| `from_doc_id` | uuid | no | The live lineage that makes the reference. |
| `to_doc_id` | uuid | no | The live lineage that is referenced. |
| `kind` | text | no | What kind of reference this is, one of cites, links_to, attaches, or replies_to. Values: `cites`, `links_to`, `attaches`, `replies_to`. |
| `context` | text | yes | Bounded surrounding context of the reference, truncated to 500 characters and null when none was captured. |
| `created_at` | timestamp with time zone | no | When the reference was extracted, which is a processing instant. |

### document_versions_visible

One row per non-tombstoned version of a live document lineage, keyed by (deployment_id, version_id) and joined to documents_live on (deployment_id, doc_id). A tombstoned version and every version of a forgotten lineage are absent, which is why the whole schema authorizes version-derived rows through this relation rather than through document_versions directly. This is version history, not fact history: is_current_version says which snapshot the lineage currently points at, and no column here asserts what the system currently believes to be true. The view carries no counts.

- **Grain:** one visible version of a live lineage (`document_version_visible`)
- **Row key:** `deployment_id`, `version_id`
- **Clock semantics:** `ingest_and_supersession_instants`
- **Joins to:** `documents_live` on `deployment_id`, `doc_id`

| Column | Type | Null | Meaning |
|---|---|---|---|
| `deployment_id` | uuid | no | The deployment that owns the version. |
| `version_id` | uuid | no | Stable identity of this observed snapshot of the lineage. |
| `doc_id` | uuid | no | The lineage this version belongs to, joinable to documents_live. |
| `version_no` | integer | no | One-based ordinal of the version within its lineage. |
| `content_hash` | text | no | Hash of the immutable bytes this version observed, shared by lineages carrying identical content. |
| `source_version_ref` | text | yes | The connector revision or etag for this snapshot, null when the source has none. |
| `status` | text | no | Processing status of the version, such as ready or failed. Values: `ingesting`, `converting`, `structuring`, `ready`, `failed`, `deleted`. |
| `current_representation_id` | uuid | yes | The reading of this version that is currently live, null while no reading has completed. |
| `ingested_at` | timestamp with time zone | no | Transaction-time origin: when this deployment ingested the snapshot. |
| `source_modified_at` | timestamp with time zone | yes | When the source says this snapshot was authored, null when the source gives no date. |
| `published_at` | timestamp with time zone | yes | The document's own publication date for this snapshot, null when unknown. |
| `language` | text | yes | Detected primary language of this snapshot, null when undetected. |
| `superseded_at` | timestamp with time zone | yes | When a newer version became current, null while this version is still the lineage's current one. |
| `is_current_version` | boolean | no | True only for the lineage's current snapshot. |

### documents_live

One row per live document lineage, keyed by (deployment_id, doc_id). A tombstoned lineage is absent, and the current version and representation coordinates are produced only from a non-tombstoned version and a ready representation, so no column can name deleted state. The two optional joins project coordinates of an already authorized lineage and admit no row of their own. This is a live-content relation, not a fact or evidence relation: title and source metadata are orientation, never asserted evidence, and the view carries no counts and no clock semantics beyond the observation instants it names.

- **Grain:** one live document lineage (`document_lineage_live`)
- **Row key:** `deployment_id`, `doc_id`
- **Clock semantics:** `source_observation_instants`

| Column | Type | Null | Meaning |
|---|---|---|---|
| `deployment_id` | uuid | no | The deployment that owns this document lineage; every join to another memory_v1 relation carries it. |
| `doc_id` | uuid | no | Stable lineage identity, unique within the deployment and never reused. |
| `source_kind` | text | no | The connector family that produced the lineage, such as google_drive or upload. |
| `source_ref` | text | yes | The connector-native stable identifier, null for one-shot sources that have none. |
| `source_uri` | text | yes | The original location of the source, null when the source has no addressable location. |
| `title` | text | yes | Best-effort human title of the lineage, which is orientation text rather than asserted evidence. |
| `versioning_mode` | text | no | The D55 currency mode of the lineage, either snapshot or living. Values: `snapshot`, `living`. |
| `origin` | text | no | The D42 provenance stamp, either external or system_generated. Values: `external`, `system_generated`. |
| `first_seen_at` | timestamp with time zone | no | The instant this deployment first observed the lineage. |
| `last_observed_at` | timestamp with time zone | yes | The instant the connector last observed the lineage, null when it has never been re-observed. |
| `current_version_id` | uuid | yes | The lineage's current snapshot, null when no non-tombstoned current version exists. |
| `current_version_no` | integer | yes | The one-based ordinal of the current version within the lineage, null when there is no visible current version. |
| `current_version_status` | text | yes | Processing status of the current version, null when there is no visible current version. Values: `ingesting`, `converting`, `structuring`, `ready`, `failed`, `deleted`. |
| `current_representation_id` | uuid | yes | The current ready reading of the current version, null when no ready representation exists. |
| `has_current_ready_content` | boolean | no | True only when the lineage has a ready current version and a ready current representation, which is the precondition every current-content relation joins on. |
| `source_modified_at` | timestamp with time zone | yes | When the source says the current snapshot was authored, which dates derived testimony. |
| `published_at` | timestamp with time zone | yes | The document's own publication date on the current version, null when unknown. |
| `language` | text | yes | Detected primary language of the current version, null when undetected. |

### entities_current

One row per externally visible survivor entity, keyed by (deployment_id, entity_id). Membership requires SURVIVING PROVENANCE, which is an explicit association to at least one live document lineage: a mention of this survivor in any non-tombstoned version of a live lineage, or a live document-entity bridge. An entity whose every source has been forgotten is therefore absent rather than orphaned, and merged entities are absent because a merge redirects to a survivor instead of rewriting history. MEMBERSHIP AND THE COUNTS ANSWER DIFFERENT QUESTIONS, and the difference is deliberate: the two counts are exact over CURRENT content only — they equal this entity's rows in mentions_live and entity_document_mentions — so an entity whose only mention sits in a superseded version of a live lineage is published here with both counts at zero and has no row in entity_document_mentions at all. A zero count is not an absence of provenance. graph_degree is a deprecated compatibility scalar fixed at zero after D98 because current adjacency is computed from live PostgreSQL relations; profile_summary is orientation text, never evidence; and the clocks are registry maintenance instants that carry no world-validity meaning. After D96, entity_type and type_confidence are vacated (always NULL); identity is the entity_id.

- **Grain:** one externally visible survivor entity (`entity_survivor_current`)
- **Row key:** `deployment_id`, `entity_id`
- **Clock semantics:** `registry_maintenance_instants`

| Column | Type | Null | Meaning |
|---|---|---|---|
| `deployment_id` | uuid | no | The deployment that owns the entity. |
| `entity_id` | uuid | no | Stable survivor identity, which is never reused and never rewritten by a merge. |
| `entity_type` | text | yes | Vacated after D96: this compatibility output position always returns NULL because identity is the entity_id and no entity class is stored; use observation or profile fact text for kind-like retrieval. |
| `canonical_name` | text | no | Preferred display name of the entity. |
| `normalized_name` | text | no | Accent-folded lower-case form of the canonical name, used for matching. |
| `type_confidence` | real | yes | Vacated after D96: this compatibility output position always returns NULL because the removed entity-class vote has no confidence value; identity decisions are recorded by resolution tier and confidence instead. |
| `profile_summary` | text | yes | Registry-maintained blurb about the entity; it is labeled orientation text and is never asserted evidence. |
| `live_mention_count` | bigint | no | Exact count of the mentions of this entity in the CURRENT content of live lineages, which is zero when every mention of it survives only in a superseded version. |
| `live_document_count` | bigint | no | Exact count of the live document lineages whose CURRENT content mentions this entity, which is zero for the same reason. |
| `graph_degree` | bigint | no | Deprecated compatibility scalar fixed at zero after D98; consumers compute live relation degree from PostgreSQL adjacency. |
| `created_at` | timestamp with time zone | no | When the entity was minted. |
| `updated_at` | timestamp with time zone | no | When the entity registry row was last maintained. |

### entity_aliases_current

One row per current alias-to-survivor mapping, keyed by (deployment_id, alias_id) and joined to entities_current on (deployment_id, entity_id). Merge redirects are resolved, so an alias recorded against a since-merged entity now names the survivor while source_entity_id preserves where it was recorded. An alias whose survivor has no surviving provenance is absent, because membership is inherited from entities_current. The clocks are observation instants rather than validity, and the view carries no counts.

- **Grain:** one current alias-to-survivor mapping (`entity_alias_current`)
- **Row key:** `deployment_id`, `alias_id`
- **Clock semantics:** `observation_instants`
- **Joins to:** `entities_current` on `deployment_id`, `entity_id`

| Column | Type | Null | Meaning |
|---|---|---|---|
| `deployment_id` | uuid | no | The deployment that owns the alias. |
| `alias_id` | uuid | no | Stable identity of this alias row. |
| `source_entity_id` | uuid | no | The entity the alias was originally recorded against, which may since have been merged away. |
| `entity_id` | uuid | no | The survivor entity the alias currently names, joinable to entities_current. |
| `alias_text` | text | no | The surface form as observed or as canonicalized. |
| `normalized_lemma` | text | no | Accent-folded lower-case match key for the alias. |
| `provenance` | text | no | Where the alias came from, either source when observed in a document or llm_canonical when emitted by the extractor. Values: `source`, `llm_canonical`. |
| `confidence` | real | yes | Confidence that this surface really names the entity, null when never scored. |
| `first_seen` | timestamp with time zone | no | When the alias was first recorded. |
| `last_seen` | timestamp with time zone | no | When the alias was last observed. |

### entity_document_mentions

One row per survivor entity and live document lineage, keyed by (deployment_id, entity_id, doc_id) and joined to entities_current on (deployment_id, entity_id) and documents_live on (deployment_id, doc_id). The mention count is EXACT rather than sampled or capped, and it counts exactly the mentions this deployment can still show: one for every row of mentions_live in this lineage whose resolution names this survivor, and nothing else. Mentions of forgotten lineages, mentions of superseded versions and non-current readings, mentions with no chunk coordinate, mentions whose own lineage disagrees with their chunk's, and mentions whose resolution has been superseded are therefore counted nowhere — a mention of superseded content is not live content and is not counted. Merge redirects are resolved before counting, so a merged entity contributes to its survivor and never appears on its own. The clocks are mention-recording instants, not world-validity, and this relation carries no evidence and no fact semantics.

- **Grain:** one survivor entity × live document (`entity_document_mention_live`)
- **Row key:** `deployment_id`, `entity_id`, `doc_id`
- **Clock semantics:** `mention_observation_instants`
- **Joins to:** `entities_current` on `deployment_id`, `entity_id`; `documents_live` on `deployment_id`, `doc_id`

| Column | Type | Null | Meaning |
|---|---|---|---|
| `deployment_id` | uuid | no | The deployment that owns both the entity and the document. |
| `entity_id` | uuid | no | The survivor entity, with merge redirects already resolved. |
| `doc_id` | uuid | no | The live lineage the entity is mentioned in. |
| `mention_count` | bigint | no | Exact count of the mentions of this survivor in this lineage's current content. |
| `first_mentioned_at` | timestamp with time zone | no | When the earliest counted mention was recorded. |
| `last_mentioned_at` | timestamp with time zone | no | When the latest counted mention was recorded. |

### evidence_lineage

One row per fact, current-testimony document lineage, and stance, keyed by (deployment_id, fact_kind, fact_id, doc_id, stance) and joined to the fact relations on (deployment_id, fact_kind, fact_id) and to documents_live on (deployment_id, doc_id). THIS RELATION IS THE SOLE PUBLIC INPUT FOR D54 EVIDENCE COUNTS: an evidence count is the number of rows here for a fact and stance, which is exactly the number of distinct current-testimony source lineages, so repeating an assertion inside one document and re-extracting the same document both leave every count unchanged while a genuinely independent second source moves it by one. claim_count is descriptive colour about how loudly one lineage says it and must never be summed into an evidence count. Evidence from forgotten lineages and from superseded testimony is absent. The assertion range is source-asserted event time, not fact validity.

- **Grain:** one fact × current-testimony document lineage × stance (`evidence_lineage`)
- **Row key:** `deployment_id`, `fact_kind`, `fact_id`, `doc_id`, `stance`
- **Clock semantics:** `assertion_event_range`
- **Joins to:** `facts_visible_history` on `deployment_id`, `fact_kind`, `fact_id`; `documents_live` on `deployment_id`, `doc_id`; `claims_live` on `deployment_id`, `representative_claim_id`

| Column | Type | Null | Meaning |
|---|---|---|---|
| `deployment_id` | uuid | no | The deployment that owns the evidence. |
| `fact_kind` | text | no | Which fact layer the evidence points at, either relation or observation. Values: `relation`, `observation`. |
| `fact_id` | uuid | no | The adjudicated fact this lineage supports or contradicts. |
| `doc_id` | uuid | no | The live document lineage that is the counted unit of corroboration. |
| `stance` | text | no | Exactly supports or contradicts. Values: `supports`, `contradicts`. |
| `source_kind` | text | no | The connector family of the lineage. |
| `source_handle` | text | no | Stable human-usable handle for the lineage, formed from its connector-native identity. |
| `claim_count` | bigint | no | How many current-testimony claims in this lineage take this stance, which is DESCRIPTIVE ONLY and is never an evidence count. |
| `representative_claim_id` | uuid | no | The most recently asserted claim of this lineage and stance, chosen deterministically as a readable exemplar. |
| `asserted_from` | timestamp with time zone | yes | Earliest assertion instant among those claims, null when none of them carries a date. |
| `asserted_to` | timestamp with time zone | yes | Latest assertion instant among those claims, null when none of them carries a date. |

### fact_claim_evidence_live

One row per current claim-to-fact association, keyed by (deployment_id, fact_kind, fact_id, claim_id, stance) and joined to facts_current or facts_visible_history on (deployment_id, fact_kind, fact_id) and to claims_live on (deployment_id, claim_id). This is the AUDITABLE BRIDGE between the two truth layers: it records which immutable testimony supports or contradicts an adjudicated fact, and stance is exactly supports or contradicts. Only current testimony from live lineages appears, and an association whose denormalized lineage disagrees with its claim's lineage is treated as mismatched state and dropped rather than exposed. The claim-validity columns are the SOURCE's asserted interval, inclusive at both endpoints and never the fact's validity; a null endpoint is unbounded or unknown. The view carries no counts: aggregate evidence_lineage instead.

- **Grain:** one current claim-to-fact association (`fact_claim_evidence_live`)
- **Row key:** `deployment_id`, `fact_kind`, `fact_id`, `claim_id`, `stance`
- **Clock semantics:** `claim_validity_immutable`
- **Joins to:** `facts_visible_history` on `deployment_id`, `fact_kind`, `fact_id`; `claims_live` on `deployment_id`, `claim_id`; `documents_live` on `deployment_id`, `doc_id`

| Column | Type | Null | Meaning |
|---|---|---|---|
| `deployment_id` | uuid | no | The deployment that owns the association. |
| `fact_kind` | text | no | Which fact layer the association points at, either relation or observation. Values: `relation`, `observation`. |
| `fact_id` | uuid | no | The adjudicated fact this claim supports or contradicts. |
| `claim_id` | uuid | no | The current-testimony claim on the other side of the bridge. |
| `stance` | text | no | Exactly supports or contradicts, matching the shipped evidence stance vocabulary. Values: `supports`, `contradicts`. |
| `doc_id` | uuid | no | The live lineage that asserted the claim, which is the unit D54 counts. |
| `source_kind` | text | no | The connector family of that lineage. |
| `source_handle` | text | no | Stable human-usable handle for that lineage, formed from its connector-native identity. |
| `asserted_at` | timestamp with time zone | yes | When the source asserted the claim, null when the source carries no date. |
| `claim_valid_from` | timestamp with time zone | yes | Immutable start of the world-time interval the SOURCE asserted, null for unbounded-before or unknown. |
| `claim_valid_until` | timestamp with time zone | yes | Immutable end of that interval, null for open-per-source or unknown. |
| `claim_valid_precision` | text | no | Granularity of the asserted interval, from unknown through instant to open. Values: `unknown`, `instant`, `day`, `month`, `quarter`, `year`, `open`. |
| `claim_valid_kind` | text | yes | Which world-interval was asserted, null when unclassified. Values: `proposition_validity`, `event_time`, `measurement_period`, `effective_period`. |
| `linked_at` | timestamp with time zone | no | When the association was recorded, which is a processing instant rather than a validity clock. |

### facts_current

Confirmed facts holding at the single evaluated_at instant and still believed. Unknown or partial windows are excluded from this strict current view; inspect facts_visible_history for possible matches and historical achievements. A NULL end alone never establishes ongoing validity. Counts are distinct current testimony lineages.

- **Grain:** one currently valid relation or observation (`fact_current`)
- **Row key:** `deployment_id`, `fact_kind`, `fact_id`
- **Clock semantics:** `bitemporal_current_at_evaluated_at`
- **Joins to:** `entities_current` on `deployment_id`, `subject_entity_id`; `entities_current` on `deployment_id`, `object_entity_id`; `fact_claim_evidence_live` on `deployment_id`, `fact_kind`, `fact_id`

| Column | Type | Null | Meaning |
|---|---|---|---|
| `deployment_id` | uuid | no | The deployment that owns the fact. |
| `fact_kind` | text | no | Which fact layer this row belongs to, either relation or observation. Values: `relation`, `observation`. |
| `fact_id` | uuid | no | Stable identity of the adjudicated fact. |
| `subject_entity_id` | uuid | no | Survivor identity of the subject entity, with merge redirects resolved. |
| `predicate` | text | yes | The governed predicate of a relation, null for an observation. |
| `object_entity_id` | uuid | yes | Survivor identity of the object entity of a relation, null for an observation. |
| `statement` | text | yes | The canonical statement of an observation, null for a relation. |
| `fact_label` | text | yes | Human-readable sentence for the fact, null when no label has been generated. |
| `valid_from` | timestamp with time zone | yes | Canonical inclusive world-time start; NULL means unknown. |
| `valid_until` | timestamp with time zone | yes | Canonical exclusive world-time end; NULL means unknown unless valid_precision is open. |
| `ingested_at` | timestamp with time zone | no | Transaction-time start: when the system first believed the fact. |
| `contradiction_group` | uuid | yes | Shared identifier of an unadjudicated contradiction, null when the fact is in no contradiction group. |
| `confidence` | real | yes | Aggregate confidence over the fact's evidence, null when never scored. |
| `evidence_count` | bigint | no | Exact count of distinct current-testimony lineages supporting the fact. |
| `contradict_count` | bigint | no | Exact count of distinct current-testimony lineages contradicting the fact. |
| `support_state` | text | no | Exactly current or withdrawn, derived at read time from the open review queue. Values: `current`, `withdrawn`. |
| `evaluated_at` | timestamp with time zone | no | The single statement instant at which both clocks were applied, shared by every current relation referenced in the same statement. |
| `valid_precision` | text | no | Chosen world-date precision; unknown and partial boundaries are distinct from explicitly open. |

### facts_visible_history

Historically visible adjudicated facts with one canonical world window and separate system timestamps. Membership requires surviving provenance. Unknown dates and partial windows are possible temporal matches, not proof of being current; open precision explicitly means ongoing. Completed windows remain believed history while invalidated_at is NULL. Evidence counts and support state are current testimony, not reconstructed historical counts.

- **Grain:** one historically visible relation or observation (`fact_visible_history`)
- **Row key:** `deployment_id`, `fact_kind`, `fact_id`
- **Clock semantics:** `bitemporal_raw`
- **Joins to:** `entities_current` on `deployment_id`, `subject_entity_id`; `entities_current` on `deployment_id`, `object_entity_id`; `evidence_lineage` on `deployment_id`, `fact_kind`, `fact_id`

| Column | Type | Null | Meaning |
|---|---|---|---|
| `deployment_id` | uuid | no | The deployment that owns the fact. |
| `fact_kind` | text | no | Which fact layer this row belongs to, either relation or observation. Values: `relation`, `observation`. |
| `fact_id` | uuid | no | Stable identity of the adjudicated fact. |
| `subject_entity_id` | uuid | no | Survivor identity of the subject entity, with merge redirects resolved. |
| `predicate` | text | yes | The governed predicate of a relation, null for an observation. |
| `object_entity_id` | uuid | yes | Survivor identity of the object entity of a relation, null for an observation. |
| `statement` | text | yes | The canonical statement of an observation, null for a relation. |
| `fact_label` | text | yes | Human-readable sentence for the fact, null when no label has been generated. |
| `valid_from` | timestamp with time zone | yes | Canonical inclusive world-time start; NULL means unknown. |
| `valid_until` | timestamp with time zone | yes | Canonical exclusive world-time end; NULL means unknown unless valid_precision is open. |
| `ingested_at` | timestamp with time zone | no | Raw transaction-time start: when the system first believed the fact. |
| `invalidated_at` | timestamp with time zone | yes | Raw transaction-time end: when the system learned the fact was superseded, null while it is still believed. |
| `contradiction_group` | uuid | yes | Shared identifier of an unadjudicated contradiction, null when the fact is in no contradiction group. |
| `confidence` | real | yes | Aggregate confidence over the fact's evidence, null when never scored. |
| `evidence_count_current` | bigint | no | LIVE count of distinct current-testimony lineages supporting the fact, read now and never a historical reconstruction. |
| `contradict_count_current` | bigint | no | LIVE count of distinct current-testimony lineages contradicting the fact, read now and never a historical reconstruction. |
| `support_state_current` | text | no | LIVE support state, exactly current or withdrawn, derived now from the open review queue and never a stored column. Values: `current`, `withdrawn`. |
| `valid_precision` | text | no | Chosen world-date precision; unknown and partial boundaries are distinct from explicitly open. |

### graph_edges_current

One row per current relation edge, keyed by (deployment_id, relation_id) and joined to entities_current on (deployment_id, subject_entity_id) and (deployment_id, object_entity_id). This is the LIVE graph surface, evaluated in PostgreSQL rather than read from a projection: it inherits the facts_current membership rule, the same half-open world-time interval, and the same shared evaluation instant, which is emitted on every row. Both endpoints are survivor identities and both are required to be visible entities, so an edge is dropped as a unit rather than dangling into an entity that has no surviving provenance. The counts are exact counts of distinct current-testimony lineages, and support state is derived at read time from the open review queue. Observations never project here, because they are entity-anchored facts rather than edges.

- **Grain:** one current relation edge (`graph_edge_current`)
- **Row key:** `deployment_id`, `relation_id`
- **Clock semantics:** `bitemporal_current_at_evaluated_at`
- **Joins to:** `entities_current` on `deployment_id`, `subject_entity_id`; `entities_current` on `deployment_id`, `object_entity_id`

| Column | Type | Null | Meaning |
|---|---|---|---|
| `deployment_id` | uuid | no | The deployment that owns the edge. |
| `relation_id` | uuid | no | Stable identity of the relation this edge projects. |
| `subject_entity_id` | uuid | no | Survivor identity of the edge's source entity, guaranteed present in entities_current. |
| `object_entity_id` | uuid | no | Survivor identity of the edge's target entity, guaranteed present in entities_current. |
| `predicate` | text | no | The governed predicate carried by the edge. |
| `fact_label` | text | yes | Human-readable sentence for the relation, null when no label has been generated. |
| `valid_from` | timestamp with time zone | yes | World-time start of the relation, null for unknown or always. |
| `valid_until` | timestamp with time zone | yes | World-time end of the relation, null while it is open; the interval is half-open. |
| `ingested_at` | timestamp with time zone | no | Transaction-time start: when the system first believed the relation. |
| `contradiction_group` | uuid | yes | Shared identifier of an unadjudicated contradiction, null when the edge is in no contradiction group. |
| `confidence` | real | yes | Aggregate confidence over the relation's evidence, null when never scored. |
| `evidence_count` | bigint | no | Exact count of distinct current-testimony lineages supporting the relation. |
| `contradict_count` | bigint | no | Exact count of distinct current-testimony lineages contradicting the relation. |
| `support_state` | text | no | Exactly current or withdrawn, derived at read time from the open review queue. Values: `current`, `withdrawn`. |
| `evaluated_at` | timestamp with time zone | no | The single statement instant at which both clocks were applied, shared with every other current relation in the statement. |

### graph_edges_visible_history

One row per historically visible relation edge, keyed by (deployment_id, relation_id) and joined to entities_current on both endpoint columns. Membership requires surviving historical provenance and two visible survivor endpoints, so a relation whose sources have all been forgotten disappears and an edge is never left dangling. Both clocks are RAW: world time is half-open, transaction time is bounded by ingested_at and invalidated_at, a null endpoint is unbounded or unknown, and membership here is not a claim that the edge currently holds. The three columns suffixed _current are LIVE CURRENT-TESTIMONY VALUES READ NOW and never assert that those counts or that support state held at any historical instant; they come from evidence_lineage and from the open support_withdrawn review row respectively.

- **Grain:** one historically visible relation edge (`graph_edge_visible_history`)
- **Row key:** `deployment_id`, `relation_id`
- **Clock semantics:** `bitemporal_raw`
- **Joins to:** `entities_current` on `deployment_id`, `subject_entity_id`; `entities_current` on `deployment_id`, `object_entity_id`

| Column | Type | Null | Meaning |
|---|---|---|---|
| `deployment_id` | uuid | no | The deployment that owns the edge. |
| `relation_id` | uuid | no | Stable identity of the relation this edge projects. |
| `subject_entity_id` | uuid | no | Survivor identity of the edge's source entity, guaranteed present in entities_current. |
| `object_entity_id` | uuid | no | Survivor identity of the edge's target entity, guaranteed present in entities_current. |
| `predicate` | text | no | The governed predicate carried by the edge. |
| `fact_label` | text | yes | Human-readable sentence for the relation, null when no label has been generated. |
| `valid_from` | timestamp with time zone | yes | Raw world-time start of the relation, null for unknown or always. |
| `valid_until` | timestamp with time zone | yes | Raw world-time end of the relation, null while it has not been capped; the interval is half-open. |
| `ingested_at` | timestamp with time zone | no | Raw transaction-time start: when the system first believed the relation. |
| `invalidated_at` | timestamp with time zone | yes | Raw transaction-time end: when the system learned the relation was superseded, null while it is still believed. |
| `contradiction_group` | uuid | yes | Shared identifier of an unadjudicated contradiction, null when the edge is in no contradiction group. |
| `confidence` | real | yes | Aggregate confidence over the relation's evidence, null when never scored. |
| `evidence_count_current` | bigint | no | LIVE count of distinct current-testimony lineages supporting the relation, read now and never a historical reconstruction. |
| `contradict_count_current` | bigint | no | LIVE count of distinct current-testimony lineages contradicting the relation, read now and never a historical reconstruction. |
| `support_state_current` | text | no | LIVE support state, exactly current or withdrawn, read now and never a historical reconstruction. Values: `current`, `withdrawn`. |

### identity_events_visible

One row per visible identity event, keyed by the synthetic pair (deployment_id, object_kind, event_id): each union arm names its own log in object_kind and supplies that log's own identifier, so the two identifier spaces cannot collide. Resolution events carry a mention and an outcome of linked or new_entity; they appear only while that exact mention is present in mentions_live, which binds them to the current-content transcript and its complete visibility gate. Merge events carry a counterpart entity and an outcome of merge or unmerge, where a split is recorded as the un-merge that reversed a merge. Every event requires its survivor entity to pass the entities_current provenance gate. Decision clocks are transaction time and carry no world-validity meaning; the view carries no counts and asserts no facts.

- **Grain:** one visible resolution/merge/split event (`identity_event_visible`)
- **Row key:** `deployment_id`, `object_kind`, `event_id`
- **Clock semantics:** `transaction_time_event`
- **Joins to:** `entities_current` on `deployment_id`, `entity_id`; `mentions_live` on `deployment_id`, `mention_id`

| Column | Type | Null | Meaning |
|---|---|---|---|
| `deployment_id` | uuid | no | The deployment that owns the event. |
| `object_kind` | text | no | Which append-only log the event comes from, either resolution_decision or merge_event. Values: `resolution_decision`, `merge_event`. |
| `event_id` | uuid | no | Identity of the event within its own log, unique together with object_kind. |
| `entity_id` | uuid | no | The survivor entity the event is about, joinable to entities_current. |
| `related_entity_id` | uuid | yes | The counterpart entity of a merge or unmerge, null for a resolution event. |
| `mention_id` | uuid | yes | The mention a resolution event decided, null for a merge event. |
| `outcome` | text | no | What the event did, one of linked, new_entity, merge, or unmerge. Values: `linked`, `new_entity`, `merge`, `unmerge`. |
| `method` | text | no | Which mechanism produced the event, such as a resolution tier or merge_event. Values: `T0`, `T3`, `T4_small`, `T4_frontier`, `human`, `merge_event`. |
| `confidence` | real | yes | Confidence recorded for the decision, null when the log records none. |
| `decided_by` | text | no | Whether the decision was automatic or human. Values: `auto`, `human`. |
| `decided_at` | timestamp with time zone | no | When the decision was made, which is a transaction-time instant. |
| `is_superseded` | boolean | no | True once a later decision replaced this one, or a later un-merge reversed it. |

### mentions_live

One row per mention occurring in current content, keyed by (deployment_id, mention_id) and joined to chunks_live on (deployment_id, chunk_id), documents_live on (deployment_id, doc_id), and entities_current on (deployment_id, resolved_entity_id). Membership binds every coordinate of the mention: the chunk must be a current-content chunk and the mention's own lineage must be that chunk's lineage, so mentions in superseded versions, in non-ready readings, in forgotten lineages, and mentions whose recorded lineage disagrees with their chunk's are all absent. Resolution is deliberately nullable and UNRESOLVED MENTIONS REMAIN VISIBLE: the five resolution columns are populated together or not at all, from the mention's single live, unsuperseded decision and only when the survivor that decision names passes the entities_current provenance gate, so a decision pointing at a retired, merged-away, or provenance-free identity leaves the whole resolution null rather than describing a decision whose subject this schema will not show. The claim coordinate is gated the same way and is null unless that claim is itself a visible claim of this lineage. Merge redirects are resolved before exposure. This relation is source transcript, not evidence and not fact; it carries no counts and no validity clocks. After D96, emitted_type and type_confidence are vacated compatibility positions that always return NULL.

- **Grain:** one mention in current content (`mention_current_content`)
- **Row key:** `deployment_id`, `mention_id`
- **Clock semantics:** `none`
- **Joins to:** `documents_live` on `deployment_id`, `doc_id`; `chunks_live` on `deployment_id`, `chunk_id`; `entities_current` on `deployment_id`, `resolved_entity_id`; `claims_visible_history` on `deployment_id`, `claim_id`

| Column | Type | Null | Meaning |
|---|---|---|---|
| `deployment_id` | uuid | no | The deployment that owns the mention. |
| `mention_id` | uuid | no | Stable identity of this mention in the immutable mention transcript. |
| `doc_id` | uuid | no | The live lineage the mention occurs in. |
| `version_id` | uuid | no | The lineage's current version the mention occurs in. |
| `representation_id` | uuid | no | The current ready reading whose offsets the mention anchors use. |
| `chunk_id` | uuid | no | The current-content chunk the mention occurs in. |
| `section_id` | uuid | yes | The section containing the mention, null when the chunk has no section in the current structure generation. |
| `claim_id` | uuid | yes | The claim the mention occurs in, exposed only while that claim is itself visible and null otherwise. |
| `surface_form` | text | no | The mention exactly as it appeared in the source. |
| `normalized_lemma` | text | no | Accent-folded lower-case form of the surface form. |
| `canonical_name_form` | text | yes | The nominative or canonical form the extractor emitted, null when it emitted none. |
| `emitted_type` | text | yes | Vacated after D96: this compatibility output position always returns NULL because extraction emits entity names rather than type classes; the source and canonical spellings remain available on this mention. |
| `type_confidence` | real | yes | Vacated after D96: this compatibility output position always returns NULL because extraction no longer produces an entity-class confidence; resolution confidence remains available separately. |
| `language` | text | yes | Language of the mention, null when undetected. |
| `char_start` | integer | yes | Start character offset of the mention within the named representation's markdown, null when unrecorded. |
| `char_end` | integer | yes | End character offset of the mention within the named representation's markdown, null when unrecorded. |
| `created_at` | timestamp with time zone | no | When the mention was recorded, which is a processing instant. |
| `resolved_entity_id` | uuid | yes | The survivor entity this mention currently resolves to, null while the mention is unresolved or while the entity that decision names is not itself visible. |
| `resolution_method` | text | yes | Which decision tier produced the live resolution, null exactly when resolved_entity_id is null. Values: `T0`, `T3`, `T4_small`, `T4_frontier`, `human`. |
| `resolution_confidence` | real | yes | Confidence of that live resolution, null exactly when resolved_entity_id is null. |
| `resolution_is_new_entity` | boolean | yes | True when the live resolution minted a new entity, null exactly when resolved_entity_id is null. |
| `resolved_at` | timestamp with time zone | yes | When the live resolution was decided, null exactly when resolved_entity_id is null. |

### page_evidence_visible

One row per visible artifact-to-target citation, keyed by (deployment_id, artifact_id, role, target_kind, target_id) and joinable to pages_live on (deployment_id, artifact_id), documents_live on target_id for claim and document targets, and the fact relations on target_id for relation targets. EACH TARGET PASSES ITS OWN VISIBILITY GATE: a citation appears only while its cited lineage is live or its cited relation still has surviving provenance, so forgetting a source removes the link rather than leaving a reference to vanished content. The authoritative citation set is evaluated once and joined directly to non-tombstoned artifact status, which is exactly the membership rule pages_live applies, so a visible page always has at least one row here and a link never outlives the page that carries it. A claim citation is a stable coordinate on the asserting LINEAGE, and its chunk content hashes are exposed only as locators inside that already authorized lineage: the hash never authorizes a read and cannot be used to bypass the lineage gate. Because several chunk coordinates in one lineage collapse into one association, link_count reports exactly how many underlying links were collapsed. The view carries no clocks.

- **Grain:** one visible K artifact-to-target association (`k_artifact_evidence_visible`)
- **Row key:** `deployment_id`, `artifact_id`, `role`, `target_kind`, `target_id`
- **Clock semantics:** `none`
- **Joins to:** `pages_live` on `deployment_id`, `artifact_id`; `documents_live` on `deployment_id`, `target_id`; `facts_visible_history` on `deployment_id`, `target_id`

| Column | Type | Null | Meaning |
|---|---|---|---|
| `deployment_id` | uuid | no | The deployment that owns the association. |
| `artifact_id` | uuid | no | The visible knowledge artifact that carries the citation. |
| `role` | text | no | What the citation does, one of supports, contradicts, or cites. Values: `supports`, `contradicts`, `cites`. |
| `target_kind` | text | no | What is cited, one of claim, relation, or document. Values: `claim`, `relation`, `document`. |
| `target_id` | uuid | no | The cited target: the asserting lineage for a claim citation, the relation for a relation citation, or the lineage for a document citation. |
| `claim_chunk_content_hashes` | text[] | yes | Sorted chunk-content hashes locating the cited claims inside the lineage, null for non-claim targets; these are locators only and never authorize a read. |
| `link_count` | bigint | no | Exact number of underlying citation links collapsed into this association. |

### pages_live

One row per visible knowledge artifact, keyed by (deployment_id, artifact_id) and joined to page_evidence_visible on the same pair. Membership is FAIL-CLOSED ON PROVENANCE as well as on status: an artifact appears only while it is not tombstoned AND at least one of its citations still points at a visible target, so a page whose every cited source has been forgotten leaves with them instead of surviving as compiled prose about content this deployment can no longer show. Both page kinds carry citations, so an artifact with none is anomalous rather than ordinary: it is absent here and counted in the operator quarantine report, where it can be recompiled or retired. A tombstoned parent, and a parent that is itself absent for either reason, is reported as null rather than dangling. Everything textual here is COMPILED ORIENTATION PROSE AT COMPILED GRAIN: page_summary is a writer's abstract of cited evidence and can never be promoted to a live fact, and the artifact body itself lives in the knowledge repository rather than in this schema. The review-flag count is exact over unprocessed flags, is_stale means the compiled page is known to lag its inputs, and last_compiled_at is a processing instant rather than a validity clock.

- **Grain:** one visible K artifact (`k_artifact_compiled_grain`)
- **Row key:** `deployment_id`, `artifact_id`
- **Clock semantics:** `compilation_instants`
- **Joins to:** `pages_live` on `deployment_id`, `parent_artifact_id`

| Column | Type | Null | Meaning |
|---|---|---|---|
| `deployment_id` | uuid | no | The deployment that owns the artifact. |
| `artifact_id` | uuid | no | Stable identity of this knowledge artifact. |
| `layer` | text | no | Content tier of the artifact, one of K1, K2, or K3. Values: `K1`, `K2`, `K3`. |
| `page_kind` | text | no | Ownership contract of the body, either compiled when machine-owned or authored when human-owned. Values: `compiled`, `authored`. |
| `git_path` | text | no | Path of the artifact's file in the knowledge repository. |
| `kind` | text | yes | Free-form editorial kind such as summary or profile, null when unset. |
| `parent_artifact_id` | uuid | yes | Parent artifact in the compile tree, exposed only while that parent is itself visible and null otherwise. |
| `page_summary` | text | yes | Writer-emitted abstract of the page; it is compiled orientation prose and is never asserted evidence. |
| `status` | text | no | Lifecycle status of the artifact, one of active, stale, or quarantined. Values: `active`, `stale`, `quarantined`. |
| `last_compiled_at` | timestamp with time zone | yes | When the artifact was last compiled, null when it has never been compiled. |
| `is_stale` | boolean | no | True when a compiled artifact is known to lag its inputs, either by status or by an unprocessed refresh. |
| `open_review_flags` | bigint | no | Exact count of unprocessed authored-review flags on the artifact, always zero for a compiled page. |
| `redaction_required` | boolean | no | True when an open authored-review flag asks the author to redact content. |

### sections_live

One row per section of the current ready representation of a live lineage, keyed by (deployment_id, section_id) and joined to documents_live on (deployment_id, doc_id). Sections of a superseded version, of a non-ready reading, of a superseded D79 structure generation, and of a forgotten lineage are all absent, so a node_path resolves to exactly one live tree. Character and block offsets are meaningful only against the named representation. The summary column is orientation text, not evidence, and the view carries no counts and no validity clocks.

- **Grain:** one section in a current ready representation (`section_current_content`)
- **Row key:** `deployment_id`, `section_id`
- **Clock semantics:** `none`
- **Joins to:** `documents_live` on `deployment_id`, `doc_id`; `document_versions_visible` on `deployment_id`, `version_id`

| Column | Type | Null | Meaning |
|---|---|---|---|
| `deployment_id` | uuid | no | The deployment that owns the section. |
| `section_id` | uuid | no | Stable identity of this section node. |
| `doc_id` | uuid | no | The live lineage the section belongs to. |
| `version_id` | uuid | no | The lineage's current version, whose bytes this section indexes. |
| `representation_id` | uuid | no | The current ready reading whose character offsets this section uses. |
| `structure_generation_id` | uuid | no | The D79 structure generation that produced this tree, always the representation's current generation. |
| `parent_section_id` | uuid | yes | The parent node in the section tree, null for the root section. |
| `node_path` | text | no | Materialized path such as 0.2.1, unique within the structure generation. |
| `heading_level` | smallint | yes | Source heading depth from one to six, null when the section carries no heading. |
| `title` | text | yes | Section title as read from the source, null when the section has none. |
| `normalized_title` | text | no | Case-folded and trimmed title used for stable matching, empty when there is no title. |
| `role` | text | no | Structural role of the section, such as body, references, or boilerplate. Values: `body`, `abstract`, `introduction`, `results`, `methods`, `discussion`, `conclusion`, `references`, `appendix`, `table`, `figure_caption`, `nav`, `boilerplate`, `legal`. |
| `ordinal` | integer | no | Position of the section among its siblings. |
| `block_start` | integer | no | First block ordinal of the section on the deterministic block grid. |
| `block_end` | integer | no | Last block ordinal of the section, inclusive. |
| `char_start` | integer | no | Start character offset of the section within this representation's markdown. |
| `char_end` | integer | no | End character offset of the section within this representation's markdown. |
| `page_start` | integer | yes | First source page of the section, null when the source is not paginated. |
| `page_end` | integer | yes | Last source page of the section, null when the source is not paginated. |
| `summary` | text | yes | Section summary generated for navigation and context; it is labeled orientation text and is never asserted evidence. |

### testimony_currency_events_visible

One row per visible D54 testimony-currency transition, keyed by (deployment_id, event_id) and joined to claims_visible_history on (deployment_id, claim_id) and to documents_live on (deployment_id, doc_id). A currency transition is BOOKKEEPING and never validity: nothing about the claim changes and no fact is adjudicated by it. Transitions of forgotten lineages and of claims whose source version is tombstoned are absent, and from_version_id is null rather than dangling whenever the superseded version is itself no longer visible, so this relation cannot be read as a tombstone side channel. The occurrence instant is transaction time; the view carries no counts and no world-validity clocks.

- **Grain:** one visible D54 transition (`testimony_currency_event_visible`)
- **Row key:** `deployment_id`, `event_id`
- **Clock semantics:** `transaction_time_event`
- **Joins to:** `documents_live` on `deployment_id`, `doc_id`; `claims_visible_history` on `deployment_id`, `claim_id`; `document_versions_visible` on `deployment_id`, `from_version_id`

| Column | Type | Null | Meaning |
|---|---|---|---|
| `deployment_id` | uuid | no | The deployment that owns the transition. |
| `event_id` | uuid | no | Stable identity of this append-only transition record. |
| `claim_id` | uuid | no | The claim whose testimony currency changed. |
| `doc_id` | uuid | no | The live lineage whose basis change drove the transition. |
| `reconciliation_id` | uuid | no | The single reconciliation run that emitted the transition, so a retried run is recognizable as one run. |
| `became_current` | boolean | no | True when the claim regained currency and false when it lost currency. |
| `reason` | text | no | Why currency changed, one of reextracted, version_superseded, version_deleted, or review_restored. Values: `reextracted`, `version_superseded`, `version_deleted`, `review_restored`. |
| `from_extractor_version` | text | yes | The superseded extractor generation for a re-extraction, null for the other reasons. |
| `from_version_id` | uuid | yes | The superseded document version, exposed only while that version is itself visible and null otherwise. |
| `occurred_at` | timestamp with time zone | no | When the transition occurred, which is a transaction-time instant and never a validity clock. |

## Shipped saved queries

Every deployment is seeded with 18 saved queries in the `examples`
namespace, status `active`, origin and assurance `shipped_example`. They are
starting points, not guarantees: RememberStack wrote them and they pass the
same validation as your statements, but what they compute is plain SQL you can
read (`GET /query/saved/examples/<name>`), copy and change. A copy is yours.

Their `parameter_schema` is empty. The parameters are the positional
placeholders in the SQL, listed here. Run one with
`POST /query/saved/examples/<name>/run` and `{"parameters": [...]}`.

| Name | What it answers | Parameters | Row limit in the SQL |
|---|---|---|---|
| `claims_verbatim` | Claims as asserted, nominated semantically and joined to live testimony | `$1` query text | 20 |
| `claims_about` | Claims that mention an entity, via live claim occurrences | `$1` entity id | 50 |
| `claims_as_of` | Claims whose canonical world-time window overlaps an inclusive interval; unknown-precision claims are counted by precision, not by bounds | `$1` interval start, `$2` interval end (timestamps) | 50 |
| `claims_hybrid_rrf` | Semantic and lexical claim channels fused by reciprocal rank | `$1` query text | 20 |
| `chunks_hybrid_rrf` | Semantic and lexical chunk channels fused by reciprocal rank | `$1` query text | 20 |
| `chunk_neighbors` | The chunks either side of one chunk in its current section | `$1` chunk id | none (± 2 chunks) |
| `documents_about` | Every live document that mentions an entity, with its live metadata | `$1` entity id | 50 |
| `pages_about` | Compiled pages that cite an entity through live page evidence | `$1` entity id | 50 |
| `relation_current` | Current relations for an entity, as adjudicated | `$1` entity id | 50 |
| `observation_current` | Current observations about an entity | `$1` entity id | 50 |
| `identity_as_of` | Bounded identity-event transcript as of one decision instant | `$1` entity id, `$2` instant (timestamp) | 100 |
| `entity_timeline` | One entity's visible facts grouped by a disclosed time bucket (day) | `$1` entity id | 200 |
| `explain` | Why the system holds a fact: history, live evidence, lineage, and source | `$1` fact id | 100 |
| `multi_hop_context` | Evidence along a route between two entities, with semantic nominations | `$1` deployment id, `$2` from entity id, `$3` to entity id, `$4` query text | 100 |
| `changed_since` | What the system learned after an instant | `$1` instant (timestamp) | 100 |
| `graph_neighborhood` | Relations within N hops of an entity (2 hops) | `$1` deployment id, `$2` entity id | none |
| `graph_path` | Routes between two entities, each returned whole (up to 4 hops) | `$1` deployment id, `$2` from entity id, `$3` to entity id | none |
| `graph_citation_path` | Directed citation routes between two live documents (up to 6 hops) | `$1` deployment id, `$2` from document id, `$3` to document id | none |

The `examples` namespace belongs to the platform: seeding refuses to
overwrite a query of yours with the same name, and never re-enables a shipped
query you disabled. Copies you make must use another namespace.

```bash
curl -s -X POST "$REMEMBER_API_URL/query/saved/examples/changed_since/run" \
  -H "Authorization: Bearer $REMEMBER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"parameters": ["2026-09-20T00:00:00Z"]}'
```

```python
from remember import Client

memory = Client()
result = memory.run_saved_query(
    namespace="examples", name="changed_since", parameters=["2026-09-20T00:00:00Z"]
)
for object_kind, object_id, occurred_at, label in result["rows"]:
    print(occurred_at, object_kind, label)
```

---

Source: https://remember.dev/docs/reference/python-sdk

# Python SDK

The `remember` package is the Python client for RememberStack. You use it to
store documents, wait until they are processed, and ask the memory what it
knows.

This page lists every public name the package exports. For the fields inside
a result (facts, claims, validity windows, negatives), see
[Result types](https://remember.dev/docs/reference/result-types).

## Install

```bash
pip install remember
```

The package needs Python 3.12 or newer. It depends only on `httpx`,
`pydantic` and `pydantic-settings`; it does not contain the engine. To run the
engine, see [Install with Docker Compose](https://remember.dev/docs/self-hosting/install).

`pip install remember` also installs the `remember` command. See
[CLI](https://remember.dev/docs/reference/cli).

The client is synchronous. There is no async client yet
([What is not built yet](https://remember.dev/docs/project/not-built-yet)).

## Public names

`remember.__all__` contains these names:

| Name | Kind | Section |
|---|---|---|
| `Client` | class | [Client](#client) |
| `RememberClient` | alias of `Client` | [Client](#client) |
| `MemoryClient` | class | [MemoryClient](#memoryclient) |
| `resolve_connection` | function | [Connecting](#connecting) |
| `MemoryApiError` | exception | [Errors](#errors) |
| `RateLimited` | exception | [Errors](#errors) |
| `StoredKeyRefused` | exception | [Errors](#errors) |
| `PipelineDeadLettered` | exception | [Errors](#errors) |
| `ConnectorNotFoundError` | exception | [Errors](#errors) |
| `CapabilityReadiness` | model | [Models](#models) |
| `ClaimValidPrecision` | enum | [Models](#models) |
| `ConnectorCreate` | model | [Models](#models) |
| `ConnectorDescriptor` | model | [Models](#models) |
| `ContextBundleV2` | model | [Models](#models) |
| `DocumentDeletion` | model | [Models](#models) |
| `DocumentPage` | model | [Models](#models) |
| `DocumentSummary` | model | [Models](#models) |
| `DocumentVersionSummary` | model | [Models](#models) |
| `Envelope` | model | [Models](#models) |
| `IngestedVersion` | model | [Models](#models) |
| `PipelineReadinessReport` | model | [Models](#models) |
| `PipelineStageReadiness` | model | [Models](#models) |
| `QueryResultDict` | dict subclass | [Models](#models) |
| `ReadinessRequirements` | model | [Models](#models) |
| `TemporalMatch` | enum | [Models](#models) |
| `ToolDescriptor` | model | [Models](#models) |
| `VersionPipelineReadiness` | model | [Models](#models) |
| `__version__` | string | The installed package version, for example `"0.17.0"`. |

`remember.credentials.CredentialError`, raised when the credential file is
unreadable or not in the current format, lives outside `__all__`. See
[Errors](#errors).

## Which client to use

| You want to | Use |
|---|---|
| Store and retrieve memory (the usual case) | `Client` (same as `RememberClient`) |
| The same, without the file-path `ingest` shortcut | `MemoryClient` |

`Client` is a subclass of `MemoryClient`. Every method listed under
[MemoryClient](#memoryclient) is available on `Client`.

## Connecting

`Client` and `MemoryClient` resolve their connection the way the CLI does,
with one function, `remember.resolve_connection()`. Each setting is resolved
on its own; first match wins:

| Setting | 1. Argument | 2. Environment | 3. Credential file | 4. Otherwise |
|---|---|---|---|---|
| Key | `api_key` | `REMEMBER_API_KEY` | `key` | no key |
| Address | `base_url` | `REMEMBER_API_URL` | `api_url` | `http://127.0.0.1:8000` |
| Project | `project` | `REMEMBER_PROJECT` | `default_project` | none |

The credential file is the one the CLI writes
([CLI: credential file](https://remember.dev/docs/reference/cli#credential-file)). It is read only
when a setting is not given as an argument or in the environment.

A key read from the credential file is only sent to the address stored
beside it. When `base_url` or `REMEMBER_API_URL` names
another address, the first request raises `StoredKeyRefused`; pass the key
explicitly to use it there. A key you pass as an argument or in
`REMEMBER_API_KEY` is sent wherever you point it.

```bash
export REMEMBER_API_URL=http://localhost:8000
export REMEMBER_API_KEY=<the key your engine accepts, if any>
```

```python
import remember

with remember.Client() as memory:
    print(memory.deployment_build_info().build_revision)
```

## Client

```python
class remember.Client(MemoryClient)
remember.RememberClient = remember.Client
```

The client for one deployment. It talks directly to the deployment's own
address.

### Constructor

```python
Client(
    *,
    api_key: str | None = None,
    base_url: str | None = None,
    project: str | None = None,
    timeout: float = 30.0,
    client: httpx.Client | None = None,
    transport: httpx.BaseTransport | None = None,
)
```

All parameters are keyword-only.

| Parameter | Meaning |
|---|---|
| `api_key` | The API key. A bare key or a full `Bearer …` value. |
| `base_url` | The deployment address. |
| `project` | Which project to use, by id or name, when the key covers several. |
| `timeout` | Seconds per HTTP request. Default 30. |
| `client` | An `httpx.Client` you built yourself, with its own address and headers. It cannot be combined with any other parameter, and nothing is resolved. The SDK does not close a client you pass in. |
| `transport` | An `httpx` transport to use instead of the network (for tests or a proxy). |

How each setting is chosen: [Connecting](#connecting). Creating a client
makes no network call.

Raises:

- `ValueError` when `client` is combined with another parameter.
- `ValueError` when the key is empty, is the bare word `Bearer`, or contains a
  line break.
- `remember.credentials.CredentialError` when the credential file has to be
  read and is unreadable or not in the current format.

### `Client.ingest`

Same signature and behaviour as [`MemoryClient.ingest`](#ingest), with one
difference: a `str` `source` is always treated as a file path. A path that
does not exist raises `FileNotFoundError` (on `MemoryClient` it raises
`ValueError`).

### `Client.ingest_file`

```python
Client.ingest_file(
    file_path: str | Path,
    *,
    filename: str | None = None,
    mime: str | None = None,
    title: str | None = None,
    source_kind: str | None = None,
    source_ref: str | None = None,
    source_modified_at: datetime | None = None,
    versioning_mode: Literal["snapshot", "living"] = "snapshot",
    source_version_ref: str | None = None,
) -> IngestedVersion
```

Reads one file and ingests it. It calls `ingest(file_path, …)` with the same
arguments.

```python
version = memory.ingest_file("notes/billing-migration-kickoff.md")
```

### Context manager

`with remember.Client() as memory:` returns the client and closes its HTTP
connection pool when the block ends. Outside a `with` block, call
`memory.close()` yourself. `close()` closes only a connection pool the SDK
created; a `client=` you injected stays open.

## MemoryClient

```python
class remember.MemoryClient
```

The typed client every other memory client builds on.

### Constructor

`MemoryClient` takes the same parameters as [`Client`](#constructor) and
resolves its connection the same way ([Connecting](#connecting)).

`MemoryClient` supports `with` and `close()` the same way `Client` does.

### Method summary

| Method | HTTP route | Returns |
|---|---|---|
| [`ingest`](#ingest) | `POST /ingest` | `IngestedVersion` |
| [`pipeline_readiness`](#pipeline_readiness) | `POST /readiness` | `PipelineReadinessReport` |
| [`wait_for_readiness`](#wait_for_readiness) | `POST /readiness`, repeated | `PipelineReadinessReport` |
| [`deployment_build_info`](#deployment_build_info) | `GET /deployment` | `DeploymentBuildInfo` |
| [`list_documents`](#list_documents) | `GET /documents` | `DocumentPage` |
| [`delete_document`](#delete_document) | `DELETE /documents/{doc_id}` | `DocumentDeletion` |
| [`list_operations`](#list_operations) | `GET /operations` | `tuple[ToolDescriptor, ...]` |
| [`run_operation`](#run_operation) | `POST /operations/{name}` | `Envelope` or `ContextBundleV2` |
| [`facts_context`](#facts_context) | `POST /operations/facts_context` | `Envelope` |
| [`claims_and_sources_context`](#claims_and_sources_context) | `POST /operations/claims_and_sources_context` | `Envelope` |
| [`combined_context`](#combined_context) | `POST /operations/combined_context` | `ContextBundleV2` |
| [`resolve_entity`](#resolve_entity) | `POST /operations/resolve_entity` | `Envelope` |
| [`resolve`](#resolve) | `GET /resolve` | `Envelope` |
| [`lookup_relations`](#lookup_relations) | `GET /lookup/relations` | `Envelope` |
| [`lookup_observations`](#lookup_observations) | `GET /lookup/observations` | `Envelope` |
| [`transcript_relation`](#transcript_relation) | `GET /transcript/relation/{id}` | `Envelope` |
| [`hydrate_relation`](#hydrate_relation) | `GET /hydrate/relation/{id}` | `Envelope` |
| [`search_claims`](#search_claims) | `GET /search/claims` | `Envelope` |
| [`search_chunks`](#search_chunks) | `GET /search/chunks` | `Envelope` |
| [`adjacent_chunks`](#adjacent_chunks) | `GET /chunks/{id}/adjacent` | `Envelope` |
| [`graph_neighborhood`](#graph_neighborhood) | `POST /graph/neighborhood` | `Envelope` |
| [`graph_path`](#graph_path) | `POST /graph/path` | `Envelope` |
| [`graph_citation_path`](#graph_citation_path) | `POST /graph/citation-path` | `Envelope` |
| [`query_sql`](#query_sql) | `POST /query/sql` | `dict` |
| [`open_query`](#open_query) | `POST /query/sql` | `QueryResultDict` |
| [`explain_sql`](#explain_sql) | `POST /query/sql/explain` | `dict` |
| [`explain_query`](#explain_query) | `POST /query/sql/explain` | `QueryResultDict` |
| [`describe_query_space`](#describe_query_space) | `GET /query/space` | `dict` |
| [`search_query_space`](#search_query_space) | `GET /query/space/search` | `list[dict]` |
| [`list_saved_queries`](#list_saved_queries) | `GET /query/saved` | `list[dict]` |
| [`describe_saved_query`](#describe_saved_query) | `GET /query/saved/{namespace}/{name}` | `dict` |
| [`run_saved_query`](#run_saved_query) | `POST /query/saved/{namespace}/{name}/run` | `dict` |
| [`call_open_query`](#call_open_query) | one of the `/query/*` routes | `object` |
| [Connector methods](#connector-methods) | `/connectors…` | not served |

Every method raises [`MemoryApiError`](#memoryapierror) when the deployment
answers with an error status, when the network fails (`status_code` 0), or
when a success response does not match the expected shape (`status_code`
200). Methods also raise `ValueError` for the client-side checks listed with
each one.

The HTTP routes are documented in the [HTTP API reference](https://remember.dev/docs/reference/http-api).

### Store

#### `ingest`

```python
MemoryClient.ingest(
    source: bytes | Path | str | None = None,
    *,
    content: bytes | None = None,
    filename: str | None = None,
    mime: str | None = None,
    title: str | None = None,
    source_kind: str | None = None,
    source_ref: str | None = None,
    source_modified_at: datetime | None = None,
    versioning_mode: Literal["snapshot", "living"] = "snapshot",
    source_version_ref: str | None = None,
) -> IngestedVersion
```

Uploads one document. The call returns as soon as the engine has stored the
bytes; processing runs afterwards. Wait with
[`wait_for_readiness`](#wait_for_readiness) before you expect the content in
results.

| Parameter | Meaning |
|---|---|
| `source` | A `Path` or `str` file path (read from disk), or `bytes`. |
| `content` | Raw bytes. Wins over `source` when both are given; `source` then only supplies the filename. |
| `filename` | Required for bytes. Defaults to the file's name for a path. |
| `mime` | Media type; an explicit value always wins. Defaults to the type of the extension of the file's real name (for a path) or of `filename` (for bytes): `.md` is `text/markdown`, `.pdf` is `application/pdf`, on every Python installation; the full table is in [Ingest files](https://remember.dev/docs/guides/ingest-files#the-mime-type). An extension outside that table takes the type your Python installation's MIME database gives it, or `application/octet-stream` if it has none. The engine converts only media types it has a converter for; see [File formats and converters](https://remember.dev/docs/self-hosting/converters). |
| `title` | A human title for the document. |
| `source_kind`, `source_ref` | The document's stable identity: what kind of source it is and its id within that kind. Always together. Ingesting again with the same pair creates a new version of the same document when the bytes differ. See [Documents, versions and sources](https://remember.dev/docs/concepts/documents-and-sources). |
| `source_modified_at` | When the source itself last changed. Must be timezone-aware UTC. Requires `source_kind`/`source_ref`. |
| `versioning_mode` | `"snapshot"` (default) or `"living"`. `"living"` requires `source_kind`/`source_ref`. See [Updating a source](https://remember.dev/docs/concepts/updating-sources). |
| `source_version_ref` | The source system's own revision label. Requires `source_kind`/`source_ref`. |

Raises `ValueError` when:

- only one of `source_kind` and `source_ref` is given;
- `source_modified_at`, `source_version_ref` or `versioning_mode="living"` is
  given without `source_kind`/`source_ref`;
- `source_modified_at` is naive or not UTC;
- a `str` path is not a file (`MemoryClient` only);
- neither `source` nor `content` is given;
- bytes are given without `filename`.

Reading a path can raise `OSError`.

Returns [`IngestedVersion`](#models). `created` is `False` when these exact
bytes were already stored for this document; no new processing starts.

```python
from datetime import UTC, datetime
from pathlib import Path

version = memory.ingest(
    Path("specs/billing-migration.md"),
    title="Billing migration spec",
    source_kind="notes",
    source_ref="specs/billing-migration.md",
    source_modified_at=datetime(2026, 9, 21, 14, 0, tzinfo=UTC),
)
print(version.doc_id, version.version_id, version.created)
```

#### `pipeline_readiness`

```python
MemoryClient.pipeline_readiness(
    *, version_ids: tuple[UUID, ...], require: ReadinessRequirements
) -> PipelineReadinessReport
```

Asks, once, whether the given versions have finished processing and whether
the capabilities you name are ready. `require` must name all four
capabilities (`pipeline`, `p1`, `live_graph`, `p3`) as `True` or `False`.

Raises `ValueError` when `version_ids` is empty.

```python
report = memory.pipeline_readiness(
    version_ids=(version.version_id,),
    require=remember.ReadinessRequirements(
        pipeline=True, p1=True, live_graph=True, p3=False
    ),
)
for v in report.versions:
    print(v.version_id, v.ready, [(s.stage, s.status) for s in v.stages])
```

What each capability means: [The pipeline and readiness](https://remember.dev/docs/concepts/pipeline).

#### `wait_for_readiness`

```python
MemoryClient.wait_for_readiness(
    version_ids: Sequence[str | UUID],
    *,
    timeout: float = 1800.0,
    poll_interval: float = 15.0,
    require_p3: bool = False,
) -> PipelineReadinessReport
```

Calls `pipeline_readiness` at once and then every `poll_interval` seconds
until the report says `ready`, requiring `pipeline`, `p1` and `live_graph`
(and `p3` when `require_p3=True`). Returns the ready report. The defaults,
30 minutes and 15 seconds, are starting points sized for single documents;
raise `timeout` for bulk loads.

A stage that is `failed` has a retry scheduled, so the method keeps
polling. A stage that is `dead_letter` has used all its attempts and never
becomes ready, so the method stops at once.

Raises:

- [`PipelineDeadLettered`](#pipelinedeadlettered) when any stage of any
  listed version is `dead_letter`;
- `TimeoutError` when `timeout` seconds pass first;
- `ValueError` when an id is not a UUID.

See [Wait until a document is queryable](https://remember.dev/docs/guides/wait-for-readiness).

```python
memory.wait_for_readiness([version.version_id])
```

#### `deployment_build_info`

```python
MemoryClient.deployment_build_info() -> DeploymentBuildInfo
```

Returns which engine build and which model bindings the deployment is
serving: `build_revision` (str), `model_bindings` (dict of str to str),
`document_binding_generation` (str or `None`), and `tools` (dict of MCP
memory tool name to tool version). The class is
`remember.models.DeploymentBuildInfo`; it is not in `__all__`.

```python
info = memory.deployment_build_info()
print(info.build_revision, info.model_bindings.get("claim_extraction"))
```

#### `list_documents`

```python
MemoryClient.list_documents(
    *,
    limit: int = 50,
    cursor: str | None = None,
    status: Literal["ingesting", "converting", "structuring", "ready", "failed"] | None = None,
) -> DocumentPage
```

Returns one page of the deployment's documents, newest document first, as a
[`DocumentPage`](https://remember.dev/docs/reference/result-types#documentpage). Pass the
returned `cursor` to read the next page; it is `None` on the last page.
`status` keeps only documents whose newest version has that status. `limit`
is 1 to 200; the deployment answers `422` outside that range. Deleted
documents are not listed. See
[`GET /documents`](https://remember.dev/docs/reference/http-api/ingest#get-documents).

```python
cursor = None
while True:
    page = memory.list_documents(cursor=cursor)
    for document in page.documents:
        print(document.doc_id, document.title, document.serving)
    if page.cursor is None:
        break
    cursor = page.cursor
```

#### `delete_document`

```python
MemoryClient.delete_document(*, doc_id: UUID | str) -> DocumentDeletion
```

Removes one document, every version of it, from the memory and returns a
[`DocumentDeletion`](https://remember.dev/docs/reference/result-types#documentdeletion) with
`doc_id`, `deleted_at`, `claims_retired`, `relations_closed` and
`observations_closed`. Its claims stop counting as evidence, and facts that
only it supported are closed with a recorded retraction. The claims and the
stored original are kept as history. Needs a `write` credential. See
[`DELETE /documents/{doc_id}`](https://remember.dev/docs/reference/http-api/ingest#delete-documentsdoc_id).

Raises `ValueError` before sending anything when `doc_id` is not a UUID, and
`MemoryApiError` with `status_code` 404 and `detail` `"document_not_found"`
when the document does not exist or is already deleted.

```python
from remember import MemoryApiError

try:
    deletion = memory.delete_document(doc_id=version.doc_id)
    print(f"{deletion.claims_retired} claims retired")
except MemoryApiError as error:
    if error.status_code != 404:
        raise
```

### Assured operations

The four assured operations are the recommended way to ask the memory a
question. Their full contracts are in
[Assured operations](https://remember.dev/docs/reference/assured-operations).

#### `list_operations`

```python
MemoryClient.list_operations() -> tuple[ToolDescriptor, ...]
```

Returns the descriptors of the deployment's operations: `resolve_entity`,
`claims_and_sources_context`, `facts_context`, `combined_context`. Each
descriptor carries the operation's input schema.

```python
for op in memory.list_operations():
    print(op.name, op.version, op.input_schema.get("required"))
```

#### `run_operation`

```python
MemoryClient.run_operation(
    *, name: str, arguments: Mapping[str, object] | None = None
) -> Envelope | ContextBundleV2
```

Runs one operation by name with its JSON arguments. Returns a
`ContextBundleV2` when the response is a combined bundle, otherwise an
`Envelope`. Use it for arguments the convenience methods below do not expose
(`k`, `evidence_per_fact`, `candidate_k`, `entity_ids` on
`claims_and_sources_context`).

The engine validates arguments and answers with an error (raised as
`MemoryApiError`) for an unknown name, a missing required argument, an
unknown argument or a value outside its bounds.

```python
result = memory.run_operation(
    name="facts_context",
    arguments={"query": "Who owns the billing migration?", "k": 25},
)
```

#### `facts_context`

```python
MemoryClient.facts_context(
    query: str,
    *,
    time: Mapping[str, object] | None = None,
    hops: int | None = None,
    predicate: str | None = None,
    entity_ids: Sequence[str | UUID] | None = None,
) -> Envelope
```

Returns the facts the memory holds true that match `query`, with their
evidence.

| Argument | Constraint (checked by the engine) |
|---|---|
| `query` | 1 to 8,192 characters |
| `time` | `{"mode": "current"}` (default), `{"mode": "at", "at": "<ISO date-time>"}`, `{"mode": "overlap", "from": "…", "to": "…"}` or `{"mode": "history"}` |
| `hops` | 1 or 2; default 1 |
| `predicate` | 1 to 200 characters |
| `entity_ids` | 1 to 19 unique entity UUIDs |

```python
facts = memory.facts_context(
    "billing migration owner", time={"mode": "history"}
)
for fact in facts.facts:
    print(fact.label, fact.validity.valid_from, fact.validity.valid_until)
```

#### `claims_and_sources_context`

```python
MemoryClient.claims_and_sources_context(query: str) -> Envelope
```

Returns what sources said about `query` (claims) and the matching source
passages (chunks). `query` is 1 to 8,192 characters.

```python
said = memory.claims_and_sources_context("Why was the cutover date moved?")
for claim in said.evidence:
    print(claim.claim_text, claim.asserted_at, claim.document_title)
```

#### `combined_context`

```python
MemoryClient.combined_context(
    query: str, *, time: Mapping[str, object] | None = None
) -> ContextBundleV2
```

Runs `claims_and_sources_context` and `facts_context` together and returns
both results in one bundle: `bundle.claims_and_sources` and `bundle.facts`.
`time` takes the same values as in `facts_context`.

```python
bundle = memory.combined_context("What did Ravi decide about invoicing?")
print(len(bundle.facts.facts), len(bundle.claims_and_sources.evidence))
```

#### `resolve_entity`

```python
MemoryClient.resolve_entity(name: str) -> Envelope
```

Returns the ranked candidate entities for a name. It never picks one
silently. `name` must be at least one character.

```python
people = memory.resolve_entity("Dana")
for candidate in people.entities:
    print(candidate.entity_id, candidate.canonical_name)

dana_id = people.entities[0].entity_id   # reused by the examples below
```

### Entities and facts

#### `resolve`

```python
MemoryClient.resolve(
    *, name: str, context_entity_ids: tuple[UUID, ...] = ()
) -> Envelope
```

Resolves a name, using up to 8 already-known entities as context to rank the
candidates. More than 8 is refused by the engine.

```python
memory.resolve(name="the migration", context_entity_ids=(dana_id,))
```

#### `lookup_relations`

```python
MemoryClient.lookup_relations(
    *,
    subject_entity_id: UUID | None = None,
    predicate: str | None = None,
    object_entity_id: UUID | None = None,
    valid_at: datetime | None = None,
    k: int = 50,
) -> Envelope
```

Returns the relations (facts between two entities) that match the pattern.
With `valid_at`, returns the relations that were valid at that instant;
without it, the current ones. At most `k` (1–400) come back; the envelope's
`truncation` says when more matched.

```python
memory.lookup_relations(subject_entity_id=dana_id, predicate="leads")
```

#### `lookup_observations`

```python
MemoryClient.lookup_observations(
    *, entity_id: UUID, property_query: str | None = None, k: int = 10
) -> Envelope
```

Returns the current observations (facts about one entity), optionally
narrowed by `property_query` text, at most `k`.

```python
memory.lookup_observations(entity_id=dana_id, property_query="role")
```

#### `transcript_relation`

```python
MemoryClient.transcript_relation(*, relation_id: UUID) -> Envelope
```

Returns the recorded decisions that shaped one relation (the `transcript`
field of the envelope).

#### `hydrate_relation`

```python
MemoryClient.hydrate_relation(*, relation_id: UUID) -> Envelope
```

Returns one relation with its evidence and the source documents behind it.

```python
relation = next(f for f in facts.facts if f.kind == "relation")
cited = memory.hydrate_relation(relation_id=relation.fact_id)
print([s.title for s in cited.sources])
```

### Search

#### `search_claims`

```python
MemoryClient.search_claims(
    *, query: str, k: int = 10, channel: Literal["semantic", "bm25"] = "semantic"
) -> Envelope
```

Searches claims by meaning (`"semantic"`) or by keywords (`"bm25"`). The
result is evidence, not facts. The engine accepts `k` from 1 to 400.

#### `search_chunks`

```python
MemoryClient.search_chunks(
    *, query: str, k: int = 10, channel: Literal["semantic", "bm25"] = "semantic"
) -> Envelope
```

Searches source passages. Same arguments and bounds as `search_claims`.

```python
hits = memory.search_chunks(query="invoice numbering", k=5, channel="bm25")
for chunk in hits.chunks:
    print(chunk.chunk_id, chunk.chunk_text[:80])
```

#### `adjacent_chunks`

```python
MemoryClient.adjacent_chunks(*, chunk_id: UUID | str, window: int = 1) -> Envelope
```

Returns the passages immediately before and after one chunk in the same document
version, in document order. `window` is 1 or 2.

Raises `ValueError` when `window` is outside 1–2 or `chunk_id` is not a UUID.

```python
around = memory.adjacent_chunks(chunk_id=hits.chunks[0].chunk_id, window=2)
```

### Graph

Details and limits: [Graph](https://remember.dev/docs/reference/http-api/graph).

#### `graph_neighborhood`

```python
MemoryClient.graph_neighborhood(
    *,
    entity_id: UUID,
    hops: int = 2,
    predicates: tuple[str, ...] = (),
    valid_at: datetime | None = None,
    believed_at: datetime | None = None,
    limit: int = 500,
    continuation: str | None = None,
    include_paths: bool = False,
) -> Envelope
```

Returns the entities and relations around one entity. The engine accepts
`hops` 1–4, `limit` 1–500 and at most 100 `predicates`. Pass the envelope's
`truncation.continuation` back as `continuation` to read the next page.

```python
around_dana = memory.graph_neighborhood(entity_id=dana_id, hops=1)
print(len(around_dana.nodes), len(around_dana.edges))
```

#### `graph_path`

```python
MemoryClient.graph_path(
    *,
    from_entity_id: UUID,
    to_entity_id: UUID,
    max_hops: int = 4,
    predicates: tuple[str, ...] = (),
    valid_at: datetime | None = None,
    believed_at: datetime | None = None,
) -> Envelope
```

Returns the shortest paths between two entities. The engine accepts
`max_hops` 1–6.

#### `graph_citation_path`

```python
MemoryClient.graph_citation_path(
    *, from_doc_id: UUID, to_doc_id: UUID, max_hops: int = 6
) -> Envelope
```

Returns citation paths from one document to another. `max_hops` 1–6.

### SQL and saved queries

SQL queries run over the query space (`memory_v1`): prepared, read-only
views and functions. The engine parses every statement and validates it
against the query space before it runs; anything outside it is rejected.
See [Query space memory_v1](https://remember.dev/docs/reference/query-space) and
[Explore memory with SQL](https://remember.dev/docs/guides/sql). Failures carry a code on
`MemoryApiError.code` (for example `relation_not_allowed`,
`statement_timeout`); the list is in [Errors](https://remember.dev/docs/reference/errors).

#### `query_sql`

```python
MemoryClient.query_sql(
    *,
    sql: str,
    parameters: list[object] | tuple[object, ...] = (),
    max_rows: int | None = None,
) -> dict[str, object]
```

Runs one statement with positional parameters (`$1`, `$2`, …). Returns the
`QueryResult/v1` object as a dict. `max_rows` must be 0 or more.

#### `open_query`

```python
MemoryClient.open_query(
    sql: str, *, parameters: Sequence[object] = (), max_rows: int | None = None
) -> QueryResultDict
```

The same call, returning a [`QueryResultDict`](#models) with `.rows`,
`.columns` and `.truncated` attributes.

```python
res = memory.open_query(
    "SELECT predicate, count(*) AS n FROM facts_current GROUP BY 1 ORDER BY 2 DESC LIMIT $1",
    parameters=[10],
)
for row in res.rows:
    print(row)
```

#### `explain_sql`

```python
MemoryClient.explain_sql(
    *, sql: str, parameters: list[object] | tuple[object, ...] = ()
) -> dict[str, object]
```

Checks and plans a statement without running it. Returns `QueryResult/v1`
as a dict.

#### `explain_query`

```python
MemoryClient.explain_query(
    sql: str, *, parameters: Sequence[object] = ()
) -> QueryResultDict
```

`explain_sql` returning a `QueryResultDict`.

#### `describe_query_space`

```python
MemoryClient.describe_query_space(
    *, pattern: str | None = None, include_examples: bool = False
) -> dict[str, object]
```

Returns the query space's views, functions, comments and limits. `pattern`
is a shell-style filter over view names.

```python
space = memory.describe_query_space(pattern="facts_*")
```

#### `search_query_space`

```python
MemoryClient.search_query_space(*, query: str, k: int = 10) -> list[dict[str, object]]
```

Searches the query space's own documentation (never your data). Each hit has
`kind`, `name`, `score`, `purpose`, `tags`. The engine accepts `k` 1–25.

#### `list_saved_queries`

```python
MemoryClient.list_saved_queries(
    *, namespace: str | None = None, status: str | None = None
) -> list[dict[str, object]]
```

Lists saved queries. Without `status`, only active versions are listed.

#### `describe_saved_query`

```python
MemoryClient.describe_saved_query(
    *, namespace: str, name: str, version: int | None = None
) -> dict[str, object]
```

Returns one saved query's SQL, parameters and declared columns.
`namespace` and `name` must match `^[a-z][a-z0-9_]*$`, otherwise
`ValueError`.

#### `run_saved_query`

```python
MemoryClient.run_saved_query(
    *,
    namespace: str,
    name: str,
    parameters: list[object] | tuple[object, ...] = (),
    version: int | None = None,
    max_rows: int | None = None,
) -> dict[str, object]
```

Runs one active saved query. Same identifier rule as `describe_saved_query`.
Returns `QueryResult/v1` as a dict. See [Saved queries](https://remember.dev/docs/guides/saved-queries).

#### `call_open_query`

```python
MemoryClient.call_open_query(*, name: str, arguments: Mapping[str, object]) -> object
```

Runs one of the seven SQL query tools by its MCP tool name (`query_sql`,
`explain_sql`, `describe_query_space`, `search_query_space`,
`list_saved_queries`, `describe_saved_query`, `run_saved_query`) with MCP
tool arguments. The `remember mcp` server uses it. Arguments are checked
strictly: unknown keys, wrong types and out-of-range numbers are refused.

### Connector methods

`connectors()`, `add_connector(*, connector)`, `pause_connector(*, connector_id)`
and `connector_status(*, connector_id)` exist in the SDK, but no deployment
serves the `/connectors` routes today; every call raises `MemoryApiError`
with `status_code` 404. See [What is not built yet](https://remember.dev/docs/project/not-built-yet).

## Errors

```text
RuntimeError
├── MemoryApiError                 every memory call
│   ├── RateLimited                429 from the deployment's admission limits
│   └── StoredKeyRefused           the stored key may not go to that address
└── PipelineDeadLettered           wait_for_readiness
Exception
└── ConnectorNotFoundError         exported, not raised today
ValueError
└── CredentialError                remember.credentials (the credential file)
```

### MemoryApiError

Raised by every memory-client method when a request fails.

| Attribute | Meaning |
|---|---|
| `status_code` | HTTP status. `0` means the request never got an answer (connection refused, timeout, DNS). `200` means the answer did not match the expected shape. |
| `detail` | The error text from the deployment. |
| `code` | A machine code, set for SQL and saved-query routes (for example `parse_error`, `quota_exceeded`, `saved_query_not_found`) and for a `429` (`rate_limited` or `concurrency_limited`); otherwise `None`. |
| `response` | Not set by the SDK (`None`). |

`str(error)` reads `API <status_code>: <detail>`.

```python
try:
    memory.query_sql(sql="SELECT * FROM pg_catalog.pg_class")
except remember.MemoryApiError as error:
    print(error.status_code, error.code)   # 422 relation_not_allowed
```

Status codes and their meaning: [Errors and status codes](https://remember.dev/docs/reference/errors).

### RateLimited

Raised when the deployment refuses a request with `429` because a key or the
deployment has reached its request-rate or in-flight limit. `code` is
`rate_limited` or `concurrency_limited`; `retry_after` is the `Retry-After`
the deployment sent, in seconds (a `float`), or `None`. The client does not
retry by itself.

```python
import time

try:
    memory.facts_context("Who owns the billing migration?")
except remember.RateLimited as error:
    time.sleep(error.retry_after or 1)
```

### StoredKeyRefused

Raised on the first request when the key came from the credential file and
the address came from `base_url` or `REMEMBER_API_URL` and is not an address
that key may be sent to ([Connecting](#connecting)). Pass the key as
`api_key` or in `REMEMBER_API_KEY` to use it there.

### PipelineDeadLettered

Raised by `wait_for_readiness` when a stage of a version it waits on is
`dead_letter`: the stage used all its retry attempts and will not finish by
waiting.

| Attribute | Meaning |
|---|---|
| `dead_lettered` | Every dead-lettered stage found, as `(version_id, stage, status)` tuples. |
| `report` | The `PipelineReadinessReport` that showed them. |

`str(error)` names each version and stage. On a self-hosted deployment the
operator can replay the work after fixing the cause. See
[When a version fails](https://remember.dev/docs/guides/wait-for-readiness#when-a-version-fails).

### CredentialError

`remember.credentials.CredentialError` (a `ValueError`) is raised when the
credential file cannot be read or written safely: it is a symlink, it is
readable by group or others, it is not a version-2 file, or the lock cannot be
taken. `remember setup --self-hosted` replaces the file.

### ConnectorNotFoundError

Exported for the connector methods; nothing raises it today.

## Models

Field-level detail for results is in [Result types](https://remember.dev/docs/reference/result-types). The
models below are Pydantic models (frozen) unless noted.

| Name | What it is |
|---|---|
| `Envelope` | The result of every retrieval call: `grain`, `temporal_scope`, `entities`, `facts`, `evidence`, `chunks`, `sources`, graph fields, `truncation`, `negative`, `freshness` and more. |
| `ContextBundleV2` | The result of `combined_context`: `contract` (`"ContextBundle/v2"`), `claims_and_sources` (an evidence `Envelope`) and `facts` (a fact `Envelope`). |
| `IngestedVersion` | The result of `ingest`: `deployment_id`, `doc_id`, `version_id` (UUIDs), `content_hash` (str), `created` (bool), and the settings in force: `mime` (str), `title` (str or `None`), `versioning_mode` (`"snapshot"` or `"living"`). |
| `DocumentPage` | The result of `list_documents`: `documents` (tuple of `DocumentSummary`) and `cursor`. |
| `DocumentSummary` | One document: `doc_id`, `title`, `source_kind`, `source_uri`, `first_seen_at`, `latest` (a `DocumentVersionSummary`), `serving`. |
| `DocumentVersionSummary` | `version_id`, `version_no`, `status`, `ingested_at`, `error`. |
| `DocumentDeletion` | The result of `delete_document`: `doc_id`, `deleted_at`, `claims_retired`, `relations_closed`, `observations_closed`. |
| `ReadinessRequirements` | The capabilities a readiness check must confirm: `pipeline`, `p1`, `live_graph`, `p3`, all required booleans. |
| `PipelineReadinessReport` | `ready`, `versions` (tuple of `VersionPipelineReadiness`), `capabilities` (dict of capability name to `CapabilityReadiness`), `document_binding_generation`, `model_bindings`, `build_revision`. |
| `VersionPipelineReadiness` | `version_id`, `ready`, `stages` (tuple of `PipelineStageReadiness`). |
| `PipelineStageReadiness` | `stage`, `component_version`, `status` (`missing`, `pending`, `running`, `succeeded`, `failed`, `dead_letter`, `skipped`), `finished_at`, `defer_reason` (why waiting work waits: `no_route`, `budget`, `scheduled`, `retry_backoff`, or `None`). |
| `CapabilityReadiness` | `required`, `ready`, `checked_at`, `reason`, `version`, `built_at`, `published_at`. |
| `ToolDescriptor` | One operation's descriptor: `name`, `description`, `input_schema`, `result_schema`, `result_contract`, `output_grain`, `answer_intent`, `mutates`, `version`, `implementation_plan_hash`. |
| `QueryResultDict` | A `dict` holding a `QueryResult/v1` response, with three read-only attributes: `rows` (list of rows; each row is a list of column values in column order), `columns` (list of `{"name", "type", "nullable"}` dicts) and `truncated` (bool). |
| `ClaimValidPrecision` | String enum: how narrow a stated validity window is. `unknown`, `instant`, `day`, `month`, `quarter`, `year`, `open`. |
| `TemporalMatch` | String enum: whether a fact `confirmed` or only `possible` overlapped the query window. |
| `ConnectorCreate`, `ConnectorDescriptor` | Connector configuration models. No deployment serves connectors today. `ConnectorCreate` refuses a `configuration` key that looks like a secret (`token`, `password`, `api_key` …) and asks for `credential_ref` instead. |

**Note:**

`QueryResultDict.rows` is annotated as a list of dicts, but each row
arrives as a list of values. Pair it with `columns` to get names:
`[dict(zip([c["name"] for c in res.columns], row)) for row in res.rows]`.

---

Source: https://remember.dev/docs/reference/cli

# CLI

This page describes the `remember` CLI (v0.17.2), installed by `pip install remember`.

The `remember` command stores documents, asks the memory questions and
connects coding agents. It
comes with the `remember` Python package:

```bash
pip install remember
remember --version
```

It needs Python 3.12 or newer. `uv tool install remember` installs it as a
standalone tool; `uvx remember …` runs it without installing.

## Commands at a glance

| Group | Command | What it does |
|---|---|---|
| Memory | [`ingest`](#remember-ingest) | Upload one file. |
| | [`query`](#remember-query) | Ask a question, or run SQL queries and saved queries. |
| | [`operations`](#remember-operations) | List or run assured operations by name. |
| | [`documents`](#remember-documents) | List documents, or delete one from the memory. |
| Agents | [`mcp`](#remember-mcp) | Serve the memory to a coding agent over MCP. |
| | [`setup`](#remember-setup) | Write MCP configuration for your coding agents. |
| | [`doctor`](#remember-doctor) | Check installation, credentials, connectivity and agent configuration. |
| Not served | [`connectors`](#remember-connectors) | Talks to routes no deployment serves yet. |
| Engine operators | [`ops`](#self-hosted-operations-remember-ops) | Pipeline repair inside the engine container. |

`remember` with no command prints help and exits with status 2.
`remember <command> --help` prints that command's flags.

## How the CLI finds your deployment

Every command that talks to a deployment (`ingest`, `query`, `operations`,
`documents`, `mcp`, `connectors`, `doctor`) accepts the same three flags:

| Flag | Meaning |
|---|---|
| `--api-url URL` | The deployment address. |
| `--api-key KEY` | The API key. A bare key or a full `Bearer …` value. |
| `--project PROJECT` | Which project to use, by id or name, when the key covers several. |

Put flags after the subcommand: `remember query sql --api-url … "SELECT 1"`.

Each setting is resolved on its own. First match wins:

| Setting | 1. Flag | 2. Environment | 3. [Credential file](#credential-file) | 4. Otherwise |
|---|---|---|---|---|
| Key | `--api-key` | `REMEMBER_API_KEY` | `key` | no key |
| Address | `--api-url` | `REMEMBER_API_URL` | `api_url` | `http://127.0.0.1:8000` |
| Project | `--project` | `REMEMBER_PROJECT` | `default_project` | none |

The Python SDK resolves the same settings the same way
([Python SDK](https://remember.dev/docs/reference/python-sdk#connecting)).

A key read from the credential file is only sent to the address stored beside
it. If `--api-url` or `REMEMBER_API_URL` names another address,
the command exits with status 2 and asks for the key explicitly, with
`--api-key` or `REMEMBER_API_KEY`. A key you pass explicitly is sent wherever
you point it.

```bash
export REMEMBER_API_URL=http://localhost:8000
export REMEMBER_API_KEY=<the key your engine accepts, if any>
remember query "Who owns the billing migration?"
```

### Output

Commands print their results to standard output: JSON for memory
commands. Warnings and errors go to standard error, so
you can pipe the output into `jq` safely.

### Exit codes

| Code | Meaning |
|---|---|
| 0 | Success. |
| 1 | The request failed (network, HTTP error, rejected key), the credential file is unusable, or a check in `doctor` failed. |
| 2 | Usage error: unknown command or flag, missing argument, or an argument the CLI could not parse (for example `--parameters` that is not a JSON array). Also a stored key refused for the address you named. |

## Memory commands

### `remember ingest`

```text
remember ingest FILE [--mime MIME] [--title TITLE]
                     [--source-kind KIND --source-ref REF]
                     [--source-modified-at ISO8601]
                     [--versioning-mode {snapshot,living}]
                     [--source-version-ref REF]
                     [--api-url URL] [--api-key KEY] [--project PROJECT]
```

Uploads one file and prints the new version as JSON:

```json
{"deployment_id":"…","doc_id":"…","version_id":"…","content_hash":"…","created":true,"mime":"text/markdown","title":"…","versioning_mode":"snapshot"}
```

`created` is `false` when these exact bytes are already stored for the same
document; nothing is processed again.

| Flag | Default | Meaning |
|---|---|---|
| `FILE` | required | Path of the file to upload. |
| `--mime` | from the file's extension (`.md` is `text/markdown`, `.pdf` is `application/pdf`; the full table is in [Ingest files](https://remember.dev/docs/guides/ingest-files#the-mime-type)), else `application/octet-stream` | Media type. The engine processes only types it has a converter for; see [File formats and converters](https://remember.dev/docs/self-hosting/converters). |
| `--title` | none | Human title. |
| `--source-kind`, `--source-ref` | none | The document's stable identity. Give both or neither. Re-ingesting with the same pair makes a new version of the same document. |
| `--source-modified-at` | none | When the source last changed, as an ISO 8601 timestamp with a UTC offset of zero, for example `2026-09-21T14:00:00+00:00`. Needs `--source-kind`/`--source-ref`. |
| `--versioning-mode` | `snapshot` | `snapshot` or `living`; `living` needs `--source-kind`/`--source-ref`. See [Updating a source](https://remember.dev/docs/concepts/updating-sources). |
| `--source-version-ref` | none | The source system's revision label. Needs `--source-kind`/`--source-ref`. |

Exit status 2 when the source arguments break these rules or the timestamp is
not UTC; 1 when the file cannot be read or the upload fails.

```bash
remember ingest notes/2026-09-18-billing-sync.md \
  --title "Billing sync, 18 September" \
  --source-kind meeting-notes --source-ref billing-sync/2026-09-18
```

It prints the `IngestedVersion` as one JSON line. When the file's
conversion is parked waiting for a conversion route, the line has
`"parked":"no_route"` and the command also prints a warning to stderr that
names the release step (`remember ops resume-no-route`); the exit status stays 0,
because the file is stored. See
[File formats and converters](https://remember.dev/docs/self-hosting/converters#files-no-route-accepts).

Ingest returns before processing ends. To wait, use the SDK's
`wait_for_readiness` or the MCP `pipeline_readiness` tool; the CLI has no
readiness command. See [Wait until a document is queryable](https://remember.dev/docs/guides/wait-for-readiness).

### `remember documents`

```text
remember documents list [--limit N] [--cursor CURSOR]
                        [--status {ingesting,converting,structuring,ready,failed}]
                        [--api-url URL] [--api-key KEY] [--project PROJECT]
remember documents delete DOC_ID [--api-url URL] [--api-key KEY] [--project PROJECT]
```

`list` prints one page of documents as JSON, newest document first, in the
shape of [`DocumentPage`](https://remember.dev/docs/reference/result-types#documentpage). Pass the
page's `cursor` to `--cursor` to read the next page; `cursor` is `null` on
the last page. `--limit` is 1 to 200 (default 50). `--status` keeps only
documents whose newest version has that status.

```bash
remember documents list --status failed | jq -r '.documents[] | "\(.doc_id) \(.title)"'
```

`delete` removes one document, every version of it, from the memory and
prints what changed:

```json
{"doc_id":"…","deleted_at":"2026-09-23T10:41:07.318000Z","claims_retired":12,"relations_closed":2,"observations_closed":1}
```

Its claims stop counting as evidence, and facts that only it supported are
closed with a recorded retraction. The claims and the stored original stay
as history. Deleting needs a `write` credential. Deleting a document that
does not exist, or is already deleted, prints
`error: API 404: document_not_found` and exits with status 1. See
[`DELETE /documents/{doc_id}`](https://remember.dev/docs/reference/http-api/ingest#delete-documentsdoc_id).

```bash
remember documents delete a1f3e0b4-1c2d-5e6f-8a9b-0c1d2e3f4a5b
```

### `remember query`

```text
remember query "QUESTION" [--combined]
remember query text "QUESTION" [--combined]
remember query sql STATEMENT [--parameters JSON] [--max-rows N]
remember query explain-sql STATEMENT [--parameters JSON]
remember query space [--pattern GLOB] [--include-examples]
remember query search-space QUERY [--k N]
remember query list-saved [--namespace NS] [--status STATUS]
remember query describe-saved NAMESPACE NAME [--version N]
remember query run-saved NAMESPACE NAME [--version N] [--parameters JSON] [--max-rows N]
remember query adjacent-chunks CHUNK_ID [--window {1,2}]
```

Every subcommand also takes `--api-url`, `--api-key` and `--project`.

#### Asking a question

`remember query "QUESTION"` is short for `remember query text "QUESTION"`.
It runs the `facts_context` operation and prints the result
[envelope](https://remember.dev/docs/reference/result-types) as indented JSON. With `--combined` it runs
`combined_context` instead and prints both the facts and what sources said.

```bash
remember query "Who owns the billing migration?"
remember query --combined "Why did the cutover move to October?"
```

**Note:**

The shorthand applies only when no argument after `query` is exactly a
subcommand name (`text`, `sql`, `explain-sql`, `space`, `search-space`,
`list-saved`, `describe-saved`, `run-saved`, `adjacent-chunks`). To ask
a one-word question such as "space", write `remember query text space`.

For other operation arguments (`time`, `entity_ids`, `k`), use
[`remember operations run`](#remember-operations).

#### SQL queries

SQL queries run over the query space (`memory_v1`): prepared, read-only views
and functions. The engine parses every statement and validates it against
the query space before it runs; anything outside it is rejected. Output is
the `QueryResult/v1` JSON object. See [Query space memory_v1](https://remember.dev/docs/reference/query-space).

| Subcommand | Arguments | What it prints |
|---|---|---|
| `sql` | `STATEMENT`; `--parameters` (JSON array of positional values for `$1`, `$2`, …); `--max-rows` (integer, 0 or more) | The result rows and their provenance. |
| `explain-sql` | `STATEMENT`; `--parameters` | The plan, without running the statement. |
| `space` | `--pattern` (shell-style filter over view names); `--include-examples` | The views, functions, comments and limits. |
| `search-space` | `QUERY`; `--k` (default 10; the engine accepts 1–25) | Matching entries of the query space's own documentation, never your data. |
| `list-saved` | `--namespace`; `--status` | Saved queries. Without `--status`, active versions only. |
| `describe-saved` | `NAMESPACE NAME`; `--version` | One saved query's SQL, parameters and columns. |
| `run-saved` | `NAMESPACE NAME`; `--version`; `--parameters`; `--max-rows` | The result of one active saved query. |

`NAMESPACE` and `NAME` must match `^[a-z][a-z0-9_]*$`. A malformed
`--parameters` value exits with status 2.

```bash
remember query sql \
  "SELECT predicate, count(*) AS n FROM facts_current GROUP BY 1 ORDER BY 2 DESC LIMIT \$1" \
  --parameters '[10]'
```

#### `adjacent-chunks`

Prints the passages immediately before and after one chunk, in document order.
`--window` is 1 (default) or 2.

```bash
remember query adjacent-chunks 6f1c2d0e-… --window 2
```

### `remember operations`

```text
remember operations list [--api-url URL] [--api-key KEY] [--project PROJECT]
remember operations run NAME [--arg KEY=VALUE]... [--api-url URL] [--api-key KEY] [--project PROJECT]
```

`list` prints one JSON descriptor per line for the four assured operations,
with each one's input schema.

`run` runs one operation. Each `--arg` is `KEY=VALUE`; the value is parsed as
JSON when it can be, otherwise kept as a string. The result prints as one
line of JSON.

```bash
remember operations run facts_context \
  --arg query="billing migration owner" \
  --arg 'time={"mode":"history"}' \
  --arg k=25
```

An `--arg` without `=` exits with status 2. Operation names and arguments are
in [Assured operations](https://remember.dev/docs/reference/assured-operations).

## Agent commands

### `remember mcp`

```text
remember mcp [--read-only] [--api-url URL] [--api-key KEY] [--project PROJECT]
remember mcp --transport http [--bind HOST:PORT] [--read-only] [--api-url URL]
```

Runs an MCP server for a coding agent. By default it speaks on standard input
and output and finds the deployment the same way as every other
command.
`--transport http` serves the same tools over HTTP at
`http://127.0.0.1:8765/mcp` instead (`--bind` picks another loopback address or port).
`--read-only` removes the tools that change memory, `ingest` and
`delete_document`.

You rarely start it yourself; `remember setup` writes the agent
configuration that starts it. The two transports, the tools, their parameters
and the errors are in [MCP tools](https://remember.dev/docs/reference/mcp).

### `remember setup`

```text
remember setup [--self-hosted] [--api-url URL] [--api-key KEY] [--mcp-url URL]
               [--agent {cursor,claude,codex,agy,all}] [--dir DIR] [--dry-run]
```

Writes each coding agent's MCP entry for Remember.

| Flag | Default | Meaning |
|---|---|---|
| `--self-hosted` | | Configure for an engine at `--api-url`, default `http://127.0.0.1:8000`. |
| `--api-url` | none | The engine address. It implies `--self-hosted` and is written into stdio entries as `REMEMBER_API_URL`. |
| `--api-key` | none | With `--self-hosted`: the engine's key, stored in the [credential file](#credential-file) and never written into agent configuration. |
| `--mcp-url` | none | The URL of your own `remember mcp --transport http` server. It implies `--self-hosted`. Agents that accept a URL entry connect to it; the others get a stdio entry. |
| `--agent` | `all` | Which agent to configure. `agy` is Antigravity; `claude` means Claude Code and Claude Desktop. |
| `--dir` | current directory | The project directory for project-level configuration. |
| `--dry-run` | off | Print the entries it would write; write nothing. |

What it does, in order:

1. Finds the launcher. It prefers an installed `remember` binary; when that
   binary lives in a virtual environment or a cache, or when there is none,
   it uses `uvx remember mcp`. Paths are resolved to absolute paths so an
   editor started from a desktop launcher finds them. With neither
   `remember` nor `uvx` on `PATH` it exits with status 1.
2. Chooses the agents. With `--agent all` it configures what it finds:
   Cursor if `.cursor/` exists in the directory, Antigravity if `.agents/`
   exists, Codex if `.codex/` exists or `codex` is installed, Claude Code if
   `claude` is on `PATH`, Claude Desktop if its configuration file or
   directory exists (or, on macOS, the app is installed). If it finds none,
   it configures Cursor and Antigravity.
3. For a self-hosted engine, stores the engine address and key in the
   credential file (see the warning below).
4. Writes one entry per agent. For a self-hosted engine the entry starts
   `remember mcp` with `REMEMBER_API_URL`. With `--mcp-url`, Cursor, Claude
   Code and Codex get a URL entry instead; Claude Desktop and Antigravity
   keep the stdio entry. A key, if any, is never written: a URL entry refers
   to the `REMEMBER_API_KEY` variable, which you set in the agent's
   environment.

Claude Code and Codex count as accepting URL entries only when their
installed command line supports them: `claude mcp add --help` must list
`--transport`, and `codex mcp add --help` must list
`--bearer-token-env-var`. Otherwise they get the stdio entry, which works
everywhere.

The exact files and their content are listed in
[MCP tools: configuration that `remember setup` writes](https://remember.dev/docs/reference/mcp#configuration-that-remember-setup-writes).

**Warning:**

`remember setup --self-hosted` replaces the credential file with the engine's
address and the key from `--api-key` (or no key). Afterwards every command
talks to that engine.

Existing configuration files must be valid JSON or TOML and must not be
symbolic links; otherwise that agent is not configured and the command exits with status 1. A failure in
one agent does not stop the others. Running the command again with the same
flags leaves the files unchanged.

```bash
remember setup --self-hosted --agent cursor --dry-run
```

### `remember doctor`

```text
remember doctor [--api-url URL] [--api-key KEY] [--project PROJECT]
```

Checks your setup and prints one line per check:

1. `remember` or `uvx` is on `PATH`.
2. The credential file: whether it is readable, and what it holds.
3. The deployment: `GET /deployment` with a 5-second timeout, resolved and
   authenticated exactly as every other command.
4. Agent configuration in the current directory: `.cursor/mcp.json`,
   `.agents/mcp_config.json`, `.codex/config.toml`, and Claude Desktop's
   configuration file. For each it checks the file parses and contains a
   `remember` server. For a stdio entry it checks the command exists and is
   executable; for a URL entry it prints the URL.

`[✓]` is a pass, `[-]` is informational, `[!]` is a failure. Exit status 1
when any check fails.

## `remember connectors`

```text
remember connectors list
remember connectors add KIND --name NAME [--config KEY=VALUE]... [--credential-ref REF]
remember connectors pause CONNECTOR_ID
remember connectors status CONNECTOR_ID
```

These commands call `/connectors` routes that no deployment serves today.
Every one fails with `API 404` and exit status 1. Connectors are listed in
[What is not built yet](https://remember.dev/docs/project/not-built-yet).

## Self-hosted operations: `remember ops`

`remember ops` repairs and inspects a self-hosted engine's pipeline. It runs
inside the engine container, which enables it by setting
`REMEMBERSTACK_INTERNAL_OPS=1`. Elsewhere, including a `remember` installed
from PyPI, it prints an error and exits with status 1, and the help output
does not list it.

```bash
docker compose exec api \
  remember ops inspect --deployment "$REMEMBERSTACK_SELFHOST_DEPLOYMENT_ID"
```

`--deployment` is the engine's deployment id
(`REMEMBERSTACK_SELFHOST_DEPLOYMENT_ID`).

| Subcommand | Flags | What it does | Output |
|---|---|---|---|
| `inspect` | `--deployment ID` | A bounded report of the pipeline: work in progress, dead letters, projections and freshness. | JSON |
| `cost-export` | `--deployment ID`, `--cursor CURSOR`, `--limit N` (default 100) | One page of the cost export, which holds no document content. Exit 2 when `--deployment` does not match the engine or the cursor is invalid. | JSON |
| `resume-no-route` | `--deployment ID` | Releases documents that were parked because no converter handled their media type, once a route now covers them. | `{"released": [...]}` |
| `replay` | `PROCESSING_ID`, `--deployment ID`, `--attempts N` (default 1), `--lane {steady,backfill}`, `--not-before ISO8601` | Reopens one dead-lettered work item with N more attempts. | JSON |
| `rebuild` | `--deployment ID`, `--snapshot-root PATH`, `--version VERSION` | Rebuilds the filesystem view snapshot. | JSON |
| `graph-catalog ensure` | none | Checks the database's graph metadata and repairs it when needed. | `{"ready", "changed", "problems_before", "problems_after", "definitions"}` |

When and why to use each: [Operating the pipeline](https://remember.dev/docs/self-hosting/operating).

## Engine container commands

The engine image `ghcr.io/writeitai/remember-stack` has its own entry point,
`python -m rememberstack.profiles.selfhost`. `compose.yaml` runs each process
with one of these commands; you use them when you write your own
orchestration.

| Command | What it runs |
|---|---|
| `setup` | Applies database migrations and prepares the deployment. Runs once; the other services start after it succeeds. |
| `api` | The HTTP API. Listens on `REMEMBERSTACK_SELFHOST_API_HOST` (default `0.0.0.0`) and `REMEMBERSTACK_SELFHOST_API_PORT` (default `8000`). This is the image's default command. |
| `worker --stage STAGE` | One pipeline worker. `STAGE` is one of `convert`, `structure`, `chunk`, `embed_chunk`, `extract_claims`, `ground_claims`, `normalize_relations`, `adjudicate_observations`, `adjudicate_supersession`, `embed_claim`, `reconcile`, `label_relation`. Run one process per stage. |
| `project --plane p3` | Builds the filesystem view snapshot once. In `compose.yaml` it belongs to the `operations` profile. |
| `mounts --root PATH [--raw-root PATH] [--artifacts-root PATH]` | Publishes the latest snapshot under a local directory. |

The pipeline stages are explained in
[The pipeline and readiness](https://remember.dev/docs/concepts/pipeline); configuration in
[Configuration](https://remember.dev/docs/self-hosting/configuration); snapshots and mounts in
[Filesystem views](https://remember.dev/docs/self-hosting/filesystem-views).

## Credential file

`remember setup --self-hosted` stores the engine address and key in
`credentials.json`. The CLI and the Python SDK both read it, after flags (or
arguments) and environment variables.

Location, first match wins:

1. `REMEMBER_CONFIG_DIR`
2. `$XDG_CONFIG_HOME/remember/`
3. `~/.config/remember/`

Protection:

- The directory is created with mode `0700` and the file with mode `0600`.
  The file is written to a temporary name, synced and renamed, so a crash
  leaves either the old file or the new one.
- The CLI refuses to read a file that is a symbolic link or that group or
  other users can read, and exits with status 1. Fix it with
  `chmod 600 ~/.config/remember/credentials.json`.
- Commands that change the file take a lock (`.lock` in the same directory).

Format, version 2, for a self-hosted engine:

```json
{
  "version": 2,
  "api_url": "http://127.0.0.1:8000",
  "key": "<the engine's key, or null>",
  "issuer": null,
  "key_id": null,
  "expires_at": null,
  "default_project": null
}
```

A file in any other shape is refused. `remember setup --self-hosted` replaces
it.

## Environment variables

| Variable | Used by | Meaning |
|---|---|---|
| `REMEMBER_API_URL` | memory commands | Deployment address. |
| `REMEMBER_API_KEY` | memory commands | API key, ahead of the credential file. |
| `REMEMBER_PROJECT` | memory commands | Project id or name for a key that covers several projects. |
| `REMEMBER_CONFIG_DIR` | all | Credential file directory. |
| `XDG_CONFIG_HOME` | all | Base for the default credential directory. |
| `REMEMBERSTACK_INTERNAL_OPS`, `REMEMBER_INTERNAL_OPS` | `ops` | Enables `remember ops`. The engine image sets it. |
| `REMEMBERSTACK_MCP_INGEST_ROOTS`, `REMEMBERSTACK_MCP_PATH_READ_MAX_BYTES` | `mcp` | Path ingest; see [MCP tools](https://remember.dev/docs/reference/mcp#ingest-from-a-path). |
| `APPDATA` (Windows), `XDG_CONFIG_HOME` (Linux) | `setup`, `doctor` | Where Claude Desktop's configuration lives. |

Names are not case-sensitive. All client variables are in
[Configuration variables](https://remember.dev/docs/reference/configuration).

---

Source: https://remember.dev/docs/reference/mcp

# MCP tools

MCP (Model Context Protocol) is how coding agents call external tools.
**`remember mcp`** gives an agent access to your memory. It is part of the
`remember` package, runs next to the agent, and talks to one
deployment. It
speaks on standard input and output (the agent starts it), or over HTTP
(agents connect to a URL).

How to connect an agent step by step:
[Connect your coding agent](https://remember.dev/docs/start/connect-your-agent).

## `remember mcp`

```bash
remember mcp [--read-only] [--api-url URL] [--api-key KEY] [--project PROJECT]
remember mcp --transport http [--bind HOST:PORT] [--read-only] [--api-url URL]
```

By default the server reads JSON-RPC 2.0 messages from standard input, one
per line, and writes one response per line to standard output. It finds the
deployment and key the same way as every other `remember` command: flags,
then environment variables, then the credential file. See [CLI: How the CLI finds your deployment](https://remember.dev/docs/reference/cli#how-the-cli-finds-your-deployment).
`--transport http` serves the same tools over HTTP; see
[Over HTTP](#over-http).

`--read-only` leaves out the two tools that change memory, `ingest` and
`delete_document`, and refuses calls to them with the error `read_only`.
`pipeline_readiness` only reads, so it stays.

### Protocol

| Method | Behaviour |
|---|---|
| `initialize` | Requires `params.protocolVersion` (any string). Answers with `protocolVersion` `2025-11-25`, `capabilities: {"tools": {}}` and `serverInfo: {"name": "rememberstack", "version": "<package version>"}`. |
| `ping` | Answers `{}`. |
| `tools/list` | Returns the tool list; see below. |
| `tools/call` | Runs one tool. `params.name` is required; `params.arguments` must be an object when present. |
| Notifications (no `id`) | Accepted; no response. |
| Anything else | Error `-32601`. |

JSON-RPC error codes: `-32700` for a line that is not JSON, `-32600` for a
message that is not a valid request, `-32602` for bad `initialize` or
`tools/call` parameters, `-32603` for a failure while building the answer
(for example `tools/list` when the deployment cannot be reached or rejects
the key).

The server offers tools only: no resources and no prompts.

### How `tools/list` is built

The tool definitions — names, descriptions, input schemas — come from the
`remember` package itself, so every server that uses the package describes
the same tools the same way. Each tool has a version number that rises when
its arguments change in a way an older engine could not handle.

Every `tools/list` call reads `GET /deployment` and lists, in this order,
each tool the deployment reports at the same version:

1. `ingest`, `pipeline_readiness` and `delete_document` (only
   `pipeline_readiness` with `--read-only`).
2. The four assured operations: `resolve_entity`, `claims_and_sources_context`,
   `facts_context`, `combined_context`.
3. The seven SQL query tools, when the deployment serves the query space.

A tool the deployment does not serve, or serves at another version, is left
out: upgrade `remember` or the engine so their versions match. If
`GET /deployment` fails (unreachable, `401`, `403`), `tools/list` fails with
`-32603`, so a wrong key never looks like an empty tool list. A full server
lists 14 tools: 3 + 4 + 7.

Every tool carries MCP tool annotations:
`readOnlyHint` is `true` for a tool that only reads and `false` for
`ingest` and `delete_document`, and `destructiveHint` is `true` only for
`delete_document`.

The server serves one deployment, so no tool takes a `project` argument; a
call that passes one is refused with `project_routing_unavailable`. A call to
a name that is not one of these tools is refused with `unknown_tool`. Neither
reaches the deployment.

### Over HTTP

```bash
remember mcp --transport http --api-url http://127.0.0.1:8000
```

The server listens at `http://127.0.0.1:8765/mcp` (Streamable HTTP, the MCP
transport for servers an agent reaches by URL). `--bind HOST:PORT` changes
the address.

| Request | Behaviour |
|---|---|
| `POST /mcp` | One JSON-RPC message per request, answered with `application/json`; a notification gets `202` and no body. |
| `initialize` | A successful one opens a session: the answer carries an `Mcp-Session-Id` header. Every later request must send it back. |
| Missing `Mcp-Session-Id` | `400`. |
| `MCP-Protocol-Version` other than `2025-11-25` | `400`. The header is optional. |
| Unknown or expired session | `404`: initialize again. A session unused for an hour expires. |
| `DELETE /mcp` with `Mcp-Session-Id` | Ends the session (`204`). |
| `GET /mcp` | `405`: the server never sends messages of its own. |
| `Origin` header that is not the server's own address | `403`. This stops a web page from reaching the server through a hostname it controls. |
| More than 16 open connections | `503` with `Retry-After: 1`, and the connection is closed. An idle connection is closed after 30 seconds. |
| Body over 32 MiB | `413`. Only an `ingest` body sent as `content_base64` gets this big; use `remember ingest` for larger files. |
| A client that stops sending for 30 seconds | The connection is closed. |

The HTTP server holds no key. It passes each caller's `Authorization`
header to the engine unchanged with every call, so the engine decides what
each caller may do, and a caller without a key reaches the engine without
one. `tools/list` therefore shows every tool the deployment serves; a call
the caller's key may not make returns `insufficient_permission`.
`--api-key` and `--project` are refused with `--transport http`.

`ingest` does not offer the `path` body over HTTP: the caller may be on
another machine.

It listens on a loopback address only; `--bind` with any other address is
refused. To let agents on other machines reach it, put a reverse proxy in
front of it that authenticates callers and terminates TLS.

### Results

Every `tools/call` result has this shape:

```json
{
  "content": [{"type": "text", "text": "<JSON>"}],
  "isError": false
}
```

The text is a JSON document: the tool's result on success, an error object
when `isError` is `true`; see [Errors](#errors).

### `ingest`

Stores one document. Returns as soon as the bytes are stored; processing
takes minutes. Call `pipeline_readiness` before expecting the content in
results.

Give exactly one body: `text`, `content_base64` or `path`.

| Parameter | Type | Required | Limits and default |
|---|---|---|---|
| `text` | string | one body is required | Non-empty UTF-8 text. Needs `filename`. Default `mime`: the text type of the filename's extension (`.md` is `text/markdown`), else `text/plain`. |
| `content_base64` | string | | Standard base64, no `data:` prefix. Needs `filename`. Default `mime`: from the filename's extension (`.md` is `text/markdown`, `.pdf` is `application/pdf`; the full table is in [Ingest files](https://remember.dev/docs/guides/ingest-files#the-mime-type)), else `application/octet-stream`. |
| `path` | string | | A file on the machine running the server. Refused unless path ingest is enabled; see [Ingest from a path](#ingest-from-a-path). Default `filename`: the file's name. Default `mime`: from the extension of the file's real name, as for `content_base64`. |
| `filename` | string | with `text` or `content_base64` | 1–512 characters. |
| `mime` | string | no | 1–255 characters. An explicit value always wins over the defaults above. The engine processes only media types it has a converter for; see [File formats and converters](https://remember.dev/docs/self-hosting/converters). |
| `title` | string | no | At most 512 characters. |
| `source_kind` | string | no | 1–128 characters. Give with `source_ref`. |
| `source_ref` | string | no | 1–512 characters. Give with `source_kind`. The same pair later makes a new version of the same document. |
| `versioning_mode` | `"snapshot"` or `"living"` | no | Default `"snapshot"`. `"living"` needs `source_kind`/`source_ref`. |
| `source_modified_at` | string | no | ISO 8601 timestamp in UTC (`Z` or `+00:00`). Needs `source_kind`/`source_ref`. |
| `source_version_ref` | string | no | 1–512 characters. Needs `source_kind`/`source_ref`. |

Unknown keys are refused. The input schema enforces the one-body rule with
`oneOf`.

Success result:

```json
{
  "deployment_id": "…",
  "doc_id": "…",
  "version_id": "…",
  "content_hash": "…",
  "created": true,
  "mime": "text/markdown",
  "title": "…",
  "versioning_mode": "snapshot",
  "parked": null,
  "pipeline": {
    "status": "accepted_not_ready",
    "next_tool": "pipeline_readiness",
    "poll_with": {
      "version_ids": ["…"],
      "require": {"pipeline": true, "p1": true, "live_graph": true, "p3": false}
    },
    "guidance": "Ingest accepted. Wait until pipeline_readiness.ready is true …"
  }
}
```

`created: false` means these exact bytes were already stored; no new
processing starts, and one readiness check tells the agent whether the
content is already available.

`parked: "no_route"` means the file's conversion is parked waiting for a
conversion route for its media type. The bytes are stored, but they are not
read until an operator adds a route if needed and runs
`remember ops resume-no-route`. The reply then has `pipeline.status` `parked_no_route`, no
`next_tool` or `poll_with`, and guidance telling the agent to tell the user
instead of polling readiness.

The server does not check body size itself. The deployment refuses a body
over its limit, and the tool returns `body_too_large`.

#### Ingest from a path

The `path` body is off by default. To allow it, list the directories the
server may read:

```bash
export REMEMBERSTACK_MCP_INGEST_ROOTS='["/home/ravi/notes", "/srv/specs"]'
# or: REMEMBERSTACK_MCP_INGEST_ROOTS=/home/ravi/notes,/srv/specs
```

| Variable | Default | Meaning |
|---|---|---|
| `REMEMBERSTACK_MCP_INGEST_ROOTS` | empty: `path` refused | A JSON array or a comma-separated list of directories. |
| `REMEMBERSTACK_MCP_PATH_READ_MAX_BYTES` | `268435456` (256 MiB) | The largest file the server reads from a path. It protects the server process; it is not the deployment's upload limit. |

Rules for a path:

- `~` is expanded and the path is resolved fully, following symbolic links.
  The result must be inside a listed directory (`path_not_allowed`).
- The target must exist and be readable (`path_unreadable`).
- It must be a regular file, not a directory, pipe or device
  (`path_not_regular_file`).
- Its size is checked before and while reading (`path_too_large`).

The path is read on the machine running `remember mcp`, never on the engine
host.

### `pipeline_readiness`

Checks whether ingested versions are processed and ready to be found.

| Parameter | Type | Required | Limits |
|---|---|---|---|
| `version_ids` | array of strings | yes | 1–1,000 version UUIDs from `ingest`. |
| `require` | object | yes | Exactly four booleans: `pipeline`, `p1`, `live_graph`, `p3`. For ordinary use: `pipeline`, `p1` and `live_graph` true, `p3` false. |

The result is the readiness report: `ready`, `versions` (each with `stages`
and their `status`), `capabilities`, `model_bindings`, `build_revision`,
`document_binding_generation`. Field detail:
[Result types](https://remember.dev/docs/reference/result-types).

The tool description tells the agent how to poll: wait about 30 seconds after
ingest, then every 30–60 seconds; treat a `failed` stage as retrying and
keep polling; stop at once and report the version and stage when a stage is
`dead_letter`; stop and report after 20–30 minutes without `ready`. An
ingest that returned `created: false` is polled the same way, because an
earlier run of the same bytes may still be processing. See
[Wait until a document is queryable](https://remember.dev/docs/guides/wait-for-readiness#over-mcp).

### `delete_document`

Removes one document, every version of it, from the memory. Its claims stop
counting as evidence, and facts that only it supported are closed with a
recorded retraction; facts other documents also support stay. The claims and
the stored original are kept as history. Ingesting the same file again later
adds it back as a new version.

| Parameter | Type | Required | Limits |
|---|---|---|---|
| `doc_id` | string | yes | The document's UUID, as `ingest` returned it or a result cites it. No other arguments are accepted. |

The result is a [`DocumentDeletion`](https://remember.dev/docs/reference/result-types#documentdeletion):
`doc_id`, `deleted_at`, `claims_retired`, `relations_closed`,
`observations_closed`.

The tool description tells the agent to delete only when the user asks to
remove a specific document, or the document is plainly wrong or unwanted,
and never as a way to correct a fact: to correct one, ingest a document that
says the right thing. The token needs `write` scope. See
[`DELETE /documents/{doc_id}`](https://remember.dev/docs/reference/http-api/ingest#delete-documentsdoc_id).

### Assured operations

These four tools are the deployment's assured operations. Their input
schemas come from the `remember` package, as for every tool. Behaviour and
results: [Assured operations](https://remember.dev/docs/reference/assured-operations).

#### `resolve_entity`

Resolves a name to ranked candidate entities; never guesses silently.

| Parameter | Type | Required | Limits |
|---|---|---|---|
| `name` | string | yes | At least 1 character. |

#### `claims_and_sources_context`

What sources said: current claims and the source passages that confirm them.

| Parameter | Type | Required | Limits and default |
|---|---|---|---|
| `query` | string | yes | 1–8,192 characters. |
| `entity_ids` | array of UUID strings | no | 1–20, unique. |
| `k` | integer | no | 1–100; default 50. |
| `candidate_k` | integer | no | 1–400; default 200; not smaller than `k`. |

#### `facts_context`

What the memory holds true: relations and observations under a time scope,
with a bounded look at the entities' neighbourhood.

| Parameter | Type | Required | Limits and default |
|---|---|---|---|
| `query` | string | yes | 1–8,192 characters. |
| `entity_ids` | array of UUID strings | no | 1–19, unique. |
| `k` | integer | no | 1–30; default 15. |
| `evidence_per_fact` | integer | no | 1–5; default 3. |
| `hops` | integer | no | 1–2; default 1. |
| `predicate` | string | no | 1–200 characters. |
| `time` | object | no | Default `{"mode": "current"}`. See below. |

`time` is one of:

```json
{"mode": "current"}
{"mode": "at", "at": "2026-09-01T00:00:00Z"}
{"mode": "overlap", "from": "2026-07-01T00:00:00Z", "to": "2026-09-30T23:59:59Z"}
{"mode": "history"}
```

Timestamps are ISO 8601 date-times. What each mode means: [Time](https://remember.dev/docs/concepts/time).

#### `combined_context`

Both of the above in one call, returned as `ContextBundle/v2`.

| Parameter | Type | Required | Limits and default |
|---|---|---|---|
| `query` | string | yes | 1–8,192 characters. |
| `entity_ids` | array of UUID strings | no | 1–19, unique. |
| `hops` | integer | no | 1–2; default 1. |
| `predicate` | string | no | 1–200 characters. |
| `time` | object | no | As in `facts_context`. |

For every operation, unknown keys are refused, and integers must be whole
numbers.

### SQL query tools

SQL queries run over the query space (`memory_v1`): prepared, read-only views
and functions. The engine parses every statement and validates it against
the query space before it runs; anything outside it is rejected. See
[Query space memory_v1](https://remember.dev/docs/reference/query-space).

All seven tools refuse unknown keys, wrong types (including `null` for a
string field), booleans in place of integers, and out-of-range numbers.

| Tool | Parameters | Result |
|---|---|---|
| `query_sql` | `sql` (string, required); `parameters` (array of values for `$1`, `$2`, …); `max_rows` (integer ≥ 0) | `QueryResult/v1` |
| `explain_sql` | `sql` (string, required); `parameters` (array) | `QueryResult/v1` with the plan; the statement does not run |
| `describe_query_space` | `pattern` (string, shell-style filter over view names); `include_examples` (boolean, default false) | The query space description |
| `search_query_space` | `query` (string, required); `k` (integer 1–25, default 10) | A list of `{kind, name, score, purpose, tags}`; searches the query space's own documentation, never your data |
| `list_saved_queries` | `namespace` (identifier); `status` (string) | Saved-query summaries; without `status`, active versions only |
| `describe_saved_query` | `namespace`, `name` (identifiers, required); `version` (integer ≥ 1) | One saved query: SQL, parameters, declared columns, validation state |
| `run_saved_query` | `namespace`, `name` (identifiers, required); `version` (integer ≥ 1); `parameters` (array); `max_rows` (integer ≥ 0) | `QueryResult/v1` |

Identifiers (`namespace`, `name`) must match `^[a-z][a-z0-9_]*$`. See
[Saved queries](https://remember.dev/docs/guides/saved-queries).

### Errors

Errors come back as a tool result with `isError: true`, so the agent can read
them. Every tool returns the same JSON:

```json
{
  "error": {
    "code": "rate_limited",
    "status_code": 429,
    "detail": "…",
    "retryable": true,
    "agent_action": "Wait retry_after seconds (if given), then retry; …",
    "retry_after": 7
  }
}
```

| Field | Meaning |
|---|---|
| `code` | A stable code: the deployment's own code when it sent one (SQL query errors, `rate_limited`), else one of the codes below. |
| `status_code` | The deployment's HTTP status. `0` when no answer arrived; `null` when the call never reached the deployment (the server refused it itself). |
| `detail` | What went wrong. |
| `retryable` | Whether the same call can succeed later. |
| `agent_action` | What the agent should do next. |
| `retry_after` | Seconds to wait, when the deployment said. |
| `reason_code`, `request_id` | Present only when known. |

| `code` | `status_code` | `retryable` | When |
|---|---|---|---|
| `invalid_arguments` | `null` | no | Missing, unknown or malformed arguments; not exactly one body; bad base64; bad UUID; bad timestamp. |
| `unknown_tool` | `null` | no | The name is not one of the server's tools. |
| `project_routing_unavailable` | `null` | no | The call passed a `project` argument. |
| `read_only` | `null` | no | A tool that changes memory, on a `--read-only` server. |
| `source_lineage_pair` | `null` | no | Only one of `source_kind` and `source_ref`, or lineage-only fields without them. |
| `encoding_error` | `null` | no | `text` cannot be encoded as UTF-8. |
| `empty_body` | `null`, or the deployment's | no | The body is empty. |
| `path_not_allowed` | `null` | no | Path ingest is off, or the path is outside the listed directories, or contains a NUL byte. |
| `path_unreadable` | `null` | no | The path does not exist or cannot be read. |
| `path_not_regular_file` | `null` | no | Directory, pipe, device or other special file. |
| `path_too_large` | `null` | no | Larger than the read limit. |
| `document_not_found` | 404 | no | `delete_document`: no such document, or it is already deleted. |
| `forget_in_progress` | 503 | yes | `delete_document` while a hard forget is running. Nothing was deleted. |
| `body_too_large` | 413 | no | The deployment refused the body as too large. |
| `unauthorized` | 401 | no | The key is missing, wrong, expired or revoked. |
| `insufficient_permission` | 403 | no | The key may not do this on this deployment. |
| `rate_limited` | 429 | yes | The key or the deployment sent too many requests; wait `retry_after` seconds. |
| `concurrency_limited` | 429 | yes | Too many calls at once; wait `retry_after` seconds. |
| `transport_error` | 0 | yes | No answer from the deployment. |
| SQL query codes | as sent | as the code | A SQL query tool failed; the codes are in [Errors and status codes](https://remember.dev/docs/reference/errors). |
| `engine_client_error` | the 4xx status | no | Any other 4xx from the deployment. |
| `engine_unavailable` | the 5xx status | yes | A 5xx from the deployment. Retry 3–5 times with back-off (2 s to 30 s); then report an outage. |
| `local_backend_error` | `null` | no | The deployment's answer did not match the expected shape. |
| `internal_error` | `null` | no | An unexpected failure in the server. |

## Configuration that `remember setup` writes

[`remember setup`](https://remember.dev/docs/reference/cli#remember-setup) writes one `remember`
entry per agent, in one of these shapes:

| Shape | When | What the agent does |
|---|---|---|
| Stdio entry | A self-hosted engine | Starts `<launcher> <args>` with `REMEMBER_API_URL`; the server reads the key from the credential file. |
| URL entry | `--mcp-url`, engine without a key, agent accepts URL entries | Connects to your `remember mcp --transport http` server. |
| URL entry with key header | `--mcp-url` with `--api-key`, agent can refer to a variable in a header | Connects to that URL and sends `Authorization: Bearer` with the value of `REMEMBER_API_KEY` from its own environment. |

Claude Code and Codex take URL entries only when their installed command
line supports them (see [`remember setup`](https://remember.dev/docs/reference/cli#remember-setup));
otherwise they get the stdio entry. A key is never written into any of
these files: a URL entry names the `REMEMBER_API_KEY` variable, never its
value.

In a stdio entry, `<launcher>` and `<args>` are one of:

| Situation | `command` | `args` |
|---|---|---|
| A `remember` binary installed outside a virtual environment or cache | absolute path of `remember` | `["mcp"]` |
| Otherwise, when `uvx` is installed | absolute path of `uvx` | `["remember", "mcp"]` |

A stdio entry's `env` block holds exactly one variable: `REMEMBER_API_URL`
for a self-hosted engine (default `http://127.0.0.1:8000`).

Existing files are merged: other servers and settings are kept, and the
`remember` entry is replaced. A file whose content would not change is not
rewritten, and a rewritten file keeps its permissions.

### Cursor

`<project>/.cursor/mcp.json`:

```json
{
  "mcpServers": {
    "remember": {
      "command": "/Users/ravi/.local/bin/remember",
      "args": ["mcp"],
      "env": {
        "REMEMBER_API_URL": "http://127.0.0.1:8000"
      }
    }
  }
}
```

A URL entry is `{ "url": "http://127.0.0.1:8765/mcp" }`. With a key header:

```json
{
  "mcpServers": {
    "remember": {
      "url": "http://127.0.0.1:8765/mcp",
      "headers": { "Authorization": "Bearer ${env:REMEMBER_API_KEY}" }
    }
  }
}
```

Cursor replaces `${env:REMEMBER_API_KEY}` with the variable's value when it
connects.

It also writes a rule file, `<project>/.cursor/rules/remember.mdc`,
replacing any earlier version:

```markdown
---
description: Use Remember bitemporal memory for codebase facts, architecture, and past decisions
globs: *
alwaysApply: false
---
# Remember Memory Integration

Before making architectural decisions, refactoring core subsystems, or answering
questions about past codebase designs, consult Remember bitemporal memory via the
available MCP tools (`facts_context`, `combined_context`, `claims_and_sources_context`, `resolve_entity`, `query_sql`).

## Retrieval Discipline
1. **Resolve entities first:** Use `resolve_entity` to obtain canonical entity IDs for people, projects, modules, or concepts.
2. **Query facts first:** Use `facts_context` (with `time.mode="history"` for historical context or achievements) as the primary authority for adjudicated truth.
3. **Fall back to claims only when needed:** Use `claims_and_sources_context` if facts are missing or verbatim source text is required.
4. **Use `query_sql`** to run sandboxed SQL against `facts_current` or `graph_edges_current`.

## Temporal Semantics
- `valid_from` / `valid_until`: When the fact was true in the real world. Granularity is given by `valid_precision` (`instant`, `day`, `month`, `quarter`, `year`, `open`, `unknown`). `open` indicates an ongoing state with a known start date and no recorded end date.
- `asserted_at`: Strictly when the source made the statement (message sent / page published). Unresolved relative phrases in claim text (e.g. "last week", "yesterday") are relative to `asserted_at`. Never confuse speech time (`asserted_at`) with event validity (`valid_from`/`valid_until`).
- Check past decisions and bitemporal validity before asserting assumptions.
- Never guess historical rationale when it is recorded in Remember.
```

### Claude Code

`remember setup` runs Claude Code's own command in the project directory,
after removing any earlier `remember` entry with
`claude mcp remove --scope local remember`:

```bash
claude mcp add --scope local remember -e REMEMBER_API_URL=http://127.0.0.1:8000 -- /Users/ravi/.local/bin/remember mcp
```

A URL entry is added with
`claude mcp add --scope local --transport http remember <url>`. Claude Code
does not get the key-header shape; with a key it gets the stdio entry. When
`claude` is not on `PATH`, `remember setup` prints the command for you to
run.

### Claude Desktop

Always a stdio entry, as JSON like Cursor's (the `mcpServers.remember` entry, no rule file),
merged into Claude Desktop's configuration file:

| System | File |
|---|---|
| macOS | `~/Library/Application Support/Claude/claude_desktop_config.json` |
| Windows | `%APPDATA%\Claude\claude_desktop_config.json` |
| Linux | `$XDG_CONFIG_HOME/Claude/claude_desktop_config.json`, default `~/.config/Claude/claude_desktop_config.json` |

Restart Claude Desktop after the change.

### Codex

`<project>/.codex/config.toml`:

```toml
[mcp_servers.remember]
command = "/Users/ravi/.local/bin/remember"
args = ["mcp"]

[mcp_servers.remember.env]
REMEMBER_API_URL = "http://127.0.0.1:8000"
```

A URL entry is `url = "<url>"` in that table; with a key header it adds
`bearer_token_env_var = "REMEMBER_API_KEY"`.

Any earlier `[mcp_servers.remember]` and `[mcp_servers.remember.*]` tables
are removed first; the rest of the file is kept. Codex loads a project's
`.codex/config.toml` only when you trust the project in Codex.

### Antigravity

`<project>/.agents/mcp_config.json`, always with a stdio
`mcpServers.remember` entry like Cursor's, and a skill file `<project>/.agents/skills/remember/SKILL.md`,
replacing any earlier version:

```markdown
---
name: remember
description: Open bitemporal memory infrastructure for AI agents. Use when looking up past decisions, system architecture, factual evidence, or attested codebase knowledge.
---

# Remember Bitemporal Memory Skill

You have access to Remember, an open bitemporal memory infrastructure for AI agents.
Use the Remember MCP tools (`facts_context`, `combined_context`, `claims_and_sources_context`, `resolve_entity`, `query_sql`, `describe_query_space`)
to query past system decisions, architectural records, and entity-relationship knowledge graphs.

## Preferred Retrieval Flow
1. **Entity resolution first (`resolve_entity`)**: When an inquiry involves a named person, organization, module, file, or concept, resolve it first with `resolve_entity` to obtain the canonical `entity_id`.
2. **Fact layer first (`facts_context`)**: Query `facts_context` (anchored by `entity_ids` when available, or by semantic text query) as the primary authority for established facts, biography, attributes, relationships, and history.
   - Use `time.mode="history"` for biography, achievements, and "has ever" questions so historical and completed facts remain visible.
   - Use `time.mode="current"` or `"at"` for what holds at an instant, and `"overlap"` for a requested interval.
3. **Sources fallback (`claims_and_sources_context`)**: Only fall back to `claims_and_sources_context` if `facts_context` lacks the answer, or if the inquiry specifically demands verbatim quotes, speaker dialogue details, or raw source context.
4. **Combined context (`combined_context`)**: Use when both adjudicated facts and source claims are needed side by side.

## Dates and Temporal Semantics
Do not collapse distinct temporal dimensions into a single generic date:

- **Facts carry `validity` with `valid_from`, `valid_until`, and `valid_precision`:**
  - `valid_from` / `valid_until`: Real-world event or state validity ("When did this happen or hold true in the world?"). Answer event-time questions using these bounds.
  - `valid_precision`: The granularity of the validity window (`instant`, `day`, `month`, `quarter`, `year`, `open`, or `unknown`).
  - `open`: Represents an ongoing state with a known start date and no recorded end date (still true/current).
  - `unknown`: No usable real-world date was given in the source. Undated facts are clean prose without temporal bracket annotations.
- **Evidence rows (claims) carry `asserted_at`:**
  - `asserted_at`: Strictly **when the source made this statement** (when the message was sent, conversation occurred, or page was published).
  - Unresolved relative phrases: If claim text still contains a relative phrase (*"last week"*, *"yesterday"*, *"two months ago"*), evaluate it relative to that row's `asserted_at`.
  - **Never confuse speech time (`asserted_at`) with real-world event validity (`valid_from` / `valid_until`).**
- **System transaction timestamps (`ingested_at`, `invalidated_at`):**
  - Record when the database learned or superseded the record. Never present system ingestion time as an event or conversation date.
```

`remember doctor` checks the Cursor, Antigravity, Codex and Claude Desktop
files; see [CLI](https://remember.dev/docs/reference/cli#remember-doctor).

---

Source: https://remember.dev/docs/reference/configuration

# Configuration variables

This page lists every environment variable that RememberStack's engine and
the `remember` package read. [Configuration](https://remember.dev/docs/self-hosting/configuration)
explains how they reach the containers.

The variables in **Client** are read by the `remember` CLI, Python client and
MCP server on your own machine. Every other section applies to a self-hosted engine.

The shipped `compose.yaml` hands every variable in `.env` to the engine
containers. It sets three itself, overriding `.env`:
`REMEMBERSTACK_DATABASE_URL`, `REMEMBERSTACK_MINIO_ENDPOINT_URL` and the
container's `REMEMBERSTACK_SELFHOST_API_PORT`
([Configuration](https://remember.dev/docs/self-hosting/configuration#what-compose-passes-through)).

Booleans accept `true`/`false` (also `1`/`0`). An empty value counts as
unset for optional settings.

## Client

| Variable | Default | Type | Purpose |
|---|---|---|---|
| `REMEMBER_API_URL` | `http://127.0.0.1:8000` | URL | The deployment endpoint. |
| `REMEMBER_API_KEY` | unset | secret | The API key, raw or as `Bearer …`. |
| `REMEMBER_PROJECT` | unset | id or name | The project to use when the key covers several. |
| `REMEMBER_CONFIG_DIR` | `$XDG_CONFIG_HOME/remember` or `~/.config/remember` | path | Where `credentials.json` lives. |
| `REMEMBERSTACK_INTERNAL_OPS` | `false`; `true` in the engine image | bool | Enables `remember ops`, which works only inside the engine image. Also read as `REMEMBER_INTERNAL_OPS`. |
| `REMEMBERSTACK_MCP_INGEST_ROOTS` | empty | JSON array or comma-separated paths | Directories from which the local MCP server may ingest a file by path. Empty refuses path ingest. |
| `REMEMBERSTACK_MCP_PATH_READ_MAX_BYTES` | `268435456` | int > 0 | Largest file the local MCP server reads for path ingest. |
| `XDG_CONFIG_HOME` | unset | path | Standard base for `REMEMBER_CONFIG_DIR` and for agent configuration that `remember setup` writes. |
| `APPDATA` | unset | path | Windows base for agent configuration that `remember setup` writes. |

The CLI and the Python client read `REMEMBER_API_KEY`, `REMEMBER_API_URL` and
`REMEMBER_PROJECT` the same way: a flag or argument wins over the variable,
and the variable wins over the credential file
([CLI](https://remember.dev/docs/reference/cli#how-the-cli-finds-your-deployment)).

## Compose file only

Read by `compose.yaml` from `.env`, not by the engine.

| Variable | Default | Type | Purpose |
|---|---|---|---|
| `REMEMBERSTACK_POSTGRES_USER` | required | string | PostgreSQL user; also used to build `REMEMBERSTACK_DATABASE_URL`. |
| `REMEMBERSTACK_POSTGRES_PASSWORD` | required | string | PostgreSQL password. `.env.example` ships none; generate it at install. |
| `REMEMBERSTACK_POSTGRES_DB` | required | string | PostgreSQL database name. |
| `REMEMBERSTACK_POSTGRES_MAX_CONNECTIONS` | `300` | int | Read by Compose only. PostgreSQL `max_connections`. The default stack's ceiling is 215 connections; see [Database connections](https://remember.dev/docs/self-hosting/scaling#database-connections). |
| `REMEMBERSTACK_BUILD_REVISION` | empty | string | Build argument when Compose builds the app image; stamped into it and reported by `GET /deployment` as `build_revision`. |

## Deployment and API

| Variable | Default | Type | Purpose |
|---|---|---|---|
| `REMEMBERSTACK_DATABASE_URL` | required | SQLAlchemy URL | PostgreSQL connection, `postgresql+psycopg://user:password@host:5432/db`. Compose builds it from the `REMEMBERSTACK_POSTGRES_*` values. |
| `REMEMBERSTACK_SELFHOST_DEPLOYMENT_ID` | required | UUID | The deployment's identity; `.env.example` ships none, generate it at install. Fixed after the first start. |
| `REMEMBERSTACK_SELFHOST_DEPLOYMENT_SLUG` | `local` | string | Short name. Fixed after the first start. Default Sentry environment. |
| `REMEMBERSTACK_SELFHOST_DEPLOYMENT_NAME` | `Local memory` | string | Display name. Fixed after the first start. |
| `REMEMBERSTACK_SELFHOST_DEFAULT_LANGUAGE` | `en` | string | Default language of the deployment. Fixed after the first start. |
| `REMEMBERSTACK_SELFHOST_API_HOST` | `0.0.0.0` | string | Address the API listens on inside the container. |
| `REMEMBERSTACK_SELFHOST_API_PORT` | `8000` | int 1–65535 | Port inside the container (Compose sets `8000`). In `.env` it is the host port Compose publishes. |
| `REMEMBERSTACK_SELFHOST_API_PUBLISH_ADDRESS` | `127.0.0.1` | host address | Read by Compose only. Host address Compose publishes the API port on. Set a token before changing it; see [Opening the API to other machines](https://remember.dev/docs/self-hosting/authentication#opening-the-api-to-other-machines). |
| `REMEMBERSTACK_SELFHOST_INGEST_BODY_MAX_BYTES` | unset (no cap) | int > 0 | Largest `POST /ingest` body; larger uploads get `413`. |
| `REMEMBERSTACK_SELFHOST_BROWSER_ORIGINS` | empty | comma-separated https origins; http only on `localhost`, `127.0.0.1` or `[::1]` | Browser origins allowed by CORS. |
| `REMEMBERSTACK_SELFHOST_TRUSTED_PRINCIPAL_SOURCE` | `false` | bool | Believe `X-Ingest-Principal-*` headers on `POST /ingest`. |
| `REMEMBERSTACK_SELFHOST_API_ADMISSION_KEY_PER_MINUTE` | unset (no limit) | int ≥ 0; `0` = no limit | Optional. Requests per minute per credential (bursts of a quarter of it). Over it: `429` `rate_limited`. |
| `REMEMBERSTACK_SELFHOST_API_ADMISSION_KEY_IN_FLIGHT` | unset (no limit) | int ≥ 0; `0` = no limit | Optional. Requests running at once per credential. Over it: `429` `concurrency_limited`. |
| `REMEMBERSTACK_SELFHOST_API_ADMISSION_DEPLOYMENT_PER_MINUTE` | unset (no limit) | int ≥ 0; `0` = no limit | Optional. Requests per minute for the whole deployment (bursts of a quarter of it). |
| `REMEMBERSTACK_SELFHOST_API_ADMISSION_DEPLOYMENT_IN_FLIGHT` | unset (no limit) | int ≥ 0; `0` = no limit | Optional. Requests running at once for the whole deployment. |

## Storage

| Variable | Default | Type | Purpose |
|---|---|---|---|
| `REMEMBERSTACK_MINIO_ENDPOINT_URL` | required | URL | S3-compatible endpoint. Compose sets `http://object-store:8333`. |
| `REMEMBERSTACK_MINIO_ACCESS_KEY` | required | secret | Object-store access key; also the bundled SeaweedFS store's admin access key. `.env.example` ships none; generate it at install. |
| `REMEMBERSTACK_MINIO_SECRET_KEY` | required | secret | Object-store secret key; also the bundled SeaweedFS store's admin secret key. `.env.example` ships none; generate it at install. |
| `REMEMBERSTACK_MINIO_REGION` | `us-east-1` | string | S3 region name used for signing. |
| `REMEMBERSTACK_SELFHOST_RAW_BUCKET_NAME` | `remember-raw` | string | Bucket for original files. Fixed after the first start. |
| `REMEMBERSTACK_SELFHOST_ARTIFACTS_BUCKET_NAME` | `remember-artifacts` | string | Bucket for converted Markdown and artifacts. Fixed after the first start. |
| `REMEMBERSTACK_SELFHOST_CORPUSFS_BUCKET_NAME` | `remember-corpusfs` | string | Bucket for filesystem-view snapshots. Fixed after the first start. |
| `REMEMBERSTACK_SELFHOST_RAW_MOUNT_ROOT` | unset | path | Default `--raw-root` for `mounts`. |
| `REMEMBERSTACK_SELFHOST_ARTIFACTS_MOUNT_ROOT` | unset | path | Default `--artifacts-root` for `mounts`. |
| `REMEMBERSTACK_SELFHOST_PROJECTION_WORK_ROOT` | `/var/lib/rememberstack/projection-work` | path | Working directory created by `setup`. |
| `REMEMBERSTACK_SELFHOST_FORGET_MANIFEST_ROOT` | `/var/lib/rememberstack/forget-manifests` | path | Where hard-forget manifests are read. |
| `REMEMBERSTACK_SELFHOST_MIGRATION_CONFIG` | `alembic.ini` | path | Alembic configuration used by `setup`. |

## Authentication

| Variable | Default | Type | Purpose |
|---|---|---|---|
| `REMEMBERSTACK_SELFHOST_API_BEARER_TOKEN` | unset | secret | Shared secret with `write` scope. Setting it turns authentication on. |
| `REMEMBERSTACK_SELFHOST_API_BEARER_BIND` | unset | `<deployment-uuid>:<sha256-hex>` | The shared secret's digest instead of the secret. Must match the token if both are set. |
| `REMEMBERSTACK_SELFHOST_REQUIRE_API_AUTH` | `false` | bool | Refuse to start unless the token, the bind or the key issuer is set. |
| `REMEMBERSTACK_SELFHOST_API_KEY_ISSUER` | unset | URL | The key issuer, matched exactly against `iss`. Setting it turns signed keys on and requires the tenant id and both URLs below. |
| `REMEMBERSTACK_SELFHOST_API_KEY_TENANT_ID` | unset | string | The issuer's tenant id for this deployment. Keys carry `aud = org:<this>` and `org = <this>`. |
| `REMEMBERSTACK_SELFHOST_API_KEY_PROJECT_ID` | the deployment id | string | The issuer's project id for this deployment, matched against a key's `projects`. |
| `REMEMBERSTACK_SELFHOST_API_SIGNING_KEYS_URL` | unset | http(s) URL | Where the issuer's JWKS of Ed25519 public keys is fetched. |
| `REMEMBERSTACK_SELFHOST_API_REVOCATION_URL` | unset | http(s) URL | Where the issuer's signed revocation document for this deployment is fetched. |
| `REMEMBERSTACK_SELFHOST_API_KEY_REFRESH_S` | `60` | float > 0 | Seconds between fetches of the key set and the revocation document. |
| `REMEMBERSTACK_SELFHOST_API_REVOCATION_MAX_AGE_S` | `3600` | float > 0 | Maximum age of the accepted revocation document; past it every signed key is refused. |

See [Authentication and scopes](https://remember.dev/docs/self-hosting/authentication).

## Pools and workers

| Variable | Default | Type | Purpose |
|---|---|---|---|
| `REMEMBERSTACK_SELFHOST_WORKER_RATE_PER_S` | `20.0` | float > 0 | Claims per second per worker process. |
| `REMEMBERSTACK_SELFHOST_WORKER_BURST` | `20.0` | float ≥ 1 | Claim burst per worker process. |
| `REMEMBERSTACK_SELFHOST_WORKER_FALLBACK_POLL_S` | `5.0` | float > 0 | Poll interval when no notification arrives. |
| `REMEMBERSTACK_SELFHOST_WORKER_SESSION_S` | `3600.0` | float > 0 | Length of one worker session. |
| `REMEMBERSTACK_SELFHOST_RETRIEVAL_POOL_SIZE` | `4` | int 1–32 | Connections for retrieval in the API. |
| `REMEMBERSTACK_SELFHOST_RETRIEVAL_POOL_TIMEOUT_S` | `1.0` | float, max 30 | Wait for a retrieval connection. |
| `REMEMBERSTACK_SELFHOST_RETRIEVAL_MAX_CONCURRENCY` | `4` | int 1–32, ≤ pool size | Retrievals at once. |
| `REMEMBERSTACK_SELFHOST_GRAPH_POOL_SIZE` | `4` | int 1–32 | Connections for graph queries in the API. |
| `REMEMBERSTACK_SELFHOST_GRAPH_POOL_TIMEOUT_S` | `1.0` | float, max 30 | Wait for a graph slot. |
| `REMEMBERSTACK_SELFHOST_GRAPH_MAX_CONCURRENCY` | `2` | int 1–32, ≤ pool size | Graph queries at once. |
| `REMEMBERSTACK_SELFHOST_GRAPH_WORK_MEM_KIB` | `16384` | int 64–65536 | PostgreSQL `work_mem` per graph query. |
| `REMEMBERSTACK_WORK_RETRY_BACKOFF_BASE_S` | `2.0` | float | First retry delay; doubles per attempt. |
| `REMEMBERSTACK_WORK_RETRY_BACKOFF_MAX_S` | `60.0` | float | Longest retry delay. |
| `REMEMBERSTACK_WORK_BUDGETS` | `[]` | JSON list | Spend ceilings per deployment, stage and lane ([Operating](https://remember.dev/docs/self-hosting/operating#spend-budgets)). |
| `REMEMBERSTACK_E1_EMBED_BATCH_SIZE` | `64` | int 1–512 | Chunks per embedding request. |
| `REMEMBERSTACK_P1_EMBED_BATCH_SIZE` | `64` | int 1–1024 | Claims or facts per embedding request. |

## Model provider

| Variable | Default | Type | Purpose |
|---|---|---|---|
| `REMEMBERSTACK_OPENROUTER_API_KEY` | required | string | OpenRouter key for every model call. |
| `REMEMBERSTACK_OPENROUTER_BASE_URL` | `https://openrouter.ai/api/v1` | URL | OpenRouter API address. |
| `REMEMBERSTACK_OPENROUTER_TIMEOUT_S` | `120.0` | float > 0 | Per-request timeout. |
| `REMEMBERSTACK_OPENROUTER_MAX_COMPLETION_TOKENS` | `32000` | int ≥ 1 | Output allowance per chat call, reasoning included. |
| `REMEMBERSTACK_OPENROUTER_CHAT_PROVIDER_ORDER` | unset | comma-separated or JSON list | Preferred chat providers, with fallback. |
| `REMEMBERSTACK_OPENROUTER_CHAT_PROVIDER_ONLY` | unset | comma-separated or JSON list | Only these chat providers, no fallback. Exclusive with the order. |
| `REMEMBERSTACK_OPENROUTER_EMBEDDING_PROVIDER_ORDER` | unset | comma-separated or JSON list | Preferred embedding providers, with fallback. |
| `REMEMBERSTACK_OPENROUTER_EMBEDDING_PROVIDER` | unset | string | The one embedding provider, no fallback. The order wins if both are set. |
| `REMEMBERSTACK_OPENROUTER_ZDR` | `false` | bool | Only zero-data-retention endpoints for chat. |
| `REMEMBERSTACK_OPENROUTER_REASONING_EFFORT` | unset | `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max` | Reasoning effort for every chat call. |
| `REMEMBERSTACK_OPENROUTER_REASONING_EFFORT_MAP` | unset | JSON object | Reasoning effort per model id; wins over the global value. |
| `REMEMBERSTACK_OPENROUTER_CHAT_THROTTLE_RETRIES` | `3` | int ≥ 0 | Retries of a chat call after HTTP 429, outside the work attempts. |
| `REMEMBERSTACK_OPENROUTER_CHAT_UPSTREAM_OVERLOAD_MAX_RETRY_AFTER_S` | `30.0` | float > 0 | Longest wait before retrying an overloaded provider. |
| `REMEMBERSTACK_OPENROUTER_INVALID_COMPLETION_CAPTURE_DIR` | unset | absolute path | Debug only: keep schema-invalid model answers as files. |
| `REMEMBERSTACK_TYPESAFE_API_KEY` | unset | string | TypeSafe AI key, required with the `jev` fact engine. Also read as `TYPESAFE_AI_API_KEY`. |
| `REMEMBERSTACK_TYPESAFE_MODEL` | `jev-latest` | string | TypeSafe model. |
| `REMEMBERSTACK_TYPESAFE_BASE_URL` | `https://api.typesafe.ai/v1` | URL | TypeSafe API address. |
| `REMEMBERSTACK_TYPESAFE_TIMEOUT_S` | `30.0` | float | TypeSafe request timeout. |

## Model seats

See [Models and providers](https://remember.dev/docs/self-hosting/models).

| Variable | Default | Type | Purpose |
|---|---|---|---|
| `REMEMBERSTACK_STRUCTURER_MODEL` | `openai/gpt-5.6-luna` | model id | Structure fallback: section anchors when headings are unusable. |
| `REMEMBERSTACK_STRUCTURER_MAX_PROMPT_CHARS` | `200000` | int ≥ 1000 | Largest prompt sent to the structure fallback. |
| `REMEMBERSTACK_STRUCTURER_MIN_HEADING_DENSITY_PER_10K` | `0.25` | float ≥ 0 | Structure check: minimum headings per 10,000 characters. |
| `REMEMBERSTACK_STRUCTURER_MAX_OVERSIZED_LEAF_RATIO` | `0.75` | float 0–1 | Structure check: largest share of oversized sections. |
| `REMEMBERSTACK_STRUCTURER_MIN_BLOCKS_FOR_LLM` | `8` | int ≥ 1 | Accepted for compatibility; not used. |
| `REMEMBERSTACK_SKELETON_CHECK_MODEL` | `z-ai/glm-4.7-flash` | model id | Checks a proposed section outline. |
| `REMEMBERSTACK_ROLE_MODEL` | `z-ai/glm-4.7-flash` | model id | Classifies section roles. |
| `REMEMBERSTACK_SUMMARY_MODEL` | `z-ai/glm-4.7-flash` | model id | Section summaries. |
| `REMEMBERSTACK_E2_EXTRACT_MODEL` | `openai/gpt-5.6-luna` | model id | Claim selection and extraction. |
| `REMEMBERSTACK_E3_NORMALIZE_MODEL` | `openai/gpt-5.6-luna` | model id | Relation normalisation. |
| `REMEMBERSTACK_OBS_SMALL_MODEL` | `openai/gpt-5.6-luna` | model id | Entity resolution. |
| `REMEMBERSTACK_FACT_MODEL` | `openai/gpt-5.6-luna` | model id | Fact adjudication with the `prompt` engine. |
| `REMEMBERSTACK_FACT_ADJUDICATION_ENGINE` | `prompt` | `prompt` or `jev` | Fact adjudication engine. Also read as `REMEMBERSTACK_FACT_ENGINE`. |
| `REMEMBERSTACK_FACT_CONFIDENCE_FLOOR` | `0.75` | float 0–1 | Below this, both the new statement and the existing fact are kept. |
| `REMEMBERSTACK_FACT_FALLBACK_TO_PROMPT` | `false` | bool | Fall back to `prompt` when a `jev` call fails. Also read as `REMEMBERSTACK_TYPESAFE_FALLBACK_TO_PROMPT`. |
| `REMEMBERSTACK_P1_EMBEDDING_MODEL` | `qwen/qwen3-embedding-8b` | model id, 1,536-dim output | Every embedding, including search queries. Fixed once anything is embedded. |

## Converters

See [File formats and converters](https://remember.dev/docs/self-hosting/converters).

| Variable | Default | Type | Purpose |
|---|---|---|---|
| `REMEMBERSTACK_SELFHOST_CONVERSION_ROUTES` | Markdown and plain text → `passthrough`; HTML, `.docx`, `.pptx`, `.xlsx` → `markitdown` ([the table](https://remember.dev/docs/self-hosting/converters#the-default-table)) | JSON object | MIME type → converter. Replaces the default table. |
| `REMEMBERSTACK_MISTRAL_OCR_API_KEY` | unset | secret | Mistral key; required by `mistral_ocr` and `image_ocr_description`. |
| `REMEMBERSTACK_MISTRAL_OCR_BASE_URL` | `https://api.mistral.ai` | URL | Mistral API address. |
| `REMEMBERSTACK_MISTRAL_OCR_MODEL` | `mistral-ocr-latest` | string | OCR model. |
| `REMEMBERSTACK_MISTRAL_OCR_TIMEOUT_S` | `300` | float > 0 | OCR request timeout. |
| `REMEMBERSTACK_MISTRAL_OCR_MAX_DOCUMENT_BYTES` | `50000000` | int > 0 | Largest file sent to OCR. |
| `REMEMBERSTACK_MISTRAL_OCR_INCLUDE_IMAGES` | `true` | bool | Keep embedded images. |
| `REMEMBERSTACK_MISTRAL_OCR_TABLE_FORMAT` | `markdown` | `markdown` or `html` | Table format. |
| `REMEMBERSTACK_MISTRAL_OCR_EXTRACT_HEADERS_AND_FOOTERS` | `true` | bool | Extract headers and footers separately. |
| `REMEMBERSTACK_MISTRAL_OCR_CONFIDENCE_GRANULARITY` | `word` | `word` or `page` | Confidence score detail. |
| `REMEMBERSTACK_MISTRAL_OCR_KEEP_PROVIDER_RESPONSE` | `true` | bool | Keep the provider response as an artifact. |
| `REMEMBERSTACK_MISTRAL_OCR_PRICE_USD_PER_1000_PAGES` | `1` | decimal ≥ 0 | Price used to record OCR cost. |
| `REMEMBERSTACK_IMAGE_DESCRIPTION_API_KEY` | unset | secret | OpenRouter key for image descriptions; required by `image_ocr_description`. |
| `REMEMBERSTACK_IMAGE_DESCRIPTION_BASE_URL` | `https://openrouter.ai/api/v1` | URL | API address. |
| `REMEMBERSTACK_IMAGE_DESCRIPTION_MODEL` | `google/gemini-2.5-flash` | model id | Vision model; must accept image input. |
| `REMEMBERSTACK_IMAGE_DESCRIPTION_TIMEOUT_S` | `120.0` | float > 0 | Request timeout. |
| `REMEMBERSTACK_IMAGE_DESCRIPTION_MAX_IMAGE_BYTES` | `10000000` | int > 0 | Largest image. |
| `REMEMBERSTACK_IMAGE_DESCRIPTION_MAX_IMAGE_PIXELS` | `40000000` | int > 0 | Largest width × height. |
| `REMEMBERSTACK_IMAGE_DESCRIPTION_MAX_IMAGE_WIDTH` | `16000` | int > 0 | Largest width. |
| `REMEMBERSTACK_IMAGE_DESCRIPTION_MAX_IMAGE_HEIGHT` | `16000` | int > 0 | Largest height. |
| `REMEMBERSTACK_IMAGE_DESCRIPTION_MAX_DESCRIPTION_CHARS` | `16000` | int > 0 | Longest description. |
| `REMEMBERSTACK_IMAGE_DESCRIPTION_MAX_TOKENS` | `4096` | int ≥ 1 | Output allowance of the description call. |
| `REMEMBERSTACK_IMAGE_DESCRIPTION_LANE_CONCURRENCY` | `2` | int 1–2 | OCR and description in parallel (2) or in turn (1). |

## Operations

| Variable | Default | Type | Purpose |
|---|---|---|---|
| `REMEMBERSTACK_OPERATIONAL_SAMPLE_LIMIT` | `20` | int 1–1000 | Cap on lists in `remember ops inspect`. |
| `REMEMBERSTACK_P3_SNAPSHOT_PREFIX` | `corpusfs/snapshots` | string | Object prefix of filesystem-view snapshots. |
| `REMEMBERSTACK_P3_FACETS` | `["by-source", "by-time", "by-topic"]` | JSON array | Top-level view directories of the corpus tree. |
| `REMEMBERSTACK_P3_SHARD_THRESHOLD` | `150` | int ≥ 2 | Entries above which a directory is split. |
| `REMEMBERSTACK_COST_EXPORT_BIND` | unset | `host:port`, `[ipv6]:port` or `unix:/path` | Address of the HTTP cost export. Unset: off. |
| `REMEMBERSTACK_COST_EXPORT_TOKEN` | unset | secret, ≥ 32 bytes | Bearer token for the cost export. |

## Observability

| Variable | Default | Type | Purpose |
|---|---|---|---|
| `REMEMBERSTACK_SENTRY_DSN` | unset | secret | Sentry-protocol DSN; unset keeps error tracking off. |
| `REMEMBERSTACK_SENTRY_ENVIRONMENT` | deployment slug | string | Environment name on events. |
| `REMEMBERSTACK_SENTRY_SAMPLE_RATE` | `1.0` | float 0–1 | Share of error events sent. |
| `REMEMBERSTACK_LANGFUSE_ENABLED`, `_HOST`, `_PUBLIC_KEY`, `_SECRET_KEY`, `_CA_FILE`, `_RUN_TAG` | `false` / empty | | Read only by the benchmark harness; no effect on the engine. |
| `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECRET_KEY`, `LANGFUSE_HOST` | empty | | Read only by the benchmark harness; no effect on the engine. |

## Knowledge plane and other unused settings

These are read by code in the engine that no shipped command runs. Setting
them has no effect on a Compose deployment.

| Variables | Read by |
|---|---|
| `REMEMBERSTACK_K_PLANNER_PLANNER_MODEL`, `_PLANNER_MODEL_FAMILY`, `_REFLECTION_MODEL`, `_REFLECTION_MODEL_FAMILY`, `_TIMEOUT_SECONDS`, `_AUTO_APPLY_MAX_EXPECTED_IMPACT`, `_TRANSCRIPT_PREFIX` | Knowledge-page planner |
| `REMEMBERSTACK_K_WRITER_MODEL`, `_TIMEOUT_SECONDS`, `_RESIDUE_CLAIM_LIMIT`, `_EVIDENCE_CLAIMS_PER_FACT`, `_TRANSCRIPT_PREFIX` | Knowledge-page writer |
| `REMEMBERSTACK_K_DRIVER_MAX_PARALLEL_PAGES` | Knowledge-page driver |
| `REMEMBERSTACK_K_CODEX_EXECUTABLE`, `REMEMBERSTACK_K_WRITER_CODEX_EXECUTABLE` (default `codex`) | Knowledge-page agent adapter |
| `REMEMBERSTACK_SYNC_DEBOUNCE_QUIET_SECONDS` (default `120`), `REMEMBERSTACK_SYNC_LANE` (default `steady`) | Watched-directory sync loop |
| `REMEMBERSTACK_BACKFILL_BATCH_SIZE` (default `500`) | Backfill seeder |
| `REMEMBERSTACK_VERTEX_PROJECT_ID`, `_LOCATION` (default `global`), `_TIMEOUT_S`, `_MAX_COMPLETION_TOKENS` (default `128000`), `_PRICE_TABLE_USD_PER_MILLION`, `_THROTTLE_RETRY_DELAYS_S` | Google Vertex model adapter, used by the benchmark harness |

---

Source: https://remember.dev/docs/reference/errors

# Errors and status codes

This page collects every error a client can meet: HTTP statuses from a
deployment, the codes SQL queries report,
the structured errors the MCP tools return, and the status values that appear in
results.

A "no" that is an answer — nothing matched, the entity is unknown, a limit
was reached — is not an error. It comes back as `200` with a typed
[`negative`](https://remember.dev/docs/reference/result-types#negativekind) in the envelope. Read that first
when a result is empty.

## Error shapes

A deployment's error body always has one key, `detail`:

| Form | Example |
|---|---|
| String | `{"detail": "body_too_large"}` |
| Object with `code` | `{"detail": {"code": "forget_in_progress"}}` |
| Object with `code` and `message` | `{"detail": {"code": "invalid_parameter", "message": "unknown argument(s): limit"}}` |
| Validation list | `{"detail": [{"type": "less_than_equal", "loc": ["query", "k"], "msg": "Input should be less than or equal to 400", "input": "500", "ctx": {"le": 400}}]}` |

Branch on the status and the code or string, not on `message`.

The `remember` Python client raises `remember.MemoryApiError` for all of
them, with `status_code` (`0` for a network failure), `detail` (the string, or
the message) and `code` (set on `/query/*` routes when the body carries a
known code at its expected status). A `429` raises its subclass
`remember.RateLimited`, whose `code` is `rate_limited` or
`concurrency_limited` and whose `retry_after` holds the `Retry-After`
seconds. `wait_for_readiness` raises
`remember.PipelineDeadLettered` when a stage it waits on is `dead_letter`
([Python SDK](https://remember.dev/docs/reference/python-sdk#pipelinedeadlettered)).

## Deployment HTTP statuses

| Status | `detail` | Meaning | What to do |
|---|---|---|---|
| `400` | `cursor is malformed` | `GET /documents` got a cursor it cannot read. | Restart paging without a cursor. |
| `401` | `a perimeter credential is required` | No `Authorization` header, and the deployment requires one. | Send `Authorization: Bearer <token>`. |
| `401` | `perimeter authentication failed` | The credential is not accepted: wrong secret, bad signature, expired, revoked, or unknown signing key. | Get a fresh token. |
| `403` | `credential is for another deployment` | The credential is valid for a different deployment. | Use the token issued for this deployment, or the right `REMEMBER_API_URL`. |
| `403` | `credential may not perform this operation` | The credential's scope does not cover the route (for example a `read` credential on `POST /ingest` or `DELETE /documents/{doc_id}`, or an `ingest` credential on `GET /deployment`). | Use a `write` credential. See [Scopes](https://remember.dev/docs/reference/http-api#scopes). |
| `404` | `Not Found` | No such route (includes `/connectors*` and `/openapi.json`, which a stock deployment does not serve). | Check the path. |
| `404` | `document_not_found` | `DELETE /documents/{doc_id}` for a document that does not exist or is already deleted. | Do not retry; check the id with `GET /documents`. |
| `404` | the operation name | `POST /operations/{name}` with a name that is not one of the four. | Use `GET /operations` for the names. |
| `404` | `{"code": "saved_query_not_found", …}` | No saved query or version by that name. | Check `GET /query/saved`. |
| `405` | `Method Not Allowed` | Right path, wrong method. | Check the method. |
| `409` | `source_forgotten` | `POST /ingest` of bytes or a source identity (`source_kind` + `source_ref`) that a hard forget removed. A forget is permanent. | Do not retry; the deployment will not take this content back. |
| `409` | `{"code": "saved_query_…", …}` | A saved query cannot run. See [SQL query codes](#sql-query-codes). | See the code. |
| `411` | `length_required` | `POST /ingest` without `Content-Length` on a deployment that caps bodies. | Send the length (curl and the `remember` client do). |
| `413` | `body_too_large` | The ingest body is over the cap. | Split the document. |
| `422` | validation list | A parameter or body field is missing, of the wrong type, out of range, or not declared. Includes a `valid_at` or `believed_at` without a zero UTC offset, and an unknown `status` on `GET /query/saved`. | Fix the request; `loc` names the field. |
| `422` | `{"code": "invalid_parameter", …}` | An operation argument or SQL-query argument was refused. | Fix the argument named in `message`. |
| `422` | `source_kind and source_ref must be supplied together` | Ingest with half a source identity. | Send both or neither. |
| `422` | `source timestamps, revisions, and living mode require source_kind/source_ref` | Ingest lineage options without a source identity. | Add `source_kind` and `source_ref`. |
| `422` | `source_modified_at must be timezone-aware UTC` | Ingest timestamp without an offset or with a non-zero one. | Send it with `Z`. |
| `422` | `X-Ingest-Principal-Kind and X-Ingest-Principal-Ref must be supplied together` | Trusted attribution with one header. | Send both or neither. |
| `422` | `invalid_ingest_principal` | Trusted attribution with an unknown kind or bad reference. | Kind is `user`, `api_credential` or `service`; reference is 1–255 printable ASCII characters. |
| `429` | `{"code": "rate_limited", …}` | The credential or the deployment has used its request rate ([admission limits](https://remember.dev/docs/reference/http-api#admission-limits), off unless configured). | Wait the `Retry-After` seconds, then retry. |
| `429` | `{"code": "concurrency_limited", …}` | Too many requests of the credential or the deployment are running at once. | Wait for your other requests to finish (`Retry-After` is `1`), then retry. |
| `500` | `Internal Server Error` | An unhandled failure: a defect. | Report it. |
| `503` | `{"code": "forget_in_progress"}` | A hard forget is running; the deployment accepts no traffic until it finishes. | Retry later. |
| `503` | `model provider unavailable` | A search, lookup, resolve or assured-operation call could not embed its query. | Retry with back-off. |
| `503` | `live graph is busy` | No graph traversal slot was free in time. | Retry with back-off. |
| `503` | `live graph result unavailable` | A traversal and the rows it pointed at disagreed. | Retry. |
| `503` | `live graph timed out` | A traversal ran past its time limit, or its database connection failed. | Retry with back-off; narrow `hops`, `max_hops` or `predicates` if it persists. |
| `503` | `{"code": "…_unavailable", …}` | A store needed by a SQL query was unavailable. See [SQL query codes](#sql-query-codes). | Retry with back-off. |

## SQL query codes

SQL queries report problems with one of 27 codes. For the statement routes
(`POST /query/sql`, `POST /query/sql/explain`,
`POST /query/saved/{namespace}/{name}/run`), a problem with the statement
itself comes back as `200` with the code in the result's `error_code` and
`termination_reason` `rejected` or `failed`. A code raised outside the
statement — a saved query that cannot run, a refused discovery argument — is
an HTTP error at the status below, with
`{"detail": {"code": "…", "message": "…"}}`.

| Code | HTTP status | Meaning | What to do |
|---|---|---|---|
| `parse_error` | 422 | The SQL does not parse, or contains a NUL byte. | Fix the syntax. |
| `multiple_statements` | 422 | More than one statement. | Send one statement. |
| `statement_not_allowed` | 422 | Not a read-only `SELECT`/`VALUES`/`WITH`, or uses a construct outside the grammar (row locks, `SELECT INTO`, `TABLESAMPLE`, `WITHIN GROUP`, a reserved `__rememberstack_` name). | Rewrite within the [grammar](https://remember.dev/docs/reference/query-space#what-a-statement-may-contain). |
| `relation_not_allowed` | 422 | A table, view or schema outside `memory_v1`. | Use the [views](https://remember.dev/docs/reference/query-space#views). |
| `function_not_allowed` | 422 | A function not on the allowlist (including `now()`), a qualified function outside `memory_v1`/`pg_catalog`, a table function, an XML expression or a session keyword. | Use an [allowed function](https://remember.dev/docs/reference/query-space#built-in-functions); pass times as parameters. |
| `function_placement_not_allowed` | 422 | A public function outside a top-level `FROM` item, packed with others in `ROWS FROM`, or given a computed argument. | Follow the [placement rules](https://remember.dev/docs/reference/query-space#public-function-placement). |
| `operator_not_allowed` | 422 | An operator or cast type outside the allowlist. | Use an allowed operator or cast. |
| `invalid_parameter` | 422 | Wrong parameter count, non-contiguous placeholders, too many or too large parameters, SQL text over 65,536 bytes, a bad filter, `k` below 1, a graph function without `$1` as this deployment's id, or a graph call with only one clock. | Fix the parameters. |
| `unbounded_recursion` | 422 | A recursive CTE that does not follow the template, or more than one. | Follow the [recursion template](https://remember.dev/docs/reference/query-space#recursion). |
| `schema_version_mismatch` | 409 | The database's `memory_v1` views do not match the server's manifest. | An operator must finish the upgrade (migrations). |
| `quota_exceeded` | 409 | More than 3 function calls of one category, the statement's 200-candidate search budget is spent, the per-minute statement-time budget is spent, or the operator has disabled SQL queries. | Simplify the statement or wait a minute. |
| `concurrency_exceeded` | 409 | Too many statements running for this caller (2) or deployment (8). | Retry after the others finish. |
| `saved_query_not_found` | 404 | No saved query or version by that name, or the deployment has no registry. | Check `GET /query/saved`. |
| `saved_query_disabled` | 409 | The saved query is disabled, or the version is a draft, deprecated or broken. | Run an active version. |
| `saved_query_incompatible` | 409 | A saved-query version failed a validation or activation check (raised by the deployment's authoring tooling, not by the HTTP routes). | Revalidate the version. |
| `saved_query_revalidation_pending` | 409 | The query space changed since the version was validated. | Revalidate the saved query. |
| `statement_timeout` | 500 | The statement ran past its timeout. | Narrow the statement, add filters or a `LIMIT`. |
| `lock_timeout` | 500 | A lock was not available within the lock timeout. | Retry. |
| `cancelled` | 500 | The statement was cancelled. Not produced by this release. | Retry. |
| `resource_limit` | 500 | Memory, temporary-file or connection limits were exceeded. | Narrow the statement. |
| `execution_error` | 500 | The statement failed while running (for example a bad cast of a parameter), or the saved-query registry could not be read. | Check parameter values and casts. |
| `pg_unavailable` | 503 | The database is unavailable. | Retry with back-off. |
| `p1_unavailable` | 503 | The search index could not be searched or read, or no embedder is configured. | Retry; check readiness (`p1`). |
| `graph_unavailable` | 503 | A graph function returned no usable status. | Retry; check readiness (`live_graph`). |
| `corpus_body_unavailable` | 503 | Chunk text could not be read. Not produced by this release. | Retry. |
| `generation_unavailable` | 503 | The search index has no usable embedding generation, or requested chunks span more than one. | Retry after indexing completes; fetch chunks from one generation. |
| `confirmation_failed` | 500 | Nominated rows could not be confirmed against the database. | Retry. |

When a code arrives inside a `200` result, `termination_reason` is `rejected`
if the statement was refused before the engine opened a transaction (parse
and grammar codes, `unbounded_recursion`, parameter-count and size problems,
`quota_exceeded`, `concurrency_exceeded`, `schema_version_mismatch`), and
`failed` if it was refused or failed after that (bad search filters,
timeouts, store codes).

## MCP tool errors

The MCP server returns a tool failure as a normal tool result with
`"isError": true` and one JSON text block. Every tool — `ingest`,
`pipeline_readiness`, `delete_document`, the assured operations and the SQL
query tools — returns the same object:

```json
{
  "error": {
    "code": "body_too_large",
    "status_code": 413,
    "detail": "Ingest body exceeds the deployment size limit.",
    "retryable": false,
    "agent_action": "Split or shorten the document; do not retry the same payload."
  }
}
```

`status_code` is the deployment's HTTP status, `0` when no answer arrived,
and `null` when the call never reached the deployment. `reason_code`,
`request_id` and `retry_after` (seconds) appear only when known. When the
deployment sent its own code — a SQL query code from the tables above, or
`rate_limited` / `concurrency_limited` — `code` is that code.

| `code` | `status_code` | `retryable` | `agent_action` |
|---|---|---|---|
| `invalid_arguments` | `null` | no | Fix the tool arguments and retry. (Or, when more than one body was given: Supply exactly one body source: path, text, or content_base64.) |
| `unknown_tool` | `null` | no | Call tools/list and use one of the listed tools. |
| `project_routing_unavailable` | `null` | no | Call the tool again without `project`. |
| `read_only` | `null` | no | Tell the user this server cannot change memory. |
| `source_lineage_pair` | `null` | no | Send both source_kind and source_ref, or neither. (Or: Provide source_kind and source_ref together with lineage fields.) |
| `empty_body` | `null`, or the deployment's | no | Provide non-empty path / text / content_base64 content. |
| `encoding_error` | `null` | no | Remove lone surrogates / invalid code points, or send content_base64 for binary. |
| `path_not_allowed` | `null` | no | Pass a clean filesystem path without NUL characters. / Use text or content_base64, or ask the operator to configure REMEMBERSTACK_MCP_INGEST_ROOTS. Do not retry path until roots are set. / Place the file under an allowlisted root, or use text/content_base64. Ask the operator to extend roots only when intentional. |
| `path_not_regular_file` | `null` | no | Point path at a regular file, or send text/content_base64. |
| `path_unreadable` | `null` | no | Pass a regular filesystem file path. / Check path on the machine running the MCP server (not the remote engine host). |
| `path_too_large` | `null` | no | Split the file, raise the local resource guard only if intentional, or use a deployment that publishes a higher capability limit. / Split the file or raise the configured read cap. |
| `document_not_found` | 404 | no | Do not retry. Check the doc_id; if the user meant this document, it is already gone from memory. |
| `forget_in_progress` | 503 | yes | Retry the same delete later with back-off; the deployment accepts no changes until the forget finishes. |
| `body_too_large` | 413 | no | Split or shorten the document; do not retry the same payload. |
| `unauthorized` | 401 | no | The key is missing, expired or revoked: run `remember login` or replace REMEMBER_API_KEY. |
| `insufficient_permission` | 403 | no | The key may not do this here: use a key with the needed permission for this deployment. |
| `rate_limited`, `concurrency_limited` | 429 | yes | Wait retry_after seconds (if given), then retry; do not retry sooner. Lower the request rate or run fewer calls at once. |
| SQL query codes | as sent, or `null` when refused before sending | for `quota_exceeded`, `concurrency_exceeded`, timeouts and store codes | Retry later with back-off. / Read the detail and fix the query or its arguments. |
| `engine_client_error` | the HTTP status (4xx) | no | Read the detail; fix the call. Do not retry it unchanged. |
| `engine_unavailable` | the HTTP status (5xx) | yes | Retry with back-off (3–5 attempts, 2s→30s). If still failing, report an operator outage. |
| `transport_error` | 0 | yes | Retry with back-off; check the deployment URL and network. |
| `local_backend_error` | `null` | no | Report a composition/contract defect; do not retry the same call. |
| `internal_error` | `null` | no | Unexpected internal failure. Do not busy-retry; report the error (and any request_id) to an operator or as a product defect. |

### JSON-RPC errors

| Code | Message | Cause |
|---|---|---|
| `-32700` | the parse error | The request line is not JSON. |
| `-32600` | `invalid JSON-RPC request` / `request is not an object` | Not a JSON-RPC 2.0 request. |
| `-32601` | `unknown method '<name>'` | A method other than `initialize`, `ping`, `tools/list`, `tools/call`. |
| `-32602` | `bad initialize params` / `bad params` / `bad arguments` | Malformed parameters. |
| `-32603` | the error text | `tools/list` could not read the deployment (unreachable, or the key was refused). |

See [MCP tools](https://remember.dev/docs/reference/mcp).

## Status values

### Document version status

| Value | Meaning |
|---|---|
| `ingesting` | Stored; conversion not started. |
| `converting` | Being converted to Markdown. |
| `structuring` | Being split into sections. |
| `ready` | Converted and structured. Extraction may still be running; use readiness. |
| `failed` | Conversion or structuring failed; `error` says why. |
| `deleted` | Deleted. Not listed by `GET /documents`. |

### Pipeline stage status

| Value | Meaning |
|---|---|
| `missing` | No work exists yet for this stage and component version. |
| `pending` | Queued. |
| `running` | In progress. |
| `succeeded` | Done. |
| `skipped` | Not needed for this version; counts as done. |
| `failed` | The last attempt failed and a retry is scheduled. Keep waiting. |
| `dead_letter` | Failed for good: every attempt is used. Stop waiting and report it. The Python client's `wait_for_readiness` raises `remember.PipelineDeadLettered` here, naming the version and stage. |

### Readiness capability reasons

See [Ingest](https://remember.dev/docs/reference/http-api/ingest#post-readiness).

### Saved query status

| Value | Runs? | Meaning |
|---|---|---|
| `draft` | no | Written, not activated. |
| `pending_revalidation` | no | Was active; the query space changed and it must be revalidated. |
| `active` | yes | The version that runs. |
| `deprecated` | no | Replaced by a newer version. |
| `disabled` | no | Turned off. |
| `broken` | no | Failed validation. |

### SQL query termination reason

`completed`, `rejected`, `failed`. See [SQL queries](https://remember.dev/docs/reference/http-api/query#how-results-and-errors-come-back).

### Connector status

`active`, `paused`, `error`. The connector routes are not served by a stock
deployment in this release.

---

Source: https://remember.dev/docs/project/benchmarks

# Benchmarks and how we measure

A memory benchmark feeds a system a long history, then asks questions whose
answers are somewhere in it. The score says how often the system's answers
are right. The number depends on much more than the memory: the model that
reads the retrieved context, the model that grades the answers, the prompt,
the retrieval tools the reader may call, and the exact engine build. A score
means something only next to all of those.

RememberStack's harness therefore fixes all of them in a named **protocol**,
checks that the running engine matches it, and refuses to run otherwise.

The dependence runs one way that matters when you read any score, ours
included: a weak answer model or a clumsy harness lowers accuracy even when
the memory holds every answer. A wrong answer has two possible causes, and
they need different fixes:

- **Retrieval**: the evidence for the gold answer never reached the answer
  model.
- **Answering**: the evidence was in what the model retrieved, and it
  answered wrong anyway, or chose a tool that could not find it.

Diagnose them separately. The run directory keeps, for every question, the
gold evidence, each tool call with its result, and the answer, so you can
check which of the two failed before blaming either the memory or the
model.

**Warning:**

No score has been recorded for the current protocol,
`RS-LoCoMo-Full-v38`. The numbers under [Recorded results](#recorded-results)
belong to earlier protocols and earlier engine builds. Do not compare them
with other systems' published scores.

## LoCoMo

LoCoMo (Maharana et al., 2024) is a set of 10 long conversations between two
people, each spread over many sessions, with questions about what was said.
The pinned copy holds 272 sessions, 5,882 turns and 1,986 questions in five
categories.

The harness keeps categories 1 to 4 (1,540 questions) and leaves out
category 5, whose questions are adversarial and have no gold answer:

| Category | Questions | What it tests |
|---|---:|---|
| 1 | 282 | Multi-hop: combining several facts. |
| 2 | 321 | Temporal: when things happened. |
| 3 | 96 | Open-domain and commonsense inference. |
| 4 | 841 | Single-hop: one fact. |

Three fixed question sets (tiers) are committed with the harness:

| Tier | Questions | Use |
|---|---:|---|
| `smoke` | 8 | Checking that a run works end to end. |
| `development` | 200 | Comparing changes during development. |
| `publication` | 1,540 | The full set; the only tier a published score may come from. |

The dataset file is `locomo10.json` from the LoCoMo repository at commit
`3eb6f2c585f5e1699204e3c3bdf7adc5c28cb376`. The harness checks its SHA-256
(`79fa87e9…a698ff4`) and refuses any other file.

### How a LoCoMo run works

The harness lives in `benchmarks/locomo/` and runs as
`python -m benchmarks.locomo`.

1. **Prepare** (local, no calls). Validates the dataset, selects the tier's
   questions, fixes the protocol and records the repository revision in a
   run directory.
2. **Ingest**, per conversation. Checks that the deployment runs the
   engine build, pipeline component versions and model bindings the
   protocol requires, then uploads each session as one Markdown document
   with a stable `source_kind`/`source_ref`.
3. **Answer**, per conversation, after processing has finished. An answer
   model gets each question and may call retrieval tools: the four assured
   operations, eight lower-level lookups (`resolve`, `lookup_relations`,
   `transcript_relation`, `lookup_observations`, `search_claims`,
   `search_chunks`, `adjacent_chunks`, `hydrate_relation`), the seven SQL
   query tools, and three tools over the filesystem view snapshot. It may
   make at most 8 tool calls and 9 model calls per question.
4. **Judge**, per conversation. A judge model labels each answer `CORRECT`
   or not against the gold answer.
5. **Summarize** (local). Scores the whole tier. A question with no answer
   or no judgement counts as wrong.

Remote stages run only with `--execute`, a clean git worktree at the
prepared revision, and a confirmation flag that repeats the conversation id.
Each stage takes hard ceilings on calls and on evaluator spend.

The summary reports:

- **Judge accuracy**: the share of questions labelled `CORRECT`. This is the
  headline number.
- **Official F1**: LoCoMo's token-overlap F1 between answer and gold answer.
- Both per category, plus model calls, retries, tokens, evaluator cost and
  failure counts. Ingest cost is not in the summary; it is on the
  deployment's cost ledger.

### The current protocol

| Setting | `RS-LoCoMo-Full-v38` (key `full-v38`) |
|---|---|
| Answer model | `openai/gpt-5.6-luna`, reasoning effort `none`, temperature 0 |
| Judge model | `openai/gpt-5.6-luna`, reasoning effort `none`, temperature 0, one judgement per answer |
| Tool calls per question | at most 8 |
| Model calls per question | at most 9 |
| Answer length cap | none |
| Ingest models | `openai/gpt-5.6-luna` for every generative step; `qwen/qwen3-embedding-8b` for embeddings (served through OpenRouter by Nebius) |
| Query space | pinned by its manifest hash |

Three variants share the v38 ingest and retrieval setup and change one
thing:

| Key | Change |
|---|---|
| `full-v38-gemma-vertex` | The answer model is Gemma 4 26B served on Google Vertex. |
| `full-v38-codex-subscription` | Answer and judge run through a Codex subscription. |
| `full-v38-glm` | Ingest uses GLM models instead of Luna. |

Scores from different protocols or variants are not comparable.

## BEAM

BEAM ("Beyond a Million Tokens", Tavakoli et al., ICLR 2026) tests memory
over very long chat histories. Its probing questions cover abilities such
as information extraction, contradiction resolution, event ordering and
abstention, and each question comes with rubric items ("nuggets").

The harness in `benchmarks/rs_harness_beam/` has three commands:

| Command | What it does |
|---|---|
| `answer-retrieval` | Answers the probing questions of a prepared run directory against a running engine, using `combined_context` and SQL queries, with an OpenRouter model (default `openai/gpt-5.6-luna`). |
| `score-official` | The BEAM paper's scorer: an LLM judge scores every nugget 0, 0.5 or 1; event-ordering questions also get Kendall's τ-b. Default rubrics: the committed 100K/1 fixture. |
| `score` | A simple containment check, kept as a placeholder. |

Limits of the BEAM harness today:

- It has no ingest step. The run directory (`questions.json`, `state.json`)
  and the ingested conversation must be prepared by other means.
- The answer agent contains search hints written for the committed 100K/1
  fixture, so its answers on that fixture are not a fair measurement.
- `score-official` prints an `overall_mean` that is not a BEAM metric;
  BEAM reports per-ability scores.

BEAM is used for internal checks only. No BEAM score is published.

## Recorded results

These are all the scores recorded in the repository (`plan/analysis/`,
`design/benchmarks/`, `decisions.md`). Each belongs to its protocol and
engine revision.

| Date | Protocol | Engine revision | Tier | Judge accuracy | Official F1 |
|---|---|---|---|---|---|
| 2026-07-31 | `RS-LoCoMo-Full-v5-strong` | extractor `07j` | publication | 517 / 1,540 (33.6%) | 0.3054 |
| before 2026-08-10 | `RS-LoCoMo-Full-v8-strong` | `0ef54549` | publication, assembled from two partial runs | 1,100 / 1,540 (71.43%) | not recorded |
| 2026-08-10 | `RS-LoCoMo-Full-v11` | `213551c7` | publication | 979 / 1,540 (63.57%) | 0.5417 |
| 2026-09-01 | `RS-LoCoMo-Full-v18` | not recorded | smoke (conversation 26) | 7 / 8 | not recorded |
| 2026-08-07 | BEAM smoke, 100K/1 fixture | not recorded | 1 conversation | `overall_mean` ≈ 0.5625 (not a BEAM metric) | n/a |

The v8 and v11 runs used different answer tools and are not directly
comparable. The analysis of the drop between them points mainly at the
answer agent choosing a weaker retrieval tool for 393 questions, rather than
at worse ingestion. It is in
`plan/analysis/locomo_v11_score_regression_analysis.md`.

No run of `RS-LoCoMo-Full-v38`, or of any protocol after v18, is recorded.
When one is, it will appear here with its protocol, revision, tier, cost
and failure counts.

## Reproduce a LoCoMo run

You need a clone of `writeitai/remember-stack` with the development
dependencies, a self-hosted engine you can wipe, and an OpenRouter key for
the answer and judge models.

**Warning:**

A benchmark ingest must go to a fresh, empty deployment. The sharding
script below deletes and recreates the Docker Compose project it runs
against. Never point it at a deployment that holds real data.

```bash
git clone https://github.com/writeitai/remember-stack.git
cd remember-stack
uv sync
```

Start the engine with the protocol's model bindings; see
[Install with Docker Compose](https://remember.dev/docs/self-hosting/install) and
[Models and providers](https://remember.dev/docs/self-hosting/models). Point the harness at it:

```bash
export REMEMBER_API_URL=http://127.0.0.1:8000
export REMEMBER_API_KEY="<token>"
export REMEMBERSTACK_OPENROUTER_API_KEY=<openrouter key>
```

Then, stage by stage, for one conversation (`conv-26`) of the smoke tier:

```bash
uv run python -m benchmarks.locomo prepare \
  --dataset /path/to/locomo10.json --tier smoke \
  --output .benchmark-runs/smoke --protocol full-v38

uv run python -m benchmarks.locomo ingest \
  --run .benchmark-runs/smoke --sample conv-26 \
  --max-documents 100 --max-evaluator-cost-usd 5 \
  --execute --confirm-isolated-deployment conv-26

# Wait until every document is processed. Then build and publish the
# filesystem view snapshot (see Filesystem views) and pass its directory
# as --p3-root below.

uv run python -m benchmarks.locomo answer \
  --run .benchmark-runs/smoke --sample conv-26 \
  --p3-root /path/to/p3 --max-questions 8 --max-agent-calls 72 \
  --max-evaluator-cost-usd 5 --execute

uv run python -m benchmarks.locomo judge \
  --run .benchmark-runs/smoke --sample conv-26 \
  --max-judge-calls 8 --max-evaluator-cost-usd 5 --execute

uv run python -m benchmarks.locomo summarize --run .benchmark-runs/smoke
```

Run `ingest`, `answer` and `judge` once per conversation in the tier. For the
publication tier, `summarize` accepts several `--run` directories and checks
that they belong to the same protocol and do not overlap.

The maintained path for full runs is the sharding script, which wipes the
stack, ingests, waits for processing, answers, judges and backs up each
conversation in turn:

```bash
LOCOMO_PROTOCOL=full-v38 LOCOMO_MAX_EVALUATOR_COST_USD=60 \
  bash benchmarks/locomo/sharding/run_shard.sh conv-26 .benchmark-runs/my-run /path/to/locomo10.json
```

It takes a comma-separated list of conversation ids. Its limits (tier,
questions, calls, cost, drain timeout) are set with `LOCOMO_*` environment
variables at the top of the script.

---

Source: https://remember.dev/docs/project/changelog

# Releases and changelog

Each release publishes the `remember` Python package to PyPI and the engine
image `ghcr.io/writeitai/remember-stack:<version>` to GitHub's container
registry. Release tags are `v<major>.<minor>.<patch>` on
`github.com/writeitai/remember-stack`. Up to 0.16.0 the Python package was
published as `rememberstack`.

The engine is pre-1.0. A minor version can change the API; changes that
need action from you are marked **Action needed**.

## 0.17.0 (22 September 2026)

**Existing deployments must be recreated.** The migrations for mutable fact
windows and multi-span claim evidence refuse a database that already holds
claims. Start from an empty deployment and send your sources again; see
[Upgrades and migrations](https://remember.dev/docs/self-hosting/upgrades).

**One package: `remember`.**

- **Action needed.** The Python client, the CLI and the MCP server ship in
  one PyPI package, `remember`. The old `rememberstack` package is
  deprecated: its final version, 0.17.0, only forwards to `remember` and
  warns on import. Run `pip install remember` and change
  `import rememberstack` to `import remember`.
- **Action needed.** The engine is no longer installed with pip. Run it from
  the container image `ghcr.io/writeitai/remember-stack`.
- The `remember` package depends only on `httpx`, `pydantic` and
  `pydantic-settings`.
- New client class `remember.Client` (also `remember.RememberClient`) with an
  `api_key` argument, `from_env()`, `ingest_file()` and path support in
  `ingest()`.

**Assured operations renamed.**

- **Action needed.** `testimony_context` is now `claims_and_sources_context`,
  `fact_context` is now `facts_context`, and `answer_context` is now
  `combined_context`. `resolve_entity` is unchanged. The combined result is
  now `ContextBundle/v2`, whose evidence part is called
  `claims_and_sources`. Update names in HTTP calls, SDK calls, CLI commands
  and agent prompts.

**CLI.**

- New commands: `remember setup` (writes MCP configuration for Cursor,
  Claude Code, Claude Desktop, Codex and Antigravity), `remember doctor`.
- `remember query "<question>"` is short for `remember query text`, which
  runs `facts_context` (or `combined_context` with `--combined`).
- `remember query adjacent-chunks`.
- `remember review` and `remember budget` are retired.

**Retrieval.**

- New `adjacent_chunks`: read the passages immediately before and after a chunk
  (`GET /chunks/{id}/adjacent`, `POST /chunks/adjacent`, SDK, CLI).
- Name resolution at query time tries exact aliases, then fuzzy name
  matches, then profile similarity, and returns no candidate, one, or an
  explicit ambiguity.
- Fact labels no longer carry a bracketed date suffix; dates stay in the
  structured validity fields.

**Processing.**

- Claims can cite several separate spans of their source (multi-span
  evidence).
- In conversations, answers such as "Yep, that one" are resolved to what the
  question referred to, keeping ordinals and specific names.
- Relative dates ("last Friday") are written into the claim text as resolved
  dates.
- Images always get OCR and a visual description.
- Originals are kept, and documents whose media type has no converter are
  parked instead of failing; `remember ops resume-no-route` releases them
  once a route exists.
- Fact adjudication engine is configurable; OpenRouter requests rotate
  between providers when one is overloaded.

## 0.16.0 (2026-09-03)

- A narrow ingest-only token scope for write access without read access.
- The deployment publishes its OpenAPI schema.
- **Behaviour change.** Dated events never merge across different dates.
- Claim validity windows use half-open bounds (`[from, until)`).

## 0.15.0 (2026-09-03)

- `GET /documents` lists what the deployment holds.
- `POST /search/claims` and `POST /search/chunks` take the query in the
  request body, so it does not appear in URLs or access logs.
- A deployment can name the browser origins allowed to call it.

## 0.14.0 (2026-09-02)

Internal changes only; no changes to the self-hosted engine or the client.

## 0.13.0 (2026-09-01)

Internal changes only; no changes to the self-hosted engine or the client.

## 0.12.0 (2026-09-01)

- The API can verify signed tokens, not only compare shared secrets.

## 0.11.0 (2026-09-01)

- Version bump; no user-visible change recorded.

## 0.10.0 (2026-09-01)

- **Behaviour change.** Entity resolution no longer holds back names that
  look like generic identifiers.
- Exact matches found earlier in the same document are reused.

## 0.9.0 (2026-08-31)

- Each document version records who ingested it.

## 0.8.1 (2026-08-31)

- Entity matching makes one yes-or-no decision per candidate, biased toward
  matching.

## 0.8.0 (2026-08-29)

- Conversion routes (media type to converter) are configurable, including a
  `mistral_ocr` route for scanned documents.
- An optional ingest body size limit, enforced before the body is buffered.

## 0.7.0 to 0.7.3 (2026-08-28)

- Uncertain entity identities are kept as uncertain and converge later
  instead of being guessed. The patch releases fix lock contention in that
  process.

## 0.6.0 (2026-08-27)

- The default facts context is built from the entity's neighbourhood plus
  fact text.

## 0.5.0 (2026-08-27)

- The graph moves to PostgreSQL 19 SQL/PGQ and is read live instead of from
  snapshots.
- Entity profiles backed by evidence.

## 0.4.0 to 0.4.3 (2026-08-13 to 2026-08-26)

- Cost export for operators.
- Claim and fact search runs in PostgreSQL (0.4.2).
- `text/plain` passes through the stock converter routes (0.4.2).
- The MCP server keeps its write tools when a deployment serves no
  operation list (0.4.1).

## 0.3.0 (2026-08-11)

- SQL queries over the `memory_v1` query space, with saved queries.
- The assured context operations.
- MCP tools `ingest` and `pipeline_readiness`.

## 0.2.0 (2026-07-30) and 0.1.0 (2026-07-23)

- First public releases: the full processing pipeline in Docker Compose,
  optional error tracking and tracing, and the LoCoMo benchmark harness.

---

Source: https://remember.dev/docs/project/not-built-yet

# What is not built yet

This page lists what you might expect and will not find. Everything here is
absent from the current release; nothing here has a date.

## Ingest

- **Audio and video.** RememberStack does not ingest audio or video yet; no
  converter turns speech or video into text.
- **URLs.** You cannot ingest a web page by its address; fetch the page
  yourself and upload the file.
- **Connectors and watched folders.** Nothing pulls documents from another
  system or a folder automatically; the `remember connectors` commands and
  SDK connector methods call routes that no deployment serves, and the
  `connectors-watched-directory` install extra installs nothing.

## Documents

- **Erasing a document.** Deleting a document removes it from the memory
  but keeps its claims and stored original as history; the HTTP API, the
  SDK, the CLI and the MCP tools cannot erase a document's bytes.
- **Deleting one version.** You can delete a whole document, not a single
  version of it.
- **Waiting for readiness from the CLI.** No `remember` command waits for a
  document to be processed; use the SDK's `wait_for_readiness` or the MCP
  `pipeline_readiness` tool.

## Knowledge

- **Compiled pages.** RememberStack does not write summary pages that
  combine what it knows about a topic; the envelope's `pages` field and the
  `compiled` grain exist but stay empty, and the `k` install extra installs
  nothing.
- **Opening a source from an agent.** No MCP tool returns a whole original
  source file; agents read passages through search and `adjacent_chunks`
  (a self-hosted engine can publish its sources as files, see
  [Filesystem views](https://remember.dev/docs/self-hosting/filesystem-views)).

## Clients

- **Async Python client.** The `remember` package is synchronous only.
- **Other languages.** There is no TypeScript, JavaScript, Go or other
  client library; use the [HTTP API](https://remember.dev/docs/reference/http-api).

## Running the engine

- **Kubernetes.** There is no Helm chart or Kubernetes manifest; the
  supported deployment is Docker Compose
  ([Install with Docker Compose](https://remember.dev/docs/self-hosting/install)).

---

Source: https://remember.dev/docs/project/glossary

# Glossary

The documentation uses each of these terms in one sense only. Where a term
has a field or value in the API, it is shown in code.

## Products

**RememberStack**
: The open-source memory engine for AI agents. It turns documents into
  claims, facts and entities, and serves them to agents without a language
  model on the read path.

**`remember` package**
: The Python client, CLI and MCP server for RememberStack (`pip install
  remember`).

**Self-hosted**
: Running the RememberStack engine yourself, with Docker Compose. See
  [Install](https://remember.dev/docs/self-hosting/install).

## Deployments

**Deployment**
: One running RememberStack memory: its own database, storage and
  credentials. Everything you ingest and query lives in exactly one
  deployment.

## Sources and documents

**Document** (`doc_id`)
: One logical file over its whole life, such as a spec or a meeting
  transcript. Its identity stays the same when its content changes. See
  [Documents, versions and sources](https://remember.dev/docs/concepts/documents-and-sources).

**Version** (`version_id`)
: One snapshot of a document's bytes. Versions are append-only and never
  edited. See [Documents, versions and sources](https://remember.dev/docs/concepts/documents-and-sources).

**Source** (`source_kind` + `source_ref`)
: Where a document comes from, given as a pair: the class of source and the
  item's stable ID within it. The pair is the document's identity. See
  [Documents, versions and sources](https://remember.dev/docs/concepts/documents-and-sources#source-source_kind-and-source_ref).

**Content object**
: The stored bytes of a file, kept once per content hash however many
  versions or documents share them.

**Representation** (`representation_id`)
: One conversion of a version into text (`document.md`). Evidence positions
  point into a representation.

**Section**
: A part of a document found from its structure, such as its headings, with
  a role. Chunks and claims know which section they belong to.

**Chunk** (`chunk_id`)
: A passage of a document's converted text: a run of whole blocks, not
  overlapping any other chunk. Chunks are what claims are extracted from and
  what passage search returns.

**Versioning mode** (`snapshot`, `living`)
: What a new version of a document means. In `snapshot` mode (the default)
  every version stays standing testimony; in `living` mode the newest
  version replaces what older ones said. See
  [Updating a source](https://remember.dev/docs/concepts/updating-sources).

**`source_modified_at`**
: When the source says the content was written or last changed. It becomes
  the said-on time of every claim from that version.

**`source_version_ref`**
: An optional revision marker from the source system, such as an ETag or
  commit SHA, stored on the version.

**Ingested by** (`ingested_by`)
: The actor that created a version: a `user`, an `api_credential` or a
  `service`. See
  [Documents, versions and sources](https://remember.dev/docs/concepts/documents-and-sources#who-ingested-a-version).

## Claims and facts

**Claim** (`claim_id`)
: One standalone statement a source made, tied to the exact text that
  supports it. Claims never change. See [Claims](https://remember.dev/docs/concepts/claims).

**Selection**
: The first extraction step: decides which statements in a chunk become
  claims and records a reason for each one it drops. See
  [Claims](https://remember.dev/docs/concepts/claims#1-selection-what-is-worth-keeping).

**Claimify**
: The second extraction step: rewrites kept statements into standalone
  claims and cites their supporting passages. See
  [Claims](https://remember.dev/docs/concepts/claims#2-claimify-make-each-statement-stand-alone).

**Grounding gate**
: The deterministic check that refuses any claim whose cited passages or
  added words are not in the source. See
  [Claims](https://remember.dev/docs/concepts/claims#3-the-grounding-gate-no-text-without-a-source).

**Attributed claim** (`is_attributed`)
: A claim that records someone's statement or stance ("Dana believes…")
  rather than asserting something directly.

**Current testimony** (`is_current_testimony`)
: Whether a claim still counts as what its source currently says. Claims
  stop being current when re-extracted, superseded in a living document, or
  deleted. See [Claims](https://remember.dev/docs/concepts/claims#current-and-superseded-testimony).

**Fact** (`fact_id`)
: A piece of knowledge the memory holds true, built from claims: either a
  relation or an observation. Facts change as evidence arrives. See
  [Facts](https://remember.dev/docs/concepts/facts).

**Relation**
: A fact that connects two entities with a predicate ("Ravi works_on billing
  migration"). Relations are the edges of the graph.

**Observation**
: A fact about one entity: a value, property, state or stance. Observations
  are searchable but are not graph edges.

**Predicate**
: The name of a relation's connection, from a governed vocabulary of 16
  core predicates, an `other:` escape value, or an extension pack. See
  [Facts](https://remember.dev/docs/concepts/facts#predicates).

**Extension pack**
: A named set of extra predicates for a domain, such as `work`.

**Adjudication**
: The step that decides how a new assertion changes the facts: add, confirm,
  adjust, supersede or contradict. See
  [Facts](https://remember.dev/docs/concepts/facts#how-a-claim-changes-the-facts).

**Evidence count** (`evidence_count`)
: The number of distinct documents whose current testimony supports a fact.

**Support withdrawn** (`support: "withdrawn"`)
: A fact whose only support disappeared because of a processing change
  rather than a source change. It is still returned, flagged. See
  [Facts](https://remember.dev/docs/concepts/facts#support-withdrawn).

**Retraction**
: A fact losing belief because a living source removed, or a deletion took
  away, its only support. Recorded as `retracted_source_removal`. See
  [Updating a source](https://remember.dev/docs/concepts/updating-sources).

**Label**
: A fact's readable sentence, without dates.

## Entities

**Entity** (`entity_id`)
: One real-world referent (a person, team, project, event) however it is
  spelled. Entities have no type. See [Entities](https://remember.dev/docs/concepts/entities).

**Alias**
: A spelling a source used for an entity.

**Profile**
: A short prose summary of an entity built from its important facts, used
  to decide identity.

**Resolution**
: Matching a name to an entity. At write time a cascade of steps decides; at
  query time `resolve_entity` returns candidates and never guesses. See
  [Entities](https://remember.dev/docs/concepts/entities).

**Tier** (`T0`–`T3`)
: The resolution step that found an entity candidate: exact alias, similar
  spelling, similar sound, profile embedding.

**Merge**
: Redirecting one entity into another when both are the same referent.
  Merges keep a snapshot and can be undone.

## Evidence and disagreement

**Evidence**
: The claims that support or dispute a fact, and the source text that
  supports a claim. See [Evidence](https://remember.dev/docs/concepts/evidence).

**Evidence span** (`evidence_spans`)
: A character range in a version's converted text that supports a claim. A
  claim has one or more.

**Stance** (`supports`, `contradicts`)
: Whether a claim backs a fact or disputes it.

**Contradiction**
: Two or more facts that cannot all be true, returned together as a
  contradiction group. See [Contradictions](https://remember.dev/docs/concepts/contradictions).

**Co-member**
: Another fact in the same contradiction group.

**Corroboration**
: Independent documents stating the same thing, counted by distinct
  documents, never by versions or repetitions.

**Supersession**
: A later fact replacing an earlier one because the world changed; the
  earlier fact's window is closed at the later one's start.

**Transcript**
: A fact's decision history: every adjudication, with outcome, method and
  confidence. See [Evidence](https://remember.dev/docs/concepts/evidence#why-do-we-believe-this).

## Time

**World time** (valid time)
: When something was true or happened: `valid_from`, `valid_until` and
  `valid_precision` on facts; the `claim_valid_*` fields on claims. See
  [Time](https://remember.dev/docs/concepts/time).

**Said-on time** (`asserted_at`)
: When a source made a statement.

**Belief time** (`ingested_at`, `invalidated_at`)
: When the memory learned a fact and when it stopped believing it.

**Precision** (`valid_precision`)
: How exact a window is: `instant`, `day`, `month`, `quarter`, `year`,
  `open` (known start, ongoing) or `unknown`.

**Claim valid kind** (`claim_valid_kind`)
: What a claim's stated time describes: `event_time`, `effective_period`,
  `measurement_period` or `proposition_validity`.

**Time mode** (`current`, `at`, `overlap`, `history`)
: Which facts a query selects by world time. See
  [Time](https://remember.dev/docs/concepts/time#asking-about-time).

**Temporal match** (`confirmed`, `possible`)
: Whether a fact's window establishes that it matches the query's time, or
  only fails to rule it out.

**Temporal scope** (`temporal_scope`)
: The part of every result that states which time it describes and when it
  was evaluated.

## Processing

**Pipeline**
: The stages that turn an ingested version into claims, facts and search
  indexes, in the background. See [The pipeline](https://remember.dev/docs/concepts/pipeline).

**Stage**
: One step of the pipeline, such as `convert` or `extract_claims`, with a
  status per version.

**Dead letter** (`dead_letter`)
: Work that failed and will not be retried automatically.

**Readiness**
: Whether given versions have finished processing and the capabilities you
  need (`pipeline`, `p1`, `live_graph`, `p3`) are ready, so the content can
  be recalled. See [The pipeline](https://remember.dev/docs/concepts/pipeline#readiness).

## Reading

**Assured operation**
: One of four fixed, registered reads: `resolve_entity`, `facts_context`,
  `claims_and_sources_context`, `combined_context`. See
  [Retrieval](https://remember.dev/docs/concepts/retrieval) and
  [Assured operations](https://remember.dev/docs/reference/assured-operations).

**Primitive**
: A lower-level read such as search, lookup, graph traversal or hydration.
  See [Retrieval](https://remember.dev/docs/concepts/retrieval#retrieval-primitives).

**Envelope**
: The object every read returns: the results plus an account of their
  grain, time, completeness and emptiness. See
  [Reading a result](https://remember.dev/docs/concepts/reading-results).

**ContextBundle/v2**
: The result of `combined_context`: a claims-and-sources envelope and a
  facts envelope side by side.

**Grain** (`fact`, `evidence`, `composite`, `compiled`)
: What kind of truth an envelope holds.

**Negative** (`unknown_entity`, `known_empty`, `boundary`)
: The typed reason an answer is empty.

**Hydration**
: Re-reading search candidates from live data before returning them, and
  dropping those that no longer hold (`dropped_by_hydration`).

**Semantic search**
: Search by meaning over embeddings (vectors), with pgvector.

**BM25**
: Keyword search that ranks by term matches, with PostgreSQL's
  `pg_textsearch`.

**RRF** (reciprocal rank fusion)
: Merging several ranked lists by summing `1 / (60 + rank)` for each item.

**Query space** (`memory_v1`)
: The prepared, read-only views and functions that SQL queries run over. See
  [Query space](https://remember.dev/docs/reference/query-space).

**SQL queries**
: Reading memory with SQL over the query space. Every statement is parsed
  and validated against the query space before it runs. See
  [Explore memory with SQL](https://remember.dev/docs/guides/sql).

**Saved query**
: A reviewed SQL query stored under a name and run with parameters. See
  [Saved queries](https://remember.dev/docs/guides/saved-queries).

**MCP**
: The Model Context Protocol, which lets coding agents call memory as
  tools. See [Connect your coding agent](https://remember.dev/docs/start/connect-your-agent).

---

Source: https://remember.dev/docs/project/contributing

# Contributing, license and trademarks

RememberStack is developed in the open at
`github.com/writeitai/remember-stack`. The engine, the `remember` package,
the benchmarks and the design documents all live in that one repository.

## License

RememberStack is licensed under the Apache License, Version 2.0. The full
text is in `LICENSE` at the root of the repository, and the `remember`
package declares the same license. The copyright holder is WriteIt.ai s.r.o.

The license lets you use, modify and redistribute the code, including in
commercial products, under its conditions (keep the notices, state your
changes). It does not grant rights to the project's names and logos; see
[Trademarks](#trademarks).

## Contributor License Agreement

Contributions are accepted under the RememberStack Contributor License
Agreement, version 1.0. Its text is `CLA.md` at the root of the repository.

You accept it in the pull request itself: the pull-request template has a
checkbox for it, and a field to name a company if you sign on its behalf. A
required check called **CLA** blocks the merge until the box is ticked.

## Trademarks

The names **RememberStack** and **remember.dev** and their logos are
trademarks of WriteIt.ai s.r.o. The policy is `TRADEMARKS.md` at the root of
the repository. In short:

- You may refer to RememberStack truthfully, say that your software is
  compatible with, built with or based on it, and use the `remember` command
  name and API identifiers as needed for installation and documentation.
- You may redistribute unmodified official releases under their name.
- You need written permission to use a mark as the main name of a company,
  product, service, package or domain, to brand anything with a logo, to
  imply that something is official or endorsed, or to put a mark on
  merchandise.
- A modified version may say it is "based on RememberStack" but needs its
  own name.

Questions and permission requests go to `info@writeit.ai`.

## Set up a development checkout

You need:

- [uv](https://docs.astral.sh/uv/) version **0.12.6** exactly (the project
  pins it);
- Python 3.12 or newer (CI uses 3.13);
- Docker, for the PostgreSQL 19 database most tests need.

```bash
git clone https://github.com/writeitai/remember-stack.git
cd remember-stack
make install          # uv sync: engine, client and development tools
```

### Checks

| Command | What it runs |
|---|---|
| `make lint` | `uv run ruff check src/ benchmarks/` |
| `make format` | `uv run ruff format src/ benchmarks/` |
| `make typecheck` | `uv run pyright src/ benchmarks/` |
| `make test` | `uv run pytest src/tests` with coverage |
| `make check` | `lint`, `typecheck` and `test` |
| `uv run lint-imports` | The architecture rules in `.importlinter` (for example, workers and surfaces must not import adapters). |
| `uv run ruff format --check src/ benchmarks/` | Formatting check, as CI runs it. |

Two rules the linters enforce:

- Configuration is read through `pydantic-settings` classes only. Ruff
  rejects `os.environ`, `os.getenv` and `os.putenv` in the code; tests set
  variables with `monkeypatch.setenv`.
- Pyright runs in `standard` mode with no checks switched off for the
  library code.

### Tests and the database

Tests live in `src/tests/`. Unit tests run without a database. Integration
tests need PostgreSQL 19 with the project's extensions, built from
`Dockerfile.postgres`. The CI script starts one on port 5432:

```bash
.github/ci/start-postgres.sh
export REMEMBERSTACK_DATABASE_URL=postgresql+psycopg://rememberstack:rememberstack_test@localhost:5432/rememberstack_test
uv run pytest src/tests/spine -q
```

Every test file must be listed in exactly one of
`.github/ci/unit-paths.txt` and `.github/ci/integration-paths.txt`. CI fails
when a new test file is in neither (`.github/ci/check_test_inventory.py`).

### What CI runs

On every pull request, `.github/workflows/ci.yml` runs: the test-inventory
check, `lint-imports`, `ruff check`, `ruff format --check`, `pyright`, the
unit tests, a contract smoke pack against PostgreSQL, and the integration
tests for workers, spine, surfaces and adapters. The **CLA** check runs
separately.

### Pull requests

The pull-request template asks for a summary (what changed and why, one
coherent change per pull request), the checks you ran, and the contributor
agreement.

Design decisions are recorded in `decisions.md`, one numbered entry per
decision (for example D108, D130). A change that alters a decided behaviour
updates or supersedes its entry.

## Repository layout

| Path | What is there |
|---|---|
| `src/remember/` | The `remember` package: Python client, CLI, `remember mcp`, `remember setup`. Depends only on `httpx`, `pydantic` and `pydantic-settings`. |
| `src/rememberstack/` | The engine. |
| `src/rememberstack/model/` | Data models shared by every layer. |
| `src/rememberstack/core/` | Domain logic that depends only on `model/`. |
| `src/rememberstack/ports/` | Interfaces to storage, queues, models and other outside systems. |
| `src/rememberstack/adapters/` | Implementations of those interfaces: PostgreSQL, object storage, OpenRouter, converters, observability. |
| `src/rememberstack/spine/` | The PostgreSQL store: schema, migrations (`spine/migrations/`), catalogs, the query space manifest. |
| `src/rememberstack/workers/` | The pipeline stages (convert through label). |
| `src/rememberstack/surfaces/` | The HTTP API, the assured operations and the SQL query sandbox. |
| `src/rememberstack/profiles/` | Composition: the self-hosted profile that wires everything together and the container entry point. |
| `src/rememberstack/eval/` | Evaluation helpers. |
| `src/tests/` | The test suite, mirroring the layers above. |
| `benchmarks/` | LoCoMo and BEAM harnesses; see [Benchmarks](https://remember.dev/docs/project/benchmarks). |
| `packages/` | The deprecated forwarding package; see the [changelog](https://remember.dev/docs/project/changelog). |
| `Dockerfile`, `Dockerfile.postgres`, `docker/` | The engine image and the PostgreSQL 19 image with extensions. |
| `compose.yaml`, `.env.example` | The Docker Compose deployment. |
| `alembic.ini` | Database migration configuration. |
| `openapi.json` | The HTTP API schema, generated by `scripts/export_openapi.py`. |
| `scripts/` | Release and consistency checks. |
| `decisions.md`, `design/`, `plan/` | The decision log, designs and analyses. |
| `.github/` | CI workflows, CI helper scripts, the pull-request template. |
