This is the data-contract leaf of epic #19791, graduated from #19786 (contracts 1–3 and the stored field list from A2). The header active-state leaf and the toolbar leaf consume what this one adds. Nothing visual ships here. The API shape was posted before implementation (comment 6213003977); this body records the delivered shape.
The Problem
A grid cannot write a record search today:
No Store contribution searches several fields. The only multi-field predicate is a filterBy function, and nothing declares which fields it reads.
The owned-key validator turns an explicit property: null into id (key.property ?? 'id', src/collection/Base.mjs), while the Filter itself materializes null. The validator and the instance disagree for exactly the columnless slot a search needs.
Collection always creates a base Filter, so a Filter subclass can't be added through a config.
getFilterValues and the Store's soft hydration read filter.property. For raw items, a null property would refuse every count, and hydration would write an item["null"] key.
No read says whether a filter actually narrows anything. A cleared header field keeps value: '', which is inert.
The Architectural Reality
isFiltered runs filterBy before the property path. disabled_ is reactive and fires change, and afterSetFilterBy is an empty todo, so swapping a callback never re-filters.
Neo.setupClass stores each class's merged config on its constructor, so reconciliation can compare any Filter subclass through cls.config.
Design authority: the graduated contract in #19786 (body SHA-256 72b90388…, signed by Claude and GPT), and the ownership rules of #19646.
The Fix
Neo.collection.SearchFilter (new, src/collection/) extends Filter. Its value holds the query: every whitespace term must occur in at least one declared field. caseSensitive_ and fieldText_ (per-field (value, item) => String hooks) are reactive, and property defaults to null. It overrides isFiltered, effective and export() (which returns null), so it has no callback to swap. An empty or whitespace-only query is inert: it matches everything, is not effective, and keeps the fields.
Filter: a generic reactive fields_ (the fields a predicate reads) and an effective getter.
Collection.beforeSetFilters: the owner-key property resolves as the Filter exposes it, so an explicit null stays null and absence takes the class default. A config may name module (a Filter subclass); creation uses it, and reconciliation compares the constructor and that class's merged config. fields compare element-wise.
Store: a filter's read set is its declared fields, else its non-nullproperty. Counting, soft hydration and isFilteredItem use it. getSearchRefusal(fields, fieldText) returns remote-projection, unknown-field, unsupported-field or null; TreeStore returns tree-population.
Decision Record impact: aligned-with #19646 (ownership). Decision Record: NOT_NEEDED.
Acceptance Criteria
Owner key: an explicit null stays the columnless slot from config to materialized Filter to complete filter-array replacement, and an identical config keeps the live instance. Duplicate equivalent contributions are refused at each stage. An omitted-property callback still resolves to id. The search coexists with header-inline, header-picker and unowned predicates.
Matching: on the acceptance fixture, the expected ids hold for case-insensitive terms, the case-sensitive mode, a title-only scope, an application restriction, and the composed predicates.
An empty or whitespace query is inert, keeps its fields and is not effective; the next term filters again. Scope-only and case-only changes re-filter through change.
Counts: under the composed predicates, the picker's counts follow everything except the picker itself. On raw items, the searched fields are checked canonically and a null property never enters the check set.
getSearchRefusal names unknown fields, unsupported types (a Date or an Array without a hook), remote populations and trees. A Date with a hook searches as the hook's text.
Soft hydration resolves declared fields on raw complex models and never writes a null key.
Out of Scope
The header active state and the toolbar UI (the epic's other leaves); the grid-level lifecycle witnesses (Store swap, grid destroy and recreate, two grids on one Store), which moved to the toolbar leaf because they need a grid; regex (#19786 OQ4); and the header-filter layout (OQ8).
Live latest-open sweep: newest open issues at 09:47Z, no equivalent (only #19791, its epic). A2A claim sweep: no claim on the search contribution. MC sweep: "grid record search toolbar scope all visible named columns", clear miss beyond #19786. Own-assignment sweep: #19790 (Table click-sort), which does not overlap.
tobiu referenced in commit af81fc0 - "feat(data): a Store-wide record search contribution with declared read sets (#19792) (#19796) on Oct 16, 2026, 12:45 PM
Context
This is the data-contract leaf of epic #19791, graduated from #19786 (contracts 1–3 and the stored field list from A2). The header active-state leaf and the toolbar leaf consume what this one adds. Nothing visual ships here. The API shape was posted before implementation (comment 6213003977); this body records the delivered shape.
The Problem
A grid cannot write a record search today:
filterByfunction, and nothing declares which fields it reads.property: nullintoid(key.property ?? 'id',src/collection/Base.mjs), while the Filter itself materializesnull. The validator and the instance disagree for exactly the columnless slot a search needs.Filter, so a Filter subclass can't be added through a config.getFilterValuesand the Store's soft hydration readfilter.property. For raw items, anullproperty would refuse every count, and hydration would write anitem["null"]key.value: '', which is inert.The Architectural Reality
isFilteredrunsfilterBybefore the property path.disabled_is reactive and fireschange, andafterSetFilterByis an empty todo, so swapping a callback never re-filters.Neo.setupClassstores each class's merged config on its constructor, so reconciliation can compare any Filter subclass throughcls.config.72b90388…, signed by Claude and GPT), and the ownership rules of#19646.The Fix
Neo.collection.SearchFilter(new,src/collection/) extendsFilter. Itsvalueholds the query: every whitespace term must occur in at least one declared field.caseSensitive_andfieldText_(per-field(value, item) => Stringhooks) are reactive, andpropertydefaults tonull. It overridesisFiltered,effectiveandexport()(which returnsnull), so it has no callback to swap. An empty or whitespace-only query is inert: it matches everything, is noteffective, and keeps the fields.Filter: a generic reactivefields_(the fields a predicate reads) and aneffectivegetter.Collection.beforeSetFilters: the owner-key property resolves as the Filter exposes it, so an explicitnullstaysnulland absence takes the class default. A config may namemodule(a Filter subclass); creation uses it, and reconciliation compares the constructor and that class's merged config.fieldscompare element-wise.Store: a filter's read set is its declaredfields, else its non-nullproperty. Counting, soft hydration andisFilteredItemuse it.getSearchRefusal(fields, fieldText)returnsremote-projection,unknown-field,unsupported-fieldornull; TreeStore returnstree-population.Contract Ledger
Neo.collection.SearchFilter(new)fields; inert when empty; local only@exampleFilter.fields_(new)null: the read set ispropertyFilter#effective(new)trueonly while the filter narrowsCollection.beforeSetFilters#19669modulecreation and reconciliationStore#getFilterValues,filter,isFilteredItemread setfields, else non-nullpropertyfieldsunchangedStore#getSearchRefusal(new),TreeStore#getSearchRefusalnullDecision Record impact: aligned-with
#19646(ownership). Decision Record: NOT_NEEDED.Acceptance Criteria
nullstays the columnless slot from config to materialized Filter to complete filter-array replacement, and an identical config keeps the live instance. Duplicate equivalent contributions are refused at each stage. An omitted-property callback still resolves toid. The search coexists withheader-inline,header-pickerand unowned predicates.effective; the next term filters again. Scope-only and case-only changes re-filter throughchange.nullproperty never enters the check set.getSearchRefusalnames unknown fields, unsupported types (a Date or an Array without a hook), remote populations and trees. A Date with a hook searches as the hook's text.nullkey.Out of Scope
The header active state and the toolbar UI (the epic's other leaves); the grid-level lifecycle witnesses (Store swap, grid destroy and recreate, two grids on one Store), which moved to the toolbar leaf because they need a grid; regex (#19786 OQ4); and the header-filter layout (OQ8).
Related
#19791 · #19786 · #19669
Live latest-open sweep: newest open issues at 09:47Z, no equivalent (only
#19791, its epic). A2A claim sweep: no claim on the search contribution. MC sweep: "grid record search toolbar scope all visible named columns", clear miss beyond #19786. Own-assignment sweep:#19790(Table click-sort), which does not overlap.Origin Session ID: bb2e8968-a6d6-4ef7-a4ed-5ab3157b1ac8 Retrieval Hint: "SearchFilter columnless owner slot module reconciliation getSearchRefusal read set"