Extracted and remote source loaders — citation verifiability¶
Feature spec for the plumbing ADR-0016 requires but does not itself build. PDF/HTML loaders and URL ingestion are v1 scope (SPEC.md §4) with no phase number — ADR-0016's own Context section says so explicitly — so this workstream is tracked here rather than folded into a numbered phase's spec.
Status: Waves 1–2 have landed (0bcb417 persists source_class/
extractor and adds get_document_records; 4b41c12 fixes fetch_chunk to
read the joined record instead of a bare source string) — the "not yet
started" note that used to sit here was stale and is corrected rather than
left wrong. PR #12 landed the contract and dispatch half of ADR-0016 (see §2)
ahead of Waves 1–2 proper, which is why §2 already read as ground truth; §4–§6
(Waves 1–2's own design) are now equally ground truth, verified against the
tree at 4b41c12, not a plan that shipped unread.
Wave 3 has now landed in part, and the part is stated precisely because
the boundary is not the one §9 implies at first reading: groundkit/extraction.py
(the ExtractorProtocol seam, PdfExtractor, HtmlExtractor and the
active_extractors() registry), the pdf/html extras, and
resolve_citation's re-extraction branch are built and tested. The ingest-side
PDF/HTML loaders are not — they need the multi-loader dispatch §9.6
explicitly declines to design, so building them would have meant guessing at
exactly the seam that section refuses to guess at.
Wave 4 has now landed (5700056): ingestion/url_loader.py fetches an
http(s) source through the SSRF guard and the redirect refusal §10.3 designed,
snapshots.py implements the path arithmetic §10.1 specified verbatim, and
retrieval/citations.py's _resolve_snapshot resolves a snapshot citation
against the stored copy exactly as §10.2 designed it — a read-and-compare with
no extractor dependency. cli.py's _is_url_source/_cmd_ingest_url close
the one thing §10.3 flagged as undecided (how grk ingest accepts a URL as
its path argument). A follow-up (73747d9, PR #21) hardened the fetch
further, closing a gap the original design did not anticipate: a
credential-shaped query parameter (an API key or token embedded in the URL
itself) is now refused before the request is made, rather than being fetched
and potentially logged or snapshotted. §3's status column carries the same
split — Wave 3's loaders still open, Wave 4 landed.
Nothing here overrides SPEC.md or ADR-0016; where this document makes a decision neither already contains, it says so and states the alternatives considered, per SPEC.md §8.
1. What ADR-0016 already settled¶
Six decisions, accepted 2026-08-15, not open for relitigation here:
- Verification is defined per
SourceClass(text/extracted/snapshot), recorded at ingest rather than re-derived from the extension at citation time. - An
extracteddocument carries an extractor identity; a mismatch between the identity recorded at ingest and the one active in the build fails closed. - HTML is
extracted(tags stripped), not a fourth class. - URL ingestion snapshots the fetched bytes locally; verification resolves against the snapshot, never a re-fetch.
- PDF/HTML ship behind an extra (ADR-0015's option-vs-command rule); URL
fetching does not need one (
httpxis already base). fetch_chunk's verdict set (verified/drifted/unresolvable) gains no fourth value. An extractor-identity mismatch isunresolvable, withdetailnaming the reason.
Read the ADR for the reasoning; it is not restated here.
2. What is already built, and what is not (ground truth, verified against the tree)¶
PR #12 landed more of ADR-0016 than the six decisions above might suggest. Already present and correct:
SourceClass = Literal["text", "extracted", "snapshot"](contracts.py).Document.source_class/Document.extractor, with a two-waymodel_validatorenforcing decision 2's pairing (anextracteddocument must carry an extractor; every other class must not).Citation.source_class,Citation.extractor,RetrievalResult.source_class,RetrievalResult.extractor, andRetrievalResult.citationpropagating all four onto theCitationit builds.resolve_citation(retrieval/citations.py) dispatching oncitation.source_classand refusingextractedandsnapshotoutright, with the URL-before-ensure_within_baseordering decision 4 requires.verify_citation(citation, expected_content, allowed_base_dir) -> bool.tests/test_source_class.py— the contract-level tests (validator pairing, dispatch refusal, the pinnedensure_within_base-resolves-a-URL hazard).
None of that is the gap. The gap is that nothing persists the class, and the failure mode is fail-open, not merely incomplete:
documents(index/metadata.py,_SCHEMA) has columnsdocument_id, source, content_hash, ingested_atonly — nosource_class, noextractor.indexer.pyhas zero references to either field.Indexer._persist_documentcallsstore.replace_document(source=doc.source, document_id=doc.document_id, content_hash=doc_hash, chunks=chunks)—doc.source_classanddoc.extractorare read off the loadedDocumentand then dropped.retrieval/search.pyhas zero references to either field.Retriever._resolvebuilds everyRetrievalResultwith nosource_class=/extractor=keyword, so both default toCitation's andRetrievalResult's Pydantic defaults —"text"andNone— regardless of what was actually ingested.service/tools.py::handle_fetch_chunkbuilds itsCitationthe same way, from asources: dict[str, str](get_document_sources()) that carries a bare path string, no class.
The consequence: Indexer accepts a hand-constructed
Document(source_class="extracted", extractor="x@1", ...) today — the
contract permits it, keyword-only defaults ask for nothing more — chunks it,
persists it, and every read path (search, fetch_chunk) reports it back as
source_class="text". A text-class citation routes through the plain
read-and-slice path ADR-0016 exists to keep extracted and snapshot
citations out of. The current default is a silent downgrade, not an
absent feature: nothing raises, nothing logs, and the citation looks
verifiable — it just verifies the wrong thing, because it verifies as if it
were byte-identical file content it never was.
No loader produces an extracted or snapshot Document yet (§3, Waves 3–4)
so this defect is unreachable from any shipped grk command today. It is
reachable from library code and from tests that construct a Document
directly, and it will become reachable from grk ingest the moment a Wave 3
loader lands — silently, unless this spec's Wave 1 closes it first.
3. The wave plan¶
| Wave | Scope | Status |
|---|---|---|
| 1 | Persist source_class/extractor; join them back through search and fetch_chunk |
landed (0bcb417) |
| 2 | Extractor-identity check at resolve time; typed fetch_chunk verdict (§5) |
landed (0bcb417, 4b41c12) |
| 3 | PDF/HTML extractors behind a pdf/html extra (ADR-0016 decisions 3, 5) |
landed, except the ingest-side loaders |
| 4 | URL fetching + local snapshot storage (ADR-0016 decision 4) | landed (5700056) |
Wave 3's exception is not a shortfall against §9 but a consequence of §9.6:
groundkit/extraction.py, both extras and resolve_citation's re-extraction
branch are built and tested, so an extracted citation produced by a
pypdf/beautifulsoup4 identity this build can run now verifies rather than
refusing. What is still absent is a .pdf/.html loader reachable from
grk ingest, because routing extensions to loaders within one invocation
needs the multi-loader dispatch §9.6 declines to design. The practical shape
of that gap: nothing in-tree yet produces an extracted document, so the
re-extraction path is exercised by tests rather than by a live ingest.
Wave 4 was named so a reader would find a scheduled gap, not a forgotten one,
and it has since closed that gap: §10 settled the seams ADR-0016 deliberately
left open — precisely enough that the implementation matches this section
with no judgment call left to make — and 5700056 built it. URL fetching and
snapshot storage now exist in src/groundkit/ (ingestion/url_loader.py,
snapshots.py), and resolve_citation's snapshot branch resolves against
the stored copy. Wave 2 had to behave correctly in their absence (§5.3);
that was the smaller, temporary obligation, superseded now that the real
branch is reachable.
4. Wave 1 — persistence and the join¶
4.1 Schema: SCHEMA_VERSION 2 → 3¶
documents gains two columns:
CREATE TABLE IF NOT EXISTS documents (
document_id TEXT PRIMARY KEY,
source TEXT UNIQUE NOT NULL,
content_hash TEXT NOT NULL,
ingested_at TEXT NOT NULL,
source_class TEXT NOT NULL DEFAULT 'text',
extractor TEXT
);
SCHEMA_VERSION: Final[int] = 3 in index/metadata.py. Confirmed with the
owner: the delete-and-re-ingest consequence is acceptable
(ADR-0004 decision 5 —
pre-1.0, an unversioned or wrong-version store is a rebuild, not a
migration). This bump is not additive the way 1→2 was. ADR-0013's bump
added a new table (collection_state); a v1 store kept working for
BM25-only reads and writes and only lost dense-manifest capability. This
bump changes documents itself — the table every read and write already
goes through — so a v1 or v2 store cannot silently keep working for
anything that touches source_class/extractor. CREATE TABLE IF NOT
EXISTS is a no-op against an existing documents table, so an old store's
table genuinely lacks the two new columns; there is no ALTER TABLE here
(pre-1.0, no migrations).
The guard stays narrow, matching the existing _require_manifest_capable
pattern rather than refusing at open() for every store below v3:
upsert_document, replace_document, and the new get_document_records
(§4.3) are the only methods that read or write the two new columns, so they
are the only ones that need to check self._schema_current and refuse. Add
a sibling guard (or generalize the existing one — implementer's choice of
shape, not of behavior) that raises StorageError naming the delete-and-
re-ingest remedy, the same way _require_manifest_capable does today for
IndexIdentityError. Every other method — get_document_hash,
get_document_id, get_document_sources, add_chunks, get_chunks,
get_chunk, delete_document, the manifest and generation methods — is
untouched by this bump and keeps its existing schema-currency requirements
exactly as they are today. This is deliberately narrower than "the whole
store refuses below v3": get_document_sources()'s query
(SELECT document_id, source FROM documents) never references the new
columns, so it keeps working unchanged against a v1, v2, or v3 store —
there is no reason to make it fail on the two it currently serves fine.
Write-path signatures (frozen decision: new parameters are keyword-only with a default, so no existing caller breaks):
async def upsert_document(
self,
source: str,
document_id: str,
content_hash: str,
*,
source_class: SourceClass = "text",
extractor: str | None = None,
) -> None: ...
async def replace_document(
self,
source: str,
document_id: str,
content_hash: str,
chunks: list[Chunk],
*,
source_class: SourceClass = "text",
extractor: str | None = None,
) -> None: ...
Both write methods gain the pair (not just replace_document, which is the
only one any production code calls today) so the protocol stays internally
consistent and upsert_document's own tests can exercise it directly.
MetadataStoreProtocol (index/protocols.py) and its conformance test
(tests/test_protocol_conformance.py) gain the same two keyword-only
parameters on both methods.
Indexer._persist_document (indexer.py) passes the two fields through on
its one call site:
await self._store.replace_document(
source=doc.source,
document_id=doc.document_id,
content_hash=doc_hash,
chunks=chunks,
source_class=doc.source_class,
extractor=doc.extractor,
)
4.2 The join: reading source_class/extractor back¶
RetrievalResult.source_class/.extractor and Citation.source_class/
.extractor must be populated from what was actually persisted, for every
document a search or fetch touches — not only the extracted ones. A mixed
collection (some text, some extracted) must report each result's real
class.
New protocol method, not a widened get_document_sources().
get_document_sources() -> dict[str, str] has five call sites outside this
workstream — Indexer's two prune paths (string equality against a resolved
path), index/dense.py::verify_dense_side_present (truthiness only),
evals/runner.py (two sites, one builds a corpus-relative path map via
Path(source), the other only reads the keys) — none of which need
source_class or extractor. Widening its value type from str to a
record would force every one of those five sites to change for a field they
never use, and Retriever.open()'s and Retriever._dense_candidates's uses
only need the key set anyway. Adding a sibling method confines the change
to the two call sites that actually need the richer read:
# contracts.py, beside SourceClass
class DocumentRecord(BaseModel):
"""A stored document's provenance, projected for the citation join (ADR-0016).
Read-only: this is a view over the `documents` row, not a second place
that fact is asserted from. `source_class`/`extractor` default exactly as
Document's own fields do, so a v3 store's rows written before this wave
(none exist pre-launch, but the defaults are the same regardless) read
back as `text`/None, matching what an old-style ingest actually produced.
"""
model_config = ConfigDict(extra="forbid", frozen=True)
source: str
source_class: SourceClass = "text"
extractor: str | None = None
# index/protocols.py — MetadataStoreProtocol
async def get_document_records(self) -> dict[str, DocumentRecord]:
"""Return {document_id: DocumentRecord} for every stored document.
Requires a schema-v3 store (§4.1) — raises StorageError otherwise, naming
the delete-and-re-ingest remedy, the same way write_manifest does for a
pre-ADR-0004 store.
"""
Two call sites switch to it:
Retriever.search()(retrieval/search.py): its onesources = await self._store.get_document_sources()read (used by every mode) becomesrecords = await self._store.get_document_records()._resolve'ssources: dict[str, str]parameter widens todict[str, DocumentRecord], and every constructedRetrievalResultgainssource=record.source, source_class=record.source_class, extractor=record.extractor._dense_candidates's and_apply_snapshot_filter'ssourcesparameter type widens to match formypy --strict, even though their only use of it ischunk.document_id in sources— membership, unaffected by the value type.Retriever.open()'sdocuments_at_opensnapshot keeps callingget_document_sources()(it only needs the key set, and there's no reason to pay for the richer read there).handle_fetch_chunk(service/tools.py): itssources = await runtime.get_document_sources()becomesrecords = await runtime.get_document_records(), and theCitationit builds gainssource_class=record.source_class, extractor=record.extractoralongside the existingsource=record.source.
CollectionRuntime (runtime.py) gains the matching thin proxy, mirroring
its existing get_document_sources:
async def get_document_records(self) -> dict[str, DocumentRecord]:
"""Return {document_id: DocumentRecord} for every document in the collection."""
self._require_open()
return await self._store.get_document_records()
5. Wave 2 — extractor-identity check and the typed verdict¶
5.1 The extractor-identity check¶
resolve_citation's current dispatch refuses every non-text class with one
generic message. Wave 2 splits extracted out into its own branch — decision
2's actual check — while snapshot keeps its exact current behavior
(Wave 4's job, untouched here, still refused).
#: Identity strings of every extractor this build can re-run a citation
#: through. Empty in Waves 1-2 by design, not a TODO: no PDF/HTML extractor
#: is wired in yet (Wave 3, ADR-0016 decisions 3/5), so no extractor
#: identity is ever active, and every `extracted` citation correctly refuses
#: regardless of what it recorded. Wave 3 populates this per extractor it
#: ships; the membership check below does not change shape when it does.
_ACTIVE_EXTRACTOR_IDENTITIES: frozenset[str] = frozenset()
if citation.source_class == "extracted":
if citation.extractor not in _ACTIVE_EXTRACTOR_IDENTITIES:
raise RetrievalError(
f"cannot resolve an 'extracted' citation for {citation.source!r}: "
f"the extractor identity recorded at ingest ({citation.extractor!r}) "
"is not active in this build "
f"({sorted(_ACTIVE_EXTRACTOR_IDENTITIES) or 'none registered'}). "
"Re-deriving these offsets needs the exact extractor that produced "
"them; refused rather than guessing (ADR-0016 decision 2).",
verdict="unresolvable",
)
# Unreachable while _ACTIVE_EXTRACTOR_IDENTITIES is empty (Waves 1-2).
# Wave 3 adds the re-extraction call here: re-run the active extractor
# over citation.source, slice [start_offset:end_offset], and return it —
# the same shape resolve_citation already has for `text`.
elif citation.source_class == "snapshot":
raise RetrievalError(
f"cannot resolve a 'snapshot' citation for {citation.source!r}: ...",
verdict="unresolvable",
)
The message must keep the substring "extracted" —
tests/test_source_class.py::test_resolve_refuses_an_extracted_citation
already asserts pytest.raises(RetrievalError, match="extracted") and stays
green under this change (the extractor it supplies, "pdf-x/1", is not in
the empty active set, so it still refuses). The snapshot branch keeps its
existing message verbatim — match="snapshot" in the sibling test is
unaffected.
This is real, not placeholder, code: the membership check is exactly what Wave 3 will exercise once it populates the set. Nothing about its shape changes when an extractor is added — only the constant does.
5.2 THE verdict mechanism — replacing the string sniff¶
fetch_chunk (service/tools.py) currently classifies a RetrievalError by
searching its message for "changed since indexing":
That already only classifies two outcomes correctly by coincidence, and
decision 2's new extractor-mismatch refusal (§5.1) — which must map to
unresolvable — would classify correctly today only because it, too, lacks
that substring. Coincidence is not a mechanism; the ADR names this exact
prose-coupling as the thing to replace.
Alternatives weighed:
- New
RetrievalErrorsubclasses (e.g. a drift error and an unresolvable error). Rejected. ADR-0016 decision 5 states a preference for no new exception types where an existing one already covers the failure (there, in the narrower context of the extras — but the reasoning generalizes:errors.py's taxonomy already has a type per kind of failure, not per classification a specific caller needs). More concretely:evals/echo.py:491callsresolve_citationdirectly and lets a failure propagate as a bareexcept-free call — it needs "did this fail", never "which of two ways". A subclass hierarchy would exist purely forfetch_chunk's benefit while every other caller pays for it in import surface, andfetch_chunk's own dispatch barely simplifies —except CitationDriftError/except CitationUnresolvableErroris not meaningfully less classification logic than reading an attribute. - A structured return from a
citations.pyhelper, sofetch_chunkmaps values instead of classifying exceptions. Rejected for this change, specifically because it changesresolve_citation's existing contract: today it always raises on failure and returnsstron success, andevals/echo.py's citation-echo check depends on exactly that — it awaitsresolve_citationwith no local exception handling at all. Making it return a value for the "expected" failures (drift, unresolvable) while still raising for a genuine containment escape would need this spec to draw that line, and it would ripple into a currently-working, already- tested call site this workstream has no reason to touch. Worth reconsidering if a third caller ever wants the same classificationfetch_chunkdoes — one call site does not justify inventing a second return protocol next to the raise-based one this module already has. - A typed attribute on
RetrievalErrorcarrying the verdict — chosen.resolve_citationkeeps its exact existing contract: it still always raisesRetrievalErroron failure, soecho.pyis untouched. Every raise site insideresolve_citationstates its own verdict once, at the point that already knows it best, instead offetch_chunkre-deriving it from prose written for a human 200 lines away. No new exception type, matching decision 5's spirit. The attribute is available to any future caller that wants the same classification without re-parsing a message.
The exact shape (implement precisely — this is the seam between Wave 1 and Wave 2, and between whichever agents build them):
# errors.py
from typing import Literal
#: The two failure verdicts a citation-resolution failure maps to. Distinct
#: from service.schemas.VerificationVerdict (which also has "verified" — a
#: success, never carried by an exception) — CitationVerdict values are a
#: literal subtype of it, so assigning one to a VerificationVerdict-typed
#: variable type-checks under mypy --strict with no cast.
CitationVerdict = Literal["drifted", "unresolvable"]
class RetrievalError(GroundkitError):
"""Error during retrieval or search.
Attributes:
verdict: For a citation-resolution failure raised by
retrieval.citations.resolve_citation, which of fetch_chunk's two
failure verdicts this maps to. None for every other
RetrievalError (an index inconsistency, an empty query, an
out-of-range top_k) — those have no fetch_chunk verdict to carry,
and leaving this unset for them is the point: nothing downstream
can read a guessed value for an error this attribute was never
meant to describe.
"""
def __init__(self, message: str, *, verdict: CitationVerdict | None = None) -> None:
super().__init__(message)
self.verdict = verdict
Every raise site inside resolve_citation (retrieval/citations.py) sets
it explicitly: the containment-escape ValueError branch and the
OSError/UnicodeDecodeError branches all pass verdict="unresolvable";
the offset-overflow branch ("source changed since indexing", the one
genuine drift case at the text class) passes verdict="drifted"; both new
branches in §5.1 pass verdict="unresolvable", per decision 6.
fetch_chunk's classification collapses to a map, not a search:
try:
resolved = await resolve_citation(citation, ctx.base_dir)
except RetrievalError as exc:
verification = exc.verdict if exc.verdict is not None else "unresolvable"
detail = str(exc)
else:
...
The is not None else "unresolvable" fallback is defensive, not load-bearing
— every path inside resolve_citation now sets a verdict, so it should
never trigger. It exists so a future RetrievalError raised inside
resolve_citation without a verdict fails toward the more conservative of
the two verdicts (unresolvable never claims a definite "the source
changed", drifted would) rather than crashing fetch_chunk outright.
grep -rn "changed since indexing" src/groundkit/service/tools.py must
return nothing once this lands — the string-sniff itself, not just its
symptom, must be gone.
5.3 What Wave 2 does not do¶
No re-extraction call exists or is stubbed with a NotImplementedError — the
membership check in §5.1 already produces the correct refusal without one.
No registry, plugin mechanism, or config surface for extractor identities is
built; _ACTIVE_EXTRACTOR_IDENTITIES is a plain empty constant, and inventing
an extension mechanism for zero registered extractors is exactly the kind of
scope creep §3 rules out. Wave 3 decides that shape when it has a real
extractor to register.
6. Done means¶
- [ ]
documentshassource_class TEXT NOT NULL DEFAULT 'text'andextractor TEXT;SCHEMA_VERSION == 3. - [ ]
upsert_documentandreplace_documentaccept*, source_class: SourceClass = "text", extractor: str | None = None;MetadataStoreProtocoland its conformance test agree. - [ ]
Indexer._persist_documentpassesdoc.source_class/doc.extractorthrough on its onereplace_documentcall. - [ ]
get_document_records()exists on the protocol, the SQLite store, andCollectionRuntime; refuses cleanly (StorageError, naming the remedy) on a pre-v3 store. - [ ]
Retriever._resolveandhandle_fetch_chunkbuild theirRetrievalResult/Citationfrom a joinedDocumentRecord, not a bare source string — every result and every fetched chunk reports the class it was actually ingested under. - [ ]
resolve_citationhas a real (currently always-refusing) extractor- identity check forextracted, gated on_ACTIVE_EXTRACTOR_IDENTITIES. - [ ]
RetrievalError.verdictexists; every raise site insideresolve_citationsets it;fetch_chunkreads it instead of string-matching."changed since indexing"does not appear inservice/tools.py. - [ ] ADR-0016 decisions 1, 2, and 6 are closed: a document's recorded class
round-trips through search and fetch_chunk unchanged (1); an
extractor-identity mismatch fails closed rather than silently
resolving as
text(2);fetch_chunk's verdict set stays three-valued with the reason indetail(6). - [ ] A regression test demonstrates the fail-open downgrade described in §2
is gone: construct a
Document(source_class="extracted", extractor="x@1", ...)directly (no loader produces one yet — this is exactly what the keyword-only defaults are for), ingest it, and assert thatget_document_records(), aRetriever.search()result'ssource_class/extractor, and afetch_chunkcall against one of its chunks all report"extracted"/"x@1"— none of the three silently reports"text"/None. Per SPEC.md §8, this test must be shown to fail against the pre-Wave-1 tree (git stashthe persistence+join change, run, observe the failure, restore, run again, observe the pass) — both directions reported. - [ ] A regression test proves the extractor-identity-mismatch path returns
"unresolvable"with adetailnaming the recorded identity, not"drifted"and not the old generic refusal text. - [ ] Waves 3 and 4 were out of this wave's scope: true when Waves 1–2 shipped, not a durable claim about the current tree — both have since landed (§3). The one piece of Wave 3 still open is the ingest-side PDF/HTML loaders; Wave 4 (URL fetching, snapshot storage) is complete.
7. Risks and open questions¶
R1 — The 2→3 bump is a harder break than 1→2, on purpose. Any collection
with documents ingested before this lands cannot accept a further write
through upsert_document/replace_document afterward — not just its dense
side, as the 1→2 bump left intact for BM25-only collections, but any
mutation of documents at all, because the table itself changed shape. Reads
that don't touch the two new columns (get_document_sources, get_chunks,
etc.) keep working. This is the accepted consequence (§4.1), stated here so
it is not mistaken for scope creep when a reviewer notices how much wider it
is than the previous bump.
R2 — _ACTIVE_EXTRACTOR_IDENTITIES is permanently empty until Wave 3, and
that is not dead code. A reviewer scanning §5.1's branch might read an
always-false membership test as unfinished. It is finished: the check is
correct for a build with zero extractors, and Wave 3's job is populating the
constant, not changing the check's shape. Worth a code comment at the
constant's definition (already drafted above) so this doesn't get "fixed"
into a NotImplementedError or a bypassed check by someone who hasn't read
this spec.
Q1 — Should get_document_records() eventually replace
get_document_sources() outright? Not decided here. §4.2 argues the
narrower two-call-site change is right now, on blast-radius grounds — five
unrelated call sites would need to change for a field they don't use. If a
third or fourth caller ever needs source_class/extractor, that balance
may flip; revisit then rather than pre-emptively unifying the two methods
for two current callers.
Q2 — Does _ACTIVE_EXTRACTOR_IDENTITIES want to be an injectable seam
(e.g. read from installed extractor plugins) once Wave 3 has a real
extractor? Deliberately not decided here (§5.3) — there is nothing to
inject yet, and designing the registration mechanism now would be guessing
at Wave 3's shape before Wave 3 exists.
Resolved by §9 below, now that Wave 3 has a real shape to design against:
the module constant is replaced by groundkit.extraction.active_extractors(),
a lazily-memoized accessor (not an import-time probe) that independently
try/excepts each candidate extractor so one missing extra never blanks out
the other. See §9.2 for the exact mechanism and the reasoning for choosing
lazy-and-memoized over an eager module-load-time probe.
8. Verification¶
A reviewer should be able to confirm, without running anything:
index/metadata.py's_SCHEMAandSCHEMA_VERSIONmatch §4.1 exactly.grep -n "source_class\|extractor" src/groundkit/indexer.py src/groundkit/retrieval/search.pyreturns real, non-default-only usages — the two files the ground truth in §2 named as having zero references today.grep -n "changed since indexing" src/groundkit/service/tools.pyreturns nothing.MetadataStoreProtocolinindex/protocols.pydeclaresget_document_records, andtests/test_protocol_conformance.pychecks its signature againstSQLiteMetadataStore's.errors.pydefinesCitationVerdictandRetrievalError.__init__acceptsverdict.
And by running the gates (orchestrator's job, not the spec's):
uv run ruff check . && uv run ruff format --check .uv run mypy(strict; the new keyword-only parameters,DocumentRecord, andRetrievalError.verdictare all fully typed)uv run pytest --cov && uv run coverage report(whole-package gate) and the core-subset gate (retrieval/*already coverscitations.pyandsearch.py;index/metadata.pystays outside the subset, unchanged from today — seepyproject.toml's existing note on why)- The two regression tests named in §6, each shown failing against reverted source before being shown passing against the fix (SPEC.md §8).
9. Wave 3 design — the extractor registry, the PDF/HTML libraries, and re-extraction¶
This section was written as the design, before the implementation. Everything
in it except the ingest-side loaders (§9.5, §9.6) is now built and tested in
src/groundkit/extraction.py and src/groundkit/retrieval/citations.py; the
text is left in its original design voice rather than rewritten into a
description, so the reasoning stays legible as reasoning — §3 carries the
status. It answers, precisely enough that three implementers
building in parallel should reach the same shape independently: which
libraries, where the shared extractor code lives, how resolve_citation
finds an extractor from an identity string, and what happens when
re-extraction itself fails. Where ADR-0016 already decided something (HTML is
extracted with tags stripped — decision 3; an identity mismatch fails
closed — decision 2; a missing extra is ConfigurationError naming the
install — decision 5), this section cites it and does not re-argue it. Where
it makes a call ADR-0016 leaves open, that is stated explicitly, with the
alternatives considered.
9.1 Library choices¶
PDF: pypdf, pinned pypdf>=6.15,<7.
Pure Python (no compiled extension, so no per-platform wheel risk on any of
3.11/3.12/3.13), BSD-3-Clause licensed (no license friction against this
repo's MIT), actively maintained, and exposes its version through the
standard importlib.metadata mechanism like any other installed
distribution — no library-specific version attribute to special-case. The
bound style is this repo's usual one (lancedb>=0.13,<1,
sentence-transformers>=3,<6 — a floor near a known-good release, a ceiling
at the next major so an unreviewed breaking change never enters silently).
The floor is 6.15, not the 5.1 this section originally specified, and
the reason is security rather than taste. That bound was written against a
snapshot in which "the latest release is 5.6.0". By the time Wave 3 was built,
pip-audit reported 37 advisories against the 5.x line (pypdf 5.9.0,
the newest release the original <6 ceiling admitted), whose fixes land
across 6.7.1 through 6.15.0 — so >=6.15 is the lowest floor that clears all
of them, and no bound under <6 is shippable at all. CI's audit job would
have failed on the original pin, which is the gate behaving exactly as
ADR-0015 decision 4 intends. The API this module depends on
(PdfReader, page.extract_text(extraction_mode=...)) is unchanged across
the major, verified by tests/test_extraction.py passing against 6.16.1 with
no edit. A version bound justified by a point-in-time "latest release" has a
shelf life; this one expired between design and implementation.
Rejected alternatives:
pdfminer.six— also pure Python, also viable. Not chosen becausepypdfhas the simpler, more directly documented plain-text extraction API for this repo's need (extract text per page, join it — no layout reconstruction), and one fewer transitive dependency.PyMuPDF(fitz) — faster, wheel-available, but dual-licensed AGPL/commercial. An AGPL dependency pulled in by an optional extra of an MIT-licensed library is a real question a design document should not answer by default; rejected to avoid raising it at all.pdfplumber— built onpdfminer.six, heavier (adds table/layout analysis this repo has no use for), rejected on the same "no more than what citation-verifiable plain-text extraction needs" grounds SPEC.md §2's determinism requirement already argues for: fewer moving parts is fewer ways for extraction to become accidentally non-deterministic.
HTML: beautifulsoup4 (import
name bs4), pinned beautifulsoup4>=4.12,<5, backed explicitly by the
stdlib "html.parser". Pure Python (its own dependency, soupsieve,
is also pure Python — no native code anywhere in the chain), MIT licensed, and
as of this writing the latest release is 4.15.0, requiring Python >=3.7.
Two determinism-critical decisions, spelled out because getting either wrong produces code that looks correct and silently isn't:
- The parser must be passed explicitly:
BeautifulSoup(text, "html.parser"), neverBeautifulSoup(text). Omitting it lets bs4 auto-select the "best available" parser, which depends on what else happens to be installed in the environment (lxml,html5lib) — the same bytes could parse differently in CI than on an operator's machine, which is precisely the non-determinism ADR-0016 decision 2's whole mechanism exists to make detectable rather than silent. Pinning"html.parser"(the stdlib parser, always present, never a second dependency) removes the environment as a variable entirely. - Decode as UTF-8 before handing text to
BeautifulSoup, not raw bytes. Passed raw bytes, bs4 runs its own encoding sniffer (UnicodeDammit), which can consult optionalchardet/charset-normalizerpackages if present — again making the result a function of what else is installed, not just of the file's bytes. This repo already assumes UTF-8 uniformly (FileLoader._read_text,resolve_citation'spath.read_text("utf-8"));HtmlExtractorkeeps that assumption rather than introducing a second, environment-sensitive decoding path. A file that fails to decode as UTF-8 raisesIngestionError, mirroringFileLoader'sUnicodeDecodeErrorhandling exactly.
Version string: importlib.metadata.version("beautifulsoup4") — the
distribution name. importlib.metadata.version("bs4") raises
PackageNotFoundError: the import name and the distribution name differ for
this package, and it is an easy, silent-until-runtime mistake to use the
wrong one here. Called out explicitly so no implementer discovers it by
watching pdf_extractor()'s sibling work and html_extractor()'s not.
Rejected alternatives:
lxml— faster, wheel-available on all three CI Pythons, but a native C extension (libxml2-backed) where bs4 +html.parseris pure Python with zero platform-wheel exposure ever. Nothing this repo needs (fault-tolerant SGML repair, XPath) justifies taking on a native dependency for "strip tags, keep text" (ADR-0016 decision 3's literal scope).selectolax— fast, but its wheel coverage for newer CPython releases has historically lagged upstream Python releases, which is precisely the "wheel-available on all three CI Pythons" criterion this choice must not fail silently on some future CI matrix bump. Not verified against 3.13 at the time of this design; rejected on that uncertainty rather than confirmed-and-still-rejected.trafilatura— a boilerplate-removal/main-content-extraction library, which is a different (and heavier, heuristic-driven) problem than "strip tags deterministically." Its whole value proposition — guessing what part of the page is the "real" content — is in tension with determinism: different versions tune those heuristics, which is a much larger surface for two versions to disagree than "same tags stripped the same way."html2text— converts to Markdown syntax rather than plain text, which is a content transformation beyond ADR-0016 decision 3's literal "tags stripped."
9.2 groundkit/extraction.py — the shared extractor module¶
Answering Q2's "protocol an extractor satisfies," "how the set is
populated," and "how resolve_citation gets from an identity string to the
extractor."
A new top-level leaf module, src/groundkit/extraction.py — not
ingestion/protocols.py alongside LoaderProtocol, and not
retrieval/citations.py itself. The reasoning is the one already written
down for identity.py, quoted because it transfers verbatim: extraction is
"placement follows groundkit.identity: a module outside whichever caller
happened to need it first, so sharing it creates no dependency between
ingest and retrieval, and nothing imports it back." An ingest-time PDF/HTML
loader and retrieval/citations.py's re-extraction call need the literal
same extractor object — ADR-0016 decision 2's whole point is that "the same
extractor that produced it" is what re-extraction verifies against, so a
separate ingest-side copy and verify-side copy of "what pypdf extraction
means" would be exactly the seam that could silently drift. Putting both the
protocol and the two concrete implementations in one shared module makes
that identity hold by construction: there is only one PdfExtractor class,
imported by both sides, not two classes that happen to agree today.
This is a deliberate, named departure from this repo's other four protocol
modules (ingestion/protocols.py, index/protocols.py,
retrieval/protocols.py, providers/protocols.py), each of which holds a
protocol whose implementations live in the same package as the code that
needs them. ExtractorProtocol has no such single owning package — that is
exactly identity.py's situation, not theirs, so it gets identity.py's
placement, not theirs.
# src/groundkit/extraction.py
"""Deterministic content extraction shared between ingest and citation
resolution (ADR-0016 decisions 2, 3). Both PdfExtractor and HtmlExtractor are
imported by exactly two callers: the Wave 3 PDF/HTML loaders (to build
Document.content) and retrieval.citations.resolve_citation (to re-derive it
at citation-resolution time). One class per format, used from both sides, is
what makes "the same extractor that produced it" (decision 2) hold by
construction rather than by two implementations staying in sync by hand.
Neither `pypdf` nor `beautifulsoup4` is imported at module level — importing
this module must never require either extra, matching the established
pattern in index/dense.py (_import_lancedb) and retrieval/rerank.py
(_import_reranker_backend).
"""
from __future__ import annotations
import importlib.metadata
from functools import lru_cache
from pathlib import Path
from typing import Any, Protocol, runtime_checkable
from collections.abc import Mapping
from groundkit.errors import ConfigurationError, IngestionError
@runtime_checkable
class ExtractorProtocol(Protocol):
"""Deterministically converts a file's bytes into extracted text.
Satisfied by every extractor this build can run — both to produce a
freshly-ingested Document.content and to re-derive that same text at
citation-resolution time (ADR-0016 decision 2). Same bytes in, same text
out: no extractor implementation may consult wall-clock time, randomness,
environment-dependent library auto-selection, or anything outside the
file's own bytes and this object's own pinned configuration.
"""
@property
def identity(self) -> str:
"""This extractor's identity string: ``"<distribution>/<version>"``.
Derived at runtime from the installed package's own distribution
metadata via `importlib.metadata.version` — never hardcoded — so a
library upgrade changes this string automatically and a mismatch
against a citation's recorded identity (ADR-0016 decision 2) is
detected without a separate manual version-tracking step.
"""
...
async def extract(self, path: Path) -> str:
"""Return the deterministic extracted text for the file at ``path``.
``path`` is already containment-checked by the caller (the loader's
own `ensure_within_base`, or `resolve_citation`'s) — this method
performs no path-safety check of its own.
Raises:
IngestionError: The file cannot be read, or its bytes cannot be
parsed as this extractor's format.
"""
...
#: Page separator for joined PDF text. Named and pinned rather than an inline
#: literal — the join behavior is part of what "deterministic" and "the same
#: extractor" (ADR-0016 decision 2) must mean identically at ingest and at
#: resolve time.
_PDF_PAGE_SEPARATOR = "\n\n"
#: Separator BeautifulSoup.get_text() inserts between what were separate
#: elements, and whether surrounding whitespace is stripped. Named for the
#: same reason as _PDF_PAGE_SEPARATOR above.
_HTML_TEXT_SEPARATOR = " "
_HTML_TEXT_STRIP = True
def _import_pypdf() -> Any:
"""Import ``pypdf`` on demand; never at module import time.
Raises:
ConfigurationError: ``pypdf`` is not installed (ADR-0016 decision 5).
"""
try:
import pypdf
except ImportError as exc:
raise ConfigurationError(
"PDF extraction requires the optional 'pdf' extra: install with "
"`pip install groundkit[pdf]` (provides pypdf)"
) from exc
return pypdf
def _import_bs4() -> Any:
"""Import ``bs4`` on demand; never at module import time.
Raises:
ConfigurationError: ``beautifulsoup4`` is not installed (ADR-0016
decision 5).
"""
try:
import bs4
except ImportError as exc:
raise ConfigurationError(
"HTML extraction requires the optional 'html' extra: install with "
"`pip install groundkit[html]` (provides beautifulsoup4)"
) from exc
return bs4
class PdfExtractor:
"""Deterministic PDF text extraction via pypdf (ADR-0016 decisions 2, 3).
Construct via :func:`pdf_extractor`, not directly — that accessor is what
guarantees ``pypdf`` already imported successfully before ``__init__``
reads its version, and is what makes every caller share one instance
(and therefore one ``identity`` computed once, not recomputed per call).
"""
def __init__(self) -> None:
self._identity = f"pypdf/{importlib.metadata.version('pypdf')}"
@property
def identity(self) -> str:
return self._identity
async def extract(self, path: Path) -> str:
import asyncio
pypdf = _import_pypdf()
return await asyncio.to_thread(self._extract_sync, pypdf, path)
def _extract_sync(self, pypdf: Any, path: Path) -> str:
"""Runs off the event loop. Pinned to plain-text extraction mode —
never pypdf's default, in case a future pypdf release changes it —
because pypdf's "layout" mode is documented to depend on system font
metrics, which would make extraction a function of the machine it
runs on rather than of the file's bytes alone.
"""
try:
reader = pypdf.PdfReader(str(path))
pages = [page.extract_text(extraction_mode="plain") or "" for page in reader.pages]
except Exception as exc:
# pypdf's own exception hierarchy (PdfReadError and friends) is
# not part of this repo's typed error surface; wrap unconditionally
# rather than let a third-party exception type escape this
# module's documented contract.
raise IngestionError(f"Failed to extract PDF text from {path.name!r}: {exc}") from exc
return _PDF_PAGE_SEPARATOR.join(pages)
class HtmlExtractor:
"""Deterministic HTML tag-stripping via BeautifulSoup + html.parser
(ADR-0016 decisions 2, 3). Construct via :func:`html_extractor`, not
directly — see :class:`PdfExtractor`'s docstring for why.
"""
def __init__(self) -> None:
self._identity = f"beautifulsoup4/{importlib.metadata.version('beautifulsoup4')}"
@property
def identity(self) -> str:
return self._identity
async def extract(self, path: Path) -> str:
import asyncio
bs4 = _import_bs4()
return await asyncio.to_thread(self._extract_sync, bs4, path)
def _extract_sync(self, bs4: Any, path: Path) -> str:
try:
raw = path.read_text(encoding="utf-8")
except OSError as exc:
raise IngestionError(f"Failed to read {path.name!r}: {exc}") from exc
except UnicodeDecodeError as exc:
raise IngestionError(f"{path.name!r} is not valid UTF-8: {exc}") from exc
# "html.parser" pinned explicitly — see §9.1's determinism discussion.
soup = bs4.BeautifulSoup(raw, "html.parser")
return soup.get_text(separator=_HTML_TEXT_SEPARATOR, strip=_HTML_TEXT_STRIP)
@lru_cache(maxsize=1)
def pdf_extractor() -> PdfExtractor:
"""The process's one PdfExtractor, built on first use and memoized.
Raises:
ConfigurationError: The 'pdf' extra is not installed.
"""
_import_pypdf()
return PdfExtractor()
@lru_cache(maxsize=1)
def html_extractor() -> HtmlExtractor:
"""The process's one HtmlExtractor, built on first use and memoized.
Raises:
ConfigurationError: The 'html' extra is not installed.
"""
_import_bs4()
return HtmlExtractor()
@lru_cache(maxsize=1)
def active_extractors() -> Mapping[str, ExtractorProtocol]:
"""Every extractor this process can actually run, keyed by identity string.
Replaces retrieval.citations._ACTIVE_EXTRACTOR_IDENTITIES (a plain
frozenset constant, Waves 1-2) with a lazily-memoized accessor. Called on
the first `extracted`-class citation resolved in this process — never at
module import time — so `import groundkit.extraction` (and therefore
`import groundkit.retrieval.citations`, which imports it) never requires
either extra.
Each candidate is probed independently via `pdf_extractor()` /
`html_extractor()`, catching only ConfigurationError (never a broader
except): one missing extra must never blank out the other, and a
genuine bug inside an installed extractor's construction must not be
silently swallowed and reported as "not registered."
"""
registry: dict[str, ExtractorProtocol] = {}
for accessor in (pdf_extractor, html_extractor):
try:
extractor = accessor()
except ConfigurationError:
continue
registry[extractor.identity] = extractor
return registry
Why lazy-and-memoized, not an import-time probe (Q2, and §7's now-resolved
Q2): an eager probe at module load — try: import pypdf; except ImportError:
... at the top of extraction.py — would make import groundkit.extraction
itself sensitive to which extras happen to be installed, which is exactly the
constraint index/dense.py and retrieval/rerank.py already rejected for
the same reason (_import_lancedb's docstring: "Never called at module
import time... so import groundkit.index.dense never requires the optional
dense extra"). Since retrieval/citations.py (and every text-class
citation resolution) must keep working on a base install with neither extra,
extraction.py follows the same discipline its two precedents already
established. @lru_cache(maxsize=1) gives "compute once, lazily" without a
hand-rolled if _cache is None: guard, and reflects only what was
importable the first time it was asked — consistent with this being a
per-process, not per-request, question (the same tradeoff pdf_extractor/
html_extractor/active_extractors all make identically).
resolve_citation's answer to "identity string → extractor":
extraction.active_extractors().get(citation.extractor) — a dict lookup, not
a scan. _ACTIVE_EXTRACTOR_IDENTITIES (Waves 1-2's frozenset, membership
only) is deleted; every reference to it in retrieval/citations.py becomes
extraction.active_extractors() (membership: citation.extractor not in
extraction.active_extractors(); the identity list for the refusal message:
sorted(extraction.active_extractors()), which iterates dict keys
identically to how it iterated frozenset members before — the refusal
message's exact wording is otherwise unchanged, so
tests/test_source_class.py::test_resolve_refuses_an_extracted_citation
(pinned to match="extracted", extractor "pdf-x/1", which will never
coincidentally collide with a real "pypdf/…" or "beautifulsoup4/…"
identity) stays green under this change with no edit).
9.3 resolve_citation's Wave 3 restructuring¶
The Wave 2 comment left in retrieval/citations.py — "Wave 3 adds the
re-extraction call here: re-run the active extractor over citation.source,
slice [start_offset:end_offset], and return it — the same shape
resolve_citation already has for text below" — is accurate about the
outcome but, read literally as "insert code at this exact point," would
duplicate the offset/drift check across two branches. This section
supersedes that literal reading with the actual shape: the containment
check and the offset/drift check are shared between text and extracted
(both resolve a local path under allowed_base_dir); only the "how do we
turn that path into text" step differs.
def _slice_verified_span(text: str, citation: Citation) -> str:
"""Slice `citation`'s offsets out of `text`, or raise the drift verdict.
Shared by every branch that has *obtained* text by whatever means its
source class requires (a raw read, a fresh extraction, or a snapshot
read) — once `text` exists, checking and slicing the offsets is
identical regardless of how it was produced.
Raises:
RetrievalError: `citation.end_offset` exceeds `len(text)` —
`verdict="drifted"`.
"""
if citation.end_offset > len(text):
raise RetrievalError(
f"Cited span [{citation.start_offset}:{citation.end_offset}] exceeds "
f"source length ({len(text)}) for {citation.source!r} — source changed "
"since indexing",
verdict="drifted",
)
return text[citation.start_offset : citation.end_offset]
async def resolve_citation(
citation: Citation, allowed_base_dir: Path, *, snapshot_dir: Path | None = None
) -> str:
if citation.source_class == "snapshot":
return await _resolve_snapshot(citation, snapshot_dir) # §10.2
# text and extracted share containment; they differ only in how `text`
# is obtained from the (already-contained) path.
try:
path = ensure_within_base(citation.source, allowed_base_dir)
except ValueError as exc:
raise RetrievalError(str(exc), verdict="unresolvable") from exc
if citation.source_class == "extracted":
extractor = extraction.active_extractors().get(citation.extractor)
if extractor is None:
raise RetrievalError(
f"cannot resolve an 'extracted' citation for {citation.source!r}: "
f"the extractor identity recorded at ingest ({citation.extractor!r}) "
"is not active in this build "
f"({sorted(extraction.active_extractors()) or 'none registered'}). "
"Re-deriving these offsets needs the exact extractor that produced "
"them; refused rather than guessing (ADR-0016 decision 2).",
verdict="unresolvable",
)
try:
text = await extractor.extract(path)
except IngestionError as exc:
raise RetrievalError(
f"cannot resolve an 'extracted' citation for {citation.source!r}: "
f"re-extraction with {extractor.identity} failed: {exc}",
verdict="unresolvable",
) from exc
else: # "text"
try:
text = await asyncio.to_thread(path.read_text, "utf-8")
except OSError as exc:
raise RetrievalError(
f"Cannot read cited source {citation.source!r}: {exc}", verdict="unresolvable"
) from exc
except UnicodeDecodeError as exc:
raise RetrievalError(
f"Cited source {citation.source!r} is not valid UTF-8: {exc}",
verdict="unresolvable",
) from exc
return _slice_verified_span(text, citation)
The refusal message's wording is preserved verbatim from Waves 1-2 except for
substituting extraction.active_extractors() for
_ACTIVE_EXTRACTOR_IDENTITIES — match="extracted" in the existing
regression test is unaffected.
Two notes on what actually landed, since the block above reaches into Wave 4
to show the finished shape. The snapshot_dir keyword and _resolve_snapshot
are §10.2's, not Wave 3's: the shipped signature is still
resolve_citation(citation, allowed_base_dir) and the snapshot branch still
raises its Wave-2 refusal, so Wave 4 adds the parameter when it adds the
thing the parameter is for. And one behaviour change this restructuring
carries that is easy to miss: the containment check now runs before the
extractor lookup, where Waves 1-2 refused an unknown identity first. An
extracted citation whose source escapes allowed_base_dir is therefore now
reported as a path violation rather than as an extractor-identity problem —
the right order, since containment is the security boundary, and pinned by
test_containment_is_checked_before_the_extractor_lookup.
9.4 What "extracted" verification does, and what happens when re-extraction fails¶
Per ADR-0016 decision 1's table: re-extract, slice, compare.
resolve_citation re-runs the same extractor instance (by identity match,
enforced in §9.3) over the source file, exactly as it would at ingest time,
then applies the identical offset/drift check text uses. There is no
separate "compare" step beyond that: the caller (fetch_chunk) already does
byte comparison against the stored chunk content once resolve_citation
returns text, exactly as it does for text-class citations today
(service/tools.py's existing resolved == chunk.content check, untouched
by this wave).
When re-extraction itself fails — a corrupted PDF, a file that no longer
parses under the recorded extractor version, a truncated download — the
extractor raises IngestionError (per ExtractorProtocol.extract's
contract), and resolve_citation catches it and re-raises as
RetrievalError(verdict="unresolvable"), symmetric with how a text
citation's OSError/UnicodeDecodeError is handled. This is deliberately
not "drifted": drifted means text was successfully obtained and
disagrees in length with what was indexed; a parse failure means the check
never got far enough to compare anything, which is what unresolvable
means (ADR-0016 decision 6). No fourth verdict is introduced, matching
decision 6 exactly.
9.5 Ingest-time missing-extra failure¶
Separate from §9.4 (which is about resolving an already-ingested
citation), this is about ingesting a .pdf/.html file when its extra
isn't installed. Per ADR-0016 decision 5 (citing ADR-0015's command/option
rule): grk ingest itself must keep working; a .pdf/.html file is an
input the command may be given, so only encountering one triggers the check.
The Wave 3 PDF/HTML loaders' .load() methods call extraction.pdf_extractor()
/ extraction.html_extractor() directly (not active_extractors(), which
would silently skip an unavailable format rather than naming it) — so a
.pdf file with the pdf extra absent raises ConfigurationError naming
pip install groundkit[pdf], propagating out of .load() and failing that
one ingest with a message an operator can act on, exactly as dense/
rerank do today for their own missing extras (ADR-0016 decision 5's
literal words — note this differs from the exact classes dense/rerank
actually raise today, StorageError and RerankerNotConfiguredError
respectively, not ConfigurationError; ADR-0016's own prose calls them
"exactly as," which is imprecise about the literal class name but not about
the shape — typed, existing, fail-closed, naming the install string. This
spec follows ADR-0016's literal ConfigurationError instruction rather than
the two precedents' literal classes, since ADR-0016 is the more specific,
directly-on-point authority for this wave.)
pyproject.toml gains two new extras, matching the existing bound style:
Neither is mirrored into the dev group's hard requirement the way dense
is — both are pure-Python and small, so there is no rerank-style
multi-gigabyte reason to keep them out of CI's default job, but the sole
proof of each backend must still be a non-continue-on-error job per
SPEC.md §3; which job that is (a new gated suite vs. the default suite with
the extras installed) is left to the implementer, since it depends on
uv.lock/CI wiring this spec does not own.
9.6 What is not decided here¶
Multi-loader dispatch is not designed here. IngestionPipeline and
Indexer are wired for exactly one loader: LoaderProtocol per run today
(ingestion/pipeline.py, Indexer.__init__); routing .md/.txt to
FileLoader, .pdf to a PDF loader, and .html to an HTML loader within
one grk ingest invocation needs either a dispatching composite loader or a
CLI-level per-extension routing decision, neither of which ADR-0016 or the
four questions this section answers speaks to. Flagged explicitly rather
than guessed at, per this document's own standing rule (§1's closing
sentence) that an undecided question gets named, not silently resolved.
Wave 4 (5700056) has since added a precedent worth noting, though it does
not settle this question. cli.py's _cmd_ingest now dispatches on source
shape before constructing a loader — _is_url_source routes to
_cmd_ingest_url, everything else to the existing FileLoader path — so a
CLI-level two-way routing decision, made before any path-shaped operation
touches source, already exists and already works. Extension-based routing
for .pdf/.html is a narrower version of the same shape (dispatch on a
string property of source, chosen before construction) rather than a
mechanism this codebase has never built. That makes it a smaller increment
than an unqualified "not designed" suggests — but it is still a smaller
increment, not a decision: URL-vs-path is a two-way, mutually exclusive split
resolved once at the top of _cmd_ingest, while .pdf/.html/.md/.txt
routing has to answer what a directory ingest does with a mix of
extensions in one run, which _is_url_source never has to, because a URL
ingest is always single-source. That question is still open.
10. Wave 4 design — snapshot storage and reaching it from resolve_citation¶
10.1 Where snapshots live, and how the path is derived (Q1)¶
The problem, stated precisely: resolve_citation(citation, allowed_base_dir)
receives one containment root, allowed_base_dir — the corpus root
(ctx.base_dir in the service, corpus_dir in evals/echo.py). A
snapshot document's bytes live under the index directory
(ctx.index_dir, a wholly separate, non-nested directory an operator points
at independently — confirmed at cli.py's _cmd_serve, which validates
--index-dir and --base-dir as two unrelated paths with no containment
relationship asserted between them). resolve_citation has no reference to
the index directory today, and Citation carries no collection field
either — so the resolver cannot, on its own, know which collection's
snapshot store to look in even if it had the index directory.
Decision: a new keyword-only parameter, snapshot_dir: Path | None = None,
supplied by the caller — the per-collection snapshot containment root,
already resolved by whoever knows the collection name, exactly mirroring how
allowed_base_dir is already computed by the caller today rather than
derived inside resolve_citation from some larger config object. This keeps
resolve_citation a pure function of what it is given (this repo's
"deterministic core" principle, SPEC.md §2) and — the deciding factor over
the alternatives below — needs no change to Citation, RetrievalResult,
or SCHEMA_VERSION (already 3; Wave 4 does not bump it again).
Path shape: <index_dir>/<collection>.snapshots/<document_id> — a
directory per collection, sibling of <collection>.sqlite3 and
<collection>.lance, matching that existing <index_dir>/<collection>.<suffix>
convention exactly (.snapshots, plural noun, because it holds many files
rather than being one file/table itself). One file per document, named by
document_id — the join key every other part of this system already uses
(chunks.document_id REFERENCES documents(document_id), Citation.document_id)
— rather than inventing a second identifier (a content hash, say) that would
need its own persistence and its own consistency argument for no benefit:
document_id is already unique per collection (documents.document_id is
the primary key) and is already on every Citation.
Two small shared, pure functions carry this convention — defined once so
the write side (Wave 4's URL loader) and the read side (resolve_citation)
cannot independently drift on what the path means, the same "shared, not
duplicated" argument §9.2 makes for the extractor classes:
# src/groundkit/snapshots.py
"""Where a URL-ingested document's local snapshot lives on disk (ADR-0016
decision 4). Pure path arithmetic, no I/O — shared by the Wave 4 URL loader
(which writes) and retrieval.citations.resolve_citation (which reads), so
the naming convention is asserted from exactly one place.
"""
from __future__ import annotations
from pathlib import Path
def snapshot_dir_for(index_dir: Path, collection: str) -> Path:
"""The containment root for one collection's stored snapshots.
Sibling of `<index_dir>/<collection>.sqlite3` and `.lance`, following the
same per-collection-suffix convention.
"""
return index_dir / f"{collection}.snapshots"
def snapshot_path_for(snapshot_dir: Path, document_id: str) -> Path:
"""The snapshot file for one document within its collection's snapshot_dir."""
return snapshot_dir / document_id
Containment is still enforced, exactly as it is for citation.source
today: document_id is a plain string field with no character-class
restriction the way collection names have
(index/metadata.py::_COLLECTION_NAME_PATTERN), so a hand-constructed or
malicious document_id (e.g. "../../etc/passwd") must not be trusted
implicitly. resolve_citation calls ensure_within_base(snapshot_path_for(
snapshot_dir, citation.document_id), snapshot_dir) before reading — the same
barrier pattern used for citation.source, applied here per this repo's
"path containment everywhere a path is read" rule (SPEC.md §7,
utils/path_safety.py's module docstring). This is defense-in-depth
regardless of what the Wave 4 loader itself guarantees about document_id
shape (it should leave document_id at Document's own
uuid.uuid4().hex default, exactly as FileLoader already does — never
hand-supplying one derived from the URL).
Call sites — verified against the tree, correcting a premise in the design brief:
service/tools.py::handle_fetch_chunk— must change. It already has everything needed:ctx.index_dir(onServiceContext) andrequest.collection(onFetchChunkRequest, defaulting to"default"). It computessnapshot_dir = snapshots.snapshot_dir_for(ctx.index_dir, request.collection)and passessnapshot_dir=snapshot_diron its existingresolve_citation(citation, ctx.base_dir)call. NoServiceContextor request-schema change needed.evals/echo.py— does not need to change._evaluate_echo_casecallsresolve_citation(positive_citation, allowed_base_dir)positionally; the new parameter is keyword-only with a default ofNone, so this call keeps compiling and behaving identically — matching the exact "keyword-only and defaulted so no existing caller breaks" pattern Wave 1 already used forsource_class/extractor(§4.1). It is also, independently, never reachable through thesnapshotbranch:build_echo_casealways constructs its twoRetrievalResults atsource_class's"text"default (verified —evals/echo.py'sRetrievalResult(...)calls at lines 208 and 217 never setsource_class), so it never needs one.retrieval/search.py— does not callresolve_citationat all, and therefore needs no change. This corrects the design brief's premise that all three modules call it:grep -rn "resolve_citation" src/groundkitreturns hits only inretrieval/citations.pyitself (the definition),service/tools.py, andevals/echo.py.Retriever._resolvebuildsRetrievalResults (which carry an unresolvedcitationcomputed field) but never reads a citation back — resolution isfetch_chunk's job, invoked separately by a client on a hit it intends to quote, perhandle_fetch_chunk's own docstring ("this is the step a client performs on a hit it intends to quote").retrieval/citations.py::verify_citation— the thin wrapper — gains the identical keyword-only passthrough:
async def verify_citation(
citation: Citation,
expected_content: str,
allowed_base_dir: Path,
*,
snapshot_dir: Path | None = None,
) -> bool:
return (
await resolve_citation(citation, allowed_base_dir, snapshot_dir=snapshot_dir)
== expected_content
)
Not currently called anywhere in production (grep -rn "verify_citation"
src/groundkit shows only its own definition and the __init__.py
re-export), so this is a signature-consistency change with zero behavioral
call sites to verify today.
Alternatives considered and rejected:
- Persist an absolute snapshot path as a new
documentscolumn. This is the portability hazard the design brief names explicitly: an absolute path baked into a row is wrong the moment the collection's SQLite file is copied to a different machine or a different--index-dir, which is a normal operation for this repo (SPEC.md's local-first, portable-index posture). It would also force anotherSCHEMA_VERSIONbump (4), with ADR-0004 decision 5's delete-and-re-ingest consequence, for information that is fully derivable fromindex_dir+collection+document_id— all three already available wherever a citation is resolved. Deriving it is strictly better: no new column, no new schema version, and the path is always correct relative to wherever the index currently lives. - Widen
Citation/RetrievalResultwith acollectionfield. Would letresolve_citationderivesnapshot_dirinternally fromindex_diralone (still a needed new parameter) plus the citation's own collection. Rejected as disproportionate: it touchescontracts.py(everyCitation/RetrievalResultconstruction site across the whole codebase, including every test fixture) to solve a problem thesnapshot_dirparameter already solves with zero contract changes. A collection is already implicit in "which store this citation came from," and every existing caller that would supply it already knows it independently (handle_fetch_chunkhasrequest.collectionright there) — so widening the contract buys nothing the parameter didn't already buy, at a much larger blast radius. - Have
resolve_citationacceptindex_dir: Path | Noneinstead of a pre-computedsnapshot_dir, and derive the.snapshotssuffix internally. Considered and rejected in favor of the pre-computed form for the same reasonallowed_base_diritself is pre-computed by the caller rather than derived insideresolve_citationfrom some largerServiceContext-shaped object:resolve_citationstays a function of exactly the paths it needs, not of an index-directory-plus-collection-name pair it would have to re-derive the convention from. It also avoidsresolve_citationneeding acollection: str | Noneparameter in addition tosnapshot_dir— the collection name folds into the onePaththe caller already computed.
10.2 resolve_citation's snapshot branch — and a call this document makes explicitly¶
A refinement ADR-0016 does not resolve at the level of detail needed to implement it, stated as a decision with reasoning, per this document's own "say so explicitly" rule (§1):
ADR-0016 decision 4 says a snapshot document's local copy is "the fetched
bytes." Read literally, that is the raw HTTP response body. But decision 3
requires HTML to have its tags stripped for retrieval quality regardless of
where the HTML came from — a URL that serves an HTML page has exactly the
same "BM25 scores <div> as a term" problem a local .html file does. If
the stored snapshot were the raw response body while Document.content
were the tag-stripped text, verification would need to re-run
HtmlExtractor over the snapshot at resolve time — reintroducing an
extractor-identity dependency into the snapshot class that decision 4's
own "the snapshot is what verifies" design was meant to avoid, and, worse,
requiring Citation to carry an extractor field for a snapshot-class
document, which contracts.py's existing model validator currently forbids
outright (extractor is only meaningful for an 'extracted' document) —
a contracts.py change this spec's Wave 3/4 scope should not need.
This spec's call: the snapshot is a copy of Document.content itself — the
fully-processed text that was actually indexed — not the raw wire bytes.
Concretely: if the fetched resource is HTML-shaped, the Wave 4 URL loader
extracts it through the identical extraction.html_extractor() Wave 3
built (raising the identical ConfigurationError if the html extra is
absent — Wave 3 and Wave 4 compose here rather than duplicating the check),
before writing the snapshot; if it's already plain text, no extraction
step runs and the decoded text is written as-is. Either way, the byte-for-byte
same string that becomes Document.content is what lands at
snapshot_path_for(snapshot_dir, document_id). The document's
source_class stays "snapshot" — never "extracted" — and extractor
stays None, exactly as contracts.py's existing validator already
requires: source_class encodes where verification looks (a local
original file, a re-run extractor, or a local snapshot copy), not whether
extraction happened somewhere upstream in the ingest pipeline. This keeps
SourceClass genuinely three-way exclusive with zero contracts.py
changes, and — the concrete payoff — makes resolve_citation's snapshot
branch a pure read-and-compare with no extractor dependency at all, exactly
as simple as the text branch:
async def _resolve_snapshot(citation: Citation, snapshot_dir: Path | None) -> str:
if snapshot_dir is None:
raise RetrievalError(
f"cannot resolve a 'snapshot' citation for {citation.source!r}: no "
"snapshot_dir was supplied to resolve_citation. A snapshot citation "
"verifies against the local copy URL ingestion stores at ingest time "
"(ADR-0016 decision 4), under <index_dir>/<collection>.snapshots/ — "
"the caller must compute that directory (snapshots.snapshot_dir_for) "
"and pass it. source is a URL, not a path, so it is never passed to "
"ensure_within_base, which would resolve it as a relative path under "
"the current directory rather than reject it as not-a-path.",
verdict="unresolvable",
)
try:
snapshot_path = ensure_within_base(
snapshots.snapshot_path_for(snapshot_dir, citation.document_id), snapshot_dir
)
except ValueError as exc:
raise RetrievalError(str(exc), verdict="unresolvable") from exc
try:
text = await asyncio.to_thread(snapshot_path.read_text, "utf-8")
except OSError as exc:
raise RetrievalError(
f"cannot read the local snapshot for {citation.source!r}: {exc}",
verdict="unresolvable",
) from exc
except UnicodeDecodeError as exc:
raise RetrievalError(
f"the local snapshot for {citation.source!r} is not valid UTF-8: {exc}",
verdict="unresolvable",
) from exc
return _slice_verified_span(text, citation) # §9.3
Consequence, stated rather than left implicit: unlike extracted,
snapshot verification at resolve time never re-runs an extractor and
therefore has no "re-extraction failed" case at all — extraction, if the
fetched resource needed any, already happened exactly once, at ingest time,
and its result is frozen into both the chunk rows and the snapshot file. A
snapshot citation can only fail for text-shaped reasons: the snapshot
file is missing, unreadable, or not UTF-8 (unresolvable), or its length no
longer covers the cited offsets (drifted — the snapshot itself would have
to have been altered or deleted out from under the index, since URL
ingestion never rewrites a snapshot after the fact within one ingest).
Rejected alternative: store the raw HTTP response body verbatim and
carry an extractor on snapshot documents too (widening contracts.py's
validator to permit the ("snapshot", extractor=...) pairing). Rejected for
the reasons stated above — it reintroduces an identity dependency and a
contracts.py change that storing the already-processed content avoids
entirely, for no compensating benefit: nothing in ADR-0016 or SPEC.md asks
for the raw wire bytes to be recoverable, only for the indexed text to be
verifiable against a stable local copy, which storing content itself
satisfies precisely.
10.3 The fetch itself: SSRF guard, redirect refusal, and the ordering constraint¶
Restating ADR-0016 decision 4's three non-negotiables with the exact mechanics, since "guard the fetch" and "refuse redirects" are correct at the ADR's level of abstraction but not yet precise enough to implement identically three times:
async def load(self, source: str) -> list[Document]:
# (a) Classify BEFORE any path-safety call — source is a URL, never
# handed to ensure_within_base. This ordering is decision 4(c)
# itself, not a detail: os.path.realpath resolves a URL string as a
# RELATIVE PATH, so containment could silently pass on a string that
# was never a path at all.
validate_endpoint_shape(source) # utils/url_safety.py — sync, no DNS
# (b) The SSRF guard, per request (not once at construction — this
# loader has no fixed endpoint the way an embedder's base_url is
# fixed; every `load()` call targets a different host). The Ollama
# private-endpoint allowance is a provider-side ClassVar
# (`_allow_private_endpoint`) that this loader never sets and never
# reads — allow_private_endpoint=False is the only value passed here,
# unconditionally.
await ensure_safe_endpoint(source, allow_private_endpoint=False)
# (c) Redirects refused, not merely un-followed: a 3xx is an error, not
# a value the caller silently gets zero content for.
async with httpx.AsyncClient(follow_redirects=False) as client:
response = await client.get(source)
if 300 <= response.status_code < 400:
raise IngestionError(
f"refusing a redirect ({response.status_code}) fetching a URL source "
"(ADR-0016 decision 4) — the destination is never followed automatically"
)
response.raise_for_status()
# ... decode, extract if HTML-shaped (§10.2), write the snapshot, build
# the Document at source_class="snapshot".
No pdf/html-shaped extra gates this loader's existence — httpx is
already a base dependency (ADR-0016 decision 5, ADR-0015's command/option
rule) — only the extraction step inside it, if the fetched resource turns
out to be HTML, defers to extraction.html_extractor() and can raise the
identical ConfigurationError §9.5 already specifies. A URL serving a
.pdf-shaped response would do the same through extraction.pdf_extractor().
Noted, not designed here: utils/url_safety.py's module docstring
currently opens with "URL safety helpers for validating outbound
embedding-provider endpoints." Wave 4 makes it that module's second
caller, so that opening line becomes stale the moment this loader exists —
worth a one-line docstring update in the same change, though it is not this
spec's file to edit.
Not decided here: how grk ingest accepts a URL as its path argument
at the CLI layer (today _cmd_ingest unconditionally does Path(args.path)
and branches on path.is_dir(), neither of which is meaningful for a URL
string) is a CLI-wiring question this spec's four questions do not cover,
named explicitly per §9.6's same standing rule rather than guessed at.
11. Design summary (Waves 3–4, at a glance)¶
| Question | Answer | Section |
|---|---|---|
How does resolve_citation reach a snapshot? |
New keyword-only snapshot_dir: Path \| None = None param, caller-computed via snapshots.snapshot_dir_for(index_dir, collection); snapshot file is snapshot_dir / document_id, containment-checked like any other read path. No Citation/RetrievalResult/SCHEMA_VERSION change. |
§10.1 |
| Extractor registry shape | ExtractorProtocol (identity: str, async extract(path) -> str) + concrete PdfExtractor/HtmlExtractor in a new shared leaf module groundkit/extraction.py (mirrors identity.py's placement). Populated by active_extractors(), a lazily-memoized accessor — never an import-time probe — that independently try/excepts each format. |
§9.2 |
| PDF library | pypdf>=5.1,<6 — pure Python, BSD-3-Clause, importlib.metadata.version("pypdf"). |
§9.1 |
| HTML library | beautifulsoup4>=4.12,<5 with the stdlib "html.parser" pinned explicitly, UTF-8-decoded before parsing. importlib.metadata.version("beautifulsoup4") — note the distribution name differs from the import name (bs4). |
§9.1 |
What extracted verification does |
Re-extract via the identity-matched extractor, then the same offset/drift check text uses (_slice_verified_span, shared). |
§9.3, §9.4 |
| What happens when re-extraction fails | IngestionError from the extractor → RetrievalError(verdict="unresolvable") — never "drifted", since no comparison text was obtained to disagree with anything. |
§9.4 |
What a snapshot verifies against |
A local copy of Document.content itself (post-extraction, if any was needed) — not the raw wire bytes — so resolution is a plain read-and-compare with no extractor dependency, ever. |
§10.2 |