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
…object whose search resolves to no field (objectui#12966) (objectstack-ai#12974) Fixes objectstack-ai#12966 Clause-②: no ## What objectstack now answers a non-blank `search` over an object whose resolved search set is empty with `400 INVALID_FIELD` (`param: 'search'`), where it used to answer every row (objectstack-ai/objectstack#24320, `ec749fbb44`). The console offered that search in three places. This PR stops offering it there, and shows the server's message where a box still shows and the search is refused. - **List toolbar (`@object-ui/plugin-list` `ListView`).** `showSearch` now also requires that the object's search resolves to at least one field. A term restored at mount (`initialSearchTerm`) is not applied on such an object, the same two-readers rule `searchlessViewOnScreen` already follows. - **Record pickers (`@object-ui/fields`).** `RecordPickerDialog` draws no search box on a searchless target, and still lists and pages the records. `LookupField`'s inline dropdown draws no type-ahead box, and still offers the records. Its options are buttons, so they stay reachable by Tab without the combobox input. - **Refusal message.** `ListView`'s load-error panel shows the server's sentence, which names the object and both remedies, when the failed request carried a term and the error's `code` is `INVALID_FIELD`. Before, it showed the generic "The server could not process this view's filter or query options" copy. The record pickers already rendered `err.message`, and so did `ObjectGrid`'s load-error panel (`loadError.message`), so neither changed. ## How "searchable" is decided The decision uses the spec's own `resolveSearchFieldResolution` (`@objectstack/spec/data`) over the inputs the engine gives it in `ObjectQL.expandSearchOnAst`: the object's field map with every `internal: true` field left out, and the OBJECT's declared `searchableFields`. No list of searchable types lives in objectui. `displayField` is not passed, because it only orders the auto-default set and never changes who is in it. The call sits in two small helpers, `objectResolvesSearch` in `packages/fields/src/widgets/searchOffer.ts` (not re-exported from the barrel) and its twin in `ListView.tsx`. Each is about twelve lines and both call the same spec resolver. I did not add a shared export, to stay inside the claimed file surface and keep `Clause-②: no`. ## Measured assumptions - **A1.** The installed `@objectstack/spec` is `18.0.0-next.3` (`node_modules/@objectstack/spec/package.json`). Its `dist/data/index.d.ts` declares `resolveSearchFieldResolution(opts: Omit of SearchFieldResolutionOptions minus requestedFields)` returning `{ allowed, source }`, the signature the engine and the ingress gate call. - **A2, per site, and what happens while the schema is unknown:** - `ListView`: the object definition comes from `dataSource.getObjectSchema` in the "Fetch object definition" effect. While it is unread, and when the data source serves none, the box is drawn, as before the card. On a searchless object the box is withdrawn when the read lands. The list is in its loading state for that read anyway, because its data fetch waits on `objectDefLoaded`. I first gated the box on the read settling, so it would never draw and then withdraw. That made the box arrive asynchronously on every list, and it broke `app-shell`'s `listViewGroupedSearch-11021` test, which types into the box right after render. That file is outside the claimed surface, so I chose "keep today's behaviour until the schema is known". The cost is a possible brief box on a searchless object, only for the length of the definition read. - `RecordPickerDialog`: the `objectSchema` prop (from `LookupField` it is `refObjectSchema`). When it is absent the box is kept. That covers `RelatedList`, `PositionHoldersSection`, `AccessExplainPanel` and `AssignedUsersSection`, which pass no schema. - `LookupField` inline dropdown: `refObjectSchema`, read at mount. The dropdown opens on a click after that, so a flash is unlikely. Until the schema is known the box is kept. - **A3.** `data-objectstack` routes every request carrying `$search` through `rawFindWithPopulate`. On a non-OK answer that path throws an `Error` whose `message` is the envelope's `error.message`, with `code` (here `INVALID_FIELD`), `status` and `httpStatus` set. `classifyLoadError` reads that as `rejected`. The message therefore reaches `ListView`, `ObjectGrid` and `useRecordQuery` unchanged, and `data-objectstack` is not touched. The envelope's `param` is not carried, so `ListView` keys on "the request carried a term" plus `code === 'INVALID_FIELD'`. ## Conflict with the order's pin (c), reported rather than chosen silently The order says a VIEW-declared `searchableFields` naming a stored field should count, "exactly as the resolver says", and that such a view on an otherwise searchless object should draw the box. The engine does not do this. The view's list is sent as the `$searchFields` override. The ingress gate `assertSearchFieldsAreSearchable` (`@objectstack/metadata-protocol`) refuses any entry outside the object's resolved allowed set, and the linter's `searchable-field-unsearchable` refuses the same view at build time. Objectstack#24320's pins also show that an explicit `searchFields` does not rescue a searchless object. A box drawn there would therefore 400 on every term. So this PR pins the resolver's declared branch at the OBJECT level instead: an object that declares `searchableFields: ['book']`, a lookup the auto-default skips, draws the box. A view-level list on a searchless object does not. The report carries this as an open question. ## Pins `packages/plugin-list/src/__tests__/ListView.searchlessObject-12966.test.tsx`: - (a) a junction object (two lookups and a formula title) draws no quick-search box; - (b) CONTROL: the same object with a text field draws it; - (c) an object declaring `searchableFields` naming a stored lookup draws it; - an `internal: true` text field does not count; - a view-level `searchableFields` does not rescue a searchless object; - a restored term reaches no fetch on a searchless object, with a CONTROL that it does on a searchable one; - (e) with no definition served the box stays, and a refused term renders the server's sentence in `list-error-state` (`data-error-kind="rejected"`), with the box still present to clear the term. CONTROL: a 400 with another code keeps the generic copy. `packages/fields/src/widgets/RecordPickerDialog.searchlessTarget-12966.test.tsx`: - (d) the dialog on a searchless target has no `record-picker-search`, lists records, and pages them (page 2 asks for `$skip: 5`), with no `$search` sent; - CONTROL: the box is kept on a text target and when the schema is unknown; - (e) the dialog renders the refusal's message in its `alert` instead of rows; - the `LookupField` dropdown on a searchless target has no `combobox` and still offers options, with a CONTROL on a text target. `packages/fields/src/widgets/searchOffer.test.ts` pins the helper: the junction, text and enum controls, the declared lookup against the declared formula, `internal`, the array field-map form, and `undefined` for an unknown schema. **Fixture triage.** `ListView.displayPopover-12423.test.tsx` served `fields: {}`, an object with no field at all, so it now resolves as searchless. That test asserts the full toolbar, including Search. I fixed it by adding a declaration: the fixture now declares `name: text`. Its assertions are unchanged. ## Ablation The fix was committed first (`3a00b9a653`). Using `node ../objectstack/scripts/ablation-replace.mjs`, `showSearch: ua?.search !== false && !searchlessViewOnScreen && !searchlessObject,` became `showSearch: ua?.search !== false && !searchlessViewOnScreen,`, today's rule. The anchor count went from 1 to 0 and the replacement count from 0 to 1. The blob went from `b59af0273ea0` to `265c67830b8a`. On the mutated source, `ListView.searchlessObject-12966.test.tsx` gave **3 failed / 6 passed**: (a), the internal-only case and the view-level case went red. The controls, the restored-term pair (it rides `searchTerm`, not `showSearch`) and (e) stayed green, which is the expected direction. Restore: the blob was `b59af0273ea0` again, equal to HEAD, and `git diff HEAD` was empty. An independent `git hash-object` after the run matched HEAD's blob too. `ListView` is imported from source, so no build was involved. ## Verification at `3a00b9a653` - Closure build `pnpm --filter '@object-ui/plugin-list^...' build`: exit 0, 13 projects. - `type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`) for `@object-ui/fields` and `@object-ui/plugin-list`: exit 0. Each package's `tsconfig.test.json` includes `src/**/*.test.ts(x)`, so the new tests are type-checked. - vitest, new and edited files: 9/9 (list) and 13/13 (fields). - vitest, the search families: every test file in `plugin-list`, `fields`, `plugin-grid` and `app-shell` that names `showSearch`, `search-icon-button`, `record-picker-search`, `searchableFields`, `$search`, `useRecordQuery` or `initialSearchTerm`. Plugin-list, fields and app-shell: 22 files and 388 tests passed after the timing change. Plugin-grid and app-shell: 15 files, all plugin-grid files passed. The 2 app-shell failures there came from the first gating, which was reverted, and the rerun above includes that file. - vitest, the whole `packages/fields/` directory: 270 files passed, 1 skipped; 4173 tests passed, 7 skipped. - vitest, the whole `packages/plugin-list/` directory: 152 files and 1540 tests passed. - `pnpm lint` narrowed to the 8 changed `.ts`/`.tsx` files (`eslint --no-inline-config --format json`): 0 errors across 8 files. The population is the package `eslint .` config. The config enables no type-aware linting (no `projectService` or `parserOptions.project` in `eslint.config.js`), so this diff cannot move any untouched file's verdict. - Each of these exited 0: `check:phantom-deps`, `self-import`, `unreferenced-sources`, `doc-example-readers`, `handler-key-reads`, `side-effects-array`, `element-data-source-declaration`, `spec-symbols`, `action-forward-parity`, `designer-field-key-parity`, `icon-record-names`, `i18n-keys`, `i18n-drift`, `i18n-designer-parity`, `control-bytes`, `check-hand-rolled-comment-mask`, `check-changeset-fixed`, `check-changeset-presence`, `check:new-line-citations` (0 new) and `check:test-path-roots`. ## Acceptance notes - `PeoplePicker` is not touched. Its target is `sys_user`, whose name and email are textual, so its set is never empty. - `ObjectGrid` standalone (a grid that owns its own toolbar search) still offers its box from the view, not from the resolver. The card scoped `ObjectGrid` to the refusal message only, and its error panel already renders `loadError.message`. From reading the code, that panel returns before the data-table that draws the grid-owned box, so a standalone grid with a refused term shows the message but has no box left to clear the term with. This is read, not measured. Carrier: none. - The changeset declares `patch` for both packages. No export, prop or type member is added. --- _Generated by [Claude Code](https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
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