Repository navigation
fix(objectql)!: a search over an object with no searchable field is refused with 400 INVALID_FIELD, not answered as every row - #24320
Conversation
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VaU1aMkDV7udHtCGM1KaRF
…s scan set; pins (wip) Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VaU1aMkDV7udHtCGM1KaRF
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VaU1aMkDV7udHtCGM1KaRF
… protocol at module top Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VaU1aMkDV7udHtCGM1KaRF
…arch-empty-set-refused
…arch-empty-set-refused
📓 Docs Drift CheckThis PR changes 2 package(s): 14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 7 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 24 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1663475f099c53afc00831f00a1e11e2df0828f7 && git checkout 1663475f099c53afc00831f00a1e11e2df0828f7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2ce2e369988c43cd36710de33b86527033646891 07f0f4975f9b8ed04b87db4865483455c9558bef && git checkout -B drift-repro 2ce2e369988c43cd36710de33b86527033646891 && git merge --no-ff 07f0f4975f9b8ed04b87db4865483455c9558bef
node scripts/docs-audit/affected-docs.mjs --json 2ce2e369988c43cd36710de33b86527033646891
|
Contract reviewServed-tier: Inputs: card #24284 (body; comments 6248115403 triage, 6248247765 claim, 6248278272 dispatch, 6249664223 Check-runs at read time (2026-10-18T12:08Z): 23 runs. 10 completed success: Check Changeset (the ① Derived judgments
② Semver levelTwo changesets, read against the diff,
③ Boundary flags
Implemented-by: VERDICT: PASS |
席位核对 ·
|
| Item | Reading |
|---|---|
| Declaration | Clause-②: no (narrowing) on the PR body and in the objectql changeset; readClause2Line reads no / narrowing. A non-blank search over an empty resolved search set flips from 200 (every row) to 400 INVALID_FIELD, param: 'search'. Nothing widens. |
| Review face | Narrowing face, CONTRACT_REVIEW_TIER record of record 6249877096, PASS, at this head. The seat re-verified its central claims in the tree: one throw emptySearchSetError(…) in ObjectQL.expandSearchOnAst, guarded by the trimmed term, reached by find and findOne directly and by the count and aggregate through expandSearchOnOptions; expandSearchToFilter changes in its docblock only; searchAll's skip now resolves over its fields minus collectInternalWriteResponseFields(obj), the engine's own scan set, and keeps continue before any query; TEMPLATE_INAPPLICABLE_PARAMS refuses search in template mode before any read. |
| Prose face | Both changesets, read sentence by sentence against the diff. The objectql changeset's verbs, doors, its four unchanged cases and its "What to do" match the code; its list of default search types matches SEARCHABLE_TEXTUAL_TYPES and SEARCHABLE_ENUM_TYPES. The metadata-protocol changeset's skip sentence and "What to do. Nothing." are true. One wording note from the record is accepted without an edit: the default set includes the name field only when its type is scannable. No sentence is false. |
| PR body | The seat corrected one row of the H3 producer table through the relay (issue_patch), from record ③.4: the import-template door refuses search before any read and is unchanged; the export door surfaces the refusal as csv and as a truncated stream as json or xlsx. The dev's report already flagged export as NOT MEASURED. |
| Gates | Dev report: 71 derived, 71 run, every exit 0, 0 NOT MEASURED; typecheck exit 0 for objectql and metadata-protocol; one ablation on the refusal (8 refusal pins red, the preservation pins green) with a dist marker proof, restored to blob == HEAD. |
| CI | 47 check-runs on this head: 39 success, 8 skipped, 0 failed. All eight skips are on the EXPECTED_SKIPS roster: six path or opt-in skips (Build Docs, Console Pin Gate, Dogfood Verify CLI, the Dogfood shards, Temporal Conformance, Packed-tarball smoke) plus the body-edit re-runs of Auto Label and Check PR Size, whose first runs succeeded. Every Test Core shard, Build Core and every Type Check and Lint lane is green. The earlier red "TypeScript Type Check" was on the superseded head fd4e073a6c: its two lanes were cancelled by the relay merge's push, not failed. Mergeable, clean. |
Disposition:
open_questions: A, A, A, all accepted as the record answers them.INVALID_FIELD: the cause is the object's field declaration, andINVALID_QUERYis reserved for shape mistakes.- The
searchAllskip judges the engine's scan set. - The console should hide search on such an object, or render the refusal.
- Deviations: all accepted.
- The
searchAllinput alignment is re-declared. - Both transports were read as the REST list and query routes, and the dispatcher, SDK and hook doors share the one engine site and one error resolver.
- The export door was NOT MEASURED, with an honest flag.
- The full suites ran on
bead2f2a4a; this head's CI is green. - The worktree and trailer notes are process.
- The
out_of_scope_findings: fixed in place, as the record confirms (③.2). The palette used to serve an internal-only object's first rows as hits.- Routed after the landing (below the filing gate, none blocking):
- to
domain:cli: the export route opens itsjson/xlsxstream before the first read, so any refusal in its loop ends a committed200with a truncated stream (③.4b, read in code, pre-existing); - to
domain:devx: one sentence inerror-catalog.mdx'sINVALID_FIELDcause (③.6); - to objectui: the consumer pointer, re-read at the head's
.objectui-sha0707b4ae37(③.3).
- to
- Seat-post notes: the message-precision notes (③.5) and the two-flag-reader lockstep (③.8c).
Landing: ordinary queue (relay pr_ready, then automerge_enable). Not governed: check-governed-merges.mjs --pr 24320 reads NOT governed, 345 changed lines.
Generated by Claude Code
Fixes #24284
Clause-②: no (narrowing)
What
A non-blank
searchover an object whose resolved search set is empty is now refused with400 INVALID_FIELD(param: 'search'), instead of being dropped and answering every row. The canonical shape is a junction object: two lookups plus a composed formula title. The auto-default scans textual and enum types only, so its set is empty.ObjectQL.expandSearchOnAst(packages/objectql/src/engine.ts).expandSearchToFilteranswersnullfor exactly two reasons, an empty term or an empty resolved set. Once the term is non-blank, the second reason is the only one left, so the refusal reuses the set the expansion already resolved and adds no second resolver.find,findOne,count(the list'stotal) andaggregateall reach this call, so every door gets the same answer: REST list, REST query, grouped read, dispatcher, SDK, and a hook'sctx.api.searchableFieldsis declared, it says none of its fields is a type the default scans, and lists those types. If a declaration exists, it lists the entries that name no stored field. Ifinternal: truecolumns were left out, it says so. It ends with the two remedies: declaresearchableFieldsnaming a stored field, or mirror the composed title onto a stored text field. No tracker number appears in the runtime text.searchAllkeeps its skip and its200(packages/metadata-protocol/src/protocol.ts). Measuring this found one gap: the skip resolved the set over the object's fields INCLUDINGinternal: trueones, which the engine never scans. An object whose only text column is internal therefore passed the skip and reachedfind. Before this PR, that returned that object's first rows as palette hits. With the refusal in place it would have failed the whole sweep. The skip now leaves internal fields out, using the same flag reader the security(data): the data door's filter and group-by positions do not honour a field'sinternal: truethe way its row read does — detail withheld pending maintainer #22646 ingress gate uses. The pin is inengine-search-empty-set.test.ts. I did not find a registered object of that shape in the composed todo app: the palette swept 36 objects and answered200.expandSearchToFilterstays pure and non-throwing. Its docblock now separates its twonullcases.Code:
INVALID_FIELD, notINVALID_QUERY. The request is well-formed. What cannot be honoured is a fact about the object's FIELDS. That is the axis #4254's explicit-override gate already answers withINVALID_FIELD, and the same code a search naming the formula title gets today.invalidQueryError's docblock reservesINVALID_QUERYfor shape mistakes that are "not about any FIELD". No new code is added. The answer is derived from the declaration, never from the caller's permissions, so it is the same for every caller.Where it landed. It landed where the claim expected it:
expandSearchOnAst, the one call site. The data door's ingress gate (assertSearchFieldsAreSearchable) is not touched. It judges only an explicit override, and a refusal there would leave the dispatcher, SDK and hook doors silent. The H1 measurement below shows the engine-side refusal reaches every door with the message and code already shaped.Measured over HTTP: before and after
The
examples/app-todobackend ran fresh on SQLite (pnpm dev -- --fresh --compile), signed in as the seeded admin. A scratch fixture addedtodo_lib_book(title: text),todo_lib_reader(name: text) andtodo_lib_loan, which has thelib_loanshape:bookandreaderlookups plustitle, a formulanameFieldwrittenrecord.book.title + " · " + record.reader.name. Seed data: 3 books, 1 reader, 5 loans. The fixture was removed afterwards and the tree is clean. The "before" run is the same tree with the refusal ablated indist/(see Ablation), and it reproduces the card's report exactly.GET /data/todo_lib_loan?search=zzzz-nothingINVALID_FIELDGET /data/todo_lib_loan?search=DuneINVALID_FIELDGET …?search=Dune&count=true&limit=1(count /total)total: 5INVALID_FIELDPOST /data/todo_lib_loan/query {"search":"zzzz-nothing"}INVALID_FIELDPOST …/query {"search":{"query":"zzzz"}}INVALID_FIELDPOST …/query {"search":"zzzz","groupBy":["book"],"aggregations":[count]}INVALID_FIELDGET …?search=Dune&searchFields=(empty override = absent)INVALID_FIELDGET …?search=Dune&searchFields=title(formula)INVALID_FIELD(virtual field)GET /data/todo_lib_loan,?search=,?search=%20%20GET /data/todo_lib_book?search=Dune/zzzz-nothingGET /search?q=Dune(palette)totalObjects: 36GET /search?q=Dune&objects=todo_lib_loanhits: [],totalObjects: 0The after-message, verbatim: "Object 'todo_lib_loan' has no searchable field, so 'search' cannot be honoured on it: it declares no 'searchableFields', and none of its fields is a type the default search scans (text / email / phone / url / autonumber / textarea / markdown / select). Answering every row instead would read as "everything matched". Declare 'searchableFields' on 'todo_lib_loan' naming a stored field, or mirror the composed title onto a stored text field and search that."
GET /data/:object/export?search=was NOT MEASURED over HTTP: the dev admin holds no export capability, so the route answers403 EXPORT_NOT_PERMITTEDbefore it reads the search. The route reads throughfindData, the door pinned below.Internal producers of
search(H3)searchAll(⌘K palette,GET /api/v1/search)200; pinned.GET /data/:object/export(thesearchparam)findDatainside its chunk loop. Ascsvit answers the400 INVALID_FIELDenvelope. Asjsonorxlsxthe route opens its stream before the first read, so a refusal there ends a committed200with a truncated stream. That is the route's existing posture for every refusalfindDatathrows in the loop. (Corrected by the seat from record ③.4.)GET /data/:object/import-template(template mode)searchis a template-inapplicable parameter, refused400 VALIDATION_ERRORbefore any read (TEMPLATE_INAPPLICABLE_PARAMS). It never reaches the engine. (Corrected by the seat from record ③.4.)@objectstack/clientQueryBuilder.search/data.findpackages/mcp), runtime, flows (service-automationcrud nodes), analyticssearchkey is emitted (git grepover each package'ssrc, tests excluded).search.searchto the data door (below).Pins
packages/objectql/src/engine-search-empty-set.test.ts, run over a realObjectQLand, for the protocol cases, a realObjectStackProtocolImplementation:find/findOne/count/aggregate× three term shapes →{ code: 'INVALID_FIELD', status: 400, object, param: 'search' }, the message's first sentence, the reason and both remedies. The driver is never read.searchFields(['title'],['book'],['nope'],[]) does not rescue it, because the allowed set, and so the fallback, is empty. This is H4's "explicit names all intersected away" case. On an object WITH a searchable set, that case falls back to the allowed set insideresolveSearchFields, so it answers filtered rows, never every row (control pin). It reaches the refusal only when the allowed set is empty too.internal: true→ refused, saying why.whereuntouched, on find and count.zzzz-nothing→ 0 rows and count 0.searchAllover {book, loan, internal-only} → the book's hit only,totalObjects: 1, and no read of the other two. Over {loan, internal-only} alone →200,totalObjects: 0.findData(list / query shapes) over the junction → the refusal; control and blank term unchanged.Pin sweep. No existing pin asserted rows for a search over an object with no searchable field. I ran the full
@objectstack/objectqland@objectstack/metadata-protocolsuites, plus everyrest/runtimetest file that sendssearch, and none flipped. No fixture needed a stored searchable field added.Ablation (the before leg)
The fix was committed first.
node scripts/ablation-replace.mjsreplacedthrow emptySearchSetError(…)withvoid emptySearchSetError(…)inengine.ts. Injected spelling count 1, removed spelling count 0. The suite ran on the mutated source: 8 failed / 3 passed. The 8 refusal pins went red, and the 3 preservation pins (blank term, control, palette skip) stayed green, which is the expected direction. The package was rebuilt;ablation-dist-preflight.mjs @objectstack/objectql 'void emptySearchSetError'reported the marker present in 4 built files. The HTTP "before" column was read from that build. Restore:ablation-replace --restore(blob == HEAD,git diff HEADempty), rebuild, thenablation-dist-preflight … --absentreported the marker absent from all 14 built files and the working tree clean.Verification record
bead2f2a4a:@objectstack/objectql441 files / 8288 tests passed.@objectstack/metadata-protocol244 files passed and 3 skipped, 20641 tests passed and 19 skipped. On the final headfd4e073a6c(after a secondorigin/mainmerge that touched neither package's source): build of themetadata-protocolclosure plusobjectql, both packages'typecheck, and the pin file (11/11).typecheck:@objectstack/objectqlexit 0 (check:test-typecheck: OK, the new test file compiles with no ledger entry),@objectstack/metadata-protocolexit 0.node scripts/pm/dispatch-gates.mjs --commands(no paths) derived 71 families onbead2f2a4a. All 71 ran, each with its exit code captured before any pipe, and all exited 0.--ranreconciliation: "71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN … a DERIVED zero". Three families first answered with a prerequisite, not a verdict:check-engine-split-ratioandcheck-plugin-teardown-shape --self-test(shallow clone; deepened), andcheck:dual-build-cjs-loads(8 unbuilt packages; built). Re-run, all three exited 0. The first run also turned up two real findings, both in the new test file, fixed and re-run green:check:test-source-alias(a dynamic import of@objectstack/metadata-protocolinside a clocked test body), andcheck:query-options-erasure(untyped option bags; the test surface rose from 236 to 245 sites and is back to 236). Onfd4e073a6cthe derived list is identical. The error / changeset / doc / ratchet families were re-run there and exited 0:check:error-status-conformance,check:dispatcher-error-vocabulary,check:doc-authoring,check:nul-bytes,check-adr-0087-registration,check-changeset-no-major,check-empty-changeset,check:query-options-erasure,check:test-source-alias.Consumer pointer (objectui, routed by the seat)
Read at objectui's pinned
.objectui-sha5e15928b. The console's list quick-search (plugin-listListView/plugin-gridObjectGrid) and the record picker (fieldsuseRecordQuery) sendsearch, throughdata-objectstackfind:GET /api/v1/data/OBJECT?search=TERM. On a junction object, that request used to get 200 with every row, which the list showed as if everything matched. It now gets400 INVALID_FIELDwithparam: 'search'and the message above.ListViewshows the search box unlessuserActions.search === false. It does not consult the object's resolved search set. What the console should do: hide quick-search and picker type-ahead on an object whose resolved set is empty, judged by the sharedresolveSearchFieldResolutionfrom@objectstack/spec/dataover the object schema (internal fields excluded). Where the box is still shown, render the refusal's message rather than an empty or unchanged list. No objectui edit is made here.Acceptance notes
@objectstack/objectqlminor (the narrowing; it carries theClause-②line and the ADR-0087not-required (no-migration-prescription)marker) and@objectstack/metadata-protocolpatch (the palette skip's alignment).protocol.tschanged only atsearchAll's skip: its docblock, and the input to the resolution the skip judges, as measured above.packages/spec,packages/rest, objectui and docs are untouched.🤖 Generated with Claude Code
Generated by Claude Code