Skip to content

fix(objectql)!: the in-process engine refuses a search-field override that names no field, as the doors and the shared declarations do - #28108

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-2i41wk
Oct 30, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-2i41wk

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Elon · project thread

Fixes #27732

Clause-②: no (narrowing) — the engine refuses the empty override the shared declaration already refuses (triage 6519918549)
Metadata-protocol: none
Skills-docs: updated here — content/docs/protocol/objectql/query-syntax.mdx (the search section) says the engine refuses an override that names no field, 400 VALIDATION_ERROR, as at the ingress. skills/** needs no change: git grep -n "searchFields\|search\.fields" -- skills at this head finds only non-empty overrides (skills/objectstack-query/SKILL.md § Full-Text Search, its key tables at lines 25-26, its eval, and skills/objectstack-data/SKILL.md:125), and no skill emits or recommends an empty one. content/docs/api/data-api.mdx already states the door's refusal.

维护者速读

What changes

ObjectQL.expandSearchOnAst is the one search expander find, findOne, count and aggregate reach (the last two through expandSearchOnOptions). It now opens by judging the two override slots, before the expansion and so before any driver read:

Read main This head
find/findOne/count/aggregate with search: 'harbour', searchFields: [] the whole default search set scanned, no error 400 VALIDATION_ERROR, param: 'searchFields', no driver read
the same with search: { query: 'harbour', fields: [] } (item 1) the whole default set refused the same way, param: 'search.fields'
the same with searchFields: ' , ' (the GET transport spelling, which reaches the engine as sent) the whole default set refused, param: 'searchFields'
searchFields: [] with no search term ignored refused: the door judges it whether or not a term rides along, and so does the engine now
a system-context caller the whole default set refused: the rule is about the shape, not the caller
override absent, or null default set unchanged
searchFields: ['name'] / search.fields: ['name'] narrows unchanged
  • The declarations' words, not a copy. The message ends with the issue message of FullTextSearchSchema.shape.fields.safeParse([]), which is SEARCH_FIELDS_NAMES_NO_FIELD ("Name at least one field, or leave the key out to search the default search fields"), the same words QuerySchema.searchFields refuses with. The test asserts the two schemas agree. No spec change.
  • The doors' answer. status: 400, code: 'VALIDATION_ERROR', the envelope assertSearchFieldsAreSearchable throws at the door for the same value, so mapDataError serves both callers the same status and code. The test pins it against findData on the same engine.
  • What counts as naming no field is the door's own derivation: a string is split on commas, trimmed and emptied of blanks; an array is taken as sent. Any other value keeps the expansion's existing handling (not this card).
  • Both slots are judged. The door reads searchFields and falls back to search.fields; the declarations refuse each on its own. The engine refuses each on its own too, so it never admits a value either declaration refuses.
  • Shape. The objectql: the in-process engine reads fields: [] as every column while every published projection declaration refuses an empty projection (found on #26954) #27008 engine stage put its refusal in a sibling module. This one is a module-private function in engine.ts (+40/−1), because the claim's file surface names engine.ts and the one expander already sits there.
  • One pin flipped by the narrowing, not a fix-to-green. engine-search-empty-set.test.ts ("an explicit searchFields narrowing does not rescue it") listed [] among the overrides that still reach the empty-search-set refusal (INVALID_FIELD, param: 'search'). An empty override no longer reaches the expansion: it is refused first, VALIDATION_ERROR. The [] entry is dropped there, with a pointer to the new file; the three named overrides it keeps still pin the empty-set refusal.

Item 2, the comma-string asymmetry: by design, not changed

Triage 6519918549: "The comma string is the query-string transport encoding of the GET form; a JSON body carries the declared array. That is by design." POST /data/:object/query keeps refusing a string searchFields at the body schema, and the GET form keeps accepting the comma string. This PR widens nothing.

Not here: the engine-side declarations (#28059)

EngineQueryOptionsSchema.searchFields, EngineAggregateOptionsSchema.searchFields and EngineCountOptionsSchema.searchFields (packages/spec/src/data/data-engine.zod.ts lines 190, 514, 559) still declare z.array(z.string()) with no minimum. Flipping them is a packages/spec edit, outside this claim's file surface, so the seat filed it as #28059 for the spec lane. Until it lands those three declarations admit [] while the engine refuses it; their search members already refuse search.fields: [] through FullTextSearchSchema.

Tests

New packages/objectql/src/engine-empty-search-fields-refusal.test.ts, 24 cases, on a real ObjectQL over a driver double that evaluates $or / $icontains and records each read. harbour hits name on two rows and city on a third, so a narrowing is visible in the answer.

  • Per verb (find, findOne, count, aggregate): searchFields: [], search: { query, fields: [] } and searchFields: ' , ' are each refused with status 400, code VALIDATION_ERROR and the named param, the declaration's words in the message, and zero driver reads.
  • Controls per verb: a named override narrows (find answers a1, a2, count 2, aggregate east 1 / west 1); an absent override searches the default set (a1, a2, a3, 3, east 2 / west 1).
  • An empty override with no search term is refused; a system-context read is refused; the REST door (findData with searchFields: '') and the engine map to the same status and code; searchFields: null searches the default set.
Reproduction on main, red/green, ablation

Step 1 of the order, a probe on origin/main bc496bf7 with this file's driver double (every row the same on all four verbs):

find      searchFields: []                    -> ["a1","a2","a3"] · driver where $or over name, city, region
find      search: {query, fields: []}         -> ["a1","a2","a3"] · same
find      searchFields: ' , '                 -> ["a1","a2","a3"] · same
find      (no override, control)              -> ["a1","a2","a3"] · same
findOne   each empty override                 -> "a1", the default-set where
count     each empty override                 -> 3, the default-set where
aggregate each empty override                 -> {"east":2,"west":1}, the default-set where

Red leg, the new file against the main engine: Tests 15 failed | 9 passed (24), every refusal case "expected a refusal, got an answer"; the nine controls green. This head: Tests 24 passed (24).

Ablation, two legs, through scripts/ablation-replace.mjs (the test imports ./engine.js, which vitest resolves to src/, so the mutation is on the resolved source):

  • Leg A, the searchFields call replaced by a marker: anchor 1 → 0, blob b26bca3e → 4903e59e. Tests 11 failed | 13 passed (24): the searchFields: [] and comma-string cases on all four verbs, no-term, system-context and REST parity. Restored: blob == HEAD b26bca3e, git diff HEAD empty.
  • Leg B, the search.fields call replaced by a marker: anchor 1 → 0, blob b26bca3e → 3cef55e0. Tests 4 failed | 20 passed (24): the search: { query, fields: [] } case on all four verbs. Restored the same way.

Acceptance notes

  • Caller census, at this head. Non-test producers that hand the engine searchFields: the global search sweep (protocol-record-reads.ts, passes it only when the queryable set is narrower and skips an object left with none, so never empty); the REST export (data-action-routes.ts, keeps an empty list so findData refuses it, which it already did); findData's stored-metadata narrowing (returns a non-empty list or refuses). None passes an empty override.
  • In-process seam, noted. refuseOrNarrowStoredMetadataEvaluate in packages/runtime/src/stored-metadata-reader-seam.ts runs narrowStoredMetadataSearch before the engine. For a stored-metadata object, an in-process searchFields: [] with a search term is replaced there by the narrowed default set, so it reaches the engine non-empty and is not refused. That is the pre-existing "empty is absent" reading one layer up, on one object family, outside this file surface. narrowStoredMetadataSearch's docblock ("an empty override is ABSENT to the engine") is stale after this PR. Carrier: seat.
  • Not changed. A non-empty override whose every name is outside the searchable set still falls back to the allowed set in process (resolveSearchFields's tolerance; the doors refuse it INVALID_FIELD). That is the search twin of objectql: an in-process projection whose every column is unknown is answered with every column (REST refuses it 400 INVALID_FIELD) #27165's all-unknown projection and is not this card.

Verification

All on head a43bf0a9 unless named.

  • Derived gates. node scripts/pm/dispatch-gates.mjs --commands (first stderr line names objectstack-ai/objectstack at the head) derived 93 families; each exit code recorded before any pipe. 93/93 exit 0. --ran: "93 derived, 93 run, 0 NOT-MEASURED, 0 UNRUN" (exit 0). A whole-tree turbo run build (exit 0) ran first, so no family hit a missing dist/.
  • First pass, on bdbb4799. 92/93: check:query-options-erasure exit 1, "test surface grew 236 → 240", the four as any bags in the new test's run() helper. Fixed in a43bf0a9 by naming the bypassed contract (as unknown as EngineQueryOptions / EngineCountOptions / EngineAggregateOptions, the form the gate asks for); the ratchet reads 236, at the ceiling. The second pass above is the full 93 again.
  • pnpm lint (eslint . --no-inline-config): exit 0 on a43bf0a9.
  • @objectstack/objectql. typecheck (tsc plus check:test-typecheck) exit 0 on a43bf0a9. test under the verify lock on bdbb4799: Test Files 494 passed (494), Tests 9616 passed (9616). The only later change is the four type assertions in the new test file; that file and engine-search-empty-set.test.ts re-ran on a43bf0a9: 35/35.
  • Dogfood battery (pnpm --filter @objectstack/dogfood test under the lock, after the whole-tree build) on bdbb4799: VERDICT command-exit 1, Test Files 1 failed | 357 passed | 3 skipped (361), Tests 1 failed | 2880 passed | 20 skipped (2901). The one red is main-red main red (Dogfood Regression Gate 3/3) since 3e0fd7a8 (#27964): the api-action door dogfood pin still expects sys_metadata_activation in the disabled-action sentence that #27842 rewrote #28082, pre-existing, not this diff: test/api-action-door-forward.dogfood.test.ts:178 still expects sys_metadata_activation in the ACTION_DISABLED message that PR feat(spec,metadata-protocol,rest,objectql,runtime): GET /meta/object serves switched-off packaged actions beside the item, and the disabled-action refusal reads as a plain sentence #27964 rewrote; it is the same on origin/main 6b125a7a. a43bf0a9 changes only type assertions in a unit test file, which the dogfood battery does not import.
  • Not measured here: CI's own lanes (Test Core with test:repo, the type-check lanes, Build Docs); this PR's check-runs answer them.
Serial

Generated by Claude Code

… that names no field

find, findOne, count and aggregate read an empty searchFields, an empty
object-form search.fields, or a comma string with no name in it as no
override, and scanned the object's whole default search set. The Data API
doors refuse that value with 400 VALIDATION_ERROR and the shared
declarations refuse it too; the engine now refuses it the same way, in the
declaration's own words, before any driver read.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4cyzHgmAWpXVdUctRKU
…override naming no field

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4cyzHgmAWpXVdUctRKU
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql, touching 5 documentable anchor(s).

19 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 59547058aeb3eccde6f267293367dabf5ef82e59.

⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • the SDK route bridge reached 58 of 210 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 — 20 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 59547058aeb3eccde6f267293367dabf5ef82e59 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 2ee7f2c41017aa1b5e018434cb0dba64d6a1c0ae — the merge of head a43bf0a98999f25655ccd18cf4db62842456aead into base 59547058aeb3eccde6f267293367dabf5ef82e59, 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 2ee7f2c41017aa1b5e018434cb0dba64d6a1c0ae && git checkout 2ee7f2c41017aa1b5e018434cb0dba64d6a1c0ae
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 59547058aeb3eccde6f267293367dabf5ef82e59 a43bf0a98999f25655ccd18cf4db62842456aead && git checkout -B drift-repro 59547058aeb3eccde6f267293367dabf5ef82e59 && git merge --no-ff a43bf0a98999f25655ccd18cf4db62842456aead

node scripts/docs-audit/affected-docs.mjs --json 59547058aeb3eccde6f267293367dabf5ef82e59

⚠️ 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 59547058aeb3eccde6f267293367dabf5ef82e59 → 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

domain:server#1 · os-elon-musk · session_016WV4czVnUdQQCVKSW5J2uk

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: a43bf0a98999f25655ccd18cf4db62842456aead
Local-runs: none
Scope: packages/objectql/src/engine.ts, packages/objectql/src/engine-empty-search-fields-refusal.test.ts, packages/objectql/src/engine-search-empty-set.test.ts, .changeset/27732-engine-empty-search-fields.md + packages/spec/src/data/search-fields-names-a-field.ts, packages/spec/src/data/query.zod.ts (QuerySchema.searchFields, FullTextSearchSchema.fields), packages/spec/src/data/data-engine.zod.ts (the three Engine*OptionsSchema.searchFields, lines 190/514/559), packages/spec/src/data/search-fields.ts (resolveSearchFields), packages/metadata-protocol/src/protocol-query-validation.ts (assertSearchFieldsAreSearchable), packages/metadata-protocol/src/protocol.ts (findData 13282-13299, narrowStoredMetadataSearch 5194), packages/runtime/src/stored-metadata-reader-seam.ts (refuseOrNarrowStoredMetadataEvaluate), packages/metadata-protocol/src/protocol-record-reads.ts:540-560, packages/rest/src/data-action-routes.ts:1290-1300,1507, content/docs/protocol/objectql/query-syntax.mdx (the prose face, read to judge the sentence, not reviewed), docs/adr/0087-metadata-protocol-upgrade-contract.md (the disposition vocabulary)

Read on this head: card #27732 (body and its 4 comments: 6519259214, 6519918549 triage, 6520880971 claim, 6523412292 os-dev-report), PR #28108 (body, 5-file list, net diff origin/main...a43bf0a9, 1 PR comment: the docs-drift advisory), the check-runs on a43bf0a9 at 2026-10-30T13:11Z, and the live tree (origin/main 9cbe507 and the head). Not read: the dispatch order, the seat's own conclusions.

Check-runs at my reading: complete and success — Lint & Repo Gates, Lint · ESLint, Lint · CI release & engine gates, Lint · docs & code guards, Lint · repo & tooling gates, Lint · scoped · query-options erasure, Lint · scoped · slot lookup & corpus, Lint · scoped · tooling self-tests, Lint · PM dispatch-gates, TypeScript Type Check, Type Check · source gates / debt ledger / consumer gates / workspace, Build Core, Build Docs, Check Changeset, Governed Surface Queue Guard, PR-body guards, Spec property liveness, Check Documentation Links, Check PR Size, Flag docs affected, Plan Test Core shards, Test Core (2/8), Test UI, Dogfood Regression Gate, Live E2E UI, Auto Label, filter. Still in_progress: Test Core shards 1, 3, 4, 5, 6, 7, 8. Path-skipped: the UI lanes, the dogfood shard matrix, Dogfood Verify CLI, Temporal Conformance, the opt-in harnesses. No failure among the complete ones; the seven open shards are the queue guard's to wait on, and this record says nothing about their outcome.

① Derived judgments

Accept-set changes the diff implies, each read in the code, not the PR body:

  1. ObjectQL.find / findOne / count / aggregate refuse searchFields: [] — right. refuseSearchOverrideNamingNoField (engine.ts 751-768) runs at the top of expandSearchOnAst (13434-13438), which is the one expander all four verbs reach (find 13717→expand, findOne 14047→expand, count/aggregate through expandSearchOnOptions, whose early return fires only when both keys are undefined). An array is taken as sent, so [] → names.length === 0 → throw before resolveSearchFields and before any driver call. Status 400, code VALIDATION_ERROR, param: 'searchFields': the envelope assertSearchFieldsAreSearchable throws at the door for the same value (protocol-query-validation.ts 889-897), so mapDataError serves both callers alike; the new test pins the parity against findData on the same engine.
  2. The object-form search: { query, fields: [] } is refused on its own slot (param: 'search.fields') — right, and it is the card's item 1 exactly: FullTextSearchSchema.fields is searchFieldsArray() (.min(1)), which the three Engine*OptionsSchema.search members reuse, so the engine-side declaration already refused this value while the engine tolerated it. Judged whenever raw is an object, independently of a sibling searchFields; that is stricter than the door's searchFields ?? search.fields precedence (protocol.ts 13282-13289) but identical to the declaration, which refuses fields: [] regardless of the sibling, and a POST body carrying both is refused by the body schema before the door gate. The engine never admits a value either declaration refuses: right.
  3. A comma string naming no field (' , ', '') is refused — right. The string arm is the door's own derivation (split, trim, drop blanks) and the accept set search-fields-names-a-field.ts publishes ([^\s,]). The GET transport spelling reaches the engine as sent through findData, so refusing it in process is the same rule, not a second one. A non-blank string keeps the pre-existing resolveSearchFields tolerance: not this card.
  4. Absent and null still search the default set — right. typeof null !== 'string', Array.isArray(null) is false → names === undefined → return; the control test pins it. ENGINE_FIND_OPTION_KEYS is untouched, so no option key is added or removed.
  5. Refused whether or not a search term rides along, and for a system context — right, and it is the door's own posture (protocol.ts 13278-13281: "checked whether or not a search term rode along"). A count({ searchFields: [] }) with no search reaches the expander because the early return needs both keys undefined.
  6. Non-string array members, objects, numbers — left to the expansion's existing handling. The door refuses these INVALID_FIELD; the declarations refuse them at parse. Not narrowed here, and the docblock says so. Right to leave, and correctly named as not this card.
  7. The flipped pin in engine-search-empty-set.test.ts — right. [] no longer reaches the empty-set refusal (INVALID_FIELD, param: 'search') because it is refused first; the three named overrides keep the metadata-protocol / objectql: a search term on an object with no searchable field is dropped silently, so the read answers every row (an empty auto-default search set is not refused) #24284 pin intact. This is the narrowing's own consequence, not a fix-to-green.
  8. Public surface — no export, type or option key changes; FullTextSearchSchema is newly imported into engine.ts from @objectstack/spec/data, which engine.ts already imports from. The message's closing words are read off FullTextSearchSchema.shape.fields.safeParse([]) (SEARCH_FIELDS_NAMES_NO_FIELD) rather than copied: one source. (lazySchema proxies .shape; the spec's own test at search-fields-names-a-field.test.ts:135 reads it the same way.) Right.
  9. In-repo producer census, read in the tree — no non-test producer hands the engine an empty override: protocol-record-reads.ts:551-558 passes searchFields only when the queryable subset is strictly narrower and skips an object left with none; data-action-routes.ts:1296-1300,1507 keeps the empty list so findData refuses it, which it already does since data: a blank or comma-only $searchFields is read as absent, so a search the caller narrowed runs over the default search fields instead of being refused #27484; the stored-metadata narrowing returns a non-empty list or throws. The UI (REST client) sends searchFields only when searchableFields is non-empty. Right.

② Semver level

.changeset/27732-engine-empty-search-fields.md: '@objectstack/objectql': patch, title fix(objectql)!:, a BREAKING banner, both declaration lines, and one ADR-0087 marker. Matches the diff: one released package moves (@objectstack/objectql), the change narrows a runtime accept set, and Metadata-protocol: none is right — no authorable key, value or type changes (the one authorable searchFields in the spec is the retired element:record_picker.searchFields tombstone, untouched; a view's searchableFields is not read here). AGENTS.md § Post-Task Checklist 3: a non-protocol change, widening or breaking, ships patch and keeps its banner and disposition — so patch with ! is the prescribed level, not an understatement. Check Changeset and the ADR-0087 gate (in Lint · CI, release & engine gates) are success on this head.

Clause-②: line — Clause-②: no (narrowing) — … at line start in both the PR body and the changeset, the one spelling readClause2Line reads; the arm is the first token inside the parenthesis. Correct: the diff widens nothing and narrows the engine's runtime accept set; triage 6519918549 directed exactly this spelling. ADR-0087 disposition not-required (no-migration-prescription): right — the body carries no FROM/TO table or rewrite block; "The remedy" is a one-line fix of a call, not a metadata migration, and the marker's reason names what the gate re-verifies (no key added, removed, renamed or re-shaped; no export change).

③ Boundary flags

open_questions: [] — none to answer.

Dev deviations, each answered:

Dev out_of_scope_findings, each answered:

Item 2 of the card (the comma-string asymmetry) — triage 6519918549 ruled it by design and not changed; the PR records it and widens nothing. Premise holds. No flag outstanding at the lane boundary; nothing escalated beyond the three should-fixes.

④ Design judgment

The narrowing, read against the published spec on main: QuerySchema.searchFields and FullTextSearchSchema.fields (query.zod.ts 455, 557) declare searchFieldsArray() = z.array(z.string()).min(1, SEARCH_FIELDS_NAMES_NO_FIELD) since PR #27725; QueryTransportParamsSchema.$searchFields declares the string and array arms of the same set; the door has refused the set since #27484. So the capability "pass an empty override and have it read as absent" is declared nowhere: the canonical declaration refuses it, the doors refuse it, and the only place it survived was the in-process engine's resolveSearchFields tolerance. This PR makes the spec's own refusal real at the engine; it takes nothing away that a declaration promised. The one declaration that still admits [] is the engine's own option type, Engine{Query,Aggregate,Count}OptionsSchema.searchFields (data-engine.zod.ts 190/514/559, pinned admitted by search-fields-names-a-field.test.ts:149), so for the window until #28059 lands the engine refuses what its own bag type admits — the inverse of the asymmetry the card opened on. That is the staging the card and triage chose (engine half here, spec half on #28059, claimed), the implementation gap is being closed by implementing, not by narrowing a consumer, and the search member of those same schemas already refuses fields: []. Acceptable; not a finding against this head.

Tried to break it:

  • Both slots with conflicting values (searchFields: ['name'] + search.fields: [], and the converse): engine refuses in each case; the declaration refuses each; a POST body is refused at the body schema; a GET cannot spell the object form. No door/engine divergence a caller can observe.
  • searchFields: [''] (a blank member): engine admits the array as sent (one member), door admits the shape then refuses '' as an unknown field, declaration admits it (.min(1) on length). Engine equals declaration; the residual is the pre-existing unknown-name tolerance (S3), not this rule.
  • Hook / flow / ctx.api callers that pass an empty override: none in the tree; a third-party one gets the door's answer now, which is the card's intent and the changeset's stated break.
  • Error shape under mapDataError: pinned by the test; object and param are set as the door sets them.
  • The message's last clause depends on safeParse([]) failing; it does on main and on every head that keeps .min(1); if the declaration were ever widened back the sentence would end in undefined. while the refusal stayed — a cosmetic coupling, not a behaviour one.

Findings:

Must-fix: none.

Should-fix S1 — file the runtime seam carrier (noted, not filed). packages/runtime/src/stored-metadata-reader-seam.ts:151-154 (refuseOrNarrowStoredMetadataEvaluate) calls narrowStoredMetadataSearch (protocol.ts 5194), whose explicit-list derivation reads searchFields: [] as names.length === 0 and falls into the "names none → NARROW to the allowed set" branch, returning the narrowed default set, which the seam then writes onto the query as searchFields. So on a stored-metadata object an in-process host handler's searchFields: [] with a search term reaches the engine non-empty and is served over the default set, unrefused: the "empty is absent" reading survives one layer above the engine for that object family. At the REST door the same value is refused first by assertSearchFieldsAreSearchable, so no wire caller sees it. The docblock at protocol.ts 5185-5187 ("an empty override is ABSENT to the engine, which would then scan the whole default set") is false after this PR. Both are outside this claim's surface and the dev reported them; the carrier is unnamed. File it (runtime, area:api), naming the seam's residual tolerance and the stale docblock as one card.

Should-fix S2 — one stale sentence in the spec, for #28059 to carry. packages/spec/src/data/search-fields-names-a-field.ts:29-32 says "the in-process engine still reads an empty override as absent." After this PR it does not. The file is packages/spec (a breach for this dev); #28059's claimed surface is data-engine.zod.ts alone. Ask #28059's seat to amend the surface by this sentence (it is the same accept-set module the card reuses), or file it on #28059's card as a one-line follow-on.

Should-fix S3 — file the all-unknown override tolerance. resolveSearchFields (search-fields.ts 263-273) still falls back to the whole allowed set when every requested name is outside it; the doors refuse the same value INVALID_FIELD. The dev names it the search twin of #27165 and leaves it "noted, not filed". A finding with no card is lost: file it (or name an existing carrier), with Clause-②: no (narrowing) expected.

Implemented-by: claude/project-thread-2i41wk
Reviewed-by: session_016WV4czVnUdQQCVKSW5J2uk

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-elon-musk · session_016WV4czVnUdQQCVKSW5J2uk

Seat ACCEPT · PR #28108 · head a43bf0a98999f25655ccd18cf4db62842456aead · 2026-10-30T13:28Z

维护者速读

席位核对

ACCEPT

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

Development

Successfully merging this pull request may close these issues.

objectql: the engine follows searchFields into its declared accept set (engine stage of #27683, the #27008 shape)

1 participant