D165 — search scope is a literal prefix on both backends¶
Status: implemented (2026-09-05).
Context. The two search backends disagreed about what scope means. The in-memory index filters
with strings.HasPrefix; the SQLite backend filtered with c.id LIKE scope || '%' and no ESCAPE
clause. Concept IDs are file paths, where _ is ordinary and common (maps/cert_rotation) and %
is legal — and to LIKE, _ is a single-character wildcard. A scope of maps_a/ therefore also
matched mapsXa/, and the doc comment promising "only concepts whose id starts with scope" was
wrong about its own code.
Nothing leaked: handleSearch re-filters SQL hits with strings.HasPrefix before returning them.
But that filter runs after LIMIT, so the out-of-scope rows were spent out of the caller's page
budget and then discarded — a scoped search silently returned fewer results than it should have,
with no signal that anything had been dropped. A whole page of over-matches returns an empty result
for a query that has hits.
Decision. likePrefixPattern escapes \, % and _, and the query pairs it with an explicit
ESCAPE '\'. scope is a literal id prefix on both backends, which is what the tool documented and
what the in-memory index always did.
Rationale. Escaping at the source was chosen over the two alternatives. Tightening the
tool-layer prefix re-filter treats the symptom and leaves the LIMIT interaction — the actual
user-visible defect — in place. Replacing LIKE with substr(c.id, 1, ?) = ? avoids metacharacters
entirely but gives up the id-prefix index range scan that makes a scoped search cheap, trading a
correctness bug for a performance one. The post-filter in handleSearch is deliberately kept: it is
now redundant, but it is the cheap assertion that keeps the two backends' semantics pinned together.
Consequences. A scoped search over IDs containing _ or % returns the results it always
promised. Table-driven tests in sqlindex_test.go cover _, % and \.