Known limitations¶
Honest and current, per repo policy. Updated with each phase.
Current state (Phases 3–5: hybrid retrieval, service surface, LLM boundary)¶
Hybrid retrieval works end-to-end locally, behind opt-in flags: grk
ingest --dense embeds each chunk into a LanceDB vector store alongside the
existing SQLite write, and grk search --mode {bm25,dense,hybrid} reads
lexical, vector, or RRF-fused (ADR-0005) results back — every mode still
resolving to a citation-bearing result with character offsets. grk eval
--dense now runs the same three strategies over the golden corpus and
reports each stage's signed delta against the BM25 baseline. Everything
remains offline with no cloud credentials by default: no flag is required,
and grk search defaults to, and stays on, BM25 (Q1,
docs/specs/phase-3-hybrid-retrieval.md) — dense and hybrid are reachable
to opt into, not yet the default. Not yet built, arriving in their phases
per SPEC.md §9:
- The dense and fusion quality delta has been measured once, locally, and
is not yet a standing CI result.
grk eval --denseemitsbm25→dense→fusioninto one report, deltas are derived at read time (evals/delta.py), and a stage that loses to baseline is reported as a loss rather than suppressed. A gated run (EVAL_GATED=1,nomic-embed-textvia local Ollama) found both dense and fusion improving on the baseline with no metric regressing — the values are in the generated artifact, never restated in docs (SPEC.md §2). Two caveats stand: that is a single run over a 10-document corpus, which R2 (docs/specs/phase-3-hybrid-retrieval.md) warns is small enough that a result may not survive corpus growth; and.github/workflows/eval-gated.ymlnow runs on a weekly cron (Mondays 06:00 UTC) alongside its label and manual triggers — re-enabled in Phase 7, as the workflow file's own note anticipated — so this is re-measured automatically now. The result quoted above is still the single run described, not yet superseded by a later one, but a regression would no longer go uncaught by CI. The CI-defaultInMemoryEmbedderproduces hash-derived vectors with no semantic signal and, on this same corpus, reports both stages as regressions instead — so a delta from it is noise with a sign, not a weak measurement. The CLI stamps an explicit warning on any such report andRunConfig.embeddingrecords the provider, so an artifact self-labels which of the two it is. run_evalrequires a disposable vector store, and its cleanup has one gap. An eval builds a throwaway index whose SQLite half lives in an OS temp directory deleted when the run ends. A caller-suppliedvector_storeis therefore treated as disposable too: it must be empty (ConfigurationErrorotherwise) and every vector the run writes is deleted on the way out, including when the run fails. The guard exists because vectors stranded in a live collection are not inert —Retriever's orphan check fails that collection's dense searches closed the moment one ranks into the candidate window, with no visible connection to having run an eval. The residual gap:Indexercommits SQLite last, so a crash part-way through ingest can leave the in-flight document's vectors in a store SQLite never recorded, and the purge — which enumerates documents via SQLite — cannot see them. The emptiness guard bounds this to a store the caller already declared disposable, and the purge logs loudly on failure, but it is not a guarantee.- Hybrid cannot abstain on a no-answer query; dense does not abstain as
configured. BM25 returns nothing when no indexed chunk shares a term with
the query, which is what makes the corpus's
no_answerjudgments measure abstention at all. A vector search has no equivalent intrinsic floor — it returns itstop_knearest neighbours regardless of distance — but the two dense-side modes differ, and the difference is worth stating precisely: - Hybrid/fusion: structural. ADR-0005 decision 6 excludes rank-derived
fused scores from
score_thresholdaltogether (_resolve(..., apply_threshold=False)), so no configuration makes a hybrid query abstain on relevance grounds. - Dense: configurational. The dense branch does apply
score_threshold, andtest_impossible_score_threshold_zeroes_bm25_and_dense_but_not_hybridproves a high enough one returns zero dense hits. The default isNoneand the eval runs unthresholded, so the measured stage abstained on nothing — but the capability exists, and this is a missing defensible value rather than a missing mechanism.
Either way no_answer_abstained_count is 0 for both stages as run, while
the baseline abstains on every one, so abstention is not comparable across
stages — read it per stage, never as a delta. See
ADR-0007,
where this is the reason the default stays BM25.
- A dense/hybrid result list can still be shorter than top_k during the
staleness window between an open() and the retriever's next reopen.
The dense path over-fetches to avoid this where it can: _dense_candidates
(retrieval/search.py) widens its fetch, doubling from top_k, until
either top_k hits survive the open()-time snapshot filter or the store
is exhausted — so a hit dropped for belonging to a document ingested after
open() is backfilled from a lower rank. The residual gap is the cap on
that widening (_MAX_SNAPSHOT_FETCH_ATTEMPTS): if enough post-open()
content outranks the eligible chunks to survive every widening attempt,
the list is returned short, and a warning is logged rather than the
truncation passing silently. Reopening the retriever clears the window.
BM25 has no equivalent gap — the stale in-memory index simply has no
representation of the new content at all.
- The embedding-identity manifest is verified at both ADR-0004 decision-3
boundaries. Wave B closed the ingest boundary — Indexer calls
verify_manifest before any load/chunk/embed/delete work, and
write_manifest binds the collection on its first real dense write. Wave
C closes the second: whenever a dense pair is supplied,
Retriever.open() verifies the collection's manifest against the
embedder's (provider, model_name, dimensions) triple before the
O(corpus) BM25 rebuild does any work. A mismatch raises
IndexIdentityError, never a re-embed and never a fallback. A collection
with no manifest (never dense-ingested) verifies trivially — there is
nothing yet for a mismatch to exist against. That one verification is
also the sole source of the dense-bound verdict ADR-0008's per-mode
refusal reads: verify_manifest returns the manifest it checked, rather
than open() reading it a second time afterwards. Two reads admitted a
race with a real silent-corruption outcome — an unbound collection passes
the identity check trivially, and a concurrent dense ingest (another task,
or another process, neither of which the store's asyncio.Lock spans)
binding it to a different provider in between made the later read report
"bound", so the retriever answered dense and hybrid by matching this
embedder's query vectors against that provider's index. The residual
window is biased closed: a collection bound after the read is treated as
unbound and both modes are refused, which is the same snapshot rule that
already governs everything else a retriever cannot see after open().
- Cross-store writes are not atomic. SQLite and the vector store share
no transaction. The write order — chunk, embed, write the manifest (once,
before the first vector add), add the new vectors, delete the previous
document's, commit SQLite last — is chosen so SQLite is never ahead of the
dense store, and so the document is never left with no vectors at any
point. A dense store behind SQLite is silent: the incremental skip key
means that document is never retried and never appears in dense results. A
dense store ahead of SQLite is detectable: Retriever.search already
fails closed on a hit whose document has no stored source. The residue of
an interrupted ingest is therefore possibly-orphaned vectors, not a
silently missing document — but those orphans carry a document ID SQLite
never recorded, so a later re-ingest cannot reclaim them by that ID.
Recovering means deleting and rebuilding the collection, which is cheap
pre-1.0 and consistent with ADR-0004 decision 5.
- Adding before deleting trades a silent failure for a loud one; it does
not eliminate the residue. Deleting first was worse — it opened a window
with no vectors at all, and a crash there followed by a content reversion
(git checkout) left SQLite's stored fingerprint matching the restored bytes,
so the document was hash-skipped forever and silently absent from dense
results. Adding first closes that: the previous vectors survive, so the
reverted content still resolves. What it costs is that the interrupted
run's vectors stay behind under an uncommitted document ID, and a single
such orphan makes Retriever.search fail closed for any query that
ranks it — not just for that document. Prune sweeps iterate SQLite, so
they cannot reach it. Fully closing this needs the document row committed
before the dense write with its content hash withheld until after
(a two-phase commit MetadataStoreProtocol does not currently expose);
until then the recovery is a collection rebuild, as above.
- Delete reconciliation warns, it does not fail. ADR-0004 decision 6
requires the caller to reconcile the vector-delete count against the
chunk count SQLite deleted. A mismatch is logged and surfaced in
IndexReport, not raised, because a collection first ingested BM25-only
and later re-ingested with the dense path enabled legitimately deletes
chunks and zero vectors — strict equality would fail a completely
healthy upgrade.
- Turning the dense path on does not backfill an existing collection.
The incremental skip gate runs before chunking and therefore before
embedding — that is exactly what keeps an unchanged document from being
re-embedded on every run. Its key is a fingerprint over content, chunker
and chunking configuration (ADR-0009), and deliberately not over the
embedding identity: including that would turn --dense on an existing
collection into an implicit full re-embed, which is the auto-backfill
ADR-0008 declined. The cost is that enabling an embedder and
vector store over a collection already ingested BM25-only leaves every
unchanged document without vectors: it is skipped, so it is never
embedded. Only documents whose content actually changes afterwards gain
vectors. The collection is then permanently half-dense until it is
re-ingested from scratch. This is also the case the delete reconciliation
above deliberately tolerates. Forcing a backfill means deleting the
collection and re-ingesting it. Reading it densely is no longer silent
(ADR-0008): a dense or hybrid search against a collection with no
embedding-identity manifest raises ConfigurationError naming the
re-ingest remedy, instead of returning zero results (dense) or BM25's
ranking stamped "stage": "fusion" (hybrid). A collection that is
manifest-bound but only partially embedded — documents that changed after
the dense path was enabled — is still not detected, and remains the
genuine half-dense case this entry describes.
- A change to chunker behaviour does not re-chunk an existing collection.
The incremental skip key is a fingerprint over document content, the
chunker's type(...).__qualname__, and the chunking configuration
(ADR-0009 decision 4). It deliberately does not carry a chunker revision:
a type name catches the realistic case — a different chunker class — and
ADR-0009 records that it "misses only a chunker whose behaviour changes
without its type changing, which is indistinguishable from a code upgrade."
That is exactly what a fix to RecursiveChunker's internals is. With
identical source bytes and identical ChunkingConfig values, the
fingerprint is byte-identical across the upgrade, so every unchanged
document is skipped and keeps its pre-upgrade chunk boundaries — and,
on a dense collection, its pre-upgrade vectors, since the skip gate runs
before both chunking and embedding. The ingest reports success; nothing is
corrupt, and every stored chunk remains a valid offset-verified substring
of its document. The collection is simply chunked by the old algorithm
until it is deleted and re-ingested, which is the same remedy ADR-0004
decision 5 and ADR-0008 name for their own cases. Upgrades that change
chunk boundaries say so in CHANGELOG.md.
- A BM25-only indexer can no longer orphan a manifest-bound collection —
it is refused (ADR-0011). An Indexer constructed without an embedder or
vector store has no vector store to delete from, so replacing or pruning a
document in a collection whose vectors an earlier dense run wrote left those
vectors surviving their documents. This was tolerated until 2026-08-15 on the
grounds that the orphans are loud — Wave C's dense read path fails closed on
them, Retriever.search raising RetrievalError on a hit whose document has
no stored source, with regression tests for both the deleted-after-open and
deleted-before-open cases.
That argument stopped holding when ADR-0009 changed the skip key. A content hash matched on unchanged content, so reaching the hazard required a document to actually change; a fingerprint derived differently matches nothing an earlier build stored, so the first BM25-only ingest after that change rewrites every document and orphans the whole dense side in one ordinary command — permanently, because the same write stores the new fingerprint and a later dense run then hash-skips every one of them. What was loud is the eventual read failure, not the write that caused it, and that failure names an index inconsistency rather than the ingest three steps upstream.
Indexer._verify_identity now refuses a run whose indexer has no embedder
when the collection is manifest-bound, before any load, chunk or write, with
a ConfigurationError naming both remedies. The residual hazard is what the
manifest cannot see: a collection holding vectors whose manifest was never
written, or an Indexer holding a store handle from before a manifest
appeared. Neither is reachable through the CLI.
- The CLI exposes the dense path, entirely opt-in. grk ingest --dense
embeds and writes vectors alongside the SQLite write; grk search --mode
{bm25,dense,hybrid} reads them back (default bm25, and it stays there —
Q1 is decided in ADR-0007). Both share --embed-provider/--embed-model/
--embed-dimensions/--embed-base-url, which resolve to their defaults
(ollama/nomic-embed-text/768/the local Ollama endpoint) only once a
dense path is actually active — supplying any of them without --dense
(ingest) or without a dense --mode (search) fails closed with
ConfigurationError rather than being silently ignored.
--embed-provider inmemory is the same offline test double described
above: correct for exercising the CLI's dense wiring, never a quality
measurement. LanceDB data for a collection lives at
<index-dir>/<collection>.lance, beside <collection>.sqlite3. The
default install, default commands, and CI need no Ollama and are
unchanged by any of this.
- The cross-encoder reranker is now called by the eval harness; Retriever.search
and grk search still cannot reach it. run_eval accepts an optional
reranker and, when supplied, appends a rerank stage over the best
upstream stage the run produced — fusion with a dense pair, bm25
without — and grk eval --rerank wires it to the CLI. That is deliberate
and narrow, not a partial rollout waiting to be finished: ADR-0012
decision 2 keeps rerank out of retrieval/search.py entirely, so it is
reachable only through grk eval --rerank, SearchMode stays the
three-way Literal ADR-0007 already settled, and a reranked grk search
remains genuinely out of scope for Phase 3.
A delta is now emitted and derivable, and reported honestly including
when it loses (SPEC.md §6) — but which run produced it matters exactly as
much as it does for the dense/fusion numbers above. The default uv run
pytest suite exercises the rerank stage's full wiring — run_eval, the
CLI flags, RunConfig's three rerank_* fields, derive_rerank_attribution
— through a protocol-conformant stub reranker rather than a cross-encoder,
because the rerank extra is not in the dev group (below) and CI must not
pull torch. That stub's delta is the same trap InMemoryEmbedder carries
for dense: structurally valid, semantically noise with a sign. A delta that
means anything needs RERANK_GATED=1 with uv sync --extra rerank
(tests/test_eval_rerank_gated.py, the companion to test_rerank_gated.py
that drives a real cross-encoder through run_eval itself rather than in
isolation), and — like the Ollama eval gate above —
.github/workflows/rerank-gated.yml now runs on its own weekly cron
(Mondays 06:30 UTC, offset from eval-gated.yml's so the two heavy jobs do
not contend for runners), alongside label and manual triggers, re-enabled
in Phase 7 — so this is now re-measured automatically.
The rerank row's meaning is configuration-dependent, and the artifact
says so rather than leaving it implicit. A reranker reorders whichever
stage was best available for the run, so two reports can agree on
corpus_hash/judgments_hash and still describe two different
experiments. RunConfig.rerank_input, rerank_candidates, and
rerank_model are therefore recorded together or not at all — a validator
enforces it — precisely so two reports are never silently incomparable in
this one dimension.
The delta against stages[0] is not the reranker's own contribution when
the input was fusion — it sums fusion's gain over BM25 with rerank's
gain over fusion into one number. derive_rerank_attribution
(evals/delta.py) diffs the rerank stage against its own input stage
instead, and the CLI prints both — but that diff is a clean isolation of
the cross-encoder's own contribution only when the input is bm25. BM25
scores independently of top_k, so the wider candidate fetch reranking
uses only adds a superset of what the baseline stage already scored. RRF
does not: it sums 1/(rrf_k + rank) over the rankings a chunk is visible
in at the fetched depth, so fusion@50 is a different ranking function
from fusion@10, not a deeper slice of it — a chunk absent entirely from
the depth-10 fused list has ranked first at depth 50. A fusion-input
attribution therefore measures the rerank pipeline at the wider candidate
depth against fusion as reported, a real production comparison, and not
the cross-encoder's isolated contribution (ADR-0012 Consequences).
A gated run (RERANK_GATED=1, cross-encoder/ms-marco-MiniLM-L-6-v2
reranking bm25, with nomic-embed-text via local Ollama for the dense
pair) found the BM25-input rerank stage improving on the baseline with no
metric regressing — the values are in the generated artifact, never
restated in docs (SPEC.md §2), and that comparison is the clean one
described above. The fusion-input delta supports no conclusion, for two
independent reasons rather than one: the attribution is confounded exactly
as described above, and separately, its movement sits within a small
number of query flips on the answerable judgment set — inside the noise
band R2 (docs/specs/phase-3-hybrid-retrieval.md) already warns about.
Read it as neither a win nor a loss on this corpus, not as a weak
positive.
The same run exposed two further caveats:
recall@10reached its ceiling on the rerank stage, so on this corpus that metric can no longer discriminate a better reranker from this one.- The reported p99 latency is the sample maximum —
MetricSet's percentiles are nearest-rank over the same small per-judgment sample noted under Phase 2 caveats below — and it is dominated by the one-time cross-encoder model load, not steady-state inference. A reader comparinglatency_p99_msacross stages without knowing that would read a once-per-process cost as a tail-latency regression in the reranker itself.
Candidate depth is over-fetched to MAX_TOP_K, pinned rather than
exposed as a CLI knob, because a depth equal to top_k would hand the
reranker a set it can only permute — its recall_at_10 would then equal
the input stage's by arithmetic, not by measurement. Truncation to top_k
still happens after reranking, so a reranker can promote a candidate the
upstream stage ranked below the cut only if that candidate was inside the
list it was handed; it cannot recover a document the upstream stage never
retrieved. That remains the ceiling on any gain.
Also still true: the rerank extra (sentence-transformers, which pulls
in torch) stays out of the default install and out of the dev group, so
the default suite never loads a model — only the pure surface (the
sigmoid, ordering, fail-closed paths) is proved offline.
A related reproducibility trap: uv run mypy disagreed with itself
depending on whether the rerank extra was installed. With
sentence-transformers (and therefore torch) resolved, the
type: ignore[import-not-found] comments guarding the optional import in
retrieval/rerank.py became unused and mypy failed on them; without the
extra, mypy needed those comments and passed. Fixed by also listing
unused-ignore on both codes, but the general shape is worth recording
because the fix is local and the gap that let it happen is not: no CI job
runs mypy with the extra installed — rerank-gated.yml, the one workflow
that installs it, runs pytest only — so this class of
environment-dependent typecheck failure is not caught automatically
anywhere, and only surfaces to whoever happens to run uv sync --extra
rerank && uv run mypy locally.
- score_threshold does not apply to hybrid results. ADR-0005
decision 6: an RRF-fused score is a function of result-set size and
retriever count, not a probability, and thresholding it would silently
mean something different from thresholding BM25 or dense scores directly.
RetrievalConfig.score_threshold therefore applies to bm25 and dense
mode results only, never to fused scores, and never to the pre-fusion
candidate lists either (thresholding those would reintroduce the same
threshold by a side door). Phase 3 ships hybrid search with no confidence
cutoff at all. This is a deliberate deferral, not an oversight (ADR-0005
Consequences).
- Filtered dense search costs O(corpus). A metadata_filter triggers a
full-table over-fetch, then filters and truncates in Python, because
metadata is stored as one opaque JSON blob rather than structured columns
and pushing caller-controlled values into a LanceDB WHERE predicate is
the hazard class ADR-0004 decision 6 exists to close. Unfiltered search
stays a cheap top-k vector query. The alternative — silently returning
fewer than top_k — is a defect, not a tradeoff, so the cost is accepted
for now. Structured metadata columns would fix it and are not built.
- Metadata filtering is equality-only. A filter matches when every
key/value pair is present and equal. No ranges, no negation, no nesting.
- index/dense.py is now in the coverage core_subset, and it is a mixed
file. Wave F added it: it is scoring, the vector peer of the already-gated
bm25.py, it holds both live ADR-0001 hazards, and retrieval/fusion.py was
already gated by the retrieval/* glob, so leaving it out gated the combiner
but not one of its inputs. The residual gap is inside the file rather than
around it — roughly two-thirds shared helpers plus InMemoryVectorStore,
one-third LanceDBVectorStore behind the optional dense extra, all under a
single number. Well-covered LanceDB rows can therefore mask a thin in-memory
path or the reverse, which is the offsetting the subset exists to prevent,
admitted here at file granularity. What makes it safe is that lancedb is
pinned in the dev group so CI genuinely exercises both halves — a convention,
not an invariant. Splitting LanceDBVectorStore into its own module would
remove the caveat and is deliberately not bundled into Wave F.
index/metadata.py remains outside the subset by per-file reasoning, not
oversight (pyproject.toml records why).
- The two vector stores diverge on zero-magnitude vectors. LanceDB's
cosine search omits them from results entirely; InMemoryVectorStore
returns them at score 0.0. Real embedding models do not emit zero vectors,
so this is documented rather than forced into artificial parity — but the
two paths are not byte-identical on that degenerate input.
- Chunk metadata still carries an ingest-time source snapshot.
ingestion/chunking.py seeds metadata["source"], duplicating a fact that
documents.source owns durably. ADR-0006 makes retrieval ignore the copy,
so it can no longer produce a wrong citation, but the stale-on-re-ingest
copy is still written and still exposed in RetrievalResult.metadata.
Removing it is a separate change with its own migration cost.
- PDF/HTML loaders — v1 scope, not yet scheduled into a phase; FileLoader
still reads .md/.markdown/.txt only, so no ingest path can create a
document from a local PDF or HTML file (see the ADR-0016 section below for
the extractors that already exist to read one back). URL ingestion is no
longer on this list: grk ingest accepts an http(s) URL directly, behind
the SSRF guard described further down, and stores a verifiable local
snapshot.
- BM25 rebuilds in memory at open — O(corpus) startup cost, accepted and
bounded by ADR-0002's revisit trigger.
- BM25 scoring is off the event loop and no longer linear in corpus size,
but an unselective query still is. BM25Index.search scores the union of
the query terms' postings rather than every indexed chunk (GK-018, erratum
to ADR-0002 decision 2), so its cost tracks the number of chunks holding a
query term. That is a real bound for a selective query and no bound at all
for an unselective one: a stopword present in every chunk has every chunk in
its postings list, so a natural-language query still scores most of the
corpus. There is no stopword list and no term-at-a-time early exit — both
change which results come back, or need a score bound to be safe, and
neither is built. Both call sites — the bm25 branch and the hybrid
branch's lexical half — still dispatch through asyncio.to_thread, as does
InMemoryVectorStore.search's equivalent scan, because the pure-Python
scoring holds the GIL for most of its run and a concurrent caller still
contends for it; what that buys is that the loop keeps turning. Pinned by
tests/test_retrieval.py::test_bm25_scoring_does_not_run_on_the_event_loop
and its dense counterpart in tests/test_dense.py, both asserting thread
identity rather than timing, and by tests/test_bm25_postings.py for the
candidate walk itself.
- A writer that loses a lock race waits, then fails; nothing retries it.
SQLiteMetadataStore sets PRAGMA busy_timeout explicitly, to
metadata.BUSY_TIMEOUT_MS. Past that window the operation raises
StorageError: database is locked and no layer above retries, so an
ordinary collision — an operator running grk ingest against a collection
the service is writing to — surfaces as a 5xx that would have succeeded on
a second attempt. The timeout was previously inherited, unnamed, from
sqlite3.connect's timeout=5.0 default; setting it here changes no
behaviour and makes the value this module's rather than the stdlib's.
- A UTF-8 BOM is not stripped at load (utf-8, not utf-8-sig), so a
leading byte-order-mark character (U+FEFF) appears in the first chunk's
content and in citations resolved from it. Cosmetic — offsets stay
correct because the loader and the citation resolver use identical
decoding.
- search results are not verified against their sources; fetch_chunk is
the step that verifies. search returns the indexed text, because
verifying per hit is one file read per result per query. fetch_chunk
re-reads the cited span and byte-compares it to the indexed chunk, which does
catch a same-length in-place edit, and reports verified / drifted /
unresolvable. So a caller who quotes a search hit directly is quoting
unverified text, and one who calls fetch_chunk first is not. That division
is deliberate, and it is the thing to know about this surface.
Read literally, resolve_citation alone only detects drift by length
(end_offset > len(text)) — but it is a resolver, not the verifier.
verify_citation and fetch_chunk both do the full comparison.
A stored checksum would not improve this and is deliberately not added.
Verification already compares the actual bytes, and a digest comparison is at
best equal to a byte comparison and at worst admits collisions. A hash only
helps when the expected content is unavailable, and it never is: Chunk
requires content == document.content[start_offset:end_offset] and that
content is persisted. A per-chunk hash would be redundant state that can
disagree with its own input — the same reason evals/schema.py stores no
delta and index/bm25.py keeps no on-disk form.
What remains genuinely open is time-of-check/time-of-use: verification
describes the file as of the read, and nothing stops an edit a millisecond
later. No checksum closes that either.
- Total ingestion size is unbounded: individual files cap at 10 MiB, but
nothing caps file count or aggregate bytes for a directory run.
- A colliding metadata["source"] key on ingest is silently overwritten by
the authoritative path, not rejected. RecursiveChunker seeds
document.source into every chunk's metadata after splatting
document.metadata, so a Document whose own metadata carries a source key
loses that value — last-write-wins, with the authoritative key winning, by
design. This is deliberate: source is never reserved by contract (neither
ingest loader auto-populates it), so treating a collision as a hard failure
would turn a benign round-trip — read a chunk's metadata back, re-ingest it —
into an error. The trade-off is that a caller who intentionally stores their
own source key in Document.metadata has it clobbered with no warning;
nothing currently surfaces that collision to the caller. Before this was
fixed the polarity was reversed, and the caller's value silently won.
- No authentication. The 127.0.0.1 bind is load-bearing, not a default.
Every Phase 4 response is unauthenticated by construction (ADR-0014
decision 1): the shared-secret header SPEC.md §7 requires for mutating
routes is not built, because the set of mutating operations is empty. The
loopback bind is therefore what decides who can reach the service at all
(decision 7) — Host validation (ADR-0024) decides which of the requests
that do arrive are answered, and neither is authentication — and
--allow-remote-access exposes an unauthenticated, content-bearing
surface — document text and absolute source paths over search,
collection topology over index_status and list_collections — to
anyone who can reach the port.
- The REST surface admits any [::…]-shaped Host. Starlette's
TrustedHostMiddleware strips the port by splitting on the first colon, so
[::1]:8765 and a bare [::1] both reduce to "[" — which the allow-list
must therefore contain for any IPv6 loopback client to be served at all, and
which also matches other bracketed literals beginning [::. This is a real
widening, and it is not reachable by the DNS rebinding it sits next to: a
bracketed address literal is never the product of a DNS answer, so no
attacker-controlled zone can cause a browser to send one. Closing it would
mean this repo owning a hand-written Host matcher and keeping it correct
against IPv6 spellings, ports, trailing dots and case (weighed and rejected in
ADR-0024).
- Host matching is case-sensitive and does not strip a trailing root dot.
Starlette compares with == and the MCP SDK with in, so LOCALHOST,
LocalHost:8765 and localhost. are refused (400 / 421). It fails closed, so
this is a compatibility gap, never a widening. Left open deliberately: the
trailing dot is enumerable and letter case is not, so covering half would read
as normalisation while being none. The trigger is a real client that cannot be
configured around it.
- A hostname --host is resolved twice — once to derive the Host
allow-list, once by the ASGI server to bind — and the answers can differ. The
dangerous direction (routable when derived, loopback when bound) needs control
of DNS for the operator's own bind name plus --allow-remote-access; the
other direction is an availability failure visible on the first request.
Address literals, including the default bind, are never resolved and are
unaffected.
- No mutating operation over either transport. No ingest, no delete, on
REST or MCP. Deliberate (ADR-0014 decision 1), not an omission: SPEC.md
§1.2's four tools — search, fetch_chunk, list_collections,
index_status — are the complete named surface, and the CLI covers
ingest at a strictly smaller surface: a local operator's typed argument,
not any caller reaching the port.
- A missing chunk_id returns 400, not 404. handle_fetch_chunk raises
ConfigurationError for it, and separating that case from an invalid
collection name would need either a new exception type (forbidden by
ADR-0014 decision 9) or message matching (forbidden for fragility) —
neither is taken, so the asymmetry stands. A non-existent collection,
by contrast, does return not-found: check_collection refuses it as a
precondition before any handler runs, which is a boundary check rather
than an exception path (ADR-0014 decision 3; see
src/groundkit/service/errors.py).
- FIXED 2026-08-18 — chunk_count() materialized the corpus to return an
integer. index_status read len(await store.get_chunks()), which
selected every chunk's full text and rebuilt each as a re-validated Chunk
model. It now runs an aggregate: SQLiteMetadataStore.count_chunks(), a
concrete capability of the SQLite store rather than a new
MetadataStoreProtocol member. That protocol stays exactly as wide as it
was — it is held to exact signature parity by conformance tests, and
widening it for a reporting convenience is the trade ADR-0012 decision 3
refused for model_name — but declining to widen it never required using
the slowest implementation behind it. CollectionRuntime holds a concrete
store, so it can ask. Pinned by
tests/test_runtime.py::test_chunk_count_does_not_read_chunk_content,
which asserts on the SQL the store executes rather than on which method was
called. The document half of the same call was missed on the first pass and
closed the same way afterwards: count_documents() replaced
len(await runtime.get_document_sources()), which built the whole
{document_id: source} mapping one line above the chunk count to take its
length.
- grk serve bounds concurrent corpus-scale work; it does not rate-limit.
search and index_status acquire a semaphore admitting
service.tools.MAX_CONCURRENT_CORPUS_SCANS at a time, so peak memory tracks
that bound rather than arrival rate. It counts concurrent operations, not
requests per caller per interval: a client that waits for each response is
unbounded, nothing attributes load to a caller, and the surface is still
unauthenticated. Waiters queue rather than being shed, so a sustained
overload shows up as latency, not as refusals — a caller cannot distinguish
a slow corpus from a busy server. Connection exhaustion is bounded
separately and generously by cli.SERVE_MAX_CONNECTIONS; see SECURITY.md.
- --host '' is classified by resolving the empty string, which is
platform-dependent. asyncio.create_server maps host == "" to all
interfaces, so the bind is routable either way, but the classification is
not: on Windows getaddrinfo("", None) returns the machine's routable
addresses and the allow-list is correctly unrestricted, while on glibc the
same call raises and the fail-closed branch produces a loopback-only list on
an all-interfaces bind — refusing every legitimate client. Both directions
are safe; it is an availability inconsistency, and it requires
--allow-remote-access either way. Found by a post-merge security review.
- The first version of that bound used the wrong mechanism, and every
document describing it was wrong with it. uvicorn's limit_concurrency was
set to a small number and described as bounding in-flight requests. It trips
on len(connections) >= limit or len(tasks) >= limit — connections
server-wide, idle keep-alive included — and substitutes a 503 app before
routing, so a trip answers every route, the Kubernetes liveness and readiness
probes included. grk serve mounts a stateful MCP transport whose clients
hold an SSE stream open for up to half an hour, so idle sessions alone could
have 503-ed a server doing no work and restart-looped the single replica.
Recorded rather than quietly corrected because the failure class is the one
this repo keeps meeting: a control whose documented semantics and actual
mechanism differ, where every test written against the documented semantics
passes.
- Read-only does not mean the process writes no bytes. Opening a WAL
database updates its -shm/-wal sidecars, and SQLiteMetadataStore.open
runs CREATE TABLE IF NOT EXISTS and a best-effort chmod on every open
(ADR-0014 decision 4). The read-only claim is scoped to durable document,
chunk, and manifest state — enforced in Python, not by the filesystem:
the process holds a read-write file handle throughout. Filesystem-level
enforcement (a read-only mount) is deferred to Phase 6.
- FIXED 2026-08-16 — two read-only operations wrote into unrelated
databases. handle_list_collections advertised every contained
*.sqlite3, and SQLiteMetadataStore.open applies _SCHEMA
unconditionally, so asking index_status for one of those names created
documents, chunks, collection_manifest and collection_state inside a
database that was not groundkit's. Both halves now go through
metadata.is_groundkit_store, a read-only identity probe: listing skips a
foreign file, and open refuses it with a StorageError before any schema
is applied. Two details are worth keeping rather than deleting with the
entry. The probe opens with mode=ro&immutable=1, and both flags are
load-bearing — mode=ro alone makes SQLite create the -shm/-wal
sidecars of a WAL database, so the guard would have performed the very write
it exists to prevent (caught by
test_list_collections_opens_nothing, which already asserted the directory
is unchanged). And the probe keys on application_id only, never
user_version, so a pre-v3 store stays readable — folding the version in
would have locked out exactly the stores ADR-0016's narrow write-guard was
written to keep working.
- Reranking a hybrid search returns a ranking no grk search --mode
hybrid produces. RRF is not depth-invariant: it sums
1/(rrf_k + rank) over the rankings a chunk is visible in at the fetched
depth, so the wider candidate depth used for reranking fuses a different
set, not a deeper slice of the same one — a chunk absent from the
depth-10 fused list can rank first at depth 50 (ADR-0012 Consequences;
ADR-0014 Consequences). The response records the input depth so a client
can tell.
- DNS rebinding is not closed. Between url_safety's resolution and
httpx's own connect-time resolution, the answer can change — the window
is two resolutions in one process, small, not zero. Closing it needs
connections pinned to the validated address with the original host
preserved in Host and SNI, which is not built (ADR-0014 Consequences).
- The proxy bypass is not closed. trust_env=False was rejected because
it also disables SSL_CERT_FILE and .netrc, which operators
legitimately need, to close a bypass in the same trust domain as
base_url itself. The threat model is operator misconfiguration, not a
hostile operator (ADR-0014 Consequences).
- The keyed document read and the
COUNT(*)aggregates are one optional capability, so a store implementing only half of it falls back to the whole table.DocumentRecordStoreProtocoldeclaresget_document_record,get_document_records,count_documentsandcount_chunkstogether, andRetriever'sisinstancecheck is all-or-nothing: a hypothetical store that could answer the keyed document read but not the counts would silently take the whole-table fallback on every search. That is correct but slow, and it is invisible — nothing logs the downgrade. Accepted because no store in this repo is in that position (all four are the same single SQL statement against the same two tables) and because splitting the protocol would make a caller test two capabilities to establish one fact. Revisit if a second real implementation ever appears. -
Within one search, a document's record is read once and memoized. A document deleted between two hits on it inside the same
searchcall is therefore still resolved from the earlier read. This is strictly no worse than the behaviour it replaced — which resolved every hit against a single snapshot taken before the search began — and the fail-closed guarantee across searches is unaffected, but the read is "live per search", not "live per hit". -
The staleness cache's rebuild cliff has been measured. It is real, and it is not the shape this entry used to claim.
index_statusreportsretriever_acquires,retriever_rebuilds,rebuild_seconds_totalandlast_rebuild_seconds(ADR-0026), and ADR-0027 quotes a reading taken through those counters under a realgrk ingest.
This entry previously said an ingest over N changed files "costs N full
BM25Index.from_store rebuilds". It does not. ADR-0013 does bump the
generation once per commit, but a rebuild costs only an acquire that lands
after a bump, and a real ingest spends most of its wall time loading, chunking
and hashing, with the commits in a short burst. Measured across two corpus
sizes, the rebuild count stayed flat when the number of changed documents was
more than doubled, and the cache-hit rate stayed high throughout — see ADR-0027
for the reading itself, which is not restated here.
What is costly is each individual rebuild, and that grows with the corpus rather than with the write volume: at the larger corpus measured, the ingest window's rebuilds cost more wall time than the ingest that provoked them, and concurrent request throughput fell accordingly, because ADR-0013 decision 5 forbids serving a waiter stale. The contention still runs both ways — the reads slow the ingest that is invalidating them.
The incremental remedy — a monotonic per-document watermark, a
get_chunks_since, and a remove_document on the lexical index, behind a
SCHEMA_VERSION bump — is withdrawn rather than unbuilt (ADR-0027). It
attacks the invalidation rate, and the rate is not the binding cost; a
handful of rebuilds per ingest would remain a handful, only cheaper. ADR-0002's
persisted postings is what the reading indicates instead, and it is indicated,
not adopted: adopting it reverses ADR-0002's choice that SQLite is the sole
durable truth, which needs its own record.
Worth keeping for whoever writes that record, because it is why the withdrawn
remedy was expensive: remove_document was the hard half. A watermark cannot
represent a row that is gone, and BM25Index's postings map is keyed by
position in its chunk list, so removing a chunk from the middle invalidates
every position above it. Whatever replaces it must be score-identical to a full
rebuild, including the insertion-order tie-break, or ADR-0002 decision 2's
"pure function of the persisted chunk set" invariant becomes false — and that
invariant is the guard against repeating ARP's memory.py _key_map drift.
- The rebuild counters are process-local and reset without saying so. They
describe one CollectionRuntime object's history, not the collection's, and
are deliberately not persisted (persisting them would need a schema bump, would
put a write on an unauthenticated read path, and would either invalidate the
cache it measures or break ADR-0013 decision 1's bump-inside-the-_op rule). A
runtime evicted by CollectionRegistry's LRU bound and later reopened starts
from zero, and so does a restarted process, so a scrape taken immediately after
either reports zeros meaning "no data" rather than "no rebuilds" — a real trap
for anyone graphing them. Nothing aggregates across processes: a multi-process
deployment must read each process's own index_status.
-
URL ingestion's writes are off the event loop; its buffering is not.
UrlLoader's snapshot write and its HTML scratch write now dispatch throughasyncio.to_thread, so neither blocks the loop for the length of a content-sized write. Two smaller things stay on the loop deliberately: the fetched body is held whole in memory before either write — the size cap, not streaming, is what bounds it, because ADR-0016 decision 4 requires refusing pastDEFAULT_MAX_BYTESrather than truncating, and a truncated read would produce offsets into a partial document — and the scratchTemporaryDirectory's create and cleanup are constant-cost syscalls that are not dispatched. What changed is the stalling, not the cost. Pinned bytests/test_url_loader.py::TestUrlLoaderWritesOffTheEventLoop, which asserts thread identity rather than timing, as its BM25 and dense counterparts above do. -
metadata_filteris a seam with no caller, and that is a decision. SPEC.md §5.3 requires metadata filtering on both dense stores from the first dense-store commit, and it exists:VectorStoreProtocol.searchdeclares it,InMemoryVectorStoreandLanceDBVectorStoreboth apply it through the single_matches_filterhelper, andtests/test_dense.pycovers both paths. Nothing in the product passes one — no CLI flag, noSearchRequestfield, andRetriever._dense_candidatescallsvector_store.search(embedding, top_k=fetch)and nothing else — so the enabled branch is the most expensive code in the dense path, sits inside a file the coveragecore_subsetgates, and is proved only by its own unit tests. Recorded here rather than left to be rediscovered as an oversight.
Exposing it is not the parameter-forwarding it looks like. index/bm25.py
has no filter at all, so a surface-level filter would apply in dense
mode, have nothing to apply to in bm25 mode (the default, ADR-0007), and
in hybrid mode filter one candidate list while RRF fused the survivors
with an unfiltered lexical list — excluded chunks re-entering the ranking
through the other side by a depth-dependent amount (ADR-0005). A filter
honoured by one of three modes and silently leaky in a second is ADR-0001
hazard 3's symptom, plausible-looking unfiltered results, one layer above
where that hazard was closed. Two further costs: the keys chunk metadata
actually carries are source — the ingest-time copy ADR-0006 made
retrieval ignore because it goes stale on re-ingest — plus
file_name/file_extension from FileLoader and content_type from
UrlLoader, nearly all of them derivable from the documents row that
owns them durably; and a non-empty filter switches LanceDB from a top-k
query to count_rows plus a full-table fetch, so a filter field on
SearchRequest would give any caller of the read-only surface a switch
that makes every dense query O(corpus), and would be the first
unbounded-cardinality input on a surface whose other fields are all
bounded scalars.
Trigger to wire it up: a concrete request to restrict results to a
subset of one collection that cannot be met by making that subset its own
collection — collections, not filters, are this repo's partitioning
mechanism (one SQLite store and one LanceDB table each, and
SearchRequest already takes collection). When that request exists the
filter belongs at Retriever's document join, over documents columns
(source, source_class, extractor), where all three modes honour it
identically and the durable source is filtered rather than its
chunk-metadata copy; metadata_filter then becomes a push-down
optimization beneath it rather than the surface itself. That change needs
its own ADR for the bounds and failure mode of a caller-supplied filter
(key count, key and value length, allowed key charset, and whether an
unknown key fails closed or matches nothing): ADR-0014 decision 6's schema
test checks field names, so it would pass such a field without
constraining any of it.
O_NOFOLLOWdoes not exist on Windows, so the snapshot write and read are unguarded there.utils/path_safety.py'sO_NOFOLLOWdegrades to0— a no-op in the flag mask — so nothing crashes and nothing changes on win32, and both regression tests (tests/test_url_loader.py::TestUrlLoaderSnapshotWriteDoesNotFollowASymlinkandtests/test_snapshot_integration.py::TestSnapshotReadDoesNotFollowASymlink) skip rather than passing vacuously. CI runs Linux, where both guards are real.UrlLoader's timeout bounds the fetch, not everything aload()call does.timeout_secondswraps the HTTP exchange. The precedingensure_safe_endpointDNS resolution and the trailing snapshot write are outside it, so a pathological resolver or filesystem can still hold aload()past the configured bound. Both are bounded by other means (the resolver by the OS, the write bymax_bytes), and neither holds a remote connection open, which is the resource the bound exists to protect.Chunk.content_hashis recomputed on every access, deliberately. It is a sort tie-break inretrieval/fusion.pyandindex/dense.py, so it is hashed once per candidate per query (index/bm25.pycaches it once at build time instead). Caching it on the model withfunctools.cached_propertywould unfreeze it: pydantic's__setattr__special-casescached_propertybefore it consultsfrozen, sochunk.content_hash = ...would silently succeed and every later reader — both tie-breaks and the valueindex/metadata.pypersists — would use a string unrelated tocontent. The cost is unmeasured and the correctness loss is not, so it stays uncached;tests/test_contracts.py::TestChunk::test_content_hash_cannot_be_decoupled_from_content_by_assignmentpins the refusal.groundkit.evals.beir.adapt_beir_datasetsupports binary-relevance BEIR collections only; a graded one is refused, not silently flattened._load_qrelsraisesEvalErroron any qrels score outside{0, 1}— TREC-COVID and FiQA grade relevance 0–2 — because aGoldSpanis(doc, quote)with no field to carry a grade, so collapsing a 2 and a 1 into the same set membership would report binary nDCG that reads as directly comparable to a collection's published graded number. SciFact and NFCorpus's binary splits adapt cleanly (SciFact's own qrels are all 0/1, which is whyevals/beir/RESULTS-scifact-2026-08-21.md's comparison to BEIR's published number is valid); a graded collection needs the harness to grow a relevance grade first, which does not exist.- The BEIR adapter refuses Windows-reserved and case-colliding ids on every platform, not only where the collision is real.
_validate_identifier(src/groundkit/evals/beir.py) rejects a document or query id whose stem isCON/PRN/AUX/NUL/COM1–9/LPT1–9regardless of host OS, and_reject_case_colliding_document_idsrejects two ids differing only in case the same way everywhere. Both hazards are genuinely platform-dependent — the device-name reservation is Windows-only and itself build-dependent (measured on Windows 11 build 26340:CON.txt,COM1andLPT1round-trip fine, but a bareNULsilently accepts the write and reads back empty, discarding the document; POSIX gives none of these names any special meaning), and the case collision only happens on Windows and default macOS, never on Linux — but the adapter refuses both everywhere. That trades a legitimate id being rejected on a platform where nothing would actually go wrong for a corpus that adapts identically no matter which machine ran it: a benchmark corpus whose contents silently depended on the host OS would be the worse defect.
Loaders workstream (ADR-0016) — URL ingestion landed (wave 4); the PDF/HTML ingest-side loaders have not¶
ADR-0016 schedules PDF/HTML/URL support in four waves. Waves 1, 2 and 4 have landed, and
wave 3 has landed except for its ingest-side .pdf/.html loaders — so the honest summary
is narrower than it used to be: groundkit records, enforces and can re-verify a source
class, and can now produce a snapshot-class document from a URL, but still cannot produce
an extracted-class document from a local PDF or HTML file. This is item 1 of SPEC.md §4.1's
v0.1.0 scope amendment, narrowed to that one remaining gap.
- The PDF/HTML extractors exist; the local-file loaders that would use them still do
not.
groundkit/extraction.pyshipsPdfExtractorandHtmlExtractorbehind thepdfandhtmlextras, with deterministic pinned configuration (extraction_mode="plain",html.parser) and an identity string derived from distribution metadata.resolve_citationlooks a citation's recorded extractor up inactive_extractors()and re-extracts on a match. What is missing is the.pdf/.htmlfile loader reachable fromgrk ingest: that needs multi-loader dispatch, whichdocs/specs/loaders-extracted-and-remote-sources.md§9.6 declines to design rather than guess at.FileLoaderstill handles.md/.markdown/.txtonly, so no shipped path turns a local PDF or HTML file into anextracted-class document, and the re-extraction path stays exercised by tests rather than by a live file ingest. URL ingestion (below) is a separate path: it already reuseshtml_extractor()at fetch time to strip tags from an HTML-shaped response, but the resulting document issource_class="snapshot"either way — the extractor identity is not recorded on it, and re-extraction never runs for it. - A credential in a URL query string is refused by name, and the list of names is not
exhaustive.
_reject_unsafe_url_shaperefused userinfo (user:pass@host) from the start because this loader records the URL verbatim asDocument.source; a?token=…reaches exactly the same places (SQLite, everyRetrievalResultandCitation, the ingest log) and was permitted until it was refused too. The check is a denylist of unambiguous parameter names (utils/url_safety.CREDENTIAL_QUERY_PARAMS), compared after folding case and-/_. A credential under a name not on that list still gets persisted.codeandstateare deliberately absent — they are OAuth credentials in one context and a country code or a US state in another, and refusing them would reject ordinary document URLs. Redacting instead of refusing is not available:documents.sourceisTEXT UNIQUE NOT NULL, andsanitize_urlredacts every query value unconditionally, so it would collapse?id=42and?id=43onto one identity. Supply credentials out of band. - Snapshot cleanup is per-document; collection-level deletion is still owed. ADR-0023
binds a snapshot's lifetime to its
documentsrow — it is removed when that document is skipped as unchanged, replaced, deleted or pruned — which closes the two defects that existed before it (a re-ingest of an unchanged URL orphaned a full copy of the fetched text on every run, and deleting a document left its text on disk indefinitely). Two gaps remain. Deleting a whole collection is not implemented at all, so the third artifact SPEC.md §7 now names (<collection>.snapshots/, alongside.sqlite3and.lance) has no code to delete it; removing a collection today is a manualrmof all three. And cleanup is best-effort by design — a snapshot that cannot be unlinked (permissions, a Windows share violation) is a logged warning, not a failed ingest, because the durable state is already correct by then. That leaves disk litter which the next ingest of the same source retries, and which nothing else sweeps: there is nogrk gc. A backup must cover all three artifacts: a restore from the.sqlite3alone cannot verify anysnapshotcitation, because the text it would compare against lives in the sibling.snapshotsdirectory.docs/guides/deployment.md's backup-scope paragraph says so. - The extractor-identity check no longer always-refuses, and neither does a
snapshotcitation. Anextractedcitation still resolves only if its recorded extractor identity is active in this build —active_extractors()naming the recorded identity and reporting the active set when a match is missing — but that registry is no longer permanently empty. Wave 3 landedPdfExtractor/HtmlExtractorbehind thepdf/htmlextras, and both extras are mirrored into thedevdependency group, so the default development and test environment already exercises a real, non-empty registry rather than the constant-empty one this entry used to describe. A build installed with neither extra still refuses everyextractedcitation — correctly, and for the same reason as before — but that is now a fact about which extras were installed, not a permanent pre-wave-3 state (fail closed either way, ADR-0016 decision 2).snapshotcitations no longer refuse at all, pending or otherwise: wave 4'sUrlLoaderwrites a local snapshot at ingest time, andresolve_citationreads it back through_resolve_snapshot(retrieval/citations.py) instead of refusing. - A pre-v3 store is now refused for writes, which is broader than the earlier promise.
SCHEMA_VERSIONwent 2→3 (ADR-0016 addssource_class/extractortodocuments). BecauseCREATE TABLE IF NOT EXISTSnever adds a column to a table that already exists, a store created before this cannot hold a source class, soupsert_document,replace_documentandget_document_recordsrefuse with aStorageErrornaming delete-and-re-ingest as the remedy. This supersedes the narrower rule that a legacy store "keeps serving BM25-only indexing unchanged" — that promise was made before the columns existed. Reads are deliberately untouched: an existing collection stays openable and searchable, it just cannot be added to. Pre-1.0 the remedy is a rebuild, never a migration (ADR-0004 decision 5). - The join has one narrow fallback worth knowing about.
Retrieverreads provenance throughDocumentRecordStoreProtocoland, for a store that does not implement it, degrades to("text", None). That is sound rather than fail-open for the stores it can apply to — a store with no way to report a source class never had one to drop, unlike the real defect this work closed (a store that had the value and discarded it).SQLiteMetadataStoreimplements the protocol and a conformance plus signature-parity test pins that. The residual is a hypothetical second real store forgetting the method; there is only one today.
Phase 6 — every IaC path exercised; two verifications are narrower than their rows read¶
Phase 6 lands in two changes (docs/specs/phase-6-iac-observability.md §3). The
first is infra/ plus its ADRs and docs and changed nothing under src/;
the second adds the OpenTelemetry instrumentation and the JSON log formatter.
Both have landed. On 2026-08-16 a real groundkit span was observed in Jaeger,
and later the same day a real terraform apply against a live AWS account
provisioned the instance, ingested a document, and served a real search over
an SSM tunnel, the full documented compose cold-start ran end to end, and the
Kubernetes sequence ran on a single-node cluster. Every IaC path has now been
exercised at least once, so what remains is not missing coverage but two
verifications whose scope is narrower than a green row reads, plus one span
site. infra/README.md is the status board and records the exact scope of what
was executed.
-
The Kubernetes run was single-node, so the
ReadWriteOncehazard the documented sequence guards against was never reproducible. Docker Desktop (kind mode, v1.36.1, one node) ran the sequence verbatim on 2026-08-16 — scale-down steps included — throughapply -k, corpus load, a completed ingest Job (43 files, 1299 chunks), Deployment 1/1 Ready and a citation-bearing search overport-forward. What a single node cannot do is produce a multi-attach failure, because there is no second node to attach the volume from. The manifests are therefore confirmed self-consistent and confirmed to work in the small case; their multi-node behaviour is unproven. This is the caseinfra/README.mdexplicitly warned would "pass in the small case and stall in the real one", so the row names the cluster kind rather than reading as a general pass. -
The compose stack's loopback-only binding is verified host-side only, and the other half could not be run. The documented cold-start sequence completed on 2026-08-16 — model pull, the ingest one-shot (43 files, 1299 chunks, 1299 vectors through Ollama),
up -d, and a citation-bearingPOST /v1/searchover127.0.0.1:8765. The binding itself was demonstrated by a differential through this host's own LAN interface address: a control container published on0.0.0.0answered on10.0.0.16:8766while the service published on127.0.0.1was refused on10.0.0.16:8765, with the host listener table showing the two bind addresses. What was not done is the check ADR-0021 decision 1 actually names — a connection attempt from a different host. It was attempted from a phone and could not complete: that device could not reach the0.0.0.0control port either, while this host reaches it fine, which places the fault in the network rather than in anything here. The only Wi-Fi available is a guest SSID with client isolation, so no device on it can reach this machine. Closing it needs a wired host or the non-guest network. Kept as a distinct gap because "the bind is correct, demonstrated locally" and "no external host was ever refused" are different claims, and only the first is currently true. -
A groundkit span has now been observed in Jaeger (2026-08-16), for
ingestretrieveand — since a later run the same day —synthesize.src/groundkit/telemetry.pyis the tracer accessor and the typed attribute helper (ADR-0022 decision 3), instrumentingIndexer.index_source,Indexer.index_directory,Retriever.searchandSynthesizer.synthesize.opentelemetry-apiis a base dependency (ADR-0022 decision 1), so every instrumentation site works with nootelextra installed — spans are simply non-recording. The verified run exercised a realingestandsearchagainst the compose stack's collector, and a sweep of the exported payload confirmed the allowlist held: no query text, no source path, no document content. Thesynthesizespan was then observed too, from adocker compose run … grk answeragainst the running stack: it carriedchat.model,chat.provider,duration_msandresult_countand nothing else, and a sweep for the question text, the completion text, both citations' offsets, the corpus path and the source filename found none of them. Two caveats keep that from being a self-contained stack test. The chat model was the operator's host Ollama viahost.docker.internal, because the stack's ownollamavolume holds only the embedding model; and the first attempts, with a larger local model, timed out againstChatConfig.timeout_seconds' 60-second default, which no CLI flag can raise. Those timeouts were independently useful: the error-path span carriedotel.status_code=ERRORandgroundkit.failure_kind=ChatErrorand still leaked nothing, which is the harder half of the allowlist claim. - FIXED 2026-08-16 — a provider timeout reported no reason at all. Both
providers/llm.py's_raise_chat_errorandproviders/embeddings.py's_raise_embedding_errorrenderedf"... failed: {detail}"from_scrub(str(exc), secret), andstr()on severalhttpxtimeout types is the empty string — so a 60-secondReadTimeoutsurfaced asChat request to <url> failed:with nothing after the colon. Found while verifying thesynthesizespan, where it cost three runs to identify a plain timeout. A shared_error_detailhelper now falls back to the exception's class name; the scrubbing is unchanged. Recorded rather than deleted for one reason worth keeping: only the chat half was reported, and the embedding half had the identical defect, which is why the fix went into a helper both call rather than into the reported call site. A chat provider timeout still cannot be raised pastChatConfig.timeout_seconds' 60-second default from the CLI — no flag exposes it. - Installing the
otelextra and settingOTEL_*does not, on its own, make a span record — and this was shipped wrong before it was caught. The variables in ADR-0022 decision 2 are read byopentelemetry.sdk._configuration, which runs under theopentelemetry-instrumentlauncher and not on import, so absent an explicittrace.set_tracer_providercall the API keeps returning aProxyTracerProviderwhose spans are non-recording. The first version of this change had all three span sites correct, the allowlist enforced, and a green suite — and exported nothing at all, silently.telemetry.configure_tracing()is the fix, called from the CLI entry point;tests/test_telemetry.py::TestConfigureTracingis the regression test, and ADR-0022 decision 1 carries an amendment recording the trap. The general lesson is the one SPEC.md §1.4 already encodes: a unit suite cannot verify an export path, and only running the stack found this. Indexer.runnever existed, and ADR-0022 decision 5 originally named it — the ADR now carries an erratum rather than a silent fix. The real public ingest entry points areIndexer.index_sourceandIndexer.index_directory(bothasync,src/groundkit/indexer.py), and each gets its own span rather than one shared "ingest" span — attributed with the document and chunk counts from theIndexReporteach returns. A child span per file, for per-file latency, is a named follow-up and not part of this change: it would need its own decision about span semantics underindex_directory's concurrent fan-out.- All three of SPEC.md §3's span sites were Phase 6's to instrument,
synthesizeincluded — this entry used to defer it and no longer does. The deferral was written while Phase 5 was under construction alongside this phase, whenproviders/synthesis.pygenuinely did not exist. Phase 5 then landed (SPEC.md §9: done 2026-08-15) and this branch mergedmain, so the seam —async Synthesizer.synthesize(query, results)— was present and settled by the time this change was written. The premise expired without the text changing, which is the failure mode to notice: a deferral justified by "it does not exist yet" has a shelf life, and nothing about merging the thing into existence makes the record update itself.
The residual worth knowing is not a missing span but a sharper allowlist
obligation: a synthesis span sits closer to prompt text, completion text and
citation spans than any other site, and none of those may become a span
attribute. Model identity, result count, latency and a typed failure code may.
- The read-only-mount deferral is discharged in part, and the part is worth
stating precisely. The "read-only does not mean the process writes no bytes"
entry above deferred filesystem-level enforcement to this phase. What that
buys is the corpus mount: /data/corpus is a read-only mount in every
deployment surface, so the documents each citation resolves against are
unwritable by the serving process, enforced by the kernel rather than scoped
in Python. /data/index remains read-write and no mount flag changes that
while ADR-0002 keeps the store in WAL mode — SQLiteMetadataStore.open writes
its sidecars, runs CREATE TABLE IF NOT EXISTS, and chmods, on every open. So
read-only is now true of the corpus and still false of the index (ADR-0021
decision 2). A read-only open path in index/metadata.py would close the
rest and is a separate src/ change with its own ADR.
- A container cannot enforce the loopback bind, so the image is not safe to
run with the obvious short command. Inside a container a process bound to
127.0.0.1 is reachable from nothing, so the image binds 0.0.0.0 with
--allow-remote-access and the guarantee moves to the publish boundary:
host-loopback publish in compose, a ClusterIP Service in Kubernetes, no
ingress rules at all in Terraform (ADR-0021 decision 1, ADR-0020 decision 2).
docker run -p 8765:8765 and --network host both publish an
unauthenticated, content-bearing surface on every interface of the host, and
the image cannot tell those cases from the safe one. What was a refusal in
Phase 4 is a warning in a container.
- The Kubernetes probes target a real operation because there is no health
endpoint. GET /v1/collections proves the process is serving HTTP and the
index directory is listable; it does not prove any collection is usable,
because handle_list_collections returns [] for a missing or empty index
directory rather than failing. A pod with an unmounted volume therefore
reports ready. Adding /healthz means widening ADR-0014 decision 2's
route-parity exclusion set — a security-relevant src/ change deliberately
not taken in a change that touches no src/ file
(docs/specs/phase-6-iac-observability.md §4.2, Q1).
- The Kubernetes in-cluster boundary rests on a NetworkPolicy, which can be
silently inert. A ClusterIP Service closes the cluster's edge and nothing
else — every pod in every namespace can dial one directly — so
infra/k8s/networkpolicy.yaml (default-deny ingress, empty podSelector) is
what actually closes in-cluster reachability to this unauthenticated,
content-bearing surface. On a cluster whose CNI does not enforce
NetworkPolicy, the API server accepts the object, reports no status, emits no
warning, and every pod can still reach the service. Enforcement has to be
confirmed against the cluster; no manifest can assert it. The same policy may
also break kubectl port-forward on a CNI that blocks node-to-pod traffic —
most permit it, since kubelet probes would otherwise fail, but that is a CNI
behaviour rather than a Kubernetes guarantee. compose and the Terraform
module rest on a kernel-level socket bind instead and are contingent on
nothing.
- The base kustomization pins sortOptions: order: fifo, and that is
load-bearing. Kustomize's legacy sort reorders by kind and has no entry for
NetworkPolicy, so it rendered after the Deployment — meaning a first
apply -k to a shared cluster could have the Deployment controller create a
reachable pod before the default-deny policy was submitted. For an
unauthenticated, content-bearing service that window is the whole exposure.
FIFO makes declaration order the applied order. Verified by rendering:
kubectl kustomize infra/k8s now emits NetworkPolicy second, ahead of
Deployment. Not gated — a render-order assertion is parked on
chore/infra-ci-checks-parked.
- The Kubernetes manifests do not solve getting documents onto the volume.
pod-corpus-loader.yaml is a kubectl cp target and a recipe, not a
mechanism. A real deployment substitutes whatever it already uses.
- Three Kubernetes sequencing rules are not enforceable by a manifest. The
ReadWriteOnce claim admits one pod, so the Deployment must be scaled to zero
before either one-shot runs, or they sit Pending on a multi-attach error — on
a multi-node cluster only, which means the wrong order passes on a laptop
cluster and stalls in production. The image must be set in both
k8s/kustomization.yaml and k8s/ingest/kustomization.yaml, because a
kustomize images: transformer reaches only its own kustomization's
resources; setting one leaves the other on the unpublished placeholder, and
setting them differently has an ingest and the serve reading it disagree about
the code that built the index. And the ingest Job must be deleted before it
is re-applied: a Job's pod template is immutable, so apply over a
completed one is accepted, creates no pod, and leaves a following
wait --for=condition=complete to return immediately against the previous
run — so within the hour before ttlSecondsAfterFinished collects it,
re-ingesting after copying new documents reports success having done nothing.
All three are documented in infra/README.md and in the manifests themselves;
none is checked by anything.
- create_ssm_vpc_endpoints creates ssm and ssmmessages only.
ec2messages is the legacy channel — the SSM agent on the pinned AL2023 image
uses the other two — and it is not offered in every region. That is more than
a tidiness point: data.aws_vpc_endpoint_service errors on a service its
region does not offer, and a failing data source fails the entire plan, so
an optional endpoint nothing used could break the whole module. Add it back
through ssm_vpc_endpoint_services if you run an older agent, in a region you
have confirmed offers it.
- embedding_base_url is escaped differently for the systemd unit than for
the ingest helper, and that asymmetry is deliberate. systemd reads % in
ExecStart as the start of a unit specifier, so %20 is not a space but an
invalid specifier and systemctl enable --now refuses the whole unit —
ending bootstrap with no service. The unit therefore doubles % to %% and
the helper does not, because a shell has no such rule and doubling it there
would pass a different URL to the ingest. Both still carry the same URL; only
one has to say so in systemd's escaping. An explicit port is separately
validated to 1-65535, since port 0 otherwise produces a real egress rule
permitting nothing usable.
- The Terraform module's data volume is prepared by a retrying unit, because
the attachment can arrive after cloud-init has finished.
aws_volume_attachment cannot be requested until the instance resource
completes, by which time cloud-init is already running. A slow attach used to
exhaust a bounded inline wait and end provisioning permanently, while
Terraform went on to attach the volume and report a clean apply — an
apparently applied deployment with no service and nothing that would retry.
Storage prep is now groundkit-storage.service, a oneshot with
Restart=on-failure that starts groundkit.service on success. It also
resolves the volume under three names, since a Xen instance renames the
requested /dev/sdf to /dev/xvdf and has neither the NVMe by-id link nor
the requested name.
- data_volume_type accepts only gp3 and gp2, which is narrower than
"any block device type". io1/io2 require an iops argument this module
does not set and st1/sc1 have a 125 GiB minimum the 20 GiB default
violates, so both fail at apply for a value the docs used to invite.
Supporting them means exposing and validating type-dependent size and IOPS.
- The Terraform module needs outbound HTTPS at boot, so a private subnet needs
NAT. Bootstrap installs docker from Amazon Linux's CDN and pulls the
container image. create_ssm_vpc_endpoints carries the Session Manager
control channel only: set on an otherwise egress-free subnet it produces an
instance an operator can open a session to and no service running on it,
because set -e ended bootstrap at dnf install. A genuinely egress-free
deployment needs a prebaked AMI, or ecr.api/ecr.dkr interface endpoints
plus the s3 gateway endpoint and a VPC-reachable package mirror — none of
which this module builds (ADR-0020 decision 3).
- The Terraform module's embedding egress is derived, and two forms of
endpoint it cannot derive fail at plan rather than being supported. The
standing egress rule is TCP 443; embedding_base_url adds a rule for its own
port when that is not 443, which is the documented case, since Ollama listens
on 11434. A host given as a DNS name cannot be turned into a CIDR at plan
time and needs embedding_egress_cidr supplied alongside it. An IPv6
endpoint is not supported at any port: the module writes cidr_ipv4 rules
only, the standing HTTPS rule included, so "443 is already covered" is a
statement about IPv4 and nothing else. Both refuse loudly, which is the
intended trade — the alternative is an embed call that times out at the
security group on a deployment that applied cleanly.
- Three Terraform inputs are constrained to character classes because they are
written into files on the instance. container_image and
embedding_base_url reach both the generated ingest helper and the systemd
unit; collection reaches the helper. They are single-quoted there, and the
classes — which exclude the single quote, $, backticks, backslashes and
whitespace — are what make that quoting a property rather than a convention.
collection mirrors index/metadata.py's own pattern and ./.. rejection,
so a name groundkit would refuse cannot reach an instance. A legitimate value
outside those classes is rejected at plan; & is deliberately still allowed.
- The Terraform deployment is x86_64 and instance_type is validated against
it. The AMI filter selects al2023-ami-2023.*-x86_64 and the container image
is built by a plain docker build on an amd64 runner, so it carries one
manifest. A Graviton instance type is refused at plan rather than by EC2 at
launch, and arm64 support is a multi-architecture image build rather than a
different value for that variable. A Graviton family the pattern does not
recognise falls through to EC2's own launch rejection, which is loud.
The conditions that produce those refusals are resource precondition
blocks, and they have never executed. terraform validate does not
evaluate a precondition and terraform console cannot reach one, so nothing
offline can. CI checks the state they reject rather than the rejection
itself — see infra/terraform/aws-ec2/README.md's status table, where that
row is deliberately separate from the rows CI earned. The instance_type
refusal is a variable validation instead, which is reachable offline and
is checked directly.
- No groundkit image is published to any registry, so the image reference in
deployment.yaml is a placeholder that will not pull, and the Terraform
module's container_image has no default.
- The Terraform module creates no backups. prevent_destroy on the data
volume means the index survives instance replacement and nothing more; there
is no snapshot schedule and no DLM policy. SPEC.md §7 names backup scope,
retention and deletion behaviour as product decisions owed before any
deployment that is not a single user's local machine, and ADR-0020 decision 4
settles exactly one of them. It is also a single instance: every AMI or
instance-type change is downtime.
- The Terraform path has now been proved against a real AWS account
(2026-08-16); compose and Kubernetes were verified separately the same day
(see the entries above). SPEC.md §1.4 requires each
path be verified with the date recorded and SPEC.md §2 forbids recording a
date no run produced, so some rows are still empty. The machine this tree
was originally written on had a Docker CLI with no running daemon, a
kubectl with no cluster context, and no cloud credential — nothing was
built, pulled, applied or planned there. What was executed and observed at
that point: the compose file parses and interpolates, the Kubernetes base
renders and every manifest parses, the Terraform module passes fmt -check
and validate against AWS provider 5.100.0 and 6.60.0, and the user-data
template renders to a script that passes bash -n. The infra CI job covers
what that machine could not — on this change's first run the image built, ran
as uid 10001 under a read-only root, and all six pinned third-party tags
resolved — and it gates all of that on every pull request from now on. It also
evaluates the two things this module derives from string inputs, rather
than only proving they parse: the ECR-registry match that decides an IAM
attachment and a docker login, and the egress rule the embedding endpoint
needs, both checked with terraform console, which resolves locals and
templatefile() without a provider credential.
A later session had a real AWS account available and closed the last
Terraform row: terraform plan against a real personal-sandbox account in
us-east-1 produced 9 resources to add with a real AMI resolved and the
ECR-detection logic correctly matched a real private-ECR image reference;
terraform apply created them; bootstrap installed docker, authenticated to
the private ECR repo and pulled the image, and mounted the data volume via
the retrying storage-prep unit; groundkit-ingest indexed a planted
document; and a real POST /v1/search over an SSM port-forward tunnel
returned the correct citation-bearing result. terraform destroy ran in the
same session. One finding worth naming here rather than only in
infra/README.md: the account's default VPC ships as all-public with no NAT
gateway, which does not satisfy this module's "private subnet with NAT"
network prerequisite — associate_public_ip_address is pinned false
unconditionally, so a public-subnet-with-no-NAT deployment would apply
cleanly and then fail bootstrap's dnf install under set -e. A NAT
gateway and a dedicated route table had to be created outside the module as
a prerequisite, which is a real operational cost of this module's documented
network requirement, not a defect it introduced. Full scope — what this run
did and did not exercise (BM25-only, no SSM VPC endpoints, one account, one
region) — is in infra/README.md.
Compose and Kubernetes are no longer unrun: the entries above record a
completed compose up cold start and a completed apply -k sequence, both
from 2026-08-16, with their own narrower caveats stated there.
Phase 5 caveats (the LLM boundary)¶
- The redaction pass exists and is wired at exactly one boundary: cloud
chat egress.
build_chatwraps everyopenai_compatiblechat provider inRedactingChatwith no operator opt-out (ADR-0017), constructing a freshRedactorper call — a long-lived one would letrestore()expand a token from one request into a value captured in another, a cross-request disclosure the mitigation itself would manufacture (regression-tested). The default pattern floor covers structurally recognizable values only: emails, E.164/US phone shapes, IPv4, long secret-shaped tokens. No person-name pattern ships — free-text name detection by regex is unreliable enough that shipping one under the label "redacts names" would be a false promise; SPEC.md §2's "names → tokens" is met through configured patterns, which is what its own "configurable patterns" clause provides. Two recorded residuals: the embedding boundary is not redacted — a deliberate SPEC §2 deviation (ADR-0017 decision 5: order-dependent tokens are unstable across ingest/search processes, a vector over redacted text stops describing the stored chunk, andCollectionManifestrecords nothing about redaction, so a mixed collection would pass identity verification); and text that already contains a literal token-shaped substring ([EMAIL_1]) is indistinguishable from a real token once redaction runs, sorestore()corrupts it (tested, documented, unfixed). - The faithfulness judge is advisory and uncalibrated. Verdicts gate
nothing and the exit code never depends on them; malformed or incoherent
model output is a
JudgeError, never coerced. The calibration procedure required before gating could ever be proposed is documented inproviders/judge.py's module docstring; no human-labeled verdict set exists yet, and normal CI never runs the judge. - Synthesis quality is unmeasured by the default
pytestsuite, but it is no longer true that nothing re-measures it automatically.grk eval --synthesisruns the planted-marker citation-echo check (SPEC.md §2) against a real chat provider and writes its own artifact (evals/results/echo-latest.json); there is deliberately no offline double for it — an echo number from a scripted chat would be noise presented as a measurement.eval-gated.yml(see above) now runsgrk eval --dense --synthesis --judgeon the same weekly cron as the dense/fusion gate, so an echo/judge result is produced on a schedule rather than only when an operator happens to run it locally — it still never runs inci.ymlitself, since that job must stay offline and credential-free and this one needs a live chat provider.
EvalReport.synthesis now has a producer — it was "structure without a
producer" until grk eval --judge landed (2026-08-16), which requires
--synthesis, synthesizes over the golden corpus against the run's best
stage, and folds the judge tallies into the main artifact. One limit
survives that change and is the reason this entry is not simply deleted:
the judge is advisory only — it exits 0 and gates nothing (SPEC.md §6)
until calibrated against human labels, so a falling faithfulness tally
fails no build even on the now-automatic weekly run.
- grk answer is CLI-only; synthesis is off the service surface
(ADR-0019). Synthesis is a read, but it adds cost amplification and
egress amplification that a loopback bind does not bound. Named
consequence: agentic-evalkit can grade groundkit's retrieval through
the HTTP/MCP ExecutionTarget boundary, but not its generative half.
- Prompt-injection defense at the synthesis boundary is structural
only. Retrieved content passes through sanitize_content
(providers/context_assembly.py, ported from ARP per ADR-0001) before
entering a prompt: delimiter-tag forgery is neutralized, control
characters stripped, lines quote-prefixed. Instruction-like phrasing
survives verbatim inside the quoting — this raises the bar against naive
smuggling and is not a guarantee a model treats the content as inert.
The ported TokenBudgetAssembler/frame_content envelope is available
but unwired; only sanitize_content is live.
- Citation-marker parsing is deliberately naive. Every [n] in a
completion counts as a marker, including bracketed numbers inside echoed
source text — the distinction is not reliably recoverable from text
alone (documented in providers/synthesis.py). Redaction tokens cannot
collide with markers: the marker regex is digits-only and token
categories always carry letters (regression-tested against ADR-0018's
recorded hazard).
- Query rewrite is reachable only through grk answer --rewrite and no
eval number measures it. Enabling it changes retrieval inputs, so its
effect on retrieval quality is unquantified; a rewrite failure is a
typed error, never a silent fallback to the original query.
Phase 2 caveats¶
- The adversarial category does not test prompt-injection resistance. With no LLM in the retrieval path, "injected text must never surface as instructions" was untestable in Phase 2. Phase 5 added the structural sanitization pass and the advisory judge described above, but neither turns this category into a resistance test — that remains an unclaimed property. What Phase 2 asserts is fixture correctness — that documents referenced by adversarial judgments genuinely contain injection-styled text, and that retrieval returns it verbatim like any other content. A passing adversarial test is not a claim that groundkit resists prompt injection.
- nDCG's IDCG is inflated by boundary-straddled gold quotes. A single gold
quote that spans a chunk boundary resolves to two relevant chunks, so
IDCG assumes two ideal positions where a retriever that found the answer
via either half alone cannot reach 1.0. Accepted because SPEC.md §6
specifies
min(|gold|, 10)with no span/chunk distinction. recall@k is unaffected — it is hit-rate, not set-recall. - No-answer queries are keyword phrases, not questions. The tokenizer is a
bare
\w+split with no stopword removal, so a query phrased as a sentence carries words present in any English prose, scores above zero, and returns hits — abstention would be unobservable. Content-word-only queries make it measurable without inventing a score threshold. This makes no-answer queries shaped differently from the other categories; that asymmetry is deliberate. - Baseline deltas are intra-run only.
evals/results/is gitignored, so no historical artifact reliably exists to diff across runs. Later stages compare against the baseline stage inside the same report. MetricSet's cutoffs are fixed at 1/5/10, sorun_evalrefuses atop_kbelow 10 (MIN_EVAL_TOP_K) rather than publishing an@10field computed from a shorter list. The floor used to live only incli.py, which left a library caller free to emit a report whoserecall_at_10was arecall@1under a@10name — a gold chunk at rank 7 scoring0.000or1.000from identical inputs depending on a cutoff nothing reading the field is obliged to cross-check. Evaluating a genuinely top-3 system is therefore not expressible here; that needs configurable cutoffs in the schema, not a smallertop_k.- Latency percentiles are computed from a small sample (one measurement per judgment), so p95/p99 are order-of-magnitude indicators, not reliable tail estimates.
- Two byte-identical chunks still tie non-deterministically in BM25 ordering — they are indistinguishable by any content-derived key. (The tie-break fix resolved the far more common case of distinct chunks tying.)
Deliberately out of scope for v1 (will not be built)¶
- Multi-tenant auth — single-operator service; the shared-secret header on mutating routes is not a tenancy model.
- Distributed indexing — one node, file-based index.
- Fine-tuning of embedding or rerank models.
- Agent loops — this is a retrieval service, not an agent runtime.
- UI beyond the docs site.
- GraphRAG.
- Broad vector-DB support — LanceDB (dense) + SQLite (metadata) behind interfaces; pgvector is a designed-for extension point, not a v1 feature.
- A semantic key-value memory store — ARP's
memory.pywas dropped per ADR-0001: its in-process key map is non-durable even over a persistent store, the exact anti-pattern this repo bans. - LLM-based reranking — rerank is a local, non-LLM cross-encoder only.