From 9fdc378a5997c1a4362b9c0af782dae8f7d67ccc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 15:27:49 +0000 Subject: [PATCH 1/2] docs(search): name the empty-search-set refusal where the data read pages describe search A non-blank search over an object whose resolved search set is empty is refused 400 INVALID_FIELD by the engine (expandSearchOnAst), on find, findOne, count and aggregate. Six hand-written pages described search without it: error-handling's read-axis parenthesis, data-api's search row, request/result table and bullet, queries' Full-Text Search, query-syntax's internal-caller sentence, schema-design's Global search, and views' toolbar-search runtime table. Claude-Session: https://claude.ai/code/session_01FM29ZqLTrnPNXxijioZwMp Co-authored-by: Claude --- content/docs/api/data-api.mdx | 9 ++++++--- content/docs/data-modeling/queries.mdx | 5 ++++- content/docs/data-modeling/schema-design.mdx | 5 ++++- content/docs/protocol/kernel/error-handling.mdx | 4 ++-- content/docs/protocol/objectql/query-syntax.mdx | 8 ++++++-- content/docs/ui/views.mdx | 2 +- 6 files changed, 23 insertions(+), 10 deletions(-) diff --git a/content/docs/api/data-api.mdx b/content/docs/api/data-api.mdx index 4371fe89f57..0b3cf2f6dc1 100644 --- a/content/docs/api/data-api.mdx +++ b/content/docs/api/data-api.mdx @@ -23,7 +23,7 @@ Query records with filtering, sorting, selection, and pagination. | `top` | query | Max records to return. No default — omitting it returns all matching records. | | `skip` | query | Offset | | `expand` | query | Comma-separated list of relations to eager-load. Must name a reference field (`lookup` / `master_detail` / `user` / `tree`) — otherwise `400 INVALID_FIELD`. | -| `search` | query | Full-text search term, scanned case-insensitively across the object's searchable fields (its declared `searchableFields`, or a text-like auto-default when none are declared). | +| `search` | query | Full-text search term, scanned case-insensitively across the object's searchable fields (its declared `searchableFields`, or a text-like auto-default when none are declared). When that set is empty — no `searchableFields` entry names a stored field, and the auto-default finds none — a non-blank term is `400 INVALID_FIELD`, never every row. | | `searchFields` | query | Comma-separated subset of the searchable fields for `search` to scan — narrows the scan, never widens it. A name outside the searchable set is `400 INVALID_FIELD`. | > **Note:** OData-style `$`-prefixed parameters (`$filter`, `$select`, `$orderby`, `$top`, `$skip`, `$expand`, `$count`, `$search`) are also accepted directly on this same endpoint as aliases — they're normalized internally to the parameter names above. There is no separate standalone OData endpoint. @@ -156,14 +156,17 @@ corrupts something the earlier axes do not: | `?search=alpha&searchFields=no_such_field` | `400 INVALID_FIELD` | | `?search=alpha&searchFields=amount` | `400 INVALID_FIELD` — real field, but not searchable | | `?search=alpha&searchFields=project_id.name` | `400 INVALID_FIELD` — search scans this object's own columns; mirror the related title instead (see below) | +| `?search=alpha` on an object with no searchable field (two lookups and a `formula` title, say) | `400 INVALID_FIELD` — nothing to scan; declare a stored field searchable or mirror the title (see below) | | `groupBy: ["status"]` | one bucket per status value | | `groupBy: ["no_such_field"]` | `400 INVALID_FIELD` | | `aggregations: [{function:"sum", field:"amount", alias:"total"}]` | the real total | | `aggregations: [{function:"sum", field:"no_such_field", alias:"total"}]` | `400 INVALID_FIELD` | | `aggregations: [{function:"count", alias:"n"}]` | `count(*)` — the one legitimate field-less form | -- **`searchFields`** — the only parameter whose failure changed which **rows** - came back. An unknown name used to be dropped, and an override left empty +- **`search`** and **`searchFields`** — the only parameters whose failure + changed which **rows** came back. A `search` over an object with nothing to + scan added no predicate, so every row came back as if every row matched. An + unknown `searchFields` name used to be dropped, and an override left empty fell back to scanning *every* searchable column: a parameter that exists only to **narrow** a search failed by **widening** it. Three causes get three messages, because the fixes differ: a name that is no field (a typo in diff --git a/content/docs/data-modeling/queries.mdx b/content/docs/data-modeling/queries.mdx index 13d50f04a18..18aaa71a68c 100644 --- a/content/docs/data-modeling/queries.mdx +++ b/content/docs/data-modeling/queries.mdx @@ -477,7 +477,10 @@ dotted `fields` path (`'customer.name'`) for a single related column: `search` takes the query text; the server resolves which fields to search from object metadata (ADR-0061). Pass `searchFields` to narrow that set — it is intersected with what -the object allows, so it can only narrow, never widen. +the object allows, so it can only narrow, never widen. When the object allows nothing — +no `searchableFields` entry names a stored field, and the default search scans none +(`internal: true` fields never count) — a non-blank `search` is refused with +`400 INVALID_FIELD` instead of answering every row, engine calls included. ```typescript { diff --git a/content/docs/data-modeling/schema-design.mdx b/content/docs/data-modeling/schema-design.mdx index 7a78034b429..96e53579e3b 100644 --- a/content/docs/data-modeling/schema-design.mdx +++ b/content/docs/data-modeling/schema-design.mdx @@ -102,7 +102,10 @@ To control exactly which fields are matched, set `searchableFields` on the objec Queries then pass a top-level `$search` parameter to match across `searchableFields` (e.g. `{ $search: 'acme' }`); a request may narrow the set with `$searchFields`. When `searchableFields` is unset, search falls back to the -name/title field plus short-text fields. +name/title field plus short-text fields. When neither leaves a stored field to scan — a +junction object of lookups with a `formula` title, say — a list search is refused +`400 INVALID_FIELD` rather than answered with every row, and global search skips the +object; mirror the title onto a stored text field, as below. #### Searching by a related record's title — mirror the value diff --git a/content/docs/protocol/kernel/error-handling.mdx b/content/docs/protocol/kernel/error-handling.mdx index a99b48c2656..48e6ec8575d 100644 --- a/content/docs/protocol/kernel/error-handling.mdx +++ b/content/docs/protocol/kernel/error-handling.mdx @@ -624,8 +624,8 @@ knows which object was addressed. The [error catalog's `INVALID_FIELD` entry](/docs/api/error-catalog#invalid_field) carries the authoritative cause text — it enumerates every read axis this one code answers on -(`select`, `expand`, `searchFields`, `groupBy`, `aggregations[].field`) and the -off-request `backfillSummaryNulls` case. +(`select`, `expand`, `search`, `searchFields`, `groupBy`, `aggregations[].field`) and +the off-request `backfillSummaryNulls` case. ### Resource Errors diff --git a/content/docs/protocol/objectql/query-syntax.mdx b/content/docs/protocol/objectql/query-syntax.mdx index a51ff829eba..e1fac11484b 100644 --- a/content/docs/protocol/objectql/query-syntax.mdx +++ b/content/docs/protocol/objectql/query-syntax.mdx @@ -1124,8 +1124,12 @@ plus short-text/enum fields), so naming a field outside that set can never widen search — and over the REST/protocol ingress it is `400 INVALID_FIELD` outright (#4254), because the engine-side intersection alone used to drop the unknown name and fall back to scanning the full searchable set. Internal callers reaching `engine.find()` directly keep -the tolerant intersection. Multiple whitespace-separated terms are AND-ed and -fields are OR-ed. Case sensitivity comes from the operator the expansion emits, not +the tolerant intersection, but not a search with nothing to scan: when no +`searchableFields` entry names a stored field and the auto-default finds none +(`internal: true` fields never count), the engine itself refuses a non-blank `search` +with `400 INVALID_FIELD` (`param: 'search'`) on `find`, `findOne`, `count` and +`aggregate` alike, rather than answering every row. Multiple whitespace-separated +terms are AND-ed and fields are OR-ed. Case sensitivity comes from the operator the expansion emits, not from the expansion: it emits `$icontains`, which ignores **ASCII case** by the rule in [Case Sensitivity](#case-sensitivity) above. So a user typing `acme` does find `ACME Corp` — search is case-insensitive by ruling diff --git a/content/docs/ui/views.mdx b/content/docs/ui/views.mdx index 8e81e5c3bfe..46c4596891b 100644 --- a/content/docs/ui/views.mdx +++ b/content/docs/ui/views.mdx @@ -154,7 +154,7 @@ all. | What you write on the view | `os validate` | Toolbar search at runtime | | :--- | :--- | :--- | | a subset of the allowed set | clean | scans exactly those columns | -| key omitted, or `searchableFields: []` | clean | scans the object's full allowed set | +| key omitted, or `searchableFields: []` | clean | scans the object's full allowed set — `400 INVALID_FIELD` when that set is empty | | a renamed / mistyped column, or a dotted path | `searchable-field-unknown` | `400 INVALID_FIELD` | | a real column outside the allowed set | `searchable-field-unsearchable` | `400 INVALID_FIELD` | | a virtual `formula` column — no stored column to scan (#6674) | `searchable-field-unsearchable` | `400 INVALID_FIELD` | From 093a016275f05a261f6cfa84bf4cac92b3b289f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 15:40:31 +0000 Subject: [PATCH 2/2] docs(search): keep the untouched neighbour lines byte-identical Re-wrap the two edits so error-handling's parenthesis and query-syntax's paragraph change only the lines that carry the new text. Claude-Session: https://claude.ai/code/session_01FM29ZqLTrnPNXxijioZwMp Co-authored-by: Claude --- content/docs/protocol/kernel/error-handling.mdx | 4 ++-- content/docs/protocol/objectql/query-syntax.mdx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/content/docs/protocol/kernel/error-handling.mdx b/content/docs/protocol/kernel/error-handling.mdx index 48e6ec8575d..8d84775eb00 100644 --- a/content/docs/protocol/kernel/error-handling.mdx +++ b/content/docs/protocol/kernel/error-handling.mdx @@ -624,8 +624,8 @@ knows which object was addressed. The [error catalog's `INVALID_FIELD` entry](/docs/api/error-catalog#invalid_field) carries the authoritative cause text — it enumerates every read axis this one code answers on -(`select`, `expand`, `search`, `searchFields`, `groupBy`, `aggregations[].field`) and -the off-request `backfillSummaryNulls` case. +(`select`, `expand`, `search`, `searchFields`, `groupBy`, `aggregations[].field`) and the +off-request `backfillSummaryNulls` case. ### Resource Errors diff --git a/content/docs/protocol/objectql/query-syntax.mdx b/content/docs/protocol/objectql/query-syntax.mdx index e1fac11484b..ca79cdfdeb8 100644 --- a/content/docs/protocol/objectql/query-syntax.mdx +++ b/content/docs/protocol/objectql/query-syntax.mdx @@ -1128,8 +1128,8 @@ the tolerant intersection, but not a search with nothing to scan: when no `searchableFields` entry names a stored field and the auto-default finds none (`internal: true` fields never count), the engine itself refuses a non-blank `search` with `400 INVALID_FIELD` (`param: 'search'`) on `find`, `findOne`, `count` and -`aggregate` alike, rather than answering every row. Multiple whitespace-separated -terms are AND-ed and fields are OR-ed. Case sensitivity comes from the operator the expansion emits, not +`aggregate` alike, rather than answering every row. Multiple whitespace-separated terms are AND-ed and +fields are OR-ed. Case sensitivity comes from the operator the expansion emits, not from the expansion: it emits `$icontains`, which ignores **ASCII case** by the rule in [Case Sensitivity](#case-sensitivity) above. So a user typing `acme` does find `ACME Corp` — search is case-insensitive by ruling