Decision records
ADR 0091 — A corpus is mutable: entry identity, and @loom/knowledge
- Status: Accepted — implemented (2026-09-07)
- Date: 2026-09-07
- Prompted by: a reopening of ADR 0090’s D5 — “knowledge is an innate requirement in the agent era, so there should be a knowledge seam or at least a package”
- Amends: ADR 0090 (D5 in particular)
- Builds on: the second-consumer rule (ADR 0083), the integrity axis (ADR 0085), SPI conformance contracts (ADR 0033), the decision seam (ADR 0016)
- KERNEL_ABI unchanged (1.1.0); EVENT_SCHEMA unchanged. No syscall, no event type, no fold
change. One optional verb and one required field on an existing SPI
(
MEMORY_STORE_SPI_VERSION1.0.0 → 2.0.0), and one first-party package that owns no storage of its own.
Context
ADR 0090 answered “should loom have knowledge?” with three verdicts, and two of them still hold:
durable memory belongs in the store plugins, and relevance retrieval is one optional search
verb. The third — D5, knowledge is application code — was argued from a corpus that only ever
grows, and that premise is wrong.
MemoryStore is append / read / clear keyed by a string. search ranks within ONE key, so
a searchable corpus has to be one key. A document — the unit that actually gets revised and
deleted — is a subset of that key, and the SPI could not name a subset. Concretely, on everything
0090 shipped:
- re-ingesting an edited document duplicated it.
appendis a list, so the corpus kept both revisions and retrieval returned the superseded passage beside the current one, with nothing in the result saying which was live. - the only removal verb was
clear, on the whole scope. Fixing one line of one wiki page meant rebuilding the entire corpus. - a document deleted upstream kept answering questions forever.
None of that is visible in a green suite, because every test ingests once. runbook-recall, the
example 0090 shipped to prove knowledge needs no loom surface, ingests a fixed array at startup
and never revises it — which is precisely why it proved what it did.
So the reopening is legitimate, but not for the reason it was raised. “Agents need knowledge” is a
statement about ubiquity, and ubiquity is an argument for availability, not for surface — D5’s
rejection of a knowledge$ construct was right and stays. What reopens D5 is narrower and
mechanical: the unit a corpus is searched in is not the unit it is edited in, and the SPI forced
a choice between them.
Decisions
D1 — Entry identity is REQUIRED; remove is the optional verb
MemoryEntry.id is optional for a caller and mandatory for a backend to round-trip.
MemoryStore.remove(key, ids) is optional, with the runtime falling back to read-filter-replace.
The asymmetry is the decision. remove is optional because its fallback is correct — reading a
key, dropping the named entries and rewriting it produces exactly the right state, just in
O(scope). Identity is not optional because the fallback DEPENDS on it: a backend that dropped ids
would make read-filter-replace match nothing and delete nothing, silently, on the path taken by
precisely those backends that have no native delete. That is the failure shape this repo keeps
producing — a capability that is present, plausible and inert — so the conformance kit tests the
round trip for every backend, remove or not.
This makes the SPI version 2.0.0 rather than 1.1.0. Both first-party SQL backends decompose an
entry into columns, and a decomposing backend silently drops a field nobody told it to keep;
adding a case they fail is a breaking change by this repo’s own rule, and the seam now has an
implementation outside this workspace to break. (loom-plugin-vector, which stores the entry
object whole, conforms unchanged — the same split that decides who has work to do.)
Neither SQL backend needed a migration or a column: both already persist the entry as JSON, so the
id is read back out of it (json_extract(entry,'$.id'), entry->>'id') behind a partial
expression index. A column would have been a second copy of a value the SPI already round-trips,
and therefore a thing able to disagree with it.
remove matching an entry with NO id is the sharp edge. SQL NULL handles it for free — NULL IN (…) is never true — but a JavaScript backend comparing entry.id against a Set will happily
match undefined against nothing and then match the string "undefined" against a missing
field if it stringifies first. The kit passes ["a", "undefined", "null", ""] for that reason.
D2 — @loom/knowledge: the document ↔ passage mapping, and nothing else
A first-party package, ~200 lines, owning no storage and no ranking. KnowledgeBase sits over an
ordinary TieredMemory and holds exactly what neither the store nor the app should:
putDocis an UPSERT. The passage ids of a document are derived (docId#0…docId#n), so a small manifest row holding the count IS the id list, and a revision can delete precisely its predecessor’s passages.sync(docs)reconciles the corpus against the source’s current set: write what changed (compared by acanonicalJson+fnv1a64fingerprint over the whole document, so a changed title ormetacounts as a revision), leave what did not, remove what the source dropped. An unchanged source costs one manifest read and zero writes, which is what makes it runnable on a schedule — and what makes deletion complete rather than best-effort.- the manifest is append-only, and pruned afterwards. Rows are
docId#<generation>, the highest wins, and superseded rows are removed only once the new one is durable. The obvious alternative — delete the old row, write the new one — has a crash window in which the document has NO row, and a document nothing counts is a document nothing can remove. This was not theoretical: the first implementation did exactly that, and the crash test written against it failed. - a two-phase
committedflag, for the inverse window. A manifest row that claims a revision whose passages were never appended would make every latersynccompare fingerprints, match, and skip the document forever — a corpus silently missing a document that every status view lists as present. Writing the row uncommitted first, and flipping it only after the passages land, converges on a re-run instead. - the tier must not evict, and the constructor refuses one that does. Under
ttlMs/maxEntriesthe store would drop passages the manifest still counts, and a document would retrieve as a fragment of itself with nothing downstream able to tell.
What is NOT in it, and why: a chunker beyond an ignorable default (packParagraphs) — how a
document splits is a property of the corpus and is most of retrieval quality; a document
vocabulary beyond { id, title?, text, meta? } — source, section, author, updatedAt differ
per corpus and belong in meta, which travels verbatim; embeddings — provider-shaped, third-party,
exactly as 0090 said; and retrieval inside agentLoop$ — still a hidden input to the fold.
D3 — Two mechanisms that were app code and should not have been
groundedRequest / passageMessages. Retrieved passages become one user message per
passage, taint intact, with the untrusted-content note in system. The shape almost every RAG
tutorial writes — passages joined into a string under a header in the SYSTEM prompt — is refused by
guardRequest at run time (ADR 0085), so today every author meets this rule as a failure rather
than as a default. groundedRequest types instructions as string, so the natural mistake does
not compile; the runtime refusal stays as the second belt. One message per passage rather than a
join, because taint is attached to a value: joining means unwrapping first, and provenance stops
travelling exactly where it starts to matter.
checkCitations / assertGrounded. A claimed citation outside the retrieved set was not
misread from a passage — there was no passage. Because the retrieval is recorded, the set of
documents the model could possibly have read is known exactly, which makes this the one
hallucination in a RAG answer that is decidable rather than judged. It costs a set membership
test and is almost never written. It deliberately does not check whether the answer’s claims follow
from the cited passage: that needs a judge, it belongs to @loom/learning’s evaluation surface, and
merging the two would turn a decidable check into an approximate one.
D4 — What this does NOT reopen
No knowledge$ construct (0090 D5 stands): it would encode documents/chunks/citations as
kernel vocabulary. No knowledge SEAM: there is nothing to plug — ranking is MemoryStore’s
seam, embeddings are a third-party backend’s business, and a KnowledgeBase with an alternative
implementation would differ only in its manifest layout. The user’s request named “a seam or at
least a package”; the honest answer is that the package is the right half of that disjunction, and
the seam it would have needed already exists one layer down.
D5 — What a passage puts in the index is part of the package’s contract
A store indexes the string and number LEAVES of an entry’s content, so the shape a passage
is stored in decides what it matches. Three of its five fields are therefore stored
differently from how they are returned:
docIdandindexare not stored incontentat all. Storing them would make a document’s own id a term matching all of its passages, and the chunk ordinal a term makingsearch("0")match the first passage of every document in the corpus. Both are the widening that made a tainted value’soriginunindexable (ADR 0090 D2), and both are what the SPI already forbids forMemoryEntry.id. They survive in the entry id —docId#index, required to round-trip and never indexed — which is the second place that requirement earns its keep. A corpus entry with no parseable passage id is REFUSED on read rather than yieldingdocId: undefined, which would flow straight into a citation.titleandmetaARE indexed, deliberately for the first and by the same rule for the second. A question is often phrased in a document’s title.metais the caller’s own vocabulary, which makes it useful as a filter and surprising as a relevance term: a{ team: "payments" }stamped on 300 documents makes “payments” match all 300. Pinned by a test rather than left to be discovered.
Consequences
sync is proportional to documents, not to the corpus. One manifest read (ids, counts and
hashes — never passage text) plus writes for what genuinely changed. The cost that remains is that
manifest read, which grows with the number of documents; a corpus of hundreds of thousands of
documents would want the manifest paged or indexed, and this is the point at which that becomes a
real design question rather than a hypothetical one.
A backend without remove pays O(scope) per document edit. Correct, and slow in a way that is
invisible until the corpus is large. The plugin-authoring guidance is now “implement remove if
the backend can express a delete”, and both first-party SQL backends do it in one indexed
statement — with the SQLite suite asserting the query plan actually USES that index, since an
index the planner ignores turns the fast path back into the slow one with extra steps.
@loom/memory was missing from SPI_PACKAGES, and had been for as long as the seam
existed. loom plugin check reported a green “SPI packages are peer dependencies” to every
memory backend while being structurally unable to see the one package that matters to it —
the same vacuity the list’s @loom/learning entry was added to fix. For this seam a bundled
copy is not a duplicate kernel but a duplicate CONTRACT: searchableText, tokenize and
clampSearchLimit decide what a backend indexes and what counts as a term, so a plugin
carrying its own copy can answer a different question than the app asked and still pass
conformance. Fixed here because ADR 0091 is what made a third-party memory backend likely
enough to matter.
The manifest occupies a second scope (<scope>::docs) in the same tier. Anything walking a
tier’s scopes sees it. Keeping it in the corpus scope was the alternative and it is worse: a
manifest row in a searchable corpus is an entry that matches queries and answers nothing.
Follow-on — the wiring surfaces (2026-09-07)
Three DX surfaces, none of them a new decision, all of them the same question: a corpus lives in
the app’s memoryStore, and nothing connected the two.
app.knowledge(scope) opens one over the configured store, cached per scope. Its two refusals are
the content: options on an already-open corpus throw rather than being ignored (two parts of an
app configuring one corpus with different chunkers chunk it differently on alternate ingests, and
the only symptom is retrieval quality), and a corpus tier that a registered memory$ would evict
throws with the escape named — both run over one store, and only the runtime that READS applies its
own policy, so app.memory would evict passages the corpus manifest still counts.
loom knowledge sync | list | search operates a corpus from outside a flow. sync is a dry run by
default, because the reconcile deletes what the source no longer lists; its preview is
KnowledgeBase.planSync, the same classification sync runs, so it previews the real operation and
not a second implementation of it. search prints that the retrieval was not recorded — the
ops escape hatch ADR 0090 named, made visible in the output rather than only in the code.
loom doctor gains the memory-store check, whose two outcomes are chosen rather than uniform: an
unconfigured store is a SKIP that names the cost (0090’s opening symptom), a configured store
without search is a FAILURE (someone chose that backend, and relevance retrieval refuses it at run
time). LoomStores.memoryStore is therefore present only when CONFIGURED — the distinction is the
signal, and it is what lets loom knowledge refuse to ingest into a store the process discards.
The second consumers (ADR 0083) are the CLI itself and runbook-recall, which builds its app,
opens its corpus from it, and registers the flow — the ordering app.knowledge imposes, documented
rather than hidden.
Follow-on — two writers, one corpus (2026-09-07)
@loom/knowledge was designed around a corpus whose documents are authored elsewhere and
reconciled from a source of truth. Asked whether knowledge that comes from a FEEDBACK LOOP fits
the same structure, the answer is mostly yes — a learning is a document whose author is the
agent, putDoc writes it, taint keeps it out of instruction positions, and ctx.decide makes a
replay reconstruct what the agent knew then. What did NOT fit is sync: a destructive
reconcile over everything the manifest holds would delete every learned document on the next run
of a nightly wiki job, because they are not in the set the wiki produced and never will be.
DocRecord.source names the writer that may revise or delete a document, and a reconcile only
ever touches its own. Three details are the decision:
undefinedis a source, not a wildcard. An unstamped corpus behaves exactly as before, and a named source cannot quietly adopt documents nobody claimed.- An absent document has no owner, so any writer may create it. The rule is about not taking someone else’s, not about permission.
- A write to a document another writer owns is REFUSED, not re-stamped. Re-stamping makes the
two writers fight — each
syncclaims the document and the other’s nextsyncdeletes it — so the corpus flaps between revisions forever while every individual run looks correct.
SyncResult.retained reports what a reconcile protected. “Deleted nothing” and “protected four
documents it does not own” describe very different corpora, and only one of them is something an
operator should stop noticing.
What this deliberately does NOT do is judge a learning. Whether one was any good is
@loom/learning’s question — trajectories, propensity, off-policy evaluation — and giving a
corpus a notion of reward would duplicate that substrate badly.
Evidence
packages/knowledge/src/KnowledgeBase.test.ts— re-ingest does not duplicate; a revision’s old passages stop retrieving (not merely rank lower); a shorter revision leaves no orphans;syncwrites NOTHING for an unchanged source (asserted by recording store calls, since “no-op” and “rewrote everything” produce identical corpora); superseded manifest rows are compacted; and the crash test — a store that throws mid-ingest — which found the non-atomic manifest update described in D2.packages/knowledge/src/durable.test.ts— the same operations overSqliteMemoryStore: a revision superseded in the FTS INDEX and not merely in the rows, and a corpus (passages and manifest) surviving a process restart.packages/memory/src/remove.test.ts— the fallback proven live on a store with neitherremovenorreplace, and proven to preferreplacewhere there is one.runMemoryStoreConformance— 36 cases now (was 25), run by four implementations; the new ones cover id round-trip,replacepreserving ids, an id NOT being searchable text, and sevenremovecases.plugins/sqlite/src/SqliteMemoryStore.test.ts—removemaintaining the FTS index (the kit cannot see this: the search join hides a stale index), and theEXPLAIN QUERY PLANassertion.packages/knowledge/src/KnowledgeBase.test.ts— the searchable projection of a passage (D5), pinned in both directions:docId/indexabsent fromsearchableText,title/metapresent.tools/cli/src/plugin.tsviaSPI_PACKAGES— verified by runningcheckPluginover a manifest that bundles@loom/memoryand watching the warning appear, since a rule that cannot fire looks exactly like one that passes.
Source: docs/adr/0091-corpus-lifecycle-and-the-knowledge-package.md in the loom repository.