ADR-0024: A purge is a build; a published index is never written
- Status: accepted
- Date: 2026-08-08
- Deciders: Theurian maintainers
- Requirements: FR-R2, NFR-4, NFR-7, SEC-13, T-10, T-17a
- Answers the open question in ADR-0022
- Narrows the index half of
ADR-0018's "the derived index has no
single-writer contract at all": a published build is never written, so there is
no live file for a second writer to reach. That property is decisions 1 and
2 plus the naming discipline, not decision 4 — every production writes a new
file under a fresh ULID and a
.buildingsuffix and publishes byos.replace, and three tests refuse the alternative (test_building_over_an_existing_file_is_refused,test_a_purge_into_an_existing_path_is_refused,test_a_purge_refuses_to_write_over_another_writers_building_file), withtest_a_purge_leaves_the_published_build_untouchedholding the published build byte-for-byte. It does not discharge ADR-0018's contract. This line read "Discharges the index half of…" until 2026-09-01, and decision 4's correction below records the measurement that retired the word. The contract itself is owed and unscheduled, tracked in #439.
Context
ADR-0022 closes by asking a question it does not answer:
So blue/green has to answer a question this ADR has not been asked yet: whether a purge produces a new build and swaps the pointer — which makes it an ordinary build under points 5 and 6, at the cost of rewriting the whole file to remove a few rows — or mutates the published build in place, which is a write to the file searches are reading and needs the writer discipline ADR-0018 owes for the index.
The question exists because Milestone 6 has to remove withdrawn rows from the
index. #15 (T-17a) is the
reason: FTS5's bm25 weights every visible row against collection statistics
computed over every row in the file, so a document retired since the last build
reweights the visible ones. The visibility gate removes rows from the result; it
does not remove them from the statistics the survivors are scored against.
Tombstones do not substitute, because a tombstoned row is still a row those
statistics count.
Two facts already recorded constrain the answer.
- ADR-0022's own guarantee was withdrawn, not delivered. Point 6 promised
that "the previous build is not deleted when a new one is published. A search
already reading it keeps a consistent view." The Milestone 5 amendment
withdrew it:
theurian index buildnow reaps every build the pointer does not name, becauseSqliteIndexStoreholds no connection between calls andsqlite3.connecton a deleted path creates an empty database there. NFR-4 — "the previously published index answers every query while a new build runs, zero read downtime" — is therefore unmet. - ADR-0018 has no contract for this writer.
theurian index buildis "serialised by nothing but the fact that a person runs it", and a purge is a second writer to a file searches are reading.
The phrase that decides the question is "at the cost of rewriting the whole file". That cost was assumed and never measured, and it is the only reason the in-place option looks attractive. So it was measured first.
What "rewriting the whole file" actually costs
The assumption conflates two different operations. Re-deriving a build reads the canonical store, chunks every revision, embeds every chunk and writes a new file. Copying a build and deleting rows from the copy re-derives nothing. Both produce a new file and a pointer swap; only the first is expensive, and the expensive part of it is the derivation, not the file.
Measured on an Apple M-series laptop, APFS, CPython 3.13.3, SQLite 3.47.1, over a corpus of mixed English and Japanese documents of ~3,000 characters with embeddings enabled — median of three runs, eight documents withdrawn:
| Corpus | Index | Re-derive (index build) |
Purge as a new build | Ratio |
|---|---|---|---|---|
| 400 documents, 1,996 chunks | 12.3 MB | 2,614 ms | 51 ms | 51× |
| 1,600 documents, 7,874 chunks | 48.5 MB | 10,957 ms | 175 ms | 63× |
| 5,000 documents, 24,481 chunks | 150.3 MB | 37,684 ms | 579 ms | 65× |
The ratio is flat across a 12× corpus range because both terms are linear in the corpus and the constants differ by two orders of magnitude. Rewriting the whole file to remove a few rows costs about a sixtieth of "an ordinary build". The premise the in-place option rested on is false.
"A few rows" is load-bearing in that sentence, and the ratio is a property of it. Every figure above withdraws eight documents. A purge costs a whole-file copy plus a delete proportional to the rows removed, while a re-derive is proportional to the corpus, so the ratio falls as the withdrawn fraction rises. Measured on 800 documents, a 24.5 MB index, against a 4,735 ms re-derive:
| Withdrawn | Purge | Ratio |
|---|---|---|
| 8 (1%) | 67 ms | 70.6× |
| 80 (10%) | 152 ms | 31.1× |
| 200 (25%) | 506 ms | 9.3× |
| 400 (50%) | 931 ms | 5.1× |
| 720 (90%) | 1,408 ms | 3.4× |
| 800 (100%) | 1,513 ms | 3.1× |
It never inverts — a purge is cheaper than a re-derive at every fraction, including withdrawing the entire corpus — so the decision does not turn on this. What turns on it is the shape of the argument: "a sixtieth" describes the case this design is for, which is a purge triggered per withdrawal (decision 5) and therefore removing few documents at a time. A caller who retired a quarter of a knowledge base in one operation would see 9×, not 60×, and would still be right to purge.
What an in-place purge costs
The saving an in-place purge buys over a copy is the copy: 175 ms on a 48.5 MB
index, against 24 ms for the DELETE alone. What it spends is a torn read.
SqliteIndexStore opens and closes a connection per call, and one
RetrievalService.search calls it several times. So an in-place purge does not
have to race a statement to be observed — it only has to land between two of
one request's connections, which is a window of milliseconds on every search
rather than a narrow one. WAL gives a consistent view of a statement, not of a
request.
Measured deterministically, by firing the purge from a wrapper exactly once
between search_lexical and search_substring, with the visibility gate
withholding nothing so that only the index decides. 38 of 300 documents
withdrawn, one query:
| Candidates | From withdrawn documents | |
|---|---|---|
| stale — the index still holds them | 69 | 25 |
| purged — the index no longer holds them | 64 | 0 |
| torn — the purge landed mid-request | 81 | 15 |
The torn response equals neither corpus's answer. It is a fusion of a lexical ranking scored against the pre-purge collection statistics with a substring ranking scored against the post-purge ones, and nothing in the response says so. In production the visibility gate still removes the 15 withdrawn rows from what is published; what survives is the scoring, which is exactly the T-17a channel the purge exists to close, held half-open for the duration of every purge.
What the purge has to prove, and what it depends on
The property is not "the withdrawn rows are gone from the result" — the gate already does that, and T-17a is the demonstration that it is not enough. It is:
An index that held the withdrawn rows and had them purged answers identically to an index that never held them.
Measured as one query against three corpora, with stale as the control so the
comparison can fail. 400 documents, every eighth withdrawn and ten times the
length of the rest — avgdl is the channel review round five measured, and a
withheld document of average length moves it least:
| Query | stale (gated) vs fresh | pruned vs fresh |
|---|---|---|
retention isolation |
different, order differs | identical |
authentication token |
different scores | identical |
quarantine ledger |
different, order differs | identical |
connection pool |
different scores | identical |
認証トークン |
different scores | identical |
接続プール |
different scores | identical |
isolation |
different, order differs | identical |
認証 |
same | identical |
"Identical" is the full ranking, chunk ids and BM25 scores to ten decimal places, untruncated.
The implementation property this rests on: FTS5's 'delete' command, issued
by chunks_fts_delete and chunks_trigram_delete, stops the row's postings
being matched and decrements the averages record — nRow and the per-column
total sizes — that bm25 reads as N and avgdl. Deleting the chunks row is
what makes the statistics equal, not merely what makes the row absent. If a
future FTS5 kept the averages record and only stopped matching the postings,
every line of that table would still say "identical" for nHit and stop saying
it for avgdl, so the fixture must carry a long-document configuration or it
stops testing the channel it names.
Corrected on 2026-09-02 in PR #498's round-one review. This sentence said
'delete'"removes the row's postings and decrements the averages record", and only the second half is true.'delete'writes a tombstone: the postings stay in the segment structure until a merge, and nothing in the shipped purge merges. The equality this section measures is real, and its stated mechanism was half wrong. The averages half is what carries the equality — the record is decremented, which is why the rankings above are byte-identical — and the postings half is not, and has a measured cost of its own.Measured at 5,950 withdrawn rows (visible 50), a purged build against its own
optimized copy against a never-held build: 1,481 trigram blocks and 5,403,892 trigram bytes purged; 11 and 33,439 optimized; 13 and 35,638 never-held — 151× the postings for rows the build no longer serves — with the substring scan at 16.8 ms, 1.1 ms and 1.2 ms and every response identical. End to end the duration was monotone in the withdrawn count at that measurement: at 5,950 withdrawn a request cost +27.4 ms more than at nothing withdrawn — the round-one measurement, +27.36 ms — and six later re-runs give +27.59…+28.18 ms, so the delta is the stable figure across separate runs while the ratio moves with its denominator: 5.08–5.67×, median 5.41×. The conclusion moves on neither. That crossed the threat model's 1.40 ms end-to-end floor (TB-1) between 500 and 1,000 withdrawn rows. Theoptimized comparison above is the source table's; aVACUUMapplied in the reproduction lands at the same 241,664 B.So this section's content conclusion is unchanged and the implementation property under it is narrowed, above, to what
'delete'actually guarantees. The residue and its closure — merging inside the purge — are owned by #499, which is a face of T-17a (the index still holds the withdrawn rows, surviving at the FTS5 segment level) rather than a new class; its recorded closure is the merge or an acceptance carrying the measured bound. The threat model's T-17a entry carries the full measurement, and these records move again when #499 lands.Amended in Milestone 7. #499 landed on 2026-09-04 in PR #545, and this is the move the sentence above promised. The correction stands as written —
'delete'does tombstone rather than remove, and the equality above still rests on the averages record alone — but the clause it ends on, "nothing in the shipped purge merges", is no longer true of the shipped purge. What implementing it revealed is that the merge belongs after_restamp, not inside_deletewhere the issue sketched it:_restamp's per-nodeUPDATEfires the node FTS triggers, so it writes a tombstone per surviving node on every purge, including one that withdraws nothing. Merging before it leavesnodes_ftsat 1.50× andnodes_trigramat 1.77× a never-held build, against 0.75× and 0.89× merging after. The later placement is the better answer because nothing writes to a full-text index after_verify, which is a property a test can pin without naming today's writers — and the placement is pinned that way, as idempotence: re-merging a published build must find nothing to remove (tests/integration/test_purge_full_text_discovery.py). The structural property this ADR cares about — a purged build holding no more of the segment structure than one that never held the withdrawn rows — istests/integration/test_purged_build_structure.py, which was written RED against the pre-merge purge.
index_purge._merge_full_textissues an FTS5optimizeover every full-text table it discovers in the build's ownsqlite_master. Discovered rather than listed, for the reason this ADR's own history demonstrates: the schema carried two of these at v3 and carries four at v4, and #499's sketch says "both FTS5 tables" — the v3 count. A merge over a written-down tuple is correct the day it is written and silently partial the day the next table lands.The measured result: a purged build's posting bytes at 0.91× and 0.95× a never-held build's on the chunk tables and 0.75× and 0.89× on the node tables, flat across the withdrawn sweep at a spread of 1.01× and 1.00× where the unmerged purge spread 6.40× and 9.52×, and query duration flat with them — 0.84–0.99× on one instrument and a non-monotonic spread of ≤5.7% on another, against control arms at 3.61×, 4.22× and 12.18×, three independent instruments in PR #545's round one. Priced on two axes, because they do not read alike and one number would misdescribe whichever case the reader had: 1.60–1.70× the purge's own duration across a twentyfold span of index size, and 6.4× falling to 1.15× as the withdrawal count rises — worst exactly where the purge is cheapest, a residue-cleanup purge, and there 472 ms on an 11.6 MB index. The absolute cost stays bounded by index size on both axes, and about an order of magnitude under this ADR's own 2,614 ms / 37,684 ms for re-deriving a build, so the recorded-acceptance fallback this ADR allowed for was not needed. Responses were byte-identical before the merge and are byte-identical after it: what the clock carried was the withdrawn count, and it no longer does.
What is left is not this ADR's. The purged file is the same size with the merge as without it, and the merge inverts its composition — 95.9% free-list pages where three quarters used to be live segment blocks. That byte face is #344's, recorded there with the measurement, and it reaches no caller:
fts5vocabover the published build returns nothing for the withdrawn-only marker that survives in raw bytes, and no MCP surface publishes an index size.
認証 is the one row where the control agrees, and that is the expected answer
rather than a weak fixture: two characters fall below the trigram floor and are
answered by the scan, which ranks by occurrences counted inside each row and
reads no collection statistic. #15 says so; this measures it. The purge is not
what fixes that path, because nothing there was broken.
Decision
A published index build is immutable. Withdrawal produces a new build and swaps the pointer, exactly as ADR-0022 points 5 and 6 describe.
-
Nothing writes to a file
active-index.jsonnames. From the moment the pointer names a build, that file is read-only for the rest of its life. This is the rule; everything below is how it is kept. -
A purge build is derived from the previous build, not from canonical state. Copy the published build to a new
theurian-index-<newId>.sqlite,DELETE FROM chunksthe withdrawn revisions in the copy, publish the copy. The FTS5 delete triggers carry the removal into both the word and the trigram index;embeddingscascades, becausePRAGMA foreign_keys = ONis inCONNECTION_PRAGMASand is applied to index connections too.
The copy inherits the parent's identity, and the purge must overwrite it.
Connection.backup copies pages, so index_metadata.index_build_id in the
new file still names the build it was copied from, and built_at still
records when that was made. Neither column is served — mcp/search.py
publishes indexBuildId from the pointer — so a disagreement here reaches no
caller. What separates the two columns is whether anything reads them at all:
index_build_idis read back.SqliteIndexStore.add_nodesselects it out of this file's ownindex_metadatato stamp each summary node with the build it belongs to, rather than taking it as an argument that could disagree with the file it writes into. The first reader this paragraph predicted has arrived, and it arrived inside the purge:purge_intoruns the forest recompute before_restamp, so the nodes it writes are stamped with the parent's id and_restamp's second statement —UPDATE nodes SET index_build_id— is what repairs them. That statement was added after a purge was measured leaving a survivor naming the build it was copied from (test_restamp_updates_survivors_index_build_id_too), which is this paragraph's own prediction landing one level down.built_atis written and never read, so for that column the original reasoning stands: the first thing to read it would find a file whose own record of itself disagrees with the pointer that names it, and would find it a long way from here. Measured at6b83be1,git grep -nw built_at packages/theurian-core/srcreturns six lines: theindex_metadatacolumn declaration, thecreateINSERT and the_restampUPDATE, plus three on the unrelatedfindings_metadatatable — a declaration, a comment and an INSERT. No SELECT of it anywhere.metadata()doesSELECT *, so the value is fetched, but neither of its two callers (retrieval_service.py,withdrawal_purge.py) reads that key.
Corrected in the #199 unit-A follow-up (#426). This paragraph said "nothing in
src/reads either back today — so this is latent rather than broken". That was true of both columns when the decision was written and is now true ofbuilt_atonly. The restamp discipline this decision asked for is what kept the record honest across the change: the reader arrived on the purge path itself, met the stale id exactly as predicted, and was already covered because_restamphad been extended tonodes. So the conclusion is unaffected; only its premise had to be split per column, and the "latent rather than broken" framing now belongs tobuilt_atalone.
- The copy is
sqlite3.Connection.backup, notshutil.copyfileand notVACUUM INTO. Measured on a 49.2 MB index with rowid gaps — the state every build after the first purge is in:
| Mechanism | Includes uncheckpointed WAL content | chunks rowids stable |
Cost |
|---|---|---|---|
shutil.copyfile |
no — lost 100 committed rows | yes | 86 ms |
VACUUM INTO |
yes | yes, observed | 157 ms |
Connection.backup |
yes | yes, by construction | 136 ms |
copyfile is disqualified: the -wal sidecar is a separate file, so a copy
taken while a writer holds committed-but-uncheckpointed content is a database
missing that content, silently.
The row above understates it, because the corpus it was measured on already had its schema checkpointed. When the uncheckpointed pages carry the schema rather than only rows, the copy has no table at all:
committed rows in source: 100 wal present: True (8272 bytes)
copyfile -> OperationalError: no such table: t
backup -> 100
VACUUM INTO is correct today and rests on something SQLite declines to
promise — the documentation says VACUUM "may change the ROWIDs of entries in
any tables that do not have an explicit INTEGER PRIMARY KEY",
chunks.chunk_id is a TEXT primary key, and chunks_fts and chunks_trigram
are content='chunks', content_rowid='rowid', so a renumbering would silently
repoint every posting in both indexes. It was observed stable on 3.47.1,
including on a gapped table. A design resting on observed-but-unpromised
behaviour becomes a silent corruption at the next release. backup copies
pages, so rowid stability is not a behaviour it could get wrong, and it is the
faster of the two.
- A purge is produced the way a build is: a new file, written through
SqliteIndexStoreand the purge module it delegates to, and published by a pointer swap.IndexStore.createand the purge are both productions of a new build; publishing is a separate step, and it writes a pointer rather than an index. Nothing outside those two modules opens an index file for writing — which is what makes point 1 keepable, because a writer that cannot reach a published file cannot collide with a reader of one.
Corrected on 2026-09-01 against measurement (#445); the work log is
docs/work-logs/2026-09-01-472-purged-build-re-measurement.md. This point made three claims. One holds and two were false when they were written, and the point above is narrowed to the one that holds. What it said is quoted here rather than deleted, because this ADR's header line and its Compliance section both rested on the pair that did not.It read: "A purge goes through the same single-writer interface as a build, and there is exactly one such interface. This is ADR-0018 point 1 applied to the index for the first time.
IndexStore.createand the purge are both productions of a new build; publishing is a separate step that takes the index write lock. Nothing outside that interface opens an index file for writing."False: "publishing … takes the index write lock". There is no index write lock for it to take. Publishing is
write_active_index_pointerinapplication/project_service.py— a write-to-temp plusos.replace, and nothing else. Measured by calling the purge's publish half exactly asapplication/withdrawal_purge.pycalls it: the lock file at<root>/.theurian/runtime/write.lockdoes not exist before the publish, does not exist after it, and is never held, while the pointer is written. The source already said so in the files that do the work —withdrawal_purge.pyrecords "No new index-write lock is taken" andproject_service.pyrecords "The purge holds no index-write lock" — so this point contradicted the code and ADR-0018's own record, which #436 corrected to owed. Statically, atfe2925c:
sh git grep -nE "flock|lockf|LOCK_EX|LOCK_SH|LOCK_NB|write_lock|WriteLock" \ -- packages/theurian-core/src # -> 33 lines over 5 filesNot one of the 33 is an index write path. Thirty-one are the state-database
WriteLockfamily — the class, its timeout error and itsflockcalls ininfrastructure/sqlite/connection.py(10), theProjectPaths.write_lockproperty inapplication/project_service.py(1), and the lines threading it throughcli/commands.py(14) andcli/migration_pipeline.py(6) towrite_transaction— and 2 aredaemon/instance.py's single-instance lock. The same key restricted to the five index-writing modules (index_store.py,index_purge.py,withdrawal_purge.py,index_builder.py,cli/index_commands.py) returns 0, against 33 over the whole tree, which is the control that says the key can match.The count is a dated measurement and it moves — it was 19 at
ec0dbcdand #478'smigrate applyserialisation took it to 33 without adding an index lock. The classification is the claim, and it is held onmainbytest_every_lock_in_the_package_belongs_to_one_of_the_two_known_familiesinpackages/theurian-core/tests/unit/test_adr_0018_claims.py: every lock line under the package source must fall in the state-database family or the daemon's single-instance lock, and its failure message says that a lock landing outside them means this point has to be re-decided against it rather than corrected as pending.The pin's reach is narrower than "every lock", and it records its own limit — cite it with that limit or the citation overclaims. Its key sees two
fcntlcalls, threefcntlflags and names built on the existing lock's stem, and nothing else;threadingprimitives,asyncioprimitives and SQLite's ownBEGIN IMMEDIATEidiom are outside it, and three uncovered examples live in this package today with the sweep green over all of them:threading.Lock()atinfrastructure/determinism.py:47,threading.BoundedSemaphoreatmcp/tools.py:505, andBEGIN IMMEDIATEatinfrastructure/sqlite/connection.py:318. So "no index write lock" is held against thefcntlfamily and the existing lock's naming, which is the shape an index write lock would realistically arrive in — not against every conceivable mutual exclusion. Widening the key means classifying those three, which is separate work;KNOWN_LOCK_FAMILIEScarries the limit.False: "there is exactly one such interface". There are eleven writable opens of an index file, across two modules, with no common gate. An AST walk over
index_store.pyandindex_purge.pyfor call sites of the two module-private factories that hand out a writable connection (_connect,_writing), plus the one rawsqlite3.connect(target)neither factory covers, atfe2925c:
Site Enclosing function Visibility index_store.py:545createpublic index_store.py:559add_chunkspublic index_store.py:626add_nodespublic index_store.py:685add_node_embeddingspublic index_store.py:835delete_nodes_grounded_in_chunkspublic index_store.py:902add_embeddingspublic index_store.py:917record_embedding_modelpublic index_purge.py:526_copyprivate index_purge.py:545_deleteprivate index_purge.py:663_restampprivate index_purge.py:690_verifyprivate Seven public methods on
SqliteIndexStoreopen an index file for writing, plusderive_purged, which delegates toindex_purge.purge_into. Nothing serialises them against each other, and nothing serialises either module against the other. "Exactly one interface" is true only if interface means "theIndexStoreport plus the purge module it delegates to" — a layering statement, not the single-writer contract ADR-0018 point 1 defines.Holds: "nothing outside that interface opens an index file for writing", and this is the clause the narrowed point keeps.
git grep -n "sqlite3.connect(" -- packages/theurian-core/srcreturns eleven code lines atfe2925c, classified by target: three writable index opens (index_store.py:265's_connect,index_purge.py:394's_writing,index_purge.py:526's copy writer), twomode=roindex opens (index_store.py:303,index_purge.py:525), three state-database opens (connection.py:216,:237,:315), two findings-store opens (findings_store.py:203,:335), and one:memory:(index_store.py:246). Every writable index open is insideindex_store.pyorindex_purge.py. The one call that looks like a counterexample —recompute_forestreachingdelete_nodes_grounded_in_chunksandadd_nodesfrom the application layer inwithdrawal_purge.py— writes to the building filepurge_intohands it, not to the published build, and reaches them throughSqliteIndexStoreeither way. Confirmed at runtime bytest_a_purge_leaves_the_published_build_untouched(tests/integration/test_index_purge.py), which holds the published build byte-for-byte across a realderive_purged.
- A purge is triggered by the withdrawal, not by a person remembering. Whatever retires, supersedes or rejects a revision publishes the purged build in the same command. The window in which the index holds a withdrawn row is then the duration of one command, rather than "until someone runs a rebuild".
This is what bounds T-17a, and the swap is not what bounds it. The swap protects the next window; it does nothing about a response already served from a build that still held the row. Nothing in this ADR remedies bytes already sent, and the only quantity under its control is how long the stale build stays published.
-
Publishing never deletes. Reclaiming becomes explicit
theurian index gc. Measured against a reader that had already resolved the pointer, with the old build reaped the way_reclaimreaps it today: 1,889 errors against 163 successful searches in 1.5 seconds, each errorIndexUnreadableError: ... no such table: chunks_fts, and a fresh empty database left at the reaped path afterwards.theurian index gcis already named by ADR-0007, ADR-0016 and ADR-0017 and does not exist; this is where it lands. Its reap rule is_reclaim's — only builds whose ULID sorts below the published one, so two concurrent producers cannot delete each other's work. -
A search holds one connection to its build for the whole request. This is what makes NFR-4 true rather than aspirational, and it is the half of point 6 that retention alone does not buy. Measured: a held connection keeps answering after its file is unlinked, in all three configurations —
journal_mode=DELETE, WAL with the sidecars kept, and WAL with all three files unlinked. A new connection at the same path does not:is_searchable()returnsFalse, the query raisesno such table: chunks_fts, and a file exists at that path again afterwards.
Index connections are therefore opened file:<path>?mode=ro with uri=True
(_open_read). Measured: the default sqlite3.connect creates a database at
a missing path; mode=ro raises unable to open database file and creates
nothing. That is what turns "the pointer outlived its file" back into the
fallback ADR-0022 promised, instead of an empty index that reports itself
healthy. The path is escaped into the URI rather than interpolated, because a
filename containing ? would otherwise be read as a query parameter and
override the mode.
Landed. The whole read surface goes through _open_read, and
test_a_read_of_a_missing_index_creates_no_file pins seven of the eleven
public read methods that raise on a missing file. The pragmas it runs rest on
index files being WAL, which test_a_built_index_is_always_in_wal_mode pins.
Coverage corrected on 2026-09-02 in PR #498's round-one review: this said "over every read method", and it is seven of eleven. The population is a runnable key rather than a description, because the first statement of it described a procedure and the count moved when a second reader chose a different key:
sh uv run --frozen python -c " import ast,pathlib s=pathlib.Path('packages/theurian-core/src/theurian/infrastructure/sqlite/index_store.py');src=s.read_text();L=src.splitlines() c=next(n for n in ast.parse(src).body if isinstance(n,ast.ClassDef) and n.name=='SqliteIndexStore') r=sorted(n.name for n in c.body if isinstance(n,ast.FunctionDef) and not n.name.startswith('_') and n.name!='session' and 'self._read' in '\n'.join(L[n.lineno-1:n.end_lineno])) print(len(r),r)"12 at
1a37c86:chunk_count,chunk_texts,holds_any_revision,metadata,raptor_path,schema_version,search_dense,search_lexical,search_substring,search_summaries,surviving_chunks,texts.The 11 and the 12 are two keys, not a disagreement, and the relation is one line: 12 is the public methods that reach
self._read; 11 is those that raise, which is 12 minusschema_version, whose contract is to return0on an unreadable build.sessionis excluded from both — it is the context manager the others open through, not a read — andis_searchableappears in neither, because it reaches_readonly by callingschema_version()and returnsFalserather than raising. Of the 11, 7 are parametrised —chunk_count,chunk_texts,metadata,search_dense,search_lexical,search_substring,texts— and 4 are not:holds_any_revision,raptor_path,search_summaries,surviving_chunks.The behaviour underneath is correct, and that is why this is a coverage gap and not a defect. Each of the four unpinned methods was driven against a missing index path (2026-09-02, this branch): all four raise
IndexUnreadableErrorand none creates a file. So the decision holds; what is narrower than the sentence is the pin, and a read method added later that forgets_open_readis caught only if it is one of the seven.
-
Withdrawal is transitive over derived content. A node whose text is derived from a chunk — a RAPTOR summary (ADR-0008) is the case this project will have — is not withdrawn by deleting the chunk. A purge can delete a row; it cannot delete a sentence out of a summary. So:
-
the index schema records, for every derived node, the chunk ids it was derived from;
- a purge deletes or recomputes every node reachable from a purged chunk through that relation, transitively, before it publishes;
- a derived node whose derivation edges cannot be resolved is deleted, not kept. An unresolvable edge is the state a schema migration or a partial build leaves, and keeping the node is the failure mode this point exists to prevent.
Recorded now, with infrastructure/raptor/ an empty package and
SummarizationProvider a port with no adapter, because the alternative is
designing the purge twice. T-10 makes cross-sensitivity mixing structurally
impossible at build time and says nothing about withdrawal after build;
this point is that gap, closed before the thing that opens it is written.
Amended in Milestone 6. The rule holds; the storage it was implemented on does not, and "deletes or recomputes" is now decided. ADR-0008, amended the same day, puts summary nodes in their own tables at index schema v4 rather than in
chunksrows withderived = 1: summary text repeats its children's terms, so nodes sharingchunks_ftswould moveN,avgdland the per-term document frequencies under every ordinary leaf query, and a visible leaf's rank would become a function of the forest's shape. That ADR also decides what "deletes or recomputes" resolves to for a summary node: withdrawal re-derives each affected tree from its surviving rows. Deleting the node leaves the purged index missing a node the never-held corpus would have — the same equality this ADR rests on, broken in the other direction — while a recompute confined to the existing node cannot reproduce a threshold or a clustering decision that the corpus itself determines.The cost model here is untouched, and is narrower than it may now read. The 51× to 65× copy-not-derive measurement is about the chunk index, and it stands: a purge still copies chunks rather than re-deriving them. What ADR-0008 adds is a re-derivation term for the affected trees only, never for the corpus, unmeasured and owed with the CL that closes the purge over nodes. Nothing in this ADR's tables measured a forest, because there was none to measure.
A failed purge over nodes lands in the residual that already exists rather than opening a new one: nothing is published, the stale build keeps serving, and
migrate applysays so —indexPurgecarriespublished: false,failed: trueand aremedynaming the rebuild (cli/commands.py). That is T-17a's residual 2, unchanged in kind by there being nodes in the build.Amended by GHSA-97q9-xxfg-33r6. The paragraph above was wrong on both counts. A failed purge over nodes did open a new channel: a
--raptorbuild's summary node keeps its build-time text, and a visible sibling leaf'sraptorPath[].titlethen carries a withheld document's content verbatim — a verbatim disclosure, not the statistical residual T-17a's residual 2 then described. And the stale build no longer "keeps serving": a purge failure now taints the active-index pointer (mark_active_index_purge_failed) and the serve path (mcp.search._published_index) stands the tainted build aside whole, degrading to the unranked canonical scan until a rebuild. The statistical and the verbatim face close together, and T-17a's residual 2 now records the narrower windows that remain (an in-flight request and a double disk fault). Pinned bytest_purge_failed_build_is_not_served.py.None of this point's three rules changes; the second one's open choice is simply now made. What changes is where their counterpart has to be written.
chunks.derivedandchunk_derivationare dropped at v4, because a column nothing will ever write serves nothing. Three places name them and move together:_DOOMED,_verify's unprovenanced-row post-condition, andIndexStore.holds_any_revision— whose unprovenanced clause is an executed SQL predicate, not a comment, and whichapplication/withdrawal_purge.pycalls as the pre-check on every withdrawingmigrate apply. Against a v4 index that predicate raisesno such table: chunk_derivation, so this drop reaches the withdrawal path and not only the purge. The traversal tests listed in Compliance below migrate to the node-table counterpart rather than being deleted — what they hold is this rule, and this rule stands. The third bullet — an unresolvable derivation edge means delete, not keep — survives the switch to re-derivation unchanged: a node that cannot say what it was built from cannot be rebuilt from it either. The amendment to ADR-0008 decision 5 names the owed tests.Landed at index schema v4. Everything above this note is the plan as it stood when the amendment was written; what follows is what the schema-v4 CL actually did, so that a later reader does not take the plan for the state.
nodes,node_derivation,nodes_fts,nodes_trigramandnode_embeddingsare inindex_schema.py;chunks.derivedandchunk_derivationare gone; and all three predicates named above moved together. The failure this amendment predicted for the third of them is reproduced rather than reasoned about: against a v4 index the v3 clause givesno such table: chunk_derivation, and it gives it even where the revision clause alone would have answered.They did not stay three predicates, and that is the correction to this note. It said
holds_any_revision's clause was "now twoSELECTs joined byUNION ALLbecause the two halves read different tables" — which is a second hand-written predicate, the arrangement v3 had and the one that let the pair disagree.holds_any_revisionnow runsindex_purge.ANY_DOOMED_ROW, composed from the same withdrawn-chunk and unanchored-node literals_DOOMEDis built from, so the pre-check is_DOOMEDminus an upward closure over an empty seed and the two agree by construction rather than by being kept in step by hand. Ten hand-enumerated graph shapes pin the equivalence (test_index_purge_nodes.py::test_holds_any_revision_agrees_with_whether_a_purge_removes_anything), each carrying its own chunk corpus so that no case can agree for the wrong reason through the withdrawn-chunk arm. A first draft shared one corpus across all ten, which made that arm answerTruewhatever the node shape; the mutation that drops the node arm survived it, and mutating the test is what found that.The disagreement was real, and closing it changed an outcome. A build whose only damage was a pre-existing dangling edge answered "nothing to purge" on the pre-check, so
migrate applyskipped it as clean without copying the file, while a purge run directly on that same build refused to publish over the one bad row: the pre-check called clean the very build a purge would not accept. Under the well-founded reading that node is exactly as ungrounded as one with no edges at all — it cannot be shown to hold nothing withdrawn — so it is removed and the build publishes.test_withdrawal_purge.py::test_a_dangling_edge_is_seen_by_the_pre_check_and_purgedpins the pre-check half andtest_index_purge_nodes.py::test_a_dangling_only_build_is_purged_rather_than_refusedthe direct-call half.Those traversal tests are six, not five — the count this amendment took from Compliance below was that section's, and it was already wrong. Compliance named five shapes while the suite held a sixth of the same family,
test_a_derived_row_that_cannot_say_where_it_came_from_is_deleted, which it never listed. All six migrated; the corrected list is in Compliance below. The population isrg "^def test.*deriv"overtests/integration/test_index_purge.pyat v3, which returns seven — the six traversal tests and_verify's unprovenanced backstop, which migrated too and belongs to the post-condition family rather than this one.
_verifygoes from three post-conditions to six, and one of the three it gains this point could not have named, because the v3 storage could not express the state it checks for: anode_derivationedge whose source chunk or source node is gone. One table made a dangling edge and an unprovenanced row the same state; two tables make them different ones, so a node can now hold an edge that points at nothing while still having an edge — which the unprovenanced count does not see. The other two are a node standing on a provenance cycle and an orphaned node embedding; the withdrawn- rows count also widens, from chunks byrevision_idto those plus nodes carrying a withdrawnsource_revision_idstamp. The cycle count is computed independently rather than by asking_DOOMEDa second time, for the reason_verifyexists at all: a post-condition computed by the function it checks cannot catch that function being wrong, and what publishes a build is a pointer swap with no later stage that looks. Nothing writes a node row yet, so all of this is still pinned over rows inserted with raw SQL.Amended in Milestone 6, by the extractive-provider CL. Both halves of "Recorded now, with
infrastructure/raptor/an empty package andSummarizationProvidera port with no adapter" are false now. That package holdsextractive.py, which implements the port. Nothing calls it — no builder maps aSummaryNodeonto a row — so this point's three rules and the raw-SQL-fixture state they were recorded against are otherwise unaffected, and the reason the purge was designed before the thing that opens it is unchanged.Written first as "this point's opening sentence is the one that changes", which corrected the adapter half and left the empty-package half standing.
Amended in Milestone 6, by the forest-builder CL. "Nothing writes a node row yet, so all of this is still pinned over rows inserted with raw SQL" is false, and so is "closed before the thing that opens it is written".
theurian index build --raptorwrites them, and this traversal now meets a graph a builder shaped rather than only fixtures the test that purges them wrote.tests/integration/test_forest_builder.py::test_withdrawing_an_item_takes_its_document_node_and_the_domain_node_above_itwithdraws one item of three: its Document node is ungrounded and dies, and the Domain node standing on that one dies with it by the upward closure, while the two unaffected Document nodes survive — a purge that took the whole forest would satisfy every assertion about the withdrawn item and destroy the property this ADR was accepted on.test_a_purged_forest_leaves_no_residue_in_a_node_text_indexreadsnodes_ftsandnodes_trigramthroughfts5vocab, which is the check a corpus that never held a node row could not make.The hand-written fixtures in
test_index_purge_nodes.pyare not made redundant by this, and that is a property of the builder rather than of the tests. A builder-written forest cannot reach three of the five unanchored arms: it writes every node before any edge in one transaction, and each node carries at least one source, so an unprovenanced node and an edge naming an absent node cannot occur; and it builds each tier only from the one below, so a provenance cycle cannot occur either. Those arms stay covered only by raw SQL, deliberately — they describe states a migration or a partial build leaves, not states this builder produces. ADR-0008's family-closure note records that under-correction and its cause.Amended in Milestone 6, by the purge-recompute CL. "Deletes or recomputes" is now re-derives, and the two-corpus equality this ADR rests on holds for the derived layer too. Until this CL a purge over nodes was delete-only: it removed every node the surviving corpus could no longer ground and stopped, which left the purged index missing a node a never-held corpus would have built from the survivors — this ADR's own equality, broken in the other direction. The purge now re-derives each scope that lost a row whole — every tree in it, over the surviving rows it reads back from the building file, coarser than decision 9's per-tree ancestor closure and subsuming it since the unaffected trees re-derive byte-for-byte — after the delete and before
_verifyand the swap, so an ungrounded re-derived node is refused by the same post-conditions a bad delete is. A purged forest then equals one built over a corpus that never held the withdrawn rows — node rows, derivation edges and node vectors alike, held bytests/integration/test_forest_purge_equality.py::test_a_purged_forest_equals_one_that_never_held_the_withdrawn_rowswith a stale control asserted different. This is the derived-layer counterpart of the chunk equality in the Compliance section's first bullet, and it is scoped to deterministic pure providers (the extractive default); a non-deterministic provider's delete-and-mark-stale fallback is recorded inmake_forest_recompute's docstring and built by nothing.The re-derivation is application-layer policy injected into this infrastructure purge, so ADR-0003's layering holds.
index_purge(infrastructure) may not name theForestBuilder, summariser and embedder the recompute needs, sopurge_intotakes an optionalrecompute_forestcallback and calls it; the composition root builds that callback (make_forest_recomputeinapplication/withdrawal_purge.py) closing over those collaborators. A passed-down callable, not an import up —test_layeringstill passes.Cost, still owed. The re-derivation term this ADR's decision-8 note left unmeasured — the derived layer of the affected scopes, never the corpus — is now code that runs and is still unmeasured; the 51×–65× copy-not-derive figures are the chunk index and are untouched. Index schema v5 adds
chunks.kind, which the re-derivation reads to key a Domain tree; the schema-mismatch rebuild (ADR-0022 point 3) is the whole migration, as at v4.Amended in Milestone 6, by the fan-out re-batch fix. The scope-clearing delete this note describes was, until this fix, keyed on the fresh trees rather than on the scope, and a re-batched Domain fan-out (ADR-0008 decision 2's amendment) reached that gap. A withdrawal that collapses a fan-out's batch count leaves a surviving top batch none of whose members was withdrawn, so the universal-grounding delete never dooms it, while the fresh derivation mints one fewer batch and never names that batch's
tree_id. Deleting by the fresh tree ids missed it; the cascade then stripped its edges when the survivors' Document nodes were re-derived, and_verifyrefused the whole purge over the unprovenanced remnant. A legitimate withdrawal thus published no purge at all — not a doubled forest, but the residual this ADR already names above: nothing published, the stale build keeps serving, andmigrate applyreports the failure. All three reviewers reproduced it.
SqliteIndexStore.delete_nodes_of_treesis nowdelete_nodes_grounded_in_chunks, seeded on the scope's surviving chunks rather than the fresh trees and walkingnode_derivationupward, so it deletes the scope's entire current node set — stale re-batched batches included — rather than only the trees the fresh derivation happens to reproduce. The equality this ADR rests on now holds at the fan-out boundary too, for deterministic pure providers:tests/integration/test_forest_purge_recompute.pyasserts a re-batching withdrawal, at the exact boundary and as a bulk withdrawal, publishes a forest identical to a never-held build, with the orphaned batch gone.
Consequences
Positive
- ADR-0022 points 5 and 6 hold for every writer, not only for builds. "The rebuild happens in a file nobody is reading" becomes true of the purge as well.
- NFR-4 is dischargeable, and points 6 and 7 are not two guards on one window — they close different ones, and point 7's exists only because point 6 creates it. Point 6 closes publication: with the old build retained, a search running across a publish sees no error at all. Retaining builds is what then makes reclaiming necessary, and point 7 is what makes reclaiming safe for a request already in flight.
Measured on a 400-document index, searches during a publish that reaps. The procedure columns are part of the result, not bookkeeping — every disagreement between two runs of this experiment has come from one of them:
| Configuration | Handle scope | Calls / request | Loop | ok | errors |
|---|---|---|---|---|---|
| neither — no retention (ships today) | per call | 3 | 1.5 s | 40 | 2,627 |
| point 7 only | request | 3 | 1.5 s | 331 | 86,496 |
| point 7 only, independently | request | not recorded | not recorded | 244 | 3,400 |
| point 7 only | process | 3 | 1.5 s | 3,420 | 0 |
| point 6 only | per call | 3 | 1.5 s | 180 | 0 |
| both | request | 3 | 1.5 s | 1,163 | 0 |
Rows two and three are the same configuration measured twice by different people, and they disagree by more than an order of magnitude in the error column. The sign is what this decision relies on, and it is the same in both; the magnitudes are properties of the harnesses. Both are recorded so that neither gets re-derived later and read as a refutation of the other — which is the whole hazard, since a reader who reproduces one number and finds the other in the history has no way to tell a disagreement from a defect.
The first row and point 6's "1,889 errors against 163 successful searches" are the same failure under the same treatment: one index call per iteration there, three per request here. What each row asserts is whether the error column is zero, never throughput.
The window point 7 is actually for is a narrower experiment: one request of four index calls with the reap landing after the first. 1 of 4 answered with a connection per call, leaving an empty database recreated at the reaped path, against 4 of 4 with one held connection and no file recreated.
The request-scoped and process-scoped rows are the same design measured two ways, and only the request-scoped one is point 7. This decision says a search holds one connection for the duration of a request, so every request beginning after the reap must open the file again and every one of them fails — 86,496 times above. A handle scoped to the process never reopens anything, so no iteration after the unlink ever asks the filesystem for the file; it measures "does a descriptor survive an unlink", which is true and is not this question. Both rows are kept because the second is what a re-measurement naturally produces: this ADR's own drafting hit it, read 0 errors, and briefly concluded that point 7 alone closed the publish window. It does not.
The earlier claim here — "retention without a held handle leaves the window
ADR-0022's amendment measured" — was false in the other direction. That window
is created by reaping at publish, which point 6 abolishes; retention alone
measures 0 errors.
- T-17a's root fix is a DELETE and a swap, not a recomputation of collection
statistics per request, and not a rebuild. The measured equality is exact.
- ADR-0018's index writer gets an interface rather than a convention, which is
the property that ADR's closing sentence says cannot be added later.
Negative
- Old builds accumulate until
theurian index gcruns. This is the same trade ADR-0017 already took for state databases — "disk usage grows untilindex gcruns. Deliberately explicit" — and the reason is the same: automatic deletion of a file a reader may hold is what point 6 measured. - Builds accumulate at one whole file per publish, and that is the real disk
cost of point 6 — not the +1.5% below. Measured on 800 documents, a 24.5 MB
index: ten publishes with nothing reaped leave ten files and 246.0 MB,
of which
gcreclaims 221.4 MB. The two numbers answer different questions, and quoting only the second understates the first by an order of magnitude: +1.5% is how much one file grows across a chain of purges, while this is how many files exist at once. A project that purges on every withdrawal reaches this within a working session, sotheurian index gcis not an occasional tidy-up — it is what makes point 6 affordable, and a user who never runs it pays a build's worth of disk per withdrawal. - A purge does not compact.
backupcopies free pages, so a chain of purges grows the file slowly: measured at 13.23 MB → 13.43 MB over 20 successive single-document purges, +1.5%, and 24.5 MB → 25.0 MB on the corpus above.gccompacts, and a fullindex buildresets it. Recorded rather than optimised, because 1.5% over 20 rounds is not a cost worth a second mechanism — unlike the accumulation above, which needs one. - Point 7's guarantee is POSIX. On Windows an unlink of a file with an open
handle fails, so
gcmust treat a failed unlink as "reclaim it next time" rather than as an error. That is a safer failure than the POSIX one and it still has to be written, orgcraises at a user for a file that is merely busy. - Point 8 costs an index schema change before RAPTOR exists, and therefore an
INDEX_SCHEMA_VERSIONbump for a table nothing yet writes to. ADR-0022 point 3 is exactly why that is affordable: an index schema change costs an index rebuild and nothing else.
Amended in Milestone 6. It is billed twice, not once. v3 bought
chunks.derivedandchunk_derivationfor a writer that will now never exist — ADR-0008 puts summary nodes in their own tables — and v4 pays a second bump to add those tables and drop these columns. The affordability argument is unchanged and is what makes this recoverable rather than costly; what is worth recording is that "land the schema ahead of the feature" bought a rehearsal of the rule and none of the storage, and the rule is the part that survived.
Neutral
- A purge and a build are now the same shape — produce a file, publish a pointer — and differ only in where the new file's rows come from. Whether they share an implementation is a question for the code, not for this ADR.
Alternatives considered
| Alternative | Why rejected |
|---|---|
| Mutate the published build in place, under a lock | Measured: a purge landing between two of one request's connections produces a response equal to neither corpus — 81 candidates against 69 stale and 64 purged, 15 of them scored against statistics the purge had already removed. WAL makes a statement consistent, not a request. What it saves is the copy: 175 ms on a 48.5 MB index. |
| Tombstone the withdrawn rows and filter at query time | What leaks is FTS5's collection statistics, and those count rows a tombstone leaves in place. The measured equality holds only where the row is deleted. |
| Purge on read | A write on the retrieval path, to a derived artifact, from a code path that must not take a write lock. It also makes the cost of a search a function of how much was withdrawn, which is the timing channel T-17 records. |
| Re-derive the whole index from canonical state for every purge | 51× to 65× the cost, measured, across a 12× corpus range — and it re-embeds, which against a real embedding provider is a network cost per purge rather than a CPU one. |
shutil.copyfile as the copy primitive |
Drops the -wal sidecar. Measured: the copy held 1,055 rows while the writer that had committed saw 955 — and where the uncheckpointed pages carry the schema, no such table. |
VACUUM INTO as the copy primitive |
Correct today and 15% slower, but rests on rowid stability that SQLite documents as not guaranteed for tables without an INTEGER PRIMARY KEY, which chunks is. Both FTS5 tables are external-content keyed on chunks.rowid. Kept as gc's compaction step, where rebuilding the b-trees is the point. |
| Keep reaping eagerly at publish time | 1,889 errors against 163 successful searches in 1.5 seconds, measured against the current _reclaim. |
| Recompute collection statistics per request instead of purging | Corrects one channel at the cost of the whole corpus per query, and leaves the withdrawn text in a file that is git-ignored, unsigned and readable (SEC-7). The index holding rows the canonical store has withdrawn is the defect; the statistics are one of its faces. |
Compliance
This ADR preceded its implementation, and most of it has now landed. The Compliance section was written before any code, deliberately — the decision is cheaper to get wrong on paper — and it listed what must go RED when each decision is violated rather than tests that did not exist. Those tests now exist, across #113; each item below names the one that holds it.
Decision 5 — the automatic withdrawal→purge trigger — is now wired
(#15). It was unwired when the
mechanism landed under #113: IndexStore.derive_purged had no caller outside
tests, so nothing fired a purge when a revision was retired, superseded or
rejected. It now has one. theurian migrate apply (cli/commands.py
migrate_apply) calls publish_purge_for_withdrawal
(application/withdrawal_purge.py) synchronously after the write transaction
commits and releases the lock, so a retirement, a supersession, a rejection or an
in-place status change publishes a purged build in the same command that applied
it — no separate index build. The revisions removed are computed against the
published index's own build flavor: revisions_to_purge reads
indexesUnapproved off the pointer, so a default index purges what may_surface
withholds from it (draft, proposed, deprecated, rejected, superseded, and any
non-current revision) while an --include-unapproved index keeps the drafts and
proposals it legitimately holds and purges only what is withheld under every flag
plus non-current revisions. The closure is held by
test_a_withdrawal_purges_the_published_index_without_a_separate_build
(tests/integration/test_absence_proof.py), parametrised over the four faces —
deprecate, supersede, reject, and an in-place draft (the flavor face) —
each of which is RED on the pre-trigger wiring. With this, every decision in this
ADR is wired.
A reclassification triggers no purge, and needs none. A changeSensitivity
moves a scope component (SEC-14, ADR-0008 decision 1),
but migration_engine._withdrawal_affected_item — the set that feeds
publish_purge_for_withdrawal — deliberately excludes it. A purge copies the
published build and deletes withheld rows; it deletes rows, it does not rewrite a
scope column, and a pure reclassification withholds nothing (its status and
current revision are unchanged), so the purge would gather the item only to
discard it. Nothing rebuilds for it, and nothing has to: the live response is
already correct, because a result reads the item's current sensitivity
(mcp/results.py), and the built index's stale sensitivity column is read by no
gate before #119 — an unsigned
local index row nothing reads is not a disclosure (SEC-7). That column matches
canonical again on the next index build, which re-derives at the item's current
label. test_a_reclassification_is_not_a_withdrawal pins that the engine produces
no purge candidate for it, and
test_a_reclassification_shows_in_the_response_before_any_rebuild pins the live
response and the harmless index lag end to end.
Amended in #119 phase 5 (2026-08-24). The paragraph above is reversed, and the ground it stood on is the reason. "A reclassification triggers no purge, and needs none" rested on the clause it states itself: the built index's stale
sensitivitycolumn "is read by no gate before #119". Phase 3 made a build write no chunk row above the deployment's declared ceiling and phase 4 made every retriever emitsensitivity IN (…), which turned that column into a gate's column and inverted the exclusion into a defect — a reclassified row is then the only above-ceiling row a served build can hold, withheld from results by the canonical re-check while its text stays inchunks_fts,chunks_trigram,nodes_ftsandnodes_trigram, whose collection statistics price every visible row against it (T-17a on this axis).So
_withdrawal_affected_itemadmitschangeSensitivitytoday, extending this ADR's decision 5 trigger set, andrevisions_to_purgereduces the candidate set against a second flavor axis read off the published pointer —indexedSensitivitiesbesideindexesUnapproved. The reasoning in the paragraph above survives the reversal in one narrow form and is worth keeping for it: a reclassification that stays within the ceiling the published build ran under still purges nothing and still copies no file, which is what lets the operation join the candidate set unconditionally rather than the engine needing to know a ceiling it cannot see.
test_a_reclassification_is_not_a_withdrawalis gone with the decision it pinned;test_migration_engine.py::test_a_reclassification_is_a_withdrawal_only_past_the_builds_own_ceilingand its siblingtest_a_reclassification_within_the_ceiling_purges_nothingare what stand there now, withtests/integration/test_sensitivity_purge.pydriving it through the real CLI. The one direction this cannot close — a reclassification back down into the ceiling, which has no row to restore and waits for the nextindex build— is recorded in ADR-0025's compliance section rather than here.
Everything below is the mechanism's own acceptance, which #113 discharges; #103 tracked these eight as one class, and all eight are green.
Landed by the change that implements this ADR:
- The equality, as one query against two corpora — landed:
test_a_purged_build_answers_as_if_the_rows_were_never_indexed, three queries with astale-index control asserted different in the same test, and the withdrawn documents ten times the corpus mean soavgdlmoves. An index built from a corpus including the withdrawn documents and then purged returns byte-identical rankings — chunk ids and scores — to one that never held them, for bothsearch_lexicalandsearch_substring. The derived layer holds the same equality as of the purge-recompute CL (Milestone 6):tests/integration/test_forest_purge_equality.py::test_a_purged_forest_equals_one_that_never_held_the_withdrawn_rowsextends it to the forest — node rows, derivation edges and node vectors — for deterministic pure providers, per decision 8's amendment above. That amendment's fan-out re-batch follow-up closes the one boundary the equality did not reach at first:tests/integration/test_forest_purge_recompute.pypins the same equality where a withdrawal re-batches a fanned-out Domain tier, unqualified for deterministic providers. - A search does not tear on a
gcunlink between its reads — landed:test_a_gc_unlink_between_a_requests_reads_does_not_tear_itdrives the real MCP path and forces the unlink between two of the request's index reads;hybrid_answerholds one connection for the request (SqliteIndexStore.session) so the held descriptor keeps the build readable. Mutating the session to a no-op makes the request fall back. - Publishing does not delete — landed:
test_publishing_a_build_no_longer_reclaims_the_one_it_replacedand the end-to-end CLI run; two builds in a row leave two files. - A search survives
gc— landed:test_a_request_inside_a_session_finishes_against_the_build_it_started_onanswers 4 of 4 reads after the unlink against 1 of 4 without, and the reaped path stays reaped. - No read of an index path uses a bare
sqlite3.connect— landed:test_a_read_of_a_missing_index_creates_no_file, parametrised over the whole read surface, asserts each read of a missing file raises rather than creating one. A read method added later that forgets_open_readis a missing entry in that list. - Derived nodes — landed, and at index schema v4 the traversal runs over the
node tables. Six tests, not the five this bullet listed until then: direct
(
test_a_node_derived_from_a_withdrawn_chunk_goes_with_it), a Domain node built from a Document node (test_withdrawal_is_transitive_through_a_document_and_a_domain_node), mixed provenance (test_a_node_derived_from_both_a_withdrawn_and_a_surviving_chunk_goes), a node that cannot say where it came from (test_a_node_that_cannot_say_where_it_came_from_is_deleted), the fixed point under an unprovenanced node (test_a_node_derived_only_from_an_unprovenanced_node_goes_with_it), and a control that an ordinary chunk is never swept in (test_an_ordinary_chunk_is_never_treated_as_an_unprovenanced_node). The fourth was in the suite from the start and was never listed here, so the count is corrected rather than the test added; the amendment to decision 8 records the population that settles it at six.
The storage under them changed and the rule did not. They were chunks
rows with derived = 1, provenanced by chunk_derivation, until the Milestone
6 amendment to ADR-0008 decision 5 gave summary nodes
their own tables; each migrated rather than being deleted, and each names its
v3 predecessor in its own docstring. Two _verify post-condition tests sit
beside them: test_verify_refuses_a_build_that_still_holds_an_unprovenanced_node,
which migrated with the rest, and
test_verify_refuses_a_build_whose_node_derivation_points_at_a_chunk_that_is_gone,
which is new at v4 because one table could not express a dangling edge as a
state distinct from having no edge at all.
The rationale this bullet gave — "nothing writes derived = 1 yet (RAPTOR
is empty), which is exactly why the traversal is pinned now" — keeps its force
and loses its subject. There is no derived column to write. What nothing
writes is a node row: infrastructure/raptor/ is an empty package and
SummarizationProvider a port with no adapter, so every fixture above goes in
with raw SQL. That is still exactly why the traversal is pinned now, and it
stays pinned that way until the builder CL (Milestone 6).
Amended in Milestone 6, by the extractive-provider CL: both halves of
"infrastructure/raptor/ is an empty package and SummarizationProvider a
port with no adapter" are false now. That package holds extractive.py,
the port's first adapter. Nothing calls it — no builder maps a SummaryNode
onto a row — so "every fixture above goes in with raw SQL" still holds, for
the reason the paragraph above already gives: nothing writes a node row.
Written first as "the second half of that sentence is false now too", which
read "the corrected first half" as this sentence's when it was the preceding
paragraph's — the subject correction from derived = 1 to a node row. The
empty-package half was left standing by that reading; ADR-0008's
family-closure note records the class it belongs to.
Amended in Milestone 6, by the forest-builder CL. "Nothing writes a node row"
is false and "it stays pinned that way until the builder CL" has arrived.
index build --raptor writes them. The raw-SQL fixtures above stay, and not
out of inertia: they reach three unanchored arms a builder-written forest
cannot produce — an unprovenanced node, an edge naming an absent node, and a
provenance cycle — because the builder writes every node before any edge in one
transaction, gives each node at least one source, and builds each tier only
from the one below. What the builder adds is the arms it can reach, over a
graph it shaped:
tests/integration/test_forest_builder.py::test_withdrawing_an_item_takes_its_document_node_and_the_domain_node_above_it
and test_a_purged_forest_leaves_no_residue_in_a_node_text_index.
- A purged build holds no orphaned row — landed:
test_a_purged_build_holds_no_embedding_of_a_withdrawn_chunk, plus a third
post-condition inside _verify that refuses to publish a build with an
orphaned embedding. ON DELETE CASCADE is enforced per connection and
PRAGMA foreign_keys defaults off, so a purge opening its own connection
without CONNECTION_PRAGMAS would delete the chunk and keep the vector — a
silent, one-directional failure a review does not catch.
- A purged build's index_metadata names itself — landed:
test_a_purged_build_names_itself_in_its_own_metadata, which also asserts the
source build is not restamped. Connection.backup copies pages, so without the
restamp the copy would carry the parent's id.
Still owed to ADR-0018, and not satisfied by point 4: its "the derived index
has no single-writer contract at all" needs one interface owning every index
write, and there are eleven writable opens across two modules with no common
gate. Its CanonicalStore.transaction() half is not touched here either.
Corrected on 2026-09-01 (#445). This paragraph said the ADR-0018 debt is "satisfied by point 4 rather than by this ADR's own tests: … discharged for the index when one interface owns every index write and a test asserts that surface." The condition it names is the right one and it is not met — decision 4's dated correction carries the measurement and the eleven-site table. What point 4 does establish is narrower: nothing outside
index_store.pyandindex_purge.pyopens an index file for writing — a layering fact, measured, and the only one of its three clauses that held.The property worth keeping is not point 4's, and attributing it there was this correction's own error, caught in review. "A published build is never written, so a second writer has no live file to reach" is decisions 1 and 2 plus the naming discipline: decision 1 states the rule, decision 2 makes a purge a copy-and-publish rather than an in-place edit, and every production writes a new file under a fresh ULID and a
.buildingsuffix, published byos.replace. Three tests refuse the alternative —test_building_over_an_existing_file_is_refused,test_a_purge_into_an_existing_path_is_refusedandtest_a_purge_refuses_to_write_over_another_writers_building_file— andtest_a_purge_leaves_the_published_build_untouchedholds the published build byte-for-byte across a realderive_purged. That is a property of when writes happen, not of how many interfaces perform them, and the single-writer contract is owed and unscheduled under #439.
NFR-4 is discharged by points 6 and 7 together, and by neither alone. It is not discharged by this ADR being accepted.
Reconciled on 2026-09-01 across every record that states it (#140 member 1). Six records state NFR-4's discharge status; the sentence above is the one that stands, and the other five disagreed with it and are corrected. The population is a key, not a list — every file carrying the dated
#140 member 1correction, read with blockquote markers stripped and whitespace collapsed: this ADR,docs/adr/0018-single-writer-synchronous-in-m1.md,docs/adr/0022-index-lives-in-its-own-database.md,docs/adr/0007-state-hash-partitioned-databases.md,packages/theurian-core/src/theurian/indexing/__init__.pyandpackages/theurian-core/src/theurian/infrastructure/sqlite/store.py. Six and five is the pair to quote; earlier drafts of this reconciliation said "three other records", "all four records" and "four different ways", none of which agreed with each other or with the corrected set. ADR-0018's Compliance section, ADR-0022's Still-owed opener andpackages/theurian-core/src/theurian/indexing/__init__.py's docstring each recorded NFR-4 as undischarged and owed to "Milestone 6's blue/green work" — which is the work this ADR is, and which has landed. Each now carries a dated correction pointing here.The evidence is the acceptance pins, read rather than re-run. NFR-4 is "the previously published index answers every query while a new build runs, zero read downtime", and its two clauses are covered differently:
- Zero read downtime — discharged, and this is the clause NFR-4 was recorded unmet for. The failure was reaping at publish: 1,889 errors against 163 successful searches in 1.5 seconds, with an empty database left at the reaped path. Point 6 abolishes that window and
test_publishing_a_build_no_longer_reclaims_the_one_it_replaced(tests/integration/test_index_gc_cli.py) holds it through the real CLI — two builds in a row leave two files. Retention makes reclaiming necessary, so point 7 closes the window that creates:tests/integration/test_gc_during_a_search.pyis decision 7's own acceptance module and its four tests cover the whole shape — a request in a session finishes against the build it started on (4 of 4 reads after a forced unlink), a read of a reaped build never recreates it, a request starting after the reap reads the published build, and the no-session case is pinned as the counterexample at 1 of 4.test_a_read_of_a_missing_index_creates_no_file(tests/integration/test_index_store.py) holds themode=rohalf over every read method, which is what keeps a reaped path from becoming an empty database that reports itself healthy.- "While a new build runs" — true by construction, with every element pinned, and no test issues a query while a build is running. A build writes to
<final>.building, a nametheurian index gcdoes not reap (test_a_build_is_written_under_a_name_gc_will_not_reclaim), renames it withos.replace, and publishes by an atomic pointer swap (write_active_index_pointer: write-to-temp plusos.replace); a build over an existing index file is refused outright (test_building_over_an_existing_file_is_refused,tests/integration/test_index_store.py); and the previously published file is retained. So a query during a build resolves the pointer to a file the build never opens. That composite is argued from pinned elements, not measured: there is no concurrency test in the suite that runs a search against a build in progress.So the mechanism is discharged and one acceptance test is still owed, and the distinction is the point of this note. ADR-0007's own Still-owed bullet states that residue exactly — "Nothing asserts a query during an in-progress build sees the previous complete state" — and it remains accurate; what has changed underneath it is that the two records it cites as agreeing with it (ADR-0018's and ADR-0022's) no longer do. It is annotated there rather than restated here. The owed test is owned by #497, whose definition of done requires every record stating this gap to move in the same pull request the test lands in, because each becomes false the moment it exists. That population is measured rather than listed — a wrap-aware, blockquote-aware search of
while a build is|during a build|during an in-progress build. Scoped to the files this record-update branch touches it is seven: ADR-0007's bullet, ADR-0018's Compliance bullet, ADR-0022's Still-owed opener, this note,indexing/__init__.py's docstring,infrastructure/sqlite/store.py's module docstring, and the CHANGELOG entry. Run repo-wide the same key returns eight, and the eighth is.theurian/knowledge/architecture/state-hash-partitioned-databases.01M0D5GWD03YD4TFJV2E0SHAVW.md— ADR-0007's dogfood-corpus twin, which is served content rather than a record and is re-seeded from its ADR rather than edited in place. It moves when the corpus is re-seeded, and that is the M7 dogfooding lane's, not this reconciliation's; the drift checker that would catch it is #317. State the scope with the number or the two disagree: seven is the record population, eight is the repository.A line-oriented
git grepunder-counts either figure, because three of the seven wrap the phrase across a soft line break and one sits inside a nested blockquote — which is why the key above is run over text with the>markers stripped and the whitespace collapsed.