Three tiers of memory
Typed claims, open-vocabulary memories and verbatim passages — what each is for, which questions each answers, and the one permission fence all three pass through.
A company's knowledge does not arrive in one shape, so louvain does not store it in one shape.
Some of what gets said is a value: a deal stage, a contract figure, who reports to whom. That belongs in something typed, where "current" has a meaning and a count is a count. Most of what gets said is not: payment terms, a supplier's excuse, the reason a launch slipped. Those have no predicate anywhere and never will. And sometimes the only correct answer is the sentence itself — what someone actually wrote, verbatim, on a Tuesday in March.
A pure knowledge graph loses the second and third categories entirely. A pure vector index over raw text can hold all three but cannot tell you a current value, cannot count without sampling, and cannot tell you when something changed. louvain writes all three tiers on the way in and fuses them on the way out.
| Tier | What it is | Trust | Answers |
|---|---|---|---|
| 2 — Claims | ontology-typed, bi-temporal, superseding, cited | asserted / hedged | lookup, count, point-in-time, history, provenance |
| 1 — Memories | atomic facts in natural language, open vocabulary | model-asserted | anything untemplated, dossiers, synthesis |
| 0 — Passages | every admitted message, document and record chunked and embedded | verbatim | needle, quotation, "what did X actually say" |
Content is content, whichever connector carried it. A Slack message, a Drive document and a HubSpot record all pass through the same three tiers — they are chunked, embedded, and read for claims and memories in exactly the same way. Permission traffic and reactions do not: the first is how the fence is built, and the second states nothing on its own.
One message, three tiers
Take a single message in #sales:
Ade Okafor, #sales, 2026-03-04
"Heads up — Acme moved to legal review this morning and Jordan Lee
is our sponsor there now. Their procurement team is pushing for
net-45 payment terms before they'll sign."That one message writes to all three tiers:
tier 2 claim Acme Corp | deal.stage = legal review (single-valued: supersedes)
claim Acme Corp | champion = Jordan Lee (object resolves to an entity)
tier 1 memory "Acme's procurement team is pushing for net-45 payment terms
before they will sign."
tier 0 passage "Heads up — Acme moved to legal review this morning and Jordan
Lee is our sponsor there now. Their procurement team is ..."Note what happened to net-45. The sales ontology has predicates for stage, value, discount ceiling, champion, risk, renewal date and competitor. It has none for payment terms. In a graph-only system that sentence is not ranked low — it is absent, because the sanitizer drops every predicate outside the vocabulary. Here it lands as a memory and as a passage, and "what terms did Acme ask for?" is answerable.
Tier 2 — claims
A claim is a typed statement about an entity: subject, predicate, value, plus the times and the sources. Everything about a claim's shape comes from the org's ontology templates — the predicate keys, whether the predicate is single-valued or accumulating, what values are legal, and how they get normalized. Adding a domain is adding a template, never editing the pipeline.
Claims are what make louvain auditable rather than merely useful:
- Supersession is real.
deal.stageis single-valued, so a new asserted stage retires the previous one and the old row stays in history.deal.riskis multi-valued, so risks accumulate. - Values are canonicalized. "$410k a year", "410000/yr" and "460k annually" do not become three different facts. Equality has to hold, or supersession and corroboration silently stop working.
- Counts are counted, not sampled. A count question executes as SQL over permission-filtered claim rows. It sees every row, not a ranked page.
- Every claim carries provenance — one attestation per source event and container, with the quote it came from.
Claims answer the questions where being approximately right is worthless: what is the stage now, what was it in June, how many accounts are at risk, who told us, and when did it change.
When a workspace has no use for claims
Typed claims need a vocabulary that fits. A workspace whose sources talk about things the enabled domains have no word for gets few of them, and the work of trying is the expensive part of ingest. Such a deployment can run a lighter extractor that writes only memories and passages — the same answers, at a fraction of the cost, because on that corpus the answers were coming from those tiers anyway. The Knowledge page's profile is what tells you which kind of workspace you have.
What a claim cannot say
A claim has no polarity. It is a subject, a predicate and a value, so there is no way to record that something is not the case. "Priya does not report to Marcus" cannot be stored as a claim, and the extractor is explicitly prevented from storing it as the positive one — a denial filed as its opposite would assert a falsehood and cite a source that contradicts it. Denials are dropped from the claim tier; the sentence itself is still held verbatim as a passage, so a reader can quote it and an answer can rest on it.
The consequence worth knowing: a denial cannot retire an existing claim. If a fact was recorded and a later message denies it, the claim stays current until something positively replaces it — a new value on a single-valued predicate, or a terminal predicate that ends it. Say what is true rather than what is not, and supersession will do its job.
A claim is only ever about something its source names. The extractor is shown context (what the room already knows, the last few messages) so that a short reply can be filed against the thing it answers — and a model will sometimes lift a subject from that context and attach an unrelated sentence to it. louvain refuses those at extraction: the subject, any entity it points at, and every year or figure in a value must appear in the message being extracted, or the claim is not written. The sentence itself is still kept as a passage. This is what makes every citation honest: the source shown for a fact is a source that states it.
A claim is never true for zero time. Valid time runs from valid_from until
invalid_at, and reads admit a claim while invalid_at is unset or still in the
future — so an end at or before the beginning would describe something true at no
instant, stored but unreachable. Such an interval is refused at extraction and
the claim is kept open instead, because an unknown end means "still true", which
is what the source said.
Tier 1 — memories
A memory is one self-contained fact in plain language, extracted from a message
with no vocabulary restriction at all. It carries an optional subject, a
certainty (asserted or hedged), a valid-from date only when the source
stated one, the source container, and an embedding.
Memories exist because "we have no predicate for that" must stop meaning "we have no memory of that". They are the tier that carries the specifics of a business nobody wrote templates for — margins on a product line, a supplier's delivery window, why a store closed early. They are model-asserted rather than typed, so they are less precise than a claim and far more comprehensive.
Memories can also stop being true: the read path honours invalid_at on a
memory exactly as it does on a claim.
Tier 0 — passages
Every admitted message is chunked and embedded, verbatim. Short messages become one passage; long ones split on sentence boundaries at roughly 900 characters with a small overlap, so a fact spanning a break is still retrievable whole from at least one chunk.
Passages are written for messages that produce no claims and no memories at all. That is the point. On a real corpus most of what people say is chatter, and the admission gate deliberately keeps chatter away from extraction — but refused for interpretation is not refused for the record. A gated message is still chunked, still embedded, still findable and still quotable.
There is exactly one exclusion. A message carrying prompt-injection markers is quarantined: its text is kept on the event row, visible in the activity feed and reprocessable by an admin, but it is not indexed as a passage. A passage is retrievable, and indexing attacker-controlled text would hand the payload to the reader as evidence on some later question — reaching the model through retrieval after being refused entry through extraction.
The edge index
A fourth, optional tier: what the memories add up to
The three tiers hold what was said. None of them holds what is true of a subject across what was said — and that is deliberate: a claim's subject must be named in the message it cites, which is what stops the extractor inventing subjects out of the context it was shown. So "prefers to build on their own experiments" has nowhere to live. It is true of a set of messages and stated by none of them.
With LOUVAIN_REFLECTOR=true, a reflector consolidates a subject's memories into
a few durable observations. Each one cites the memory ids it rests on, so a
summary can always be traced to the sentences behind it, and each is written
within a single room — its audience is exactly the audience of the rows it
summarises, so it needs no new permission rules and cannot widen what anyone can
see. Consolidations appear on an entity's dossier under What this adds up to.
It is off by default, and honestly bounded: on the public LongMemEval_S subset it moves accuracy from a 70.0% mean to 73.3%. It helps questions answered by the gist of what someone has said, and it is capped in the reader's window because a summary that paraphrases a date cannot corroborate one — questions about exact figures need the verbatim row, not the summary of it.
The reflector also gives the open tier something it never had: supersession. Typed claims retire an old value by predicate cardinality, but two contradictory memories both stood, separated only by ranking. When the reflector finds a genuine replacement it marks the older row invalid from the moment the newer statement was made — so asking "as of" an earlier date still returns the old answer, and nothing is deleted.
Alongside the tiers, extraction writes relationship edges — subject,
relation, object — with both ends resolved through the same entity resolver the
typed tier uses, so an edge and a claim about the same person share one node.
Edges are deliberately not embedded: an edge is found by walking from an
entity, not by similarity to a question. Vector search over works_at would
return every employment fact in the company, ranked by nothing useful.
An edge whose relation the vocabulary already knows is not kept as an edge.
Every entity-valued predicate declares the relations that state it subject-first
(reports_to is person.manager) and object-first (manages is the same fact
from the other end), together with the kinds each end must be. An edge that
matches one of those — and whose ends satisfy the predicate's subject and object
kinds — becomes the typed claim it should have been, filed the right way round.
An edge that does not stays open: "not in the vocabulary" is a true statement,
not a loss. Nothing guesses an endpoint's kind; an edge the extractor could not
type on a predicate that allows several kinds stays open rather than being filed
under the first.
Edges are what make two-hop questions work. "Who does the Nyali store manager report to?" cannot be answered by similarity alone, because the sentence naming the manager and the sentence naming their manager share no words with each other. Retrieval seeds from the first pass, resolves the names it found to entity ids, and walks their edges by id in both directions. The entity dossier does the same from either end. A hedged edge is shown as hedged.
One stored fact answers both directions. "Who does Marcus report to?" reads the
person.manager claim on Marcus; "who reports to Priya?" reads every
person.manager claim whose object is Priya, matched by entity identity, and
returns the subjects. Neither direction is stored twice.
How a question uses the tiers
The deterministic planner gets first refusal. If the question compiles to a typed plan — a lookup, a count, a point-in-time, a history, a provenance question — it executes as permission-filtered SQL and renders in code. No model runs on that path, so the answer can be wrong only if the claims are wrong.
A plan that produces a value still has to earn it. The value is checked against the retrieved evidence before it is returned, because trusting a plan merely because it returned non-null trusts the weakest link in the chain (one routing rule, one extraction) over the strongest. Computed answers are the deliberate exception: a count is correct by construction and appears verbatim in no message, so it is not asked to prove itself against source text.
When the plan yields nothing, the question is not unanswerable — it is untyped.
Hybrid recall runs both a vector query and a lexical query against each tier and
fuses the ranks reciprocally, rather than putting cosine distance and ts_rank
on a scale nobody calibrated. Embeddings find "what are Bidco's terms" from a
sentence that shares no words with it; lexical search finds the exact SKU, price
or surname that a 768-dimension vector rounds away. The verified reader then
composes an answer from the retrieved evidence and nothing else, and every
number, date and name it writes is checked against the evidence text, sentence
by sentence. A sentence carrying something unsupported is not a low-confidence
sentence, it is a fabrication, and it is struck; the sentences that verify are
the answer, and an answer with none left is refused rather than shipped with a
caveat.
Two kinds of question add a leg of their own, because similarity cannot see the axis they are asking on. A question asking for a figure ("how many eggs") adds the lexical matches that contain a digit, since prose about a subject outnumbers the one row that counts it. A question asking for the latest or most recent thing adds the lexical matches ordered by when they were said, since six older messages about an offer will outrank this morning's on wording alone; the cue word itself is read as intent and dropped from the lexical query rather than matched, so "recently" does not surface a menu called "Recents". Both legs add candidates to the same fusion. Neither widens the page, because the reader's window is fixed and a bigger page only sharpens the competition for it.
One refusal is worth calling out on its own: louvain will not count by sampling retrieved evidence. Retrieval returns a ranked, capped set, so counting it counts what was retrieved, not what exists. The deterministic count plan counts rows in SQL and stays authoritative; a question that falls through to retrieval gets an honest refusal instead of a confident wrong integer.
One fence, applied before ranking
Every row in every tier carries the container it came from — the channel, folder or project where the thing was actually said. Claims carry it on their attestations; memories and passages carry it on the row itself.
The asking principal's readable container set is resolved once, up front,
and applied as a predicate in the WHERE clause of every retrieval query:
-- every tier, every query, including both second-hop queries
where org_id = $orgId
and container = any($containers)
and occurred_at <= $asOfSo ORDER BY and LIMIT only ever see rows this viewer may read. Unreadable
rows never enter a candidate set, never influence a rank, and never reach a
reranker or a model. A retrieval layer that ranks first and filters second
leaks through ordering even when it never returns the row.
Rerankers may reorder and drop, but cannot introduce a row they were not given, so the fence holds whatever they do. Plugin retrievers are handed the already-resolved container set — that seam exists for finding things differently, never for finding more things.
If the viewer has no readable containers, recall returns nothing. That is not a degradation to report; it is the system working.
Adding retrieval surface must never add an access path. That is why all three tiers inherit the same container membership instead of getting an authorization model of their own — see Permissions for how the readable set is computed, and Time and truth for how supersession and valid time decide which of the visible rows is current.
Names the source equated
An extractor writes names the way a document does — "Ivosidenib (Tibsovo)",
"basal cell carcinoma / BCC". When an entity is created, the aliases its own
name implies (a parenthetical, a spaced slash, an "aka") are written alongside
it, so the next mention of either half resolves to the same entity instead of
forking it, and a question naming "Tibsovo" reaches the deterministic plan.
Nothing is inferred across names — "International Business Machines" never
becomes "IBM" unless a source wrote both — because a wrong merge is worse than
a missed one. apps/api/scripts/backfill-aliases.ts catches up entities
created before this existed.
Two nodes that are one thing
Narrow resolution forks: "Scott" and "Scott Waddell" are two nodes until someone says otherwise, and the sayer is an admin, not a similarity score (ADR-017). A miner proposes pairs it can justify — a name whose words all sit inside another name of a compatible kind, counted across the whole workspace, corroborated by shared connections — and confirms on its own only what a source itself stated, such as a connected account that carries both an address and a display name. A pair the admin confirms is merged by pointer: the fork's rows stay where they were written, every read resolves a name to the node and everything merged into it, and the merge can be undone. A rejected pair is never proposed again. While a pair is undecided, a question about the short name is refused and the refusal names the candidate. See Possible duplicates.
Quickstart — self-hosted
Run louvain on your own machine, point it at a model you control, and prove it works before anyone depends on it.
Permissions
How louvain computes what one person is allowed to see, why two people can correctly get different answers to the same question, and why a refusal looks exactly like a missing record.