Skip to content

fix(objectql)!: a search over an object with no searchable field is refused with 400 INVALID_FIELD, not answered as every row - #24320

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-24284-search-empty-set-refused
Oct 18, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-24284-search-empty-set-refused

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #24284

Clause-②: no (narrowing)

What

A non-blank search over an object whose resolved search set is empty is now refused with 400 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.

  • One judgement, one place: ObjectQL.expandSearchOnAst (packages/objectql/src/engine.ts). expandSearchToFilter answers null for 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's total) and aggregate all reach this call, so every door gets the same answer: REST list, REST query, grouped read, dispatcher, SDK, and a hook's ctx.api.
  • The message names the object and says why its set is empty. If no searchableFields is 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. If internal: true columns were left out, it says so. It ends with the two remedies: declare searchableFields naming a stored field, or mirror the composed title onto a stored text field. No tracker number appears in the runtime text.
  • searchAll keeps its skip and its 200 (packages/metadata-protocol/src/protocol.ts). Measuring this found one gap: the skip resolved the set over the object's fields INCLUDING internal: true ones, which the engine never scans. An object whose only text column is internal therefore passed the skip and reached find. 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's internal: true the way its row read does — detail withheld pending maintainer #22646 ingress gate uses. The pin is in engine-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 answered 200.
  • expandSearchToFilter stays pure and non-throwing. Its docblock now separates its two null cases.

Code: INVALID_FIELD, not INVALID_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 with INVALID_FIELD, and the same code a search naming the formula title gets today. invalidQueryError's docblock reserves INVALID_QUERY for 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-todo backend ran fresh on SQLite (pnpm dev -- --fresh --compile), signed in as the seeded admin. A scratch fixture added todo_lib_book (title: text), todo_lib_reader (name: text) and todo_lib_loan, which has the lib_loan shape: book and reader lookups plus title, a formula nameField written record.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 in dist/ (see Ablation), and it reproduces the card's report exactly.

request before after
GET /data/todo_lib_loan?search=zzzz-nothing 200, 5 rows 400 INVALID_FIELD
GET /data/todo_lib_loan?search=Dune 200, 5 rows 400 INVALID_FIELD
GET …?search=Dune&count=true&limit=1 (count / total) 200, total: 5 400 INVALID_FIELD
POST /data/todo_lib_loan/query {"search":"zzzz-nothing"} 200, 5 rows 400 INVALID_FIELD
POST …/query {"search":{"query":"zzzz"}} 200, 5 rows 400 INVALID_FIELD
POST …/query {"search":"zzzz","groupBy":["book"],"aggregations":[count]} 200, 3 buckets 400 INVALID_FIELD
GET …?search=Dune&searchFields= (empty override = absent) 200, 5 rows 400 INVALID_FIELD
GET …?search=Dune&searchFields=title (formula) 400 INVALID_FIELD (virtual field) unchanged
GET /data/todo_lib_loan, ?search=, ?search=%20%20 200, 5 rows unchanged
control GET /data/todo_lib_book?search=Dune / zzzz-nothing 200, 1 / 0 rows unchanged
GET /search?q=Dune (palette) 200, 1 hit (book), totalObjects: 36 unchanged
GET /search?q=Dune&objects=todo_lib_loan — 200, hits: [], totalObjects: 0

The 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 answers 403 EXPORT_NOT_PERMITTED before it reads the search. The route reads through findData, the door pinned below.

Internal producers of search (H3)

producer verdict
searchAll (⌘K palette, GET /api/v1/search) skips an empty set before it queries. Now judged on the engine's scan set (internal columns left out). Stays 200; pinned.
GET /data/:object/export (the search param) surfaces the refusal: it reads through findData inside its chunk loop. As csv it answers the 400 INVALID_FIELD envelope. As json or xlsx the route opens its stream before the first read, so a refusal there ends a committed 200 with a truncated stream. That is the route's existing posture for every refusal findData throws in the loop. (Corrected by the seat from record ③.4.)
GET /data/:object/import-template (template mode) unchanged: search is a template-inapplicable parameter, refused 400 VALIDATION_ERROR before any read (TEMPLATE_INAPPLICABLE_PARAMS). It never reaches the engine. (Corrected by the seat from record ③.4.)
@objectstack/client QueryBuilder.search / data.find surface the refusal: a passthrough to the data door.
MCP (packages/mcp), runtime, flows (service-automation crud nodes), analytics no producer: no search key is emitted (git grep over each package's src, tests excluded).
import by-name lookup resolution no producer: it does not use search.
server-side record picker none in this repo. The console's picker sends search to the data door (below).

Pins

packages/objectql/src/engine-search-empty-set.test.ts, run over a real ObjectQL and, for the protocol cases, a real ObjectStackProtocolImplementation:

  • junction object × 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.
  • an explicit 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 inside resolveSearchFields, so it answers filtered rows, never every row (control pin). It reaches the refusal only when the allowed set is empty too.
  • a declaration whose entries are all unscannable → the refusal lists them.
  • an object whose only text column is internal: true → refused, saying why.
  • blank / whitespace term → every row, where untouched, on find and count.
  • control object → filters; zzzz-nothing → 0 rows and count 0.
  • searchAll over {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/objectql and @objectstack/metadata-protocol suites, plus every rest / runtime test file that sends search, and none flipped. No fixture needed a stored searchable field added.

Ablation (the before leg)

The fix was committed first. node scripts/ablation-replace.mjs replaced throw emptySearchSetError(…) with void emptySearchSetError(…) in engine.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 HEAD empty), rebuild, then ablation-dist-preflight … --absent reported the marker absent from all 14 built files and the working tree clean.

Verification record

  • Tests on merged head bead2f2a4a: @objectstack/objectql 441 files / 8288 tests passed. @objectstack/metadata-protocol 244 files passed and 3 skipped, 20641 tests passed and 19 skipped. On the final head fd4e073a6c (after a second origin/main merge that touched neither package's source): build of the metadata-protocol closure plus objectql, both packages' typecheck, and the pin file (11/11).
  • typecheck: @objectstack/objectql exit 0 (check:test-typecheck: OK, the new test file compiles with no ledger entry), @objectstack/metadata-protocol exit 0.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands (no paths) derived 71 families on bead2f2a4a. All 71 ran, each with its exit code captured before any pipe, and all exited 0. --ran reconciliation: "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-ratio and check-plugin-teardown-shape --self-test (shallow clone; deepened), and check: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-protocol inside a clocked test body), and check:query-options-erasure (untyped option bags; the test surface rose from 236 to 245 sites and is back to 236). On fd4e073a6c the 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-sha 5e15928b. The console's list quick-search (plugin-list ListView / plugin-grid ObjectGrid) and the record picker (fields useRecordQuery) send search, through data-objectstack find: 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 gets 400 INVALID_FIELD with param: 'search' and the message above. ListView shows the search box unless userActions.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 shared resolveSearchFieldResolution from @objectstack/spec/data over 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

  • The changesets are @objectstack/objectql minor (the narrowing; it carries the Clause-② line and the ADR-0087 not-required (no-migration-prescription) marker) and @objectstack/metadata-protocol patch (the palette skip's alignment).
  • The file surface follows the claim, plus the changesets. protocol.ts changed only at searchAll'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

@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 18, 2026
@github-actions

github-actions Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata-protocol, @objectstack/objectql, touching 5 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/objectql/src/search-filter.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/data-api.mdx (via INVALID_FIELD (literal, a string literal in emptySearchSetError), searchableFields (literal, a string literal in emptySearchSetError))
  • content/docs/api/error-catalog.mdx (via INVALID_FIELD (literal, a string literal in emptySearchSetError))
  • content/docs/api/error-handling-server.mdx (via INVALID_FIELD (literal, a string literal in emptySearchSetError))
  • content/docs/automation/hook-bodies.mdx (via INVALID_FIELD (literal, a string literal in emptySearchSetError))
  • content/docs/data-modeling/queries.mdx (via INVALID_FIELD (literal, a string literal in emptySearchSetError), searchableFields (literal, a string literal in emptySearchSetError))
  • content/docs/data-modeling/schema-design.mdx (via INVALID_FIELD (literal, a string literal in emptySearchSetError), searchableFields (literal, a string literal in emptySearchSetError))
  • content/docs/deployment/validating-metadata.mdx (via INVALID_FIELD (literal, a string literal in emptySearchSetError))
  • content/docs/kernel/contracts/data-engine.mdx (via INVALID_FIELD (literal, a string literal in emptySearchSetError))
  • content/docs/permissions/authorization.mdx (via INVALID_FIELD (literal, a string literal in emptySearchSetError))
  • content/docs/protocol/kernel/error-handling.mdx (via INVALID_FIELD (literal, a string literal in emptySearchSetError))
  • content/docs/protocol/objectql/index.mdx (via INVALID_FIELD (literal, a string literal in emptySearchSetError))
  • content/docs/protocol/objectql/query-syntax.mdx (via INVALID_FIELD (literal, a string literal in emptySearchSetError), searchableFields (literal, a string literal in emptySearchSetError))
  • content/docs/protocol/objectql/types.mdx (via INVALID_FIELD (literal, a string literal in emptySearchSetError))
  • content/docs/ui/views.mdx (via INVALID_FIELD (literal, a string literal in emptySearchSetError), searchableFields (literal, a string literal in emptySearchSetError))

⛔ 7 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via searchableFields (literal, a string literal in emptySearchSetError))
  • content/docs/releases/v17/17-1.mdx (via searchAll (symbol, a method of class ObjectStackProtocolImplementation), searchableFields (literal, a string literal in emptySearchSetError))
  • content/docs/releases/v17/17-3.mdx (via INVALID_FIELD (literal, a string literal in emptySearchSetError))
  • content/docs/releases/v17/17-4.mdx (via searchableFields (literal, a string literal in emptySearchSetError))
  • content/docs/releases/v17/17-5.mdx (via INVALID_FIELD (literal, a string literal in emptySearchSetError))
  • content/docs/releases/v17/17-6.mdx (via INVALID_FIELD (literal, a string literal in emptySearchSetError))
  • content/docs/releases/v17/17-7.mdx (via INVALID_FIELD (literal, a string literal in emptySearchSetError))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/objectql/src/search-filter.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 24 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 2ce2e369988c43cd36710de33b86527033646891 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 1663475f099c53afc00831f00a1e11e2df0828f7 — the merge of head 07f0f4975f9b8ed04b87db4865483455c9558bef into base 2ce2e369988c43cd36710de33b86527033646891, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 2ce2e369988c43cd36710de33b86527033646891 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 07f0f4975f9b8ed04b87db4865483455c9558bef
Local-runs: none
Scope: packages/objectql/src/engine.ts, packages/objectql/src/search-filter.ts, packages/objectql/src/engine-search-empty-set.test.ts, packages/metadata-protocol/src/protocol.ts + packages/spec/src/data/search-fields.ts, packages/objectql/src/secret-fields.ts, packages/core/src/utils/internal-write-response.ts, packages/types/src/thrown-http-error.ts, packages/rest/src/error-response.ts, packages/runtime/src/http-dispatcher.ts, packages/rest/src/data-action-routes.ts, packages/rest/src/import-template.ts, packages/client/src/query-builder.ts, packages/runtime/src/sandbox/body-runner.ts, packages/metadata-core/src/engine-findone-predicate.ts, packages/spec/src/api/errors.zod.ts, content/docs/api/error-catalog.mdx, .changeset/config.json, .objectui-sha, scripts/check-changeset-no-major.mjs, scripts/pm/clause2-line.mjs, scripts/check-adr-0087-registration.mjs, .github/workflows/pr-automation.yml, .github/workflows/lint.yml, and in objectui at 5e15928b (the pin the consumer pointer cites): packages/plugin-list/src/ListView.tsx, packages/plugin-grid/src/ObjectGrid.tsx, packages/fields/src/widgets/useRecordQuery.ts, packages/data-objectstack/src/index.ts. The two .changeset/24284-*.md files are the prose face, judged under ②.

Inputs: card #24284 (body; comments 6248115403 triage, 6248247765 claim, 6248278272 dispatch, 6249664223 os-dev-report read as claims, 6249716675 re-declared claim), the filing finding ③.7 of record 6247399985 on PR #24274, PR #24320 (body, 6-file list), the net diff git diff 2ce2e36998 pr/24320 (merge base 2ce2e36 is origin/main at the fetch; +340/−5, byte-matching the report's per-file line budget), and the head's check-runs. Nothing was built, run or re-run.

Check-runs at read time (2026-10-18T12:08Z): 23 runs. 10 completed success: Check Changeset (the changeset-check job that runs check-changeset-no-major.mjs --base MERGE_BASE and check-adr-0087-registration.mjs --base MERGE_BASE), Governed Surface Queue Guard, Auto Label, Check PR Size, Check Documentation Links, Flag docs affected by code changes, and the four claim/card guards. 1 skipped by design (Packed-tarball smoke). 12 in progress and not waited on: every Lint job (PM dispatch-gates, ESLint, docs & code guards — the job hosting check:error-status-conformance and check:dispatcher-error-vocabulary —, repo & tooling gates, CI/release & engine gates, the three scoped jobs) and the four Type Check jobs. No Build or Test Core run is in the listing at read time. Those runs are the gate verdict on the new pins and the moved code; this record does not stand in for them.

① Derived judgments

  1. One judgement, one site — RIGHT. ObjectQL.expandSearchOnAst (engine.ts) now throws emptySearchSetError(ast.object, schema, internalExcluded) — the third argument is "the object declares at least one internal: true field" — in the branch where expandSearchToFilter answered null AND normalizeSearch(raw).query.trim() is non-empty. expandSearchToFilter returns null for exactly two reasons (blank query at its first line; searchFields.length === 0 after resolveSearchFields), so with the blank case excluded the remaining reason is the empty resolved set, judged on the set the expansion itself resolved. expandSearchToFilter itself is unchanged in code (docblock only) and stays pure. Every verb reaches the site: find (engine.ts line 13965) and findOne (line 14286) call it directly; count (line 19881) and aggregate (line 20024) call it through expandSearchOnOptions, the carrier that holds only object, where, search, searchFields.

  2. Exactly what flips from 200 to 400: a non-blank search (a string, or an object form with a non-blank query/q) on an object whose resolved search set is empty — no searchableFields entry naming an existing, non-virtual, non-internal field, and no non-hidden, non-internal field of a SEARCHABLE_TEXTUAL_TYPES/SEARCHABLE_ENUM_TYPES type outside SEARCH_AUTO_EXCLUDED_FIELDS. Doors, each traced to the engine: REST list GET and query POST (findData passes search: options.search to engine.find at protocol.ts 14718 and to engine.count for the paged total at 14862; find throws first, so the count's try/catch fallback is never reached); the grouped read (aggregate); the dispatcher and the SDK (QueryBuilder.search writes query.search = { query, ...options }, a passthrough to the data door); a hook body's ctx.api.object(...).find/findOne (body-runner.ts 1010–1017 call ql.find directly). findOne flips from a worse answer than 200-every-row: searchIsPredicate (engine-findone-predicate.ts) counts a non-blank term as a predicate, so a junction-object findOne({ search }) used to pass the predicate guard and return the FIRST row of the whole object under limit: 1 (the ObjectQL silently drops unsupported predicate keys; findOne then returns the first row #4419 shape); it is now refused before the guard. The export route (GET /data/:object/export?search=) hands the term to findData inside its chunk loop (data-action-routes.ts 1127–1143), so the refusal is reached — see ③.4 for what the wire then carries.

  3. What stays as it was — RIGHT, and pinned. A blank or whitespace term ('', ' ', { query: ' ' }) is not a search: raw != null enters the block, expandSearchToFilter returns null on the blank check, and the throw is guarded by the same trim, so where is untouched (pin: 5 rows, count 5, where composes). An empty explicit override (searchFields='' or []): the door's assertSearchFieldsAreSearchable returns on names.length === 0 (unchanged), and resolveSearchFields treats an empty requested list as absent — on an object WITH a set the search runs over that set as before; on an object WITHOUT one it reaches the refusal (the PR's ?search=Dune&searchFields= row, measured 400). The REST 读路径:searchFields / groupBy / aggregations 指向不存在的字段时被静默降级(#4226 收口后剩下的三条轴) #4254 and security(data): the data door's filter and group-by positions do not honour a field's internal: true the way its row read does — detail withheld pending maintainer #22646 refusals of a NAMED field fire at the data door before the engine and are untouched in the diff (assertSearchFieldsAreSearchable 13539–13660 at the head is byte-identical to the base). A searchFields naming the formula title still answers the gate's own virtual INVALID_FIELD. An object with a searchable field filters exactly as before (control pin: Dune → b1, zzzz-nothing → 0).

  4. Decided from the declaration alone — RIGHT. emptySearchSetError reads ast.object, the registry schema's searchableFields array, the two type vocabularies and a boolean "this object declares an internal: true field"; no caller context, no permission answer, no row (pin: the driver is never read on any verb). The refusal is therefore the same for every caller through every door. What the message names is metadata the object declares, never a value — the posture of the sibling declaration gates (assertOrderByIsMaterializable, the dotted-projection refusal, REST 读路径:searchFields / groupBy / aggregations 指向不存在的字段时被静默降级(#4226 收口后剩下的三条轴) #4254 at the door), all of which also run before the security middlewares. The internal: true parenthetical reveals only that some internal column exists; the describe door does not withhold the field's existence (protocol.ts has no describe-side internal strip; security(data): the data door's filter and group-by positions do not honour a field's internal: true the way its row read does — detail withheld pending maintainer #22646 withholds the VALUE), so nothing new is disclosed.

  5. An explicit searchFields whose names are all intersected away — RIGHT, two answers by door, both unchanged in direction. Through the data door, a name outside the allowed set is refused by REST 读路径:searchFields / groupBy / aggregations 指向不存在的字段时被静默降级(#4226 收口后剩下的三条轴) #4254 before the engine (unchanged). To a direct engine caller, resolveSearchFields keeps its tolerant posture: when the intersection is empty but allowed is non-empty it falls back to the allowed set, a FILTERED answer, never every row (control pin: searchFields: ['nope'] on lib_book answers b2 for emma). It reaches the new refusal only when allowed is itself empty (pin: ['title'], ['book'], ['nope'], [] on the junction all refused). So no override can rescue an object with no set, and no override on an object with a set changes answer.

  6. searchAll's skip input is the engine's scan set — RIGHT, and the palette keeps 200. The engine builds scanFields as schema.fields minus collectInternalReadFields(schema) (objectql secret-fields.ts: def.internal === true) and resolves over that. The skip at protocol.ts 15862–15874 now builds fieldMetaByName minus collectInternalWriteResponseFields(obj) (core internal-write-response.ts: def.internal === true, documented as the cross-package twin "restated rather than imported" because objectql depends on metadata-protocol), then calls the same resolveSearchFieldResolution with the same searchableFields and the same nameField ?? displayNameField precedence, and continues on an empty set before any query. Same metadata (type, hidden, options, internal ride on each fields entry), same filters, same answer; the FLS narrowing (getQueryableFields) that follows only ever hands the engine a subset of that set, so the engine's intersection cannot be empty when the skip was passed. Pinned over a real ObjectStackProtocolImplementation on a real ObjectQL: {book, loan, internal-only} → the book's hit, totalObjects: 1, no read of the other two; {loan, internal-only} → 200, totalObjects: 0. The PR body's "same flag reader the security(data): the data door's filter and group-by positions do not honour a field's internal: true the way its row read does — detail withheld pending maintainer #22646 ingress gate uses" is true (assertSearchFieldsAreSearchable at 13592 uses the core twin); the engine's own reader is the objectql twin, and the equivalence rests on the two keeping one verdict, which both docblocks promise.

  7. Internal producers that hand search to an object they did not choose — one, and it skips. A git grep over every published package's src (tests excluded) finds search: as a query key at three protocol.ts sites (findData's find and count, searchAll's search: q) and nowhere else; http-dispatcher.ts 2392 search: searchRegistered is a capability flag, not a term; MCP, runtime, flows (service-automation crud-nodes.ts names search only as an icon) and analytics emit no search key; import by-name resolution does not use it. The export route hands the CALLER's own term to the caller's own object. No internal caller fails on this change.

  8. Public surface — nothing widens. emptySearchSetError is module-private; the three new imports in engine.ts (normalizeSearch, the two type sets) already existed on search-filter.ts; protocol.ts adds no export; the error's own shape (status, httpStatus, code, object, param) is the shape the door's REST 读路径:searchFields / groupBy / aggregations 指向不存在的字段时被静默降级(#4226 收口后剩下的三条轴) #4254/security(data): the data door's filter and group-by positions do not honour a field's internal: true the way its row read does — detail withheld pending maintainer #22646 refusals already carry. The accept set of the data read narrows; no spec key, export or authorable spelling moves.

  9. The wire — RIGHT at both doors. resolveThrownHttpError (types thrown-http-error.ts 206) reads .status (400) and keeps .code when it parses as ErrorCode; INVALID_FIELD is a StandardErrorCode member (spec errors.zod.ts 56), so error.code is INVALID_FIELD with no declaredCode demotion. The REST door reads it through error-response.ts and the dispatcher through HttpDispatcher.errorFromThrown (1240), one resolver for both — which is why the dev's "both transports" measured over REST GET/POST, with the dispatcher/SDK/hook doors covered by engine-level pins, is a sound reading (③.2). The catalog documents INVALID_FIELD as 400 (error-catalog.mdx 144, quick-reference row 966), so check:error-status-conformance has nothing new to reconcile.

  10. The code: INVALID_FIELD, not INVALID_QUERY — RIGHT. invalidQueryError's own docblock (protocol.ts 4874–4883) reserves INVALID_QUERY for aggregation-axis SHAPE mistakes "not about any FIELD", and the catalog's entry (320) says the same; INVALID_FIELD's entry already carries every searchFields refusal on this axis. The request here is well-formed; the fact that cannot be honoured is the object's field declaration. One code per axis, no new catalog entry, no spec edit — as the dispatch required.

② Semver level

Two changesets, read against the diff, scripts/check-changeset-no-major.mjs and scripts/pm/clause2-line.mjs. Both packages are in the one fixed group (.changeset/config.json, 73 members). The Check Changeset check-run is success on this head, and that job runs both the level axis and the ADR-0087 registration gate; its conclusion is CI's verdict.

  • @objectstack/objectql minor — RIGHT. The data read's accept set narrows, which is BREAKING; under the launch-window rule a breaking change ships minor with the breaking-ness carried by the feat(objectql)!: summary (the gate's bang signal) and by the ADR-0087 disposition, never by major. The disposition not-required (no-migration-prescription) is the honest category: no spec key, export, stored shape or authorable spelling is added, removed or renamed, and the body carries no arrow rewrite, FROM/TO pair or rewrite table for findMigrationPrescription to contradict it; the "What to do" paragraph is a metadata remedy, not a code rewrite. The body's claims match the diff: the two reasons a set is empty, every verb and door named in ①.2, the four unchanged cases of ①.3, and "Global search still passes over an object with nothing to scan". One wording note, not a defect: "the default set (the name/title field and the text, … and select fields)" reads as if the name/title field is always in the set; autoDefaultFields only LEADS with it when it is itself of a scannable type ($search 自动字段集:nameField/name/title 被无条件前置,绕过 SEARCH_AUTO_EXCLUDED_FIELDS——搜索会打到主键 #4483). The runtime message is exact.
  • @objectstack/metadata-protocol patch — RIGHT. The palette's accept set is unchanged (200 before and after); what changes is the answer for an object whose only text column is internal — from that object's first perObject rows served as hits (the pre-existing cliff the skip's own docblock names) to being passed over — a bug fix, non-breaking, no !, no marker owed, and "What to do. Nothing." is true.
  • The declaration. Clause-②: no (narrowing) on the PR body and in the objectql changeset — RIGHT on both limbs: nothing widens (①.8), the accept set narrows. By judgeLevel a no (narrowing) carries the axis, objectql graded minor discharges it and metadata-protocol's patch is set aside as the residual — verdict discharged, exit 0, which is what the green check-run says. This is also what made the review owed at this tier (the committed tree touches no published schema and no governed path — Governed Surface Queue Guard success).
  • PR title fix(objectql)!: against changeset summary feat(objectql)!: — both carry the bang; the type word differs. Cosmetic; no gate reads it.

③ Boundary flags

  1. H2, INVALID_FIELD vs INVALID_QUERY (open question 1, shipped A) — ANSWERED, right; ①.10.

  2. The searchAll skip input (open question 2, shipped A; the one deviation from the claim's "docblock only") — ANSWERED, right and necessary. Without it the refusal would have failed the whole sweep for an internal-only object, a worse regression than the one fixed; the one-line alignment keeps a single answer to "what the engine scans" and keeps the palette 200. The seat re-declared the surface for exactly this line (claim 6249716675), so it is inside the claim at this head. The report's out_of_scope_findings entry — the pre-change palette served an internal-only object's first rows as hits — is REAL at the merge base (the skip judged over fields including internal ones; the engine then excluded them, expanded to nothing, and find returned unfiltered rows) and is correctly fixed in place rather than filed.

  3. The console pointer (open question 3; consumer_pointer) — TRUE in substance at the pin it cites, with one stale fact. Read at objectui 5e15928b: ListView.tsx 1607 showSearch: ua?.search !== false && !searchlessViewOnScreen (the box is shown unless userActions.search === false; nothing consults the resolved set — true); useRecordQuery.ts 200 sets params.$search; data-objectstack/src/index.ts 4925–4926 maps $search to the search query parameter of GET /api/v1/data/OBJECT (so the wire spelling the pointer gives is right); ObjectGrid.tsx 2944 sends $search and, at 5888–5890, derives searchEnabled from schema.searchableFields when declared, else showSearch ?? true — so a junction object with no declaration still offers search there too; the pointer's "never consults the resolved search set" is attributed to ListView and is true of it. The stale fact: the pointer says "pinned .objectui-sha 5e15928b", but the head under review pins 0707b4ae37120d45d6401170666a0b76cb7a3546 (main moved the pin in chore(objectui): move the console pin from 5e15928b4631 to 0707b4ae3712, the newest green objectui main #24311, f5e98a35d1, after the dev's last merge; the dev's fd4e073a6c still pinned 5e15928b). Not blocking — no objectui edit is in this PR and the pointer's prescription (hide quick-search and picker type-ahead when resolveSearchFieldResolution over the schema, internal fields excluded, is empty; else render the refusal) does not depend on the pin — but the adopting seat routes it naming the current pin and re-reads the four files there.

  4. The export door, a correction to the PR body's H3 table — not blocking. (a) The import-template row is WRONG: in template mode (?template=true on the export path) search is in TEMPLATE_INAPPLICABLE_PARAMS (import-template.ts 292–294) and readTemplateMode answers refused → 400 VALIDATION_ERROR before any row is read (data-action-routes.ts 872–875). That door never reaches the engine and is unchanged by this PR; it does not "surface the refusal". (b) The export row is right for format=csv and only loosely right for json/xlsx: the route sets its headers, writes [ for json (1109) and opens the xlsx stream (1110) BEFORE the first findData of the loop (1143), and its catch (1255–1259) says in its own words that once headers are sent "the client receives a truncated stream which signals failure". So a junction-object ?search= export flips from "200, every row" to: a 400 INVALID_FIELD envelope via handleRouteError on csv (headers set, not flushed), and a committed 200 followed by a truncated stream on json (xlsx depends on whether the stream writes on open; not traced). This is the route's pre-existing posture for EVERY refusal findData throws inside the loop (REST 读路径:searchFields / groupBy / aggregations 指向不存在的字段时被静默降级(#4226 收口后剩下的三条轴) #4254's own searchFields refusal, a filter refusal, a 403) and is loud rather than silent, so it meets the card's Expected; but it is not the 400-with-remedy the body claims for that door. ESCALATED as a one-line card candidate for the owning lane (dedupe words: export first chunk before headers; export refusal truncated stream): defer the first write until the first findData returns. The dev's own "NOT MEASURED over HTTP" flag on export was honest.

  5. Message precision — not blocking, two notes for a later pass. (a) The auto branch says "none of its fields is a type the default search scans" when the set is empty; an object whose only text column is hidden: true, or carries a name in SEARCH_AUTO_EXCLUDED_FIELDS (space, company_id, …), is refused with that sentence although the TYPE is scannable — the field is excluded by flag or by name. (b) The ('internal: true' fields are never searched) parenthetical is keyed on "the object declares any internal field", not on "an internal field of a scannable type was left out", so it appears for an internal lookup too. Both are wording, decided from the declaration, and prescribe the right remedy.

  6. Error catalog — docs pointer, not blocking. INVALID_FIELD's Cause text (error-catalog.mdx 144–161) lists the searchFields refusals but not "a search over an object with no searchable field". The Flag docs affected by code changes run is success and the claim kept content/docs/** out of scope; the adopting seat hands domain:devx one sentence for that entry.

  7. The remaining deviations — process, not contract. The full suites ran on bead2f2a4a and only build, typecheck, the pin file and the ratchet/error/changeset families on fd4e073a6c; this head 07f0f4975f is a further relay merge of main whose diff against the merge base is identical to the report's listing, so the in-progress check-runs are the verdict on it (preamble). The first pin run outside the verify lock, the detached shared checkout and the trailer choice are lane hygiene already named in the report.

  8. Reviewer's own flags, none blocking. (a) The refusal, like its sibling declaration gates, runs before the security middlewares: a caller with no read right on a junction object now gets this 400 where it previously got the permission answer after a search that did nothing; what it learns is the object's declaration state, which the describe door already serves. (b) check:dispatcher-error-vocabulary and check:error-status-conformance are hosted by the in-progress Lint · docs & code guards run; the new site stamps a StandardErrorCode member with a declared 400, the shape both gates already accept from the door's own refusals, so no new registration is expected — the run decides. (c) The two internal-flag readers (objectql's and core's) are kept in lockstep by docblock promise; the only test naming the core one is the write-response tripwire. The skip now depends on their agreement for the palette's 200; a one-line pin asserting the two verdicts equal over a fixture would make that dependency a red test instead of a sentence.

Implemented-by: claude/issue-24284-search-empty-set-refused
Reviewed-by: session_01JtSQQ3EWsbNPzyYMug1tXy

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-18T12:40Z · ACCEPT

Head: 07f0f4975f, the seat's relay merge of main (pr_update_branch, clean) over the dev's fd4e073a6c. The merge brings in PR #24307's engine.ts region, landed as 1bf5f6e17c after the dev's last merge. Card #24284; the claim is re-declared as 6249716675. Dev report: 6249664223.

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.
    1. INVALID_FIELD: the cause is the object's field declaration, and INVALID_QUERY is reserved for shape mistakes.
    2. The searchAll skip judges the engine's scan set.
    3. The console should hide search on such an object, or render the refusal.
  • Deviations: all accepted.
    • The searchAll input 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.
  • 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 its json / xlsx stream before the first read, so any refusal in its loop ends a committed 200 with a truncated stream (③.4b, read in code, pre-existing);
    • to domain:devx: one sentence in error-catalog.mdx's INVALID_FIELD cause (③.6);
    • to objectui: the consumer pointer, re-read at the head's .objectui-sha 0707b4ae37 (③.3).
  • 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

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 18, 2026 12:41
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 18, 2026 12:41
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 18, 2026
Merged via the queue into main with commit ec749fb Oct 18, 2026
48 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24284-search-empty-set-refused branch October 18, 2026 13:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants