ADR-0013 — A cached CollectionRuntime whose validity is a marker persisted in SQLite¶
- Status: Accepted (owner, 2026-08-15)
- Date: 2026-08-15
- Deciders: Andy Freeman (owner)
Context¶
Phase 4 puts a REST API and an MCP server in front of the retrieval path. Both are
long-lived processes answering many requests against a collection that a different
process — grk ingest, the documented first half of grk ingest ./docs && grk
serve-mcp (SPEC.md §9) — may write to at any moment. Nothing in the current design
survives that.
Retriever.open() is a snapshot that never refreshes. ADR-0002 accepted this
deliberately: BM25Index.from_store() rebuilds postings in memory from the persisted
chunk set, so the index cannot drift from SQLite, and it costs O(corpus) at every
open. ADR-0002's Consequences then name the asymmetry that makes a long-lived
retriever unacceptable rather than merely stale: a hit against modified or deleted
content fails closed (RetrievalError, no stored source), but a query that should
match content ingested after open() has no representation in the stale index at
all and silently returns zero results. Wave C gave the dense path the same
semantics by filtering rather than by rebuild (_dense_candidates restricts hits to
documents_at_open), so both paths are silently blind to new content in exactly the
same way. A server that opens one retriever at startup is wrong the moment anyone
ingests, and wrong in the one direction this repo's fail-closed rule cannot detect.
The deferred design's sequencing constraint is met. The Codex repository-review
document (GroundKit_Repository_Simplification_System_Design.docx, repo root,
untracked) proposed a CollectionRuntime composition root and a versioned collection
snapshot. That proposal was deferred on 2026-08-14. Its own rollout constraint reads:
"land correctness fixes first; add CollectionRuntime behind current CLI behavior;
migrate one command at a time; require regression, lifecycle, and gated-provider
evidence before Phase 4 exposure", and its decision section sequences "a focused
CollectionRuntime composition root and a versioned collection snapshot … and only then
connect REST and MCP adapters." Phase 4 is the exposure that constraint names.
That document must be read with care rather than cited as authority: its milestone M1
was already delivered by 04dfc5b, and it cites two ADRs that did not exist when it
was written. The quotes above were extracted from the file itself for this ADR rather
than taken from any summary of it. Its M2 exit criterion — "CLI ingest/search/eval
use one lifecycle path with no behavior regression" — is not adopted in full here;
see decision 7.
SPEC.md §2 constrains the shape of the fix. "No in-process state shadowing
persisted state. Anything the index knows must be recoverable from disk (ADR-0001
hazard 7)." A cached Retriever is in-process state shadowing persisted state —
unless its validity is derived from something persisted. This is why an
invalidate-on-my-own-writes cache is not the answer: its validity is decided
in-process, so it is correct exactly until someone runs grk ingest in another
terminal, which is the workflow SPEC.md §9 documents.
Reopen-per-request was rejected on measurement, not on taste.
scripts/measure_retriever_open.py is the method; it exists because the Phase 3 spec
(R4) required measuring open() cost "rather than discovering it in Phase 4 when the
service opens retrievers per request." Run on the owner's development machine on
2026-08-15, 10 repeats per configuration, the dense figures rose from tens of
milliseconds at 100 documents to roughly half a second at 2 000 documents / 8 000
chunks. Regenerate with uv run python scripts/measure_retriever_open.py --sizes 100
500 2000 --repeats 10; the numbers are point-in-time script output on one machine,
not facts about groundkit, and are deliberately not restated here (SPEC.md §2, no
number in any doc that wasn't generated). Two things make them worse than they look:
the rebuild serializes on the metadata store's single asyncio.Lock, so concurrent
requests queue behind one another rather than paying the cost in parallel, and the
dense figure already includes the manifest verification, the LanceDB connect, and the
documents_at_open query that Wave C added.
The schema-version mechanism already exists; this ADR extends it rather than
inventing one. ADR-0004 decision 4 stamps PRAGMA application_id (GRK1) and
PRAGMA user_version (SCHEMA_VERSION) as the last statements before commit on a
newly created store, SQLiteMetadataStore.open verifies both, and a
collection_manifest table already exists. Any claim that the schema records no
identity and no version is pre-Phase-3 text and is stale.
The TOCTOU discipline this ADR must not break. ADR-0004 decision 3, and the
docstring on Retriever.open(), establish that store.verify_manifest() returns the
manifest it checked so that one read decides both "does the identity match" and
"is this collection dense-bound." Two reads admitted a real silent-corruption race. A
staleness check adds another store read to that sequence, so its placement is a
correctness decision, not plumbing.
Decision¶
1. The marker is a monotonic per-collection generation counter, bumped inside the same transaction as every write¶
SQLiteMetadataStore gains an integer generation, starting at 0 on creation and
incremented by every mutating store method — upsert_document, add_chunks,
replace_document, delete_document, write_manifest — as a statement inside that
method's existing _op, before its commit(). MetadataStoreProtocol gains one read,
get_generation() -> int | None.
In-transaction, not adjacent to it. The bump commits with the write or not at all. A bump issued as a separate call after the write opens a window in which content is newer than the marker — a reader then observes "unchanged" over changed data, the precise failure this mechanism exists to prevent. The reverse is merely wasteful.
One bump per commit, not one per method call. Every _op that commits durable state
advances the marker, and no branch inside it decides whether the write "really" changed
anything — delete_document on an absent id deletes zero rows, commits, and advances.
Over-bumping costs one redundant rebuild; under-bumping serves stale results silently and
forever, so where the two are traded, bump.
The rule is tied to the commit rather than to the method because that is the form
that can be checked by reading the code instead of trusting each caller to classify its
own branches. It also settles the one case where the two formulations disagree:
write_manifest re-called with the identity the collection already holds returns before
any INSERT and before any commit, so durable state is untouched, a retriever built
against it is still valid, and it correctly advances nothing. Bumping there would force a
write on the path every re-ingest of a bound collection takes, in order to invalidate a
cache nothing invalidated.
Monotonic and opaque. The only operation is equality against a previously observed
value. A counter beats (doc_count, max_rowid), which the most common write defeats —
replace_document deletes then inserts, and SQLite reuses freed rowids without
AUTOINCREMENT, so a replacement can land on the same rowid with an unchanged count.
It beats a content-derived digest over chunk hashes, which is O(corpus) per read: the
cost being removed.
What the read costs, named. A one-row primary-key lookup against a one-page table
is effectively always in SQLite's page cache. The cost is not SQLite: it is one
asyncio.to_thread dispatch and one acquisition of the store's single asyncio.Lock
per request, contending with in-flight ingest writes on that lock. It is bounded work
per request against unbounded work avoided, but it is not free, and
scripts/measure_retriever_open.py is owed a mode that times a warm acquire against a
rebuild so this claim is measured rather than asserted.
Dense mutations are covered transitively. Indexer maintains the invariant that
SQLite is never ahead of the dense store, and write_manifest bumps on its own. Because
the SQLite commit is last, an observed bump implies the dense write already landed.
The standing obligation: a future dense-only mutation path with no SQLite counterpart
would be invisible to the marker. Enforcement is therefore structural — the bump lives
inside the store's mutating _ops, not at call sites — plus the classification test in
the implementation notes.
2. The marker lives in a new single-row collection_state table, and SCHEMA_VERSION goes to 2¶
CREATE TABLE IF NOT EXISTS collection_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
generation INTEGER NOT NULL,
updated_at TEXT NOT NULL
);
The CHECK (id = 1) single-row pin mirrors collection_manifest exactly, so a second
row is unrepresentable in the schema rather than prevented by discipline. It lives in
SQLite because ADR-0002 makes SQLite the durable truth. updated_at is diagnostic
only, for index_status; it is never consulted for validity.
This extends ADR-0004's existing verification; it does not add a parallel one.
Adding a table is a schema change, so SCHEMA_VERSION becomes 2 and the existing
open()-time check covers it unchanged. Declining the bump would make the version a
decoration and leave v1-shaped and v2-shaped files indistinguishable.
The blast radius is real and is accepted on ADR-0004 decision 5's own reasoning.
manifest_capable is an exact equality check, so every collection created before
this change becomes non-schema-current: write_manifest and verify_manifest refuse
it, and existing dense collections must be deleted and re-ingested. Pre-1.0, every
index is reproducible from grk ingest in seconds, so a migration path would be code
written to preserve data that costs seconds to regenerate — the argument
index/metadata.py already records against SCHEMA_VERSION itself.
The bump is also load-bearing rather than cosmetic, and this is the sharper reason.
executescript(_SCHEMA) runs on every open with CREATE TABLE IF NOT EXISTS, so
without a version bump the new table would appear in a legacy collection
transparently and the cache would engage over it. An older grk binary lacking the
bump logic could then write to that collection without moving the counter, and the
service would serve stale results silently and indefinitely — exactly the defect this
ADR exists to close. With the bump, such a store reads as non-current, the cache
disables, and the failure degrades to correct-and-slow. The exact-equality check is
not widened to >=: that would reopen ADR-0004 decision 5, and a real migration
story is owed at 1.0, not here.
Internally _manifest_capable is renamed _schema_current, since it now gates two
unrelated capabilities.
3. A store that is not schema-current has no marker; the runtime treats it as permanently stale¶
get_generation() returns None — it does not raise — when _schema_current is
false. CollectionRuntime treats None as "cannot assert freshness," rebuilds on
every request, and logs a warning once per runtime naming the remedy.
This is fail-closed in the correct direction, and the precision matters: the marker answers one question — has anything changed since I last looked? — and a missing marker makes that question unanswerable. The safe answer to an unanswerable freshness question is "assume it changed." That degrades to reopen-per-request: correct, slow, loudly labelled. Refusing outright was rejected — nothing is wrong on a legacy BM25-only store, only slow, and SPEC.md §2's fail-closed rule governs wrong answers, not slow ones. Backfilling a marker was rejected on a sharper ground: it would write v2 tables into a file stamped v1, producing a store whose version stamp lies about its shape.
index_status reports the disabled state explicitly, so it is visible rather than
inferred from latency.
4. The marker is read first, on the runtime's own store handle, and the artifact is stamped with that pre-build value¶
The rebuild sequence, entirely on one store handle:
G = await store.get_generation()— before anything elsemanifest = await store.verify_manifest(identity_of(embedder))— unchangedawait verify_dense_side_present(...)— unchangedbm25 = await BM25Index.from_store(store, ...)— unchangeddocuments_at_open = frozenset(await store.get_document_sources())— unchanged- publish
(G, retriever)
Steps 2–5 are Retriever.open() exactly as it stands. retrieval/search.py gains no
behaviour change from this ADR — step 1 happens in the runtime before the call, step
6 after it.
Why ADR-0004's race is not reintroduced. That hazard is two reads of the manifest
producing verdicts that can disagree. The generation read touches a different table,
produces neither verdict, and is consulted by neither. verify_manifest remains the
single read answering both questions together. If a concurrent dense ingest binds the
collection between steps 1 and 2, step 2 observes the new manifest and verifies
against it — raising IndexIdentityError or producing a consistent pair from that one
read, exactly as today. The artifact is then stamped G while its content reflects
G+1, so the next request rebuilds. Conservative, never wrong.
Why the ordering is load-bearing. Every read at steps 2–5 happens at or after step
1, so the artifact reflects a store state at generation ≥ G. The validity predicate
is get_generation() == G. Because the counter is monotonic, equality implies no
commit landed after step 1, so the artifact is exactly the state at G. If a commit
landed during the build, current > G, the predicate is false, and the artifact is
rebuilt even though it may already contain that write. The stamp is a lower bound on
freshness, and the error is always toward a redundant rebuild.
Reading the marker last inverts that and is unsafe. Stamping a value read after step
5 means a commit landing during steps 2–5 raised the counter without necessarily being
reflected in reads that already completed — BM25Index.from_store ran at step 4,
before the commit. The predicate then holds indefinitely and the runtime serves an
index missing that write until the next write happens to bump again. That is
ADR-0002's silent-zero-results outcome made permanent by caching: worse than the
problem being fixed. Stamp before build; never after.
Deliberately not done: a single read transaction spanning steps 1–5. It would make
the stamp exact rather than a lower bound, but requires widening
MetadataStoreProtocol with transaction-scope semantics — a signature-parity-tested
seam changed for an optimization — and blocks WAL checkpointing across an O(corpus)
rebuild. Revisit if redundant rebuilds under write-heavy load are measured to matter,
borrowing ADR-0002's discipline that the measurement is the trigger.
5. Single-flight: one rebuild per observed generation, and a waiting request is never served the stale artifact¶
The runtime holds the cached (generation, retriever) pair and one asyncio.Lock.
Each request reads the generation, returns the cache on a match without taking the
lock, and otherwise queues, re-checks under the lock against the generation it
observed before queueing, and rebuilds only if still unsatisfied.
N concurrent stale requests produce exactly one rebuild. The leader rebuilds and publishes; waiters re-check, find the cache stamped with the generation they observed, and return.
The re-check deliberately does not take a fresh read inside the lock. Re-reading would make each waiter chase a moving target under sustained writes: every waiter would observe a newer generation than the leader just satisfied and rebuild again, converting write pressure into an unbounded rebuild storm. Reusing the pre-queue observation caps rebuilds at one per observed generation and gives every waiter a well-defined freshness bound.
A request arriving during a rebuild waits; it is not served the stale artifact.
This costs real tail latency — concurrent requests during a rebuild each absorb it —
and is chosen anyway, because serving stale "just for the duration of the rebuild"
reintroduces silent zero results for new content at the moment of highest probability
that someone is actively ingesting. Serving stale behind a stale: true flag was
rejected: a flag nobody is required to read is not fail-closed.
Cancellation. The rebuild body runs shielded with the lock released by a
done-callback, mirroring SQLiteMetadataStore._run's proven pattern, so a client
disconnecting does not abandon a rebuild others are blocked on. The distinction is
recorded honestly: in _run the shield is a correctness property; here it is a
throughput property, since a Retriever owns nothing closable and an abandoned
half-built one leaks nothing.
A failed rebuild does not publish. The cache is assigned only after
Retriever.open() returns. On failure the exception propagates and the previous
artifact — still cached but stamped with a non-matching generation — is unreachable
through the fast path. That is structural, not defensive code.
6. Ownership: process-lifetime resources versus collection-derived ones¶
Process-lifetime: the SQLiteMetadataStore handle; the EmbeddingProtocol (an
HTTP client independent of collection state, whose identity is re-verified against the
manifest on every rebuild); and the RerankerProtocol when configured — a
multi-gigabyte model that must never be reconstructed because a collection changed.
Collection-derived, rebuilt on every generation change: the Retriever, and the
vector-store handle with it.
The vector store belongs on the derived side because of a specific defect, not for
symmetry. LanceDBVectorStore.open() caches self._table and returns [] from
search() when it is None, never re-checking for a table created later. A runtime
opened dense-paired over a collection whose .lance table does not yet exist therefore
holds _table = None permanently; when an out-of-process grk ingest --dense later
writes vectors, a Retriever-only rebuild would probe the stale handle, get [], and
raise StorageError naming a lost dense side on a healthy collection. The runtime
therefore takes a vector-store factory, not an instance. The embedder/factory pair
is both-or-neither, validated through the existing groundkit.identity helpers.
Who closes what. Retriever has no close(), so a rebuild is a pointer swap.
Standing obligation: if a future Retriever acquires a closable resource, the rebuild
path is where the superseded one must be closed. The same applies to the superseded
vector-store handle, which exposes no close() today and is simply dropped.
aclose() is idempotent and ordered — drain the rebuild lock, drop the artifact,
close the embedder, close the store last, since the store's lock is what in-flight
operations serialize on. Acquiring after close raises a typed error.
7. The runtime absorbs the CLI's read lifecycle; the eval runner keeps its own¶
Today there are two production callers of Retriever.open() — cli.py (_cmd_search)
and evals/runner.py. Naively, Phase 4 makes four.
_cmd_search is absorbed. The cache never hits in a one-shot process, which is
fine: the value is one lifecycle, and the default suite then exercises the runtime's
open path on every CLI test rather than only in service tests.
_cmd_ingest is not absorbed. It is a write lifecycle over an Indexer, not a read
lifecycle over a Retriever; folding it in would make the runtime mean two things.
The eval runner keeps its own lifecycle, and this is a deliberate divergence from the
design document's M2 exit criterion ("CLI ingest/search/eval use one lifecycle
path"). run_eval opens exactly one Retriever after ingestion completes, and the
comment there states the requirement: one retriever serves every stage, so stages
compare retrieval strategy rather than index state. Routed through the runtime,
behaviour would be identical — nothing writes between stages, so every acquire would
return the same cached object — but the guarantee would weaken from structural ("one
open() call, one object") to contingent ("the cache must hit, and it will because
nothing writes"). For the one path in this repo that emits a hash-pinned artifact whose
cross-run comparability is the entire point, that is the wrong trade, and it buys
nothing: the runner's store lives in a per-run temp directory no other process can
reach.
Net: three prospective new copies collapse into one; the eval runner's survives with
its reason recorded. Revisit trigger: if run_eval ever writes to its store
between stages, its single-retriever assumption breaks and it needs the runtime.
Implementation record (2026-08-19, GK-026). This decision was recorded and then not
carried out. _cmd_search kept its own Retriever.open(), cli.py imported only
CollectionRegistry, and Phase 5 added _cmd_answer — a fourth read lifecycle written
against the very pattern this decision retires, and one it could not have counted. The
claimed benefit went with it: no CLI test referenced CollectionRuntime, so "the
default suite then exercises the runtime's open path on every CLI test" described a
suite that did not exist. Both commands now open through cli.py's
_open_read_runtime, and the arithmetic above is restated rather than merely preserved:
four Retriever.open() call sites become two — the runtime's and the eval runner's
— and the reason the second survives is unchanged.
The claim is held in place by tests that cannot pass against a command holding its own
retriever, since on that path none of what they observe exists:
test_search_reads_through_the_collection_runtime and
test_answer_reads_through_the_collection_runtime record the acquire, and
test_search_cannot_answer_when_the_runtime_refuses_a_retriever /
test_answer_builds_no_chat_provider_when_the_runtime_refuses make a refusing acquire
abort the command — the sharper half, because recording a call proves only that the
runtime was consulted, while a refusal that stops the command proves the result came
down that path.
Two consequences are recorded here rather than left to be rediscovered:
- The CLI now reads the generation marker before it builds a retriever, so
grk searchandgrk answerover a collection predating decision 2's schema emit the runtime's "freshness cannot be asserted" warning on stderr (never stdout, so--jsonoutput is unaffected). Its wording addresses a server — "every request will rebuild its retriever" — and on a one-shot command that request is the only one. The remedy it names, re-ingesting into a fresh collection, is correct for both. - The dense vector store is now opened by a factory the runtime invokes during a
rebuild, never eagerly by the command. That makes structural an ordering the previous
code held by convention and a comment:
SQLiteMetadataStore.openis what validates a collection name, and<index-dir>/<collection>.lanceinterpolates that same name into a path. A rebuild happens strictly after the store is open, so the path can no longer be derived before the name it is derived from has been validated.
8. The module is src/groundkit/runtime.py, added explicitly to the coverage core subset¶
Placement follows identity.py, whose docstring records the precedent: a module holding
invariants that Indexer, Retriever and run_eval all need identically, living
outside whichever caller happened to need it first "precisely so that sharing them
creates no dependency between ingest and retrieval." CollectionRuntime has the same
three callers and the same hazard — per-caller copies that stay identical in logic while
diverging in prose.
One property does not carry over and is stated rather than implied: identity.py is
an import leaf, importing only contracts, errors and the protocol modules with nothing
importing it back. A CollectionRuntime is a composition root — it must import
Retriever, the metadata store and the vector store, because composing them is its
job. What it keeps from the precedent is top-level placement and the
nothing-imports-it-back property: only cli.py and service/ depend on it, so ingest
and retrieval gain no dependency on each other through it.
pyproject.toml's [tool.groundkit.coverage].core_subset already mixes one glob with
three explicit file paths, so src/groundkit/runtime.py is added as a fourth explicit
entry rather than relying on placement to catch it. The gate is the reason for the
entry, not a side effect: this module decides whether an answer is computed over
current data, and its hardest code — the single-flight and cancellation paths — is
exactly the class that is unreachable on a non-failing path and therefore invisible to a
green suite.
The honest asymmetry: the marker read is gated; the marker bump lives in
index/metadata.py, which pyproject.toml deliberately keeps outside the subset. The
half whose omission causes silent staleness is ungoverned by the core gate, and its
named regression tests carry that weight.
9. One runtime per collection, behind a registry that will not create a collection it was merely asked about¶
A registry holds one runtime per collection name and hands them out as a refcounted async context manager — the natural shape for both a FastAPI dependency and an MCP tool handler.
The registry must not create collections implicitly. SQLiteMetadataStore.open()
creates the file if absent, so a registry that opened on demand would let a request for
an arbitrary name create an empty SQLite file — a trivial disk-fill against a service
whose reads are unauthenticated. The registry checks the file exists before opening and
refuses otherwise, raising ConfigurationError — the type SQLiteMetadataStore.open
already raises for a malformed name, so bad-name and unknown-name failures are one type
at one boundary.
What bounds the number held open is memory, not file handles. BM25Index retains
its chunk list, so a cached retriever pins the collection's entire chunk text resident.
N open collections means N corpora in memory. The bound is an explicit
max_open_collections setting enforced by LRU eviction of zero-refcount entries.
Eviction never closes a runtime with an in-flight request: the registry exceeds its
bound temporarily with a logged warning rather than closing a store out from under a
live request. A future delete_collection must go through the registry so it can evict
before unlinking both the .sqlite3 and its .lance sibling (SPEC.md §7).
10. Any new Protocol seam gets a signature-parity conformance entry¶
If the runtime or registry introduces a typing.Protocol, it gains an entry in
tests/test_protocol_conformance.py using assert_signature_parity, never
isinstance. isinstance on a runtime-checkable Protocol compares member names only
and would pass straight through the query → _query rename that caused ARP's
signature drift (ADR-0001 hazard 4). The added MetadataStoreProtocol.get_generation
member is covered by the existing conformance test for that protocol.
Alternatives considered¶
- Reopen a
Retrieverper request. Rejected on the measurement above, and worse than the raw figures suggest because the rebuild serializes on the store's single lock. It remains the correctness baseline this design must match, and decision 3 falls back to exactly it. - Invalidate on the service's own writes. Rejected: validity decided in-process is
what SPEC.md §2 forbids, and it misses out-of-process
grk ingest— correct precisely until the tool is used as documented. - Time-based refresh (TTL). Rejected: wrong in both directions at once. It serves stale results for up to its own length after every ingest and rebuilds an unchanged corpus on every expiry for nothing, with a staleness bound nobody chose on evidence.
- The SQLite file's mtime. Rejected on four grounds: under WAL the commit lands in
-waland the main file's mtime need not move; timestamp granularity is coarse; mtime is not monotonic — a clock adjustment or restore can move it backwards, and a decreasing marker makes a stale cache look fresh; and it observes the file rather than the transaction. PRAGMA data_version. A close fit, rejected on a documented clause: it changes only for commits made by other connections and deliberately not for commits on the same connection — so it would miss exactly the writes a service's own ingest route makes. It is also per-connection rather than persisted, failing SPEC.md §2's recoverable-from-disk test. Confirm against SQLite's pragma documentation before implementing anything that assumes otherwise.(doc_count, max_rowid), and a content digest over chunk hashes. Rejected per decision 1.- A per-collection
instance_idto detect delete-and-recreate. Rejected because it does not work: a runtime holding an open handle to an unlinked file keeps reading the old inode, including itsinstance_id. Decision 9's registry-mediated delete is the supported path; out-of-bandrmis documented as a limitation rather than half-solved. - Folding the generation into the existing
get_document_sources()read. Rejected: the freshness decision must be made before a retriever is selected, not inside one, and it would widen a signature-parity-tested return type for a micro-optimization. - Placing the module in
retrieval/to catch theretrieval/*glob automatically. Rejected per decision 8: it puts a composition root that importsindex.metadatainside the retrieval package, and an explicitcore_subsetentry achieves the same gate without the layering compromise. - Persisted BM25 postings (ADR-0002's own deferred alternative). Not an alternative: it attacks rebuild cost, not staleness, and persisted postings still need a validity signal isomorphic to this marker. The two compose.
Consequences¶
Positive. The cache's validity is a persisted fact, satisfying SPEC.md §2 rather
than being excused from it. Out-of-process grk ingest is observed, which is what makes
grk ingest ./docs && grk serve-mcp work as documented. Steady-state request cost drops
from an O(corpus) rebuild to one indexed single-row read. The Retriever contract is
untouched, so ADR-0002, ADR-0004, ADR-0006, ADR-0007 and ADR-0008 all stand unmodified,
and ADR-0004 decision 3's one-read property is preserved by construction. Three
prospective lifecycle copies collapse into one.
Costs. Every request pays one extra store-lock acquisition and thread dispatch,
contending with in-flight writes. A rebuild is a latency spike absorbed by every
concurrent request. Memory becomes the binding resource for a multi-collection service.
SCHEMA_VERSION = 2 invalidates every existing collection for dense work.
MetadataStoreProtocol gains a member, so every in-test structural fake must implement
it — and because mypy --strict covers tests as well as src, those surface as
typecheck failures rather than runtime surprises.
What this does NOT fix.
- A one-request staleness window remains, and no design of this shape eliminates it: another process can commit between the marker read and the answer. The claim is bounded staleness — never more than one request behind, self-correcting — not linearizability. Reopen-per-request has the identical window for the identical reason.
- The dense over-fetch cap is unchanged. What changes is the window's duration: previously bounded by process lifetime, now by one generation.
- Orphaned vectors are unchanged. The marker tracks SQLite; an interrupted ingest leaves vectors under a document ID SQLite never recorded, and no bump describes them.
- Cross-store atomicity is unchanged.
- Out-of-band edits to source files are invisible. The marker tracks the store, not
the filesystem; a same-length in-place edit still defeats
resolve_citation's length-only drift check. - Deleting a collection's files out of band while serving is not recoverable. On POSIX the runtime keeps reading the unlinked inode, including its generation.
- Half-dense collections remain undetected, per the existing no-backfill limitation.
- Adjacent gap flagged for ADR-0014:
SQLiteMetadataStoresetsforeign_keysandjournal_modebut notbusy_timeout. Under WAL readers do not block on a writer, but two writers do, and SQLite's default busy timeout is 0 — so the loser fails immediately with a locked-database error surfacing asStorageError. Tolerable for a CLI, questionable for a service.
References¶
- ADR-0002 — SQLite as durable truth, rebuild-at-open, the single-connection/one-lock model, and the staleness analysis whose silent-zero-results branch is this ADR's motivating defect.
- ADR-0004 — decision 3's one-read TOCTOU
discipline; decision 4's
application_id/user_versionstamp this ADR bumps; decision 5's pre-1.0 rebuild-not-migrate rule. - ADR-0008 — the per-mode refusal reading the dense-bound verdict each rebuild re-derives.
- ADR-0012 — decision 2 defers per-request rerank to Phase 4; decision 6 above puts the reranker on the process-lifetime side.
- ADR-0014 — the service surface that consumes this runtime.
- SPEC.md §2, §7, §8, §9.
scripts/measure_retriever_open.py— the measurement method, and the script owed a mode that measures this design's own per-request cost.src/groundkit/identity.py— the placement precedent decision 8 follows, and the one property it cannot.KNOWN_LIMITATIONS.md— the entries this ADR explicitly does not close.