ADR-0042 — Filter and Query are documented-only aggregate component schemas
Status: accepted
Context
Issue #310 hoisted the DTO shapes (<Entity>Create/Item/List/…) into named components.schemas; issue #313 did the same for the derivable query shapes <Entity>Pagination/Include/Sort. Two shapes were left out of both: the filter grammar (filter[field][operator]=value, and/ or/not groups, the filter={...} JSON escape hatch — doc 05) and the aggregate "everything a read can be parameterized by" shape GraphQL/MCP and programmatic callers reason about (filter + sort + pagination + select + include + search). #310 explicitly ruled out migrating the REST query-param wiring to style: deepObject unless a later decision adopted it — this ADR is that decision point.
Two questions had no default the codebase implied:
- Does
Queryreplace the REST wire format? The aggregate shape could either (a) merely document, as a named schema, the same parameters REST already accepts as flat bracket keys (filter[age] [gte]=18,sort=-createdAt, …), or (b) become the actual OpenAPI parameter shape viastyle: deepObject, changing how every list route declares its query params. Option (b) is a breaking change toKAVO_API_GUIDEand every generated route'sApiQueryset, for a grammar (doc 05) this ADR is barred from altering. - How faithfully does
Filtermodel the operator grammar? A loose{ type: "object" }escape hatch documents nothing; a fully faithful per-operator, recursively-grouped schema is more work but lets a client generator actually type filter construction.
Decision
Query is a documented-only aggregate; REST is untouched. filter, sort, limit/offset, select, include, search[...] stay exactly the flat bracket query params KAVO_API_GUIDE and 05-query-grammar.md already document — no deepObject migration, no change to listQueryParams/applyPaginationDocs/applySearchQueryDocs. Query is published as a components.schemas entry for a GraphQL/MCP resolver or a programmatic (QueryContext) caller to reference — the OpenAPI document's one place to see the whole query surface as a single typed shape — but no REST parameter $refs it, and no REST behavior changes.
Filter models the operator grammar structurally, per field. For each of the entity's own scalar columns on the resolved filterable allowlist, Filter carries a property named for that field, valued by an operator object exposing exactly the operators doc 05 grants every kind (eq/ne/in/notIn/isNull/isNotNull/gt/gte/lt/lte/ between, typed to the field's own kind), narrowed to doc 05's one kind-specific rule: like/ilike appear only on string-kind fields. and/or are arrays of Filter, not is one Filter — mirroring the wire parser's unary NOT, not the AST's variadic one (doc 05 §1). A relation-path filter (profile.city) is valid on the wire but not enumerable as a property at bind time — the same "known gap, not a lie" limitation includableRelations's own doc comment already accepts for nested include paths — so Filter carries no additionalProperties: false; closing the schema would reject a filter Kavo actually accepts.
Both ride the same seam #313 built, extended rather than duplicated.applyQuerySchemaDocs (swagger.ts) gains two more slots — filter and query — on the x-kavo-query-schemas extension it already stamps on list routes at bind time (KavoBinder.onModuleInit, ADR-0012); hoistQuerySchemas's bySlot map (register-schemas.ts) gains filter: "Filter" and query: "Query", so both resolve to <Entity>Filter / <Entity>Query through the exact collision/clone rules (#310) the other five query-shape and DTO components already use. Both are isList-gated like pagination/sort — REST's own filter= param is itself list-only (listQueryParams's isList guard in applySwaggerMetadata), so a shape documenting that grammar has no single-row route to ride either.
Filter's and/or/not self-reference the entity's own expected component name. The $ref recursion is built at bind time as #/components/schemas/<Entity>Filter — the name hoistQuerySchemas will in fact register the schema under, absent a genuine cross-entity name collision. This is the same assumption <Entity>Pagination_2's existing precedent already lives with (applyQuerySchemaDocs's own doc comment): a _2 in the final document is a signal to disambiguate with an explicit name, not a case either bind time or hoist time computes for. Query composes the same way — $refs to the entity's own expected Filter/Sort/Pagination/Include names, plus inline (non-hoisted) select/search shapes built from the same resolved allowlists applySearchQueryDocs/the response-projection docs already read.
Consequences
- No REST behavior changes.
KAVO_API_GUIDEand every flat query param stay exactly as documented; this ADR only adds two named schemas no existing parameter$refs. - A genuine cross-entity name collision on
<Entity>Filter(two registered entities sharing a class name) leavesFilter's own recursive$refs pointing at the losing_2name rather than the actual stored schema — the same accepted edge case<Entity>Paginationalready has when one entity's own operations disagree on pagination strategy. Not solved here; disambiguating with an explicit component name is the existing remedy for the whole family. Filter's operator set is uniform across scalar kinds except the one rule doc 05 states outright (like/ilike, string-only); it does not invent additional per-kind restrictions (e.g. barringgt/ltonboolean/enum) that doc 05 itself does not draw — a client that sends a nonsensical comparison still gets the same 400 doc 05 already documents, from the normalizer, not from the schema.
Amendment (2026-08-31, issue #344)
The sparse-fieldset query parameter was renamed from fields to select (wire select= / select[<relation>]=, QueryContext.select, NormalizedQueryContext.select). This ADR's text and the <Entity>Query aggregate's property were updated in place to match. search[fields] (a sub-key of search, unrelated to projection) and the allowed.selectable config key are unchanged. No backward-compatible fields alias was kept — a request still sending fields= is now an unrecognized query parameter and is ignored, so the response falls back to the default representation.