Two facets of the same #56 principle — never present a superset as the filtered result — that the query path violates locally. Both fixes live at/around Stack.query().
C1 — a search that sanitizes to nothing returns everything
buildWhereClause adds the FTS condition only if (sanitized) (packages/sqlite-shared/src/query.ts:131-137):
if (f.search) {
const sanitized = sanitizeSearch(f.search);
if (sanitized) { conditions.push(...MATCH...); }
// else: the search clause silently vanishes → full table returned
}
A search that the sanitizer strips entirely ("*", punctuation-only, a bare stripped operator) drops the filter and returns every record, presented as the search result — the exact silent-superset failure #56 rules out.
Decision: "a query for nothing matches nothing." When f.search is present but sanitizes to empty, force an empty result — push an always-false condition (e.g. a literal 0) rather than omitting the clause. A search for an unsearchable term legitimately has no matches; that's honest, a full-table dump is not.
C2 — hoist the capability pre-check into Stack.query()
APIAdapter.queryRecords already fails loud (APIAdapterCapabilityError, before dispatch) when filter.content/filter.search is used against a server that hasn't declared contentFieldQuery/fullTextSearch (adapter-api/src/index.ts:426-439). But Stack.query() itself performs no such check, so a local adapter that doesn't support a capability silently returns the unfiltered superset — e.g. MemoryAdapter (fullTextSearch: false) ignores f.search entirely and returns everything.
Decision: hoist the check into Stack.query() (the invariant layer), against this.adapter.capabilities, so local and wire behave identically — the "local and remote behave identically" phrasing the spec already uses for ifVersion. Throw before dispatching when filter.search is used without fullTextSearch, or filter.content without contentFieldQuery.
With both: a search against a non-FTS adapter throws (C2) before reaching an adapter; a search against an FTS adapter that sanitizes to empty matches nothing (C1). They compose.
Work items
Refs
#56 (never-silently-widen), #50 (query honesty), #90 (contentFieldQuery contract — overlapping). From docs/design-assessment-2026-07.md §C1/§C2 (PR #105).
Two facets of the same #56 principle — never present a superset as the filtered result — that the query path violates locally. Both fixes live at/around
Stack.query().C1 — a search that sanitizes to nothing returns everything
buildWhereClauseadds the FTS condition onlyif (sanitized)(packages/sqlite-shared/src/query.ts:131-137):A
searchthat the sanitizer strips entirely ("*", punctuation-only, a bare stripped operator) drops the filter and returns every record, presented as the search result — the exact silent-superset failure #56 rules out.Decision: "a query for nothing matches nothing." When
f.searchis present but sanitizes to empty, force an empty result — push an always-false condition (e.g. a literal0) rather than omitting the clause. A search for an unsearchable term legitimately has no matches; that's honest, a full-table dump is not.C2 — hoist the capability pre-check into
Stack.query()APIAdapter.queryRecordsalready fails loud (APIAdapterCapabilityError, before dispatch) whenfilter.content/filter.searchis used against a server that hasn't declaredcontentFieldQuery/fullTextSearch(adapter-api/src/index.ts:426-439). ButStack.query()itself performs no such check, so a local adapter that doesn't support a capability silently returns the unfiltered superset — e.g.MemoryAdapter(fullTextSearch: false) ignoresf.searchentirely and returns everything.Decision: hoist the check into
Stack.query()(the invariant layer), againstthis.adapter.capabilities, so local and wire behave identically — the "local and remote behave identically" phrasing the spec already uses forifVersion. Throw before dispatching whenfilter.searchis used withoutfullTextSearch, orfilter.contentwithoutcontentFieldQuery.StackQueryError(bad_request/400) — "this stack can't honor the requested filter" is a request that can't be served as posed; avoids a new taxonomy entry. (A dedicatedStackCapabilityErroris possible but not worth it; flag if you disagree.)APIAdapter's existing check: the core check now fires first; keepAPIAdapterCapabilityErroras redundant defense-in-depth or fold it into the core path — unify on one error either way.contentFieldQueryhalf by contract ("local adapters MUST declaretrue"). C2 is the complementary mechanism (fail loud when any declared-false capability is used) and additionally covers thefullTextSearchhalf, which Require contentFieldQuery for local adapters; keep it negotiable only over the wire #90 doesn't. Land them together or in sequence; note the overlap so neither regresses the other.With both: a
searchagainst a non-FTS adapter throws (C2) before reaching an adapter; asearchagainst an FTS adapter that sanitizes to empty matches nothing (C1). They compose.Work items
buildWhereClause: sanitized-emptysearch→ always-false condition (matches nothing), not an omitted clauseStack.query(): pre-checkfilter.search/filter.contentagainstadapter.capabilities; throwStackQueryErrorwhen unsupportedAPIAdapter's existingAPIAdapterCapabilityError(defense-in-depth vs. fold-in) and with Require contentFieldQuery for local adapters; keep it negotiable only over the wire #90's contractsearch: "*"returns empty, not all;searchagainstMemoryAdapterthrows rather than returning the full set;contentfilter against acontentFieldQuery:falseadapter throwsRefs
#56 (never-silently-widen), #50 (query honesty), #90 (contentFieldQuery contract — overlapping). From
docs/design-assessment-2026-07.md§C1/§C2 (PR #105).