Recall architecture
How Polign Recall is built. The page covers the record it stores, the fold
that turns records into answers, the registry that holds the correction rules, search, cached
reads, the recall serve front door, agent resume, audit bundles, and what a storage
backend has to provide. It describes recall 0.18.
Recall is open source under Apache 2.0. The code paths this page names are in github.com/Polign/recall, and the same material is kept in the repository as docs/architecture.md. For what Recall does and why, start with the introduction.
The shape of the system
Recall is a library with two optional processes around it. The library holds every rule: what a statement must look like, how corrections work, how history folds into an answer. It stores nothing itself. A backend holds the records, and the backend is a vector store you run: polign_db in your own bucket, or a PostgreSQL, MongoDB or Qdrant you already have.
recall mcp, and
recall serve run the same code; they differ in who they answer and how.- The library is what a Go program imports. It has no dependencies outside
the Go standard library. Everything below is implemented here, in the package
internal/engine, and exposed through the root package. recall mcpwraps the library in a Model Context Protocol server over stdio. It serves one agent in one process against one collection. The Python client starts this process for you and talks to it over JSON-RPC.recall servewraps the same library in an HTTP server for many agents at once. It authenticates callers, gives each namespace its own collection, and speaks both MCP over streamable HTTP and plain HTTP verbs. The Python client, LangGraph, LiveKit, andrecall connectall reach it this way.
A memory is an event
Recall writes one kind of record: an event. An event says that a subject stands in a predicate relation to a value, and when that was observed. It is never updated in place. A correction is a new event, and forgetting is a new event with the retraction flag set. The log of events for one subject and predicate is the complete record of what was ever said about that pair.
| Field | What it holds |
|---|---|
subject, predicate, value | The statement. The subject is lowercased; the value is stored with the type the registry declares for the predicate (string, number, boolean; enums, dates, and references are strings in a canonical form). |
observed_at | When the statement was made. The fold orders by this, not by when the backend accepted the write, so a backdated statement lands where it belongs in history. It is stored twice: as a fixed-width RFC 3339 string that sorts lexically, and as milliseconds for numeric range filters. |
retraction | True for a forget. A retraction withdraws a value, or every value of the pair when it names none. |
replaces, replaced_by | Which earlier beliefs this statement made untrue, under any predicate, and which later statement withdrew this one. Both are recorded on the events, and the fold derives answers from them. |
evidence, evidence_id | The exact excerpt a statement was drawn from, and the id of the note holding the whole text. This is what explain reads back. |
kind, source, confidence | Fact or preference; who said it (user_stated, agent_inferred, tool_result); a number from 0 to 1. |
id | A hash of the statement and its instant. Content alone would make a re-assertion overwrite the retraction between the two, so the instant is part of the identity. |
In the backend an event is one vector with this metadata attached. The vector is the embedding of the statement rendered as a sentence ("user prefers editor neovim"), so semantic search and exact filtered reads see the same statements. The metadata also carries that sentence, with the evidence appended, as one text field for a backend that indexes text.
Three families of record share the memory collection and are read by the same code:
- Statements, described above.
- Registry changes, filed under the subject
recall:registry. When a write defines a predicate under an open vocabulary, or an operator redefines, merges, or splits one, the change is an event in the same log. A reader folds the registry's history first, then reads statements through the definitions that held at the instant it is asking about. - Extraction records, filed under
recall:extraction. When a model works out the statements in a passage, the proposals it made are recorded beside the passage. Remembering the same text again replays them instead of asking the model again, so a retried write files exactly as the first attempt did.
Agent resume records and recall serve keys live in collections of their own,
because every record in a memory collection must decode as an event or an export fails.
Reading folds the log
Beliefs are never stored. They are derived, every time, by a function the code calls the fold: given the events of a pair, the predicate's cardinality, and an instant, return the values believed at that instant. "What is believed now" and "what was believed on Tuesday" are the same call with a different ceiling. That is the whole design, and the diagram below shows one customer's shipping address as the fold sees it.
The fold walks the pair's events in observation order, stopping at the instant asked about, and applies each one:
- An assertion replaces what is held when the predicate was single-valued at that instant, and accumulates when it was multi-valued. Cardinality is read as of the event, so a question about an instant before a registry change is answered the way it was at the time.
- A retraction withdraws the matching value, or every value of the pair when it names none.
- A statement's
replaceswithdraws the earlier beliefs it names, whatever predicate they sit under. - An assertion under a predicate that excludes this one
(
dislikesexcludeslikes) withdraws the same value here, exactly like a targeted retraction. The excluding predicate's events are folded alongside only to know what it held at each instant. Exclusions are resolved in the fold, not at write time, so an old log is covered by a rule added later, and a predicate marked historical (such asdid) is never withdrawn by any of this. - Two events sharing an instant are ordered the same way on every node and every replay, and a retraction never sorts before the assertion it withdraws.
Because the fold is a pure function of the events, a client crash between two writes cannot produce a state the fold reads wrongly. The earlier design this replaced wrote a new record and then rewrote each superseded one; a crash in the middle left two live values for a single-valued predicate. Here there is nothing to rewrite.
The write path
A remember is a read followed by at most one append, plus a retraction per
correction it carries:
- Validate. The subject is normalized, the predicate must be registered (or definable, under an open vocabulary), the value must match the predicate's type, and confidence and source must be in range. Nothing reaches the backend until this passes.
- Read the pair's complete history and fold it at the statement's instant. A history that would exceed the per-pair limit is refused rather than truncated.
- Return early if the value already holds. Restating a belief is not new information, so no event is written. Idempotence lives here, not in the record id.
- Resolve the corrections. Each belief the statement names in
replacesis checked at the same instant. One that is not held, was stated later, is a note, or is historical is reported back as unreplaced instead of failing the write: the statement is still true. - Append one event, then one retraction per correction, each naming the
event that caused it. If the process dies between the two, the next
rememberof the same statement finds the event present and the retractions missing, and completes them. The error message says so.
The write returns what it displaced, derived from the fold before and after, and a short list of related beliefs: current values about the same subject under other predicates that may contradict the new one. Nothing in Recall acts on that list. It is shown so that the agent, or the person, can confirm a correction the rules did not cover.
The read path
Two shapes of read exist, and the first is the one everything else builds on.
- An exact read names a subject and predicate. Recall lists every event of that pair from the backend, with one row more than the limit so that a truncated listing is detected and refused, and folds them at the instant asked. Nothing approximate is involved.
- A search carries text. Recall searches the backend for events whose sentence matches, groups the hits by pair, then folds each candidate pair exactly as above, and ranks the resulting beliefs by the rank of the pair's best hit. A superseded value that ranks top of the search therefore cannot come back as a live belief: the hit only says which pair the question is about, and the fold says what that pair holds now. Candidates are read four times wider than the requested limit, because a pair can fold to nothing.
Every typed belief in an answer carries replaced, the values it displaced, and
elsewhere, the same value held about the same subject under other predicates at
that instant. The second is a read-time safety net for a correction nobody linked: the reader
sees "likes mangoes" beside "dislikes mangoes" with both dates, instead of one slot alone. It is
a bounded number of exact listings per answer, and nothing is decided by it.
Search without a model
Recall ships with a lexical embedder: signed feature hashing of the words
in a sentence into a 256-dimensional vector. It needs no model, no key, and no network, and it
retrieves overlapping words, not synonyms. Its space is versioned
(recall-lexical-v1-256) and the default collection name carries that version, so a
collection never mixes it with model embeddings.
On polign_db, every search runs twice: once as a vector query and once as a BM25 keyword query over the text field, and the two rankings are fused. With the lexical embedder the keyword ranking leads and the vector hits it lacks follow, since those are mostly events written after the text index last caught up. With a model embedder the two are merged by reciprocal rank fusion, which rewards a hit both rankings found. On a backend without text search, the vector ranking stands alone.
A time phrase in the question ("two weeks ago", "last Saturday", "in March") is read as a hint: the span it names is searched first, then everything. The hint reorders candidates and never drops any.
Bring your own embedding service with -embed-url for paraphrase matching.
That is a per-collection decision: changing embedders means a new collection.
Cached reads
Reading a pair means listing its history and folding it, every time. For a backend that can say whether a collection changed, Recall keeps a bounded, process-local view of folded pairs and skips the listing when nothing did.
- polign_db exposes a watermark per collection: an opaque token that changes on any mutation. A cached pair is served while the watermark it was folded under is unchanged and the entry is younger than 30 seconds. A changed watermark triggers a full validated read and a second check. A change during that read, which any write to the collection causes, is retried briefly and then served as an uncached fold rather than published as a mixed view.
- The view holds at most 1,024 pairs, invalidates its own pair on every write so a client reads its own writes even when the watermark lags, and is rebuilt from the log on restart. It adds no replication, no cross-writer ordering, and no snapshot; the durable log remains the authority.
- A backend without watermarks, such as Qdrant, serves every read uncached. The answers are the same; the reads cost more.
The registry
The registry is the set of predicates a store accepts, and for each one its cardinality (single or multi), its value type, the predicates it excludes, whether it is historical, and any former names. It is the only place a correction rule lives. Recall ships starter registries for coding, personal assistant, support, sales, and voice agents, and a store can load its own.
Two modes exist:
- Closed. A write naming an unregistered predicate is refused, with the
list of registered names and the advice to file the statement as a
note. This stops an agent inventing near-duplicates ("editor_preference" beside "prefers_editor") that split one fact across two names that never meet. - Open. A write may name any predicate, and its first use defines it as an
event in the registry log. A new name whose embedding is within a strict similarity of an
existing one, with the same stored type, is filed under the existing predicate instead; a
wrong match is undone with
Split.
note is registered in every store and is the one predicate that can hold
anything: multi-valued free text that never supersedes and ranks after typed beliefs. "Nothing
matched" always has a place to be written. Once a predicate for that kind of fact exists,
Promote files each note under it and keeps the note in history.
Changing a registry later does not change what was remembered before. Redefine, merge, and
split are events; the fold reads each statement through the definition that held when it was
made, and explain lists the definitions, oldest first, that decide an answer.
Text in, text out
Since 0.18 the MCP tools take plain text by default: remember(text),
recall(question), forget(text). No predicate appears in a tool, an
argument, or an answer. Underneath, the records are the same events, so a client that starts
here loses nothing when it moves to typed statements.
- The whole passage is written first as a note, the episode, so that every statement drawn from it can name it as evidence. Restating text already kept returns the existing note.
- The model named by
-extract-modelproposes statements: subject, predicate, value, and an exact excerpt as evidence. Its proposals are untrusted input. Each is validated against the registry like any write, and the batch is folded as a whole so one passage may carry a correction. A proposal whose predicate is not registered is kept as a note, not dropped. - The proposals are recorded beside the episode as an extraction record, so a retried or
repeated
rememberreplays them without a second model call.
The model decides how a statement is filed. It does not decide what to forget. Replacement follows the predicate's rule as before, every filing is recorded, and a wrong filing is corrected with one registry event that every answer then follows. Without a model configured, the text is kept as written and replaces nothing.
Serving many agents
recall serve puts a front door in front of the library. The server holds the
backend's credentials; agents hold Recall keys. Each namespace gets its own collection, so
nothing a caller sends can reach another namespace's records.
- Keys are
rcl_<id>_<secret>. The store keeps the SHA-256 of the whole key beside its namespace, so a leaked store yields no credentials. A key record is written once; renewal rewrites it with a later expiry; revocation writes a separate tombstone record that nothing overwrites. Every check reads both, which is why last-writer-wins storage cannot resurrect a revoked key and no compare-and-swap is needed. Keys live in a control collection beside the memory, on any backend that can read a record by id; a backend that cannot uses a keys file instead. - Kubernetes workload identity. A pod posts its projected service account
token. The server asks the API server to review it, with the same audience, and mints a
short-lived key for
<tenant>:<pod name>. Nothing in that identity comes from the client: the tenant is the service account's namespace or a label on it, and the agent is the pod's name, or with agent-sandbox the name of the SandboxClaim the API server says owns the pod. A pod can only get a key for itself, and with it only resume itself. The server talks to the API server with its own mounted credentials over one HTTPS call per exchange; no client library is involved. - Collection naming. A namespace may use letters, digits, and
_-.:, the same rule polign_db applies to key namespaces. Its collection is the base name, the namespace lowercased with other characters replaced by underscores, and a hash of the original, so two namespaces that sanitize alike ("a.b" and "a_b") never share a collection. - Identity envelope. A request may repeat the namespace, session, or agent its credential carries, and is refused when it names another. A key for one agent cannot resume another.
The contract every client is written against, tool by tool and field by field, is memory-api-v1.md in the repository. A test in the server checks the tool definitions against it, so a tool cannot change without the document changing.
Resume
An agent resumes from what it wrote down, not from a snapshot of its process. It keeps five
kinds of record in a collection of its own, the memory collection's name with
_agents appended:
- One working state: a handoff note to its next instance (goal, plan, progress, focus, open questions), superseded on every update with each earlier version kept.
- Pointers to where its work lives: a branch, an object key, an image.
- Its turns, verbatim, so the last few can be replayed.
- Large tool outputs, kept whole and fetched by reference.
- Its actions: each tool call that touches the world, recorded before it runs and closed with its result when it returns.
Resume takes the agent's lease, reads those records back, adds
memories that match its focus, and renders one briefing within a token budget. A lease is a
fencing token: an epoch that only increases, granted and renewed by the backend, so a holder
presenting an old epoch has been superseded and a second pod resuming the same agent cannot
write over the first. On polign_db, PostgreSQL and MongoDB the backend also checks the epoch
on every agent write, so a process that stalled past its lease is refused even when its own
clock says the lease is fresh. Qdrant cannot grant leases; there, resume works only with
Unleased: true, which is safe only when something else keeps one process per
agent. Resume agents without snapshots walks through
the measured behavior.
Actions
The records say what the agent was doing, not whether its last tool call happened. A
harness brackets each call that touches the world: BeginAction before it runs,
EndAction with its result when it returns. A crash between the two leaves the
action open, and the next resume lists it first in the briefing. Each action has a class,
which says what running it again would do:
| Class | For | Open after a crash |
|---|---|---|
pure | Reads | Closed as abandoned. The agent runs it again if it still needs the answer. |
idempotent | Writes that are safe to repeat with the same key | Run again by the harness with the action's key, which is fixed when the action begins. |
external | Anything that could happen twice: send, post, charge, delete | Passed to a resolver that checks the outside system. If nothing can tell, it is marked unknown and stays at the top of every briefing until it is ended. |
Reconcile settles open actions before the model takes another step, and
writes each settled outcome into the turns so the model sees what happened. Recall never runs
a tool itself. Durable agent actions describes the kill
test behind it.
Audit
ExportAudit produces a bundle: the complete event histories in scope, the
registry used to interpret them, an explicit as-of time, and two version strings,
recall-event-v3 for the record fields and recall-fold-v4 for the fold
rules. A standalone verifier, recall-audit, reads a bundle and reproduces the
beliefs offline, with no database and no embedding model. It rejects unknown fields, duplicate
ids, invalid typed events, and unsupported versions, and checks an optional digest obtained
through a separate channel.
Replay establishes what the supplied events imply under the supplied rules at the recorded time. It cannot prove the exporter included every event, and the format does not sign data. Export refuses to truncate: a scope larger than the export limit fails rather than producing a partial bundle.
The backend contract
A backend is a vector store with filtered listing. Three methods are required and the rest unlock capabilities. Backends register themselves by name at init, the way database drivers do, and a program picks one by importing it; the conformance suite is what a new backend has to pass.
| Method | Used for | polign_db | PostgreSQL | MongoDB | Qdrant |
|---|---|---|---|---|---|
Put | Append an event, a registry change, a key record, an agent record | yes | yes | yes | yes |
List (filtered, with a total) | Complete history of a pair; exports; detecting a truncated read | yes | yes | yes | yes |
Search (vector, filtered) | Candidate pairs for a question | yes | yes | yes, exact | yes |
Get by id | Evidence lookups for explain; the key store | yes | yes | yes | yes |
SearchText | Keyword ranking, fused with the vector ranking | yes, BM25 | yes, full-text search | yes, text index | no |
Watermark | Cached reads | yes | yes | yes | no |
| Leases (acquire, renew, release) | One process per agent on resume | yes | yes | yes | no |
PutFenced | An agent write checked against its lease by the backend | yes | yes, in the write's transaction | yes, in a transaction on a replica set | no |
Recall provides the memory rules, validation, and history; the backend provides persistence and access. Concurrent reads and writes follow the backend's consistency guarantees. Recall adds no transaction isolation and no replication. Two clients that write the same pair at the same instant are ordered by observation time, with a tie broken deterministically, and both events survive in history.
Limits, stated plainly
- A pair's history is read whole on every uncached read and write. It is capped at 10,000 events, and a write that would exceed the cap is refused. Audit export has the same cap for its whole scope.
- The lexical embedder finds words, not meanings. A question phrased differently from the statement needs a model embedder.
- Cached reads, keyword search, and leases need polign_db, PostgreSQL or MongoDB. On Qdrant every read refolds history, search is vector-only, and resume is unleased.
- Text mode calls the model on every
rememberandforget, and the text is sent to its provider. - Nothing is deleted. A forget is a withdrawal; the record stays readable through history and audit. If you need erasure, that is a backend operation outside Recall's model.
Go deeper
- docs/architecture.md in the repository: this page with the file paths that implement each part.
- memory-api-v1.md: the tool and HTTP contract.
- materialization.md: cached reads in detail.
- audit.md: bundles and the offline verifier.
- How polign_db works: the storage engine underneath the default backend.