ADR-0014 — Phase 4 is a read-only service surface, and outbound endpoints are guarded¶
- Status: Accepted (owner, 2026-08-15) — decision 7 amended by ADR-0024
- Date: 2026-08-15
- Deciders: Andy Freeman (owner)
Decision 7's threat model was incomplete
This ADR argues that the 127.0.0.1 bind is the access control, so no
authentication is needed. That holds against anyone who must route to the
port, and it does not hold against a browser: DNS rebinding walks a
victim's browser onto a loopback socket the attacker cannot reach directly.
ADR-0024 closes it by validating Host on both transports, and records why
the no-authentication position still stands once it is validated. Read the
two together; decision 7 below is retained as written so the amendment has
something to amend.
Context¶
SPEC.md §1.2 names the MCP server's tools exactly: search, fetch_chunk,
list_collections, index_status — "real and installable, not a demo" — over stdio
and streamable HTTP. §4 requires a FastAPI REST surface mirroring them. Ingest is not
among the four, and §1.2 is cited here as the authority so that a later reader does
not "restore" it as an oversight.
SPEC.md §7 requires that mutating REST routes and mutating MCP operations sit behind a shared-secret header with a constant-time compare, disabled when the secret is unset, and that the service bind 127.0.0.1 by default — the rule being per operation, not per transport. All four named tools are reads. That requirement can therefore be satisfied two ways: ship a guard over a surface with nothing to guard, or ship no guard and record why it is met vacuously. Choosing by omission is the outcome this ADR exists to prevent.
Three facts make "no mutating operation" the right scope rather than a convenience.
SPEC.md §7 names product decisions Phase 4 has not made — deletion behavior (including
that deleting a collection means deleting its .sqlite3 and its .lance sibling,
"neither inferable from the other's absence"), file permissions, backup scope, and
retention. Ingest is path-based and contained by FileLoader(allowed_base_dir=...); an
HTTP ingest route widens that boundary from "what this operator typed" to "what any
caller reaching the port can name." And with no mutation, the residual exposure is
disclosure, which the loopback bind bounds and a shared secret would not.
Two Phase 3 constraints must be respected rather than routed around. ADR-0012 decision 2
keeps rerank out of Retriever.search and out of SearchMode, deferring it to "Phase 4,
where a service boundary makes 'rerank this request' a per-request option."
RerankerNotConfiguredError already exists so a missing extra can never look like a
successful rerank.
Outbound, the guard SPEC.md §7 requires is genuinely absent.
EmbeddingConfig.base_url is a free-form operator-controlled str with no validator,
and OpenAICompatibleEmbedder concatenates it into {base_url}/v1/embeddings and POSTs
with an Authorization: Bearer header. KNOWN_LIMITATIONS and SECURITY.md both argue
exploitability is low because "no network-facing caller can set base_url; the REST/MCP
surface that would expose one is a Phase-4 stub." Phase 4 builds that surface. The first
half stays true by decision 6 below, but "the exploit path does not exist yet" is no
longer available as a sentence.
One claimed defect in that area was investigated and does not exist. It was proposed
that _post_json accepts a 3xx response whose body is valid JSON, on the reasoning that
raise_for_status() raises only for 4xx and 5xx. That was tested directly against
unmodified source with an httpx.MockTransport returning a 302 carrying a well-formed
embedding body: the call refused it, raising HTTPStatusError("Redirect response
'302 Found' … Redirect location: …"). Modern httpx raises on an unfollowed redirect.
SPEC.md §7's redirect: error semantics are therefore already satisfied, and no
redirect defect exists to fix. What remains is hardening only (decision 11).
One claimed defect is real and was confirmed by execution. _sanitize_url redacts
every query-parameter value but rebuilds with urlunsplit((scheme, netloc, path, …)),
and netloc includes user:password@. Run against unmodified source,
https://user:hunter2@api.example.com/v1/embeddings?api-key=sk-live-123 sanitizes to
https://user:hunter2@api.example.com/v1/embeddings?api-key=*** — the query credential
redacted, the password verbatim. This is the ADR-0001 hazard 6 class the function exists
to close, missed on one URL component.
Finally, a defect found while designing: SQLiteMetadataStore.open creates
<index_dir>/<collection>.sqlite3 when absent. A naive index_status?collection=anything
handler mutates the filesystem on every request for an unknown name.
Decision¶
1. Every Phase 4 operation is read-only on both transports, and the shared-secret guard is not built¶
SPEC.md §7's clause is satisfied vacuously: the set of mutating operations is empty, so the set requiring the header is empty. This is a scope decision, not a scope reduction — SPEC.md §1.2's four tools are the complete named surface and ingest was never among them. It is recorded here, in SECURITY.md and in KNOWN_LIMITATIONS.md so it reads as a decision rather than a missing feature. Any later phase adding a mutating operation must build the header, the constant-time compare and the unset-secret disable in the same change, must make SPEC.md §7's four product decisions first, and must supersede this ADR rather than amend it.
2. Read-only is enforced structurally by five independent checks¶
- A declarative registry is the only way to register an operation.
service/tools.pyholdsTOOLS: Final[tuple[ToolSpec, ...]], andToolSpec.side_effectis a closedLiteral["read_only"]with exactly one member. Adding a mutating operation requires widening thatLiteral, whichmypy --strictblocks until someone does it visibly. - Registry↔route parity. A test enumerates the app's
APIRoutes, excludes the framework-owned doc paths, and asserts the(path, method)set equals the set derived fromTOOLS. A route added with a bare decorator fails. - Registry↔MCP parity. The same assertion over the MCP server's registered tool names. The rule is per operation, not per transport, so an MCP-only tool is caught exactly as a REST-only route is.
- Handlers can only reach a read-only facade, exposing search, chunk lookup, document sources, manifest and counts — nothing else. A test asserts its public member set against an exact allow-list, so a rename does not defeat it.
- An import scan over the service package fails on any import of
groundkit.indexer,groundkit.ingestion.loaders, orgroundkit.ingestion.pipeline— the anti-dependency-scan pattern the portfolio already uses, applied one package down. It fires three steps upstream of the route that would have exposed a write.
3. A request can never open a collection that does not exist¶
The service resolves <index_dir>/<collection>.sqlite3 and returns a not-found response
when absent; SQLiteMetadataStore.open is never called on a non-existent collection.
--index-dir must exist at startup or serving fails; the service never creates it.
Collection names pass the existing validation before the existence check, so a
traversal attempt is a validation failure rather than a not-found that leaked a probe
result. The test asserts both the response and that the index directory's contents
are unchanged.
This composes with ADR-0013 decision 9, which places the same refusal in the registry; the service-side check is the boundary translation, not a second mechanism.
4. "Read-only" means no document, chunk, or manifest state changes — not that 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. The claim is
scoped to durable content and is enforced in Python: the process holds a read-write file
handle. Opening with mode=ro was rejected (alternatives). Filesystem-level enforcement
is deferred to Phase 6, where a container can mount the index directory read-only.
5. One runtime, two transports, one registry¶
grk serve runs uvicorn over the FastAPI app and mounts the MCP streamable-HTTP
transport on the same app; grk serve-mcp runs the stdio transport. Both construct the
same read-only facade over the same CollectionRuntime registry (ADR-0013).
Differs deliberately: framing, error rendering, process lifecycle, and binding — only the HTTP path binds a socket, so decision 7 applies to it alone. One stdio-specific rule is load-bearing: stdout carries JSON-RPC frames and nothing else; logging goes to stderr, and a test captures stdout across a tool call and asserts every line parses as JSON-RPC.
Must not differ: the operation set (checks 2 and 3), request and response models — the MCP tool input schema is generated from the same Pydantic model rather than hand-written a second time, which is precisely the seam drift ADR-0001 hazard 4 exists to catch — read-only-ness, the error code for a given exception type, and config provenance.
6. Embedder, reranker, index directory and containment root come from serve-time resolution, unreachable from a request¶
The service receives a fully-constructed EmbeddingConfig. Resolution happens at serve
time through the promoted _resolve_embedding_config — moved out of cli.py into a
shared module rather than imported as a private CLI helper, and rather than copied. The
argparse adapter stays in cli.py; the shared function takes explicit keyword parameters
typed as EmbeddingConfig's own field types, preserving the property the original
docstring defends (namespace attributes are Any, so the provider Literal needs no
cast at the call site) while keeping both rules it enforces: defaults come from a fresh
EmbeddingConfig() rather than a second copy of its field defaults, and ValidationError
is translated to ConfigurationError at that single construction site.
This makes the constraint hold by construction: there is no resolution path inside
service/ for a request to reach. It is nonetheless made executable, because a
structural argument decays as the surface grows. A test asserts that no MCP tool input
model and no REST route schema accepts any base_url, index_dir, base_dir, or
embed_* field — walking the generated OpenAPI component schemas and every registered
MCP tool's generated input schema, so models added later are covered without the test
being told about them. Every request model is frozen=True, extra="forbid", so an unknown
field is a rejection rather than a silent ignore. That test is what keeps SECURITY.md's
sentence "no network-facing caller can set base_url" true.
--base-dir is required for both serve commands: it is the containment root
resolve_citation checks against, and a service that cannot verify any citation should
not start, given that verifiable citations are the product claim (SPEC.md §2).
7. The service binds loopback, and this is load-bearing rather than a default¶
--host defaults to a named DEFAULT_SERVE_HOST = "127.0.0.1" and --port to a named
DEFAULT_SERVE_PORT. At startup the host is classified with ipaddress; a non-loopback
host is refused unless --allow-remote-access is also passed, in which case a warning
names what is exposed.
Keeping this is not optional and its rationale is independent of the shared secret.
SPEC.md §7 states the bind in the same sentence as the header, so dropping the guard
makes it easy to drop the bind with it. With decision 1 there is no authentication of
any kind, and SPEC.md §7 records that SQLite is content-bearing: a search response
carries document text and absolute source paths, and index_status and
list_collections carry collection topology. The bind is therefore the service's only
access control, and KNOWN_LIMITATIONS names it as load-bearing rather than as a default.
Tests pin the default, the refusal, the acknowledged override, and both IPv4 and IPv6
loopback.
Note the polarity inversion against decision 10, so nobody "unifies" them: the same
ipaddress classification requires loopback for the inbound bind and rejects it
for outbound provider endpoints.
8. Response models reuse contracts.py; exactly two thin wrappers are new¶
search returns the existing SearchResponse unchanged, so a client parsing
grk search --json and one parsing the REST route parse the same shape.
list_collections returns a list of names and needs no model at all.
Two wrappers are irreducible, and the reason each is irreducible is recorded so neither is mistaken for a parallel DTO:
index_statusnests the existingCollectionManifestfor the identity half —contracts.pyalready documents it as identity minus operational settings, exactly the leak-safe subset a status endpoint needs. What no contract models is counts, so the wrapper adds document and chunk counts, dense availability, schema version, and the ADR-0013 generation. It returns no document sources, no chunk content, no queries, no index directory, no containment root, nobase_url, noapi_key_env.fetch_chunknests the existingCitationand adds content plus a verification verdict. It cannot reuseRetrievalResult: that model requiresscore: float = Field(ge=0.0), and a fetch has no score — supplying0.0invents a number SPEC.md §2 forbids and drivesis_high_confidenceoff a fabricated value.Chunkcannot serve either: it carries nosource, which onlyDocumentholds (ADR-0006).
ToolSpec and the request models are likewise new, but they model the transport, not the
domain; nothing in contracts.py describes an operation registry or a request.
9. Bounds and errors reuse what exists; no new exception types¶
MAX_TOP_K is imported from retrieval/search.py, never redeclared. ARP's own
tools.py carries a private _MAX_TOP_K = 50; porting that constant alongside the code
would create a second source of truth for one bound, which is the drift MAX_TOP_K's
single definition already prevents — cli.py, retrieval/__init__.py and
evals/runner.py all import the one constant.
MAX_QUERY_LEN is genuinely new — nothing in groundkit bounds query length today. It
lands as a named constant beside MAX_TOP_K in retrieval/search.py, never inline
(SPEC.md §5.2: thresholds are named constants, not inline literals).
errors.py's existing hierarchy is sufficient and the service defines no new exception
type. It maps the existing types to HTTP status codes and JSON-RPC error codes through
one shared function used identically by both transports, matched subclass-first — a
mis-ordered chain is a real bug class and a test pins the order. Only an allow-list of
types has its own message returned to a caller; every other type returns a fixed detail.
Four points that are decisions rather than table entries:
RetrievalErrormaps to a conflict rather than a bad request, and that is only correct because the request schema makes the caller-error cases unreachable.Retriever.searchraises it both for an empty query and out-of-rangetop_k— caller errors — and for index inconsistency, a server-side data fault. The type does not distinguish them and message-matching is not an option. The request model therefore pinsqueryto a minimum length and at mostMAX_QUERY_LEN, andtop_kto1..MAX_TOP_K, so those never reach the handler. A test pins that precondition; if it ever stops holding, the mapping becomes wrong, and that test is what says so.RerankerNotConfiguredErrormaps to not-implemented, not bad-request or unavailable: the request is well-formed and would be servable on an install with the extra, and a missing optional extra is not transient.ConfigurationErrormaps to bad-request only because every reachable one in Phase 4 is caused by a request field — an invalid collection name, or a dense mode against a collection with no manifest (ADR-0008). Startup config faults exit non-zero before the socket binds. An endpoint rejection (decision 10) is the operator-fault case, and it does not reach this mapping: it is raised inside the embedding call path, where the existing_raise_embedding_errorboundary already converts it, so it surfaces as the embedding-backend mapping with a fixed detail.- Credential safety is by construction, not by scrubbing at the edge. No
EmbeddingErrormessage reaches a response body — that class returns a fixed detail — so the sanitized-URL and scrubbed-secret text stays server-side. The mapper never reads__cause__,__context__,repr(exc), or a traceback: the ADR-0001 hazard 6 chain-severing discipline applied at the egress boundary, with a test that plants a sentinel in a__cause__and asserts it appears in neither body nor headers. Every response carries a request id, and the full unscrubbed exception is logged server-side against it. Query text is DEBUG only; a test asserts the INFO access log carries id, route, status, latency and result count, and never the query (SPEC.md §3).
10. Outbound endpoints are validated by a new utils/url_safety.py, shaped as path_safety's peer¶
It carries over the ensure_* naming, the validate-at-the-boundary/raise-typed/never-coerce
contract, and the docstring discipline of stating why a check is spelled as it is. Two
divergences are stated rather than mimicked: path_safety inlines its containment check
into both public helpers because that is the barrier pattern CodeQL recognizes for
py/path-injection, and there is no comparably recognized sanitizer for
py/request-forgery, so the check lives in one place; and no non-raising is_safe_* peer
ships, because nothing needs one.
It raises ConfigurationError — an endpoint is operator configuration, which is that
type's documented meaning. No new exception type is introduced.
The check runs in two parts. Shape, at embedder construction, no DNS: scheme
allow-list, non-empty host, and rejection of userinfo of any kind (a credential does not
belong in a config URL, and http://expected.example.com@evil.example/ is the classic
parser-confusion vector), a query string or fragment (the module concatenates paths onto
base_url), and a host that looks like an IPv4 address but does not parse as one. The
message never echoes the URL, because the URL may hold the credential being rejected.
Address, per request immediately before the POST, resolved through a thread so a
blocking resolver cannot stall the event loop, with an injectable resolver seam so no
unit test touches the network. Construction-time-only checking was rejected: a service
binds once and serves for days, which is the widest possible window between check and
connect.
Classification uses ipaddress, never a regex, and unmaps before classifying.
IPv6Address("::ffff:127.0.0.1").is_loopback is False — the IPv6 loopback test is
equality with ::1 — so a classifier reading .is_loopback on the un-unmapped address
admits the exact IPv4-mapped spelling SPEC.md §7 names. .is_private does consult
ipv4_mapped in current CPython, but that behavior has moved across point releases, so
relying on it makes the guard's correctness a function of the interpreter patch version.
Mapped, 6to4 and Teredo forms are unmapped explicitly, then rejected on loopback,
private, link-local, multicast, reserved or unspecified, returning which predicate fired.
Every resolved address is checked, not the literal only, and that step is what closes
non-decimal IPv4 spellings. A host that already parses as a literal is classified without
DNS, so the default loopback endpoint costs no resolution. Otherwise every address the
resolver returns is classified and any unsafe one refuses. The mechanism is worth stating
because it shows the two steps are not redundant: Python's ipaddress deliberately
rejects 0177.0.0.1, 0x7f.1 and 2130706433, so they fall through as hostnames,
while glibc's resolver accepts all three and returns 127.0.0.1. The resolver check,
not the literal parser, is the barrier.
The Ollama exception is scoped around, as a class attribute. _HttpEmbedder gains a
ClassVar defaulting to False; OllamaEmbedder sets it True;
OpenAICompatibleEmbedder inherits False. A ClassVar rather than a constructor
keyword, because a keyword would make requesting the exception for any provider a legal
thing to write — scoping over the guard rather than around it. There is no parameter,
so there is nothing to pass. It is named for permitting private endpoints generally, not
loopback specifically, because SPEC.md §9's Phase 6 compose topology reaches Ollama at a
bridge-network address; a flag named for loopback that also admits RFC1918 is the
name/behaviour mismatch this repo keeps catching. It does not relax the shape check.
11. Redirect handling is hardened, not fixed¶
follow_redirects=False is pinned explicitly on the embedding client, which today
inherits it as an httpx default. This closes no present hole — an unfollowed 3xx
already raises through raise_for_status(), verified by execution against unmodified
source — and the docstring must say so rather than implying otherwise. It is a pin against
a future httpx default change and against an injected test client that sets it True.
12. service/* and utils/url_safety.py stay outside the coverage core subset¶
SPEC.md §8 defines the subset as retrieval, chunking, scoring and citation resolution; a
transport layer is none of those, and widening it to mean "anything important" dissolves
what it measures. Both remain under the whole-package gate, and the security-load-bearing
behaviour is proved by the named tests in decisions 2, 3, 6, 7 and 9 — a stronger
control, since a coverage number cannot distinguish an enforcement test from a decorative
one. Recorded for the same reason pyproject.toml records why index/metadata.py stays
out. Note src/groundkit/runtime.py is added to the subset, explicitly, per ADR-0013
decision 8.
13. Any new Protocol seam gets a signature-parity conformance entry¶
Any typing.Protocol this surface introduces gains an entry in
tests/test_protocol_conformance.py using assert_signature_parity, never isinstance
— which compares member names only and would pass through the query → _query rename
that caused ARP's signature drift (ADR-0001 hazard 4).
Alternatives considered¶
- Ship the shared-secret guard anyway over read-only routes. Rejected: a constant-time compare protecting nothing makes the next reader believe the mutation question was answered, and establishes a half-built control a later mutating route could be attached to without re-deriving SPEC.md §7's four product decisions.
- Include one mutating operation — ingest — behind the secret. Rejected: it is not in SPEC.md §1.2's tool list, and it widens containment from a local operator's typed argument to any caller reaching the port. The CLI covers the use case with a strictly smaller surface.
- Enforce read-only by HTTP method — every route a GET. Rejected for a specific
reason: access logs record the request line at INFO, so a query in a query string
writes every query into an INFO log, directly against SPEC.md §3.
searchis therefore a POST with a body, which means method alone cannot certify read-only-ness and the registry has to. - Open SQLite
mode=rofor OS-level enforcement. Rejected for Phase 4: a read-only connection to a WAL database still needs the-shmfile and cannot perform WAL recovery, so it fails in exactly the situations a restart after unclean shutdown produces. Trading a reliable code-level guard for an OS-level one that intermittently refuses to start is the wrong trade; the clean fix is a read-only mount in Phase 6. - A fourth
SearchModefor rerank. Rejected — ADR-0012 decision 2, unchanged. - Expose
rerank_candidatesas a request field. Rejected for ADR-0012 decision 1a's reason: candidate depth decides whether the@kmetrics could move at all, and a per-request knob makes two responses silently incomparable in the dimension a reader is least likely to check. - New response DTOs for every operation. Rejected:
SearchResponse,CitationandCollectionManifestalready model the domain, and a parallel DTO layer would let the HTTP surface drift fromgrk search --json. Only the two irreducible wrappers in decision 8 are new. - A new
UnsafeEndpointErrortype. Rejected:errors.py's existing hierarchy is sufficient, an endpoint is operator configuration, and the embedding-path boundary already produces the right transport mapping without a new type. - Strip the stale
metadata["source"]copy from responses. Rejected: it would make the HTTP response diverge fromgrk search --jsonfor the same query. The clean fix — removing the ingest-time copy — is already an open KNOWN_LIMITATIONS item. - Two processes, one per transport. Rejected: SPEC.md §7 states the rule is per operation because Phase 4 ships both surfaces over one runtime, and two runtimes means two caches over one index, doubling the rebuild and giving two snapshots that drift.
- A regex address classifier, and classifying with
.is_privatealone. Rejected per decision 10. - A constructor keyword for the Ollama allowance. Rejected per decision 10.
trust_env=Falseto close the proxy bypass. Rejected: it also disablesSSL_CERT_FILEand.netrc, which operators legitimately need, to close a bypass in the same trust domain asbase_urlitself.- Extend the guard to URL ingestion now. Rejected: URL ingestion is unscheduled (KNOWN_LIMITATIONS), so it would be code with no caller and no test that could fail.
Consequences¶
- The service discloses document content and absolute filesystem paths to anyone who
can reach the port, with no authentication. That is the honest cost of decision 1 and
the entire justification for decision 7. An operator passing
--allow-remote-accesshas published their corpus, and SECURITY.md says so in those words. - Every future phase adding an operation pays a five-check tax. That is the intent, and it is also real friction: a Phase 5 author adding cited synthesis — a read — touches the registry, both parity tests and the schema module before their route runs.
- The
RetrievalErrormapping rests on a schema precondition rather than on the type system. One test guards it; that is thinner than the rest of this ADR and is named as such. - Reranking a hybrid search returns a ranking no
grk search --mode hybridproduces, because RRF is not depth-invariant and the set fused at the wider candidate depth differs in membership. The response records the input depth so a client can tell. - The Ollama allowance has no test-visible override, so existing tests pointing
OpenAICompatibleEmbedderat a loopback mock will start failing and must be re-pointed at a public-looking hostname with an injected resolver. This is a real, non-trivial test-suite change, not a footnote. - DNS rebinding is not closed. Between the guard'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 in
Hostand SNI, which is not built. Pretending otherwise would be a false claim in SECURITY.md. - The proxy bypass is not closed (decision 9's alternatives). The threat model is operator misconfiguration, not a hostile operator.
EmbeddingProtocol.embedcan now raiseConfigurationErrorfrom the guard, so_HttpEmbedder.embed'sRaises:docstring must be updated or it becomes wrong.- Most tests this ADR owes cannot be shown to fail against unfixed source, because
service/api.pyandservice/mcp_server.pyare docstring-only stubs andutils/url_safety.pydoes not exist. That is stated plainly in the implementation notes rather than implied away.
Amendment (2026-08-15) — decision 10's .is_loopback claim is version-dependent¶
Decision 10 states that IPv6Address("::ffff:127.0.0.1").is_loopback is
False, and uses that to argue a classifier reading the property admits the
IPv4-mapped spelling. That is true only of older CPython patch releases. On
Python 3.11.9 it is False; on the later 3.11, 3.12 and 3.13 patches CI runs it
is True, because the property now consults ipv4_mapped — the same migration
decision 10 already documented for .is_private.
No implementation changes and no hole is opened. service/binding.py and
utils/url_safety.py both unmap explicitly before classifying, precisely so
the verdict is not a function of the interpreter patch version, and both behave
identically under either stdlib behaviour. Decision 10's reasoning — do not
depend on these properties' treatment of mapped addresses — is strengthened by
this, not weakened; only its parenthetical statement of fact needs the caveat.
Found the way such things should be: a test asserted the stdlib behaviour directly, passed on the developer's 3.11.9, and failed on all three CI versions. That assertion has been removed — the test now pins the guard's behaviour and says why asserting the interpreter's would be pinning something that drifts.
Amendment (2026-08-15) — the not-found/bad-request boundary, settled¶
Decision 9 maps ConfigurationError to a 400/invalid_request. Decision 3
calls a request against a non-existent collection a not-found.
CollectionRegistry itself raises ConfigurationError for exactly that
case. Read against each other without their call sites, those two decisions
look like they conflict.
They do not, because decision 3 was never describing an exception path.
The not-found is produced structurally, not by discrimination between
exception types. service/errors.py's check_collection runs as a
precondition at the transport boundary, before any handler — and therefore
before the registry — is reached: it validates the collection name, then
checks <index_dir>/<collection>.sqlite3 for existence, and returns a 404
ErrorRendering directly when it is absent. The registry's own
ConfigurationError for an unknown collection is defense-in-depth that a
well-formed request never reaches, not a second opinion the mapper has to
arbitrate between. No message is matched against the exception and no new
exception type was introduced to distinguish the two cases — the two
answers come from two different places in the call, which is why this is
robust rather than a string comparison waiting to rot.
Name validation runs before the existence check, and the order is itself the security property: a traversal attempt in the collection name is reported as a validation failure, not as a not-found that would have confirmed whether the traversed-to path exists. A probe learns nothing either way.
The residual, recorded rather than left to omission: a missing
chunk_id surfaces as 400, not 404. handle_fetch_chunk raises
ConfigurationError for it, and there is no check_chunk precondition
analogous to check_collection — a chunk cannot be checked for existence
without first doing the lookup the handler exists to perform. Separating
that case from an invalid collection name would need either a new
exception type (forbidden by decision 9) or message matching (forbidden
for fragility), and this ADR takes neither. 400 is also the reading
decision 9's own rationale already gives: "every reachable one in Phase 4
is caused by a request field," and chunk_id is a request field exactly as
a collection name is.
This amendment revises neither decision 3 nor decision 9; it records how
they compose at the one call site where they meet.
src/groundkit/service/errors.py's module docstring states the same
resolution for the same reason — a reader who hits a 400 on a GET for a
missing chunk is meant to find it in either place.
References¶
- SPEC.md §1.2 (the four tools, cited as the authority that ingest is not among them), §2, §3, §4, §5.2 (named constants), §7, §8, §9.
- ADR-0012 — decision 2's deferral, discharged here; decision 1a's candidate depth adopted verbatim.
- ADR-0013 — the runtime this surface consumes, and its registry-side refusal that decision 3 translates.
- ADR-0007, ADR-0008, ADR-0006.
- ADR-0001 hazard 4 (the drift the generated MCP schema
avoids) and hazard 6 (the
__cause__chain, and the userinfo component_sanitize_urlmissed). src/groundkit/utils/path_safety.py— the design peer, and the two deliberate divergences.src/groundkit/contracts.py—SearchResponse,Citation,CollectionManifest, reused rather than mirrored.