Skip to content

fix(spec)!: the published searchFields declarations refuse a value naming no field, as the Data API doors do - #27725

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-odnwcq
Oct 29, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-odnwcq

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

domain:spec#3 · os-support-ai · session_01WGufzgbJhFoXYsLSe5WSPP

Requested by Yibo Wang · project thread

Fixes #27683

Clause-②: no (narrowing) — the published declarations of the search-field override (QueryTransportParamsSchema.$searchFields, QuerySchema.searchFields, FullTextSearchSchema.fields, reached through QueryWithTransportSchema and FindDataRequestSchema.query) refuse a value naming no field, which they admitted before. The doors already refused it with a 400 (#27484 / PR #27670); nothing is widened and no key is added, removed or renamed.
Metadata-protocol: none
Skills-docs: none — every place under skills/** that teaches the narrowed members still holds (git grep -n "searchFields\|search\.fields" -- skills at this head): skills/objectstack-query/SKILL.md § Full-Text Search teaches a non-empty searchFields and "Omit it to search the object's declared searchableFields", its key tables (lines 25-26) list the engine option, which this PR does not narrow, and its eval and skills/objectstack-data/SKILL.md:125 emit only named overrides. No skill emits or recommends an empty override.

Metadata-protocol: none was measured, not assumed: no metadata type embeds QuerySchema, FullTextSearchSchema or the transport bag (git grep of every *.zod.ts under packages/spec/src/{ui,automation,ai,system,kernel,security} for the three schema names and for searchFields: only prose comments in ui/component.zod.ts). The readers are request bodies, the driver and API contracts, and codegen.

PR #27670 (#27484) merged at 86f2afeb before this branch started, so there is no landing order to name; its predicate was measured on main.

维护者速读

  • 改了什么:已发布的三处 searchFields 声明(QueryTransportParamsSchema.$searchFields、QuerySchema.searchFields、FullTextSearchSchema.fields)不再接受“一个字段都没写”的值(空串、只有逗号和空格、空数组、对象形 search.fields: []),三处描述带同一句接受集说明;POST /data/:object/query 因此在请求体 schema 处拒收,回 400 VALIDATION_FAILED(原为门内的 VALIDATION_ERROR),与 select: [] 一致。
  • 为什么改:数据接口自 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 起已拒收这些值,但发布的 Zod 与 JSON Schema 仍声明接受,按 schema 生成的客户端和 AI 会写出被门拒收的请求;select 的同类声明早已收紧(spec: QueryTransportParamsSchema.select / $select and GetDataRequestSchema.select admit an empty projection that the findData and getData doors refuse (sibling of #26817) #26909)。
  • 风险与回滚:只收窄声明,接受集与门完全一致(有逐值对照测试);POST /query 的错误码从 VALIDATION_ERROR 变 VALIDATION_FAILED,仍是 400、仍不读库。引擎侧声明不动(下一阶段卡)。回滚即还原本 PR。
  • 先看:packages/spec/src/data/search-fields-names-a-field.ts(接受集与两条臂),packages/rest/src/data-empty-search-fields-door.test.ts(POST 行迁到 schema 拒收表、末尾 parity 用例),packages/spec/src/data/query.zod.ts(两处成员)。
  • 两分钟验证:pnpm --filter @objectstack/spec exec vitest run src/data/search-fields-names-a-field.test.ts,应见 39 个用例全绿。
  • 需要你做的:无,席位落地(进队前欠一次契约复核)。

What changes

Measured first, on origin/main bfdaba43 (#27670 merged)

Each declaration parsed with the four spellings of "names no field", next to what the protocol door (assertSearchFieldsAreSearchable, the gate findData calls for the standalone slot and for the object-form search.fields) answers for the same value. The door column is the registry-less host, so it is the shape rule alone; a registered object answers the same for these values, because the empty check runs before the field-map lookup.

value $searchFields QuerySchema.searchFields FullTextSearchSchema.fields FindDataRequestSchema query.$searchFields / query.searchFields / query.search.fields EngineQueryOptionsSchema / EngineAggregateOptionsSchema / EngineCountOptionsSchema .searchFields protocol door (standalone / search.fields)
'' accept string refused (array-only) n/a accept / string refused / n/a n/a (array-only) 400 VALIDATION_ERROR / 400 VALIDATION_ERROR
' , ' accept string refused n/a accept / string refused / n/a n/a 400 VALIDATION_ERROR / 400 VALIDATION_ERROR
[] accept accept accept accept / accept / accept accept / accept / accept 400 VALIDATION_ERROR / 400 VALIDATION_ERROR
search: { fields: [] } (see FullTextSearchSchema.fields) accept accept accept (via the shared FullTextSearchSchema) 400 VALIDATION_ERROR
[''] (control) accept accept accept accept accept accepted as a value; refused as an unknown field once the object is known
'name' / ['name'] (control) accept accept (array) accept accept accept accepted

The door's predicate, read at packages/metadata-protocol/src/protocol-query-validation.ts on main: a string is split(','), trimmed and emptied of blank members; an array is kept as sent; the override is refused as naming no field iff that leaves zero names. That is the set used here, exactly.

After, at this PR's head

value $searchFields QuerySchema.searchFields FullTextSearchSchema.fields FindDataRequestSchema (all three slots) engine-side declarations protocol door
'', ' ', ',', ' , ' refused (invalid_format, "Name at least one field, or leave the key out to search the default search fields") string refused, as before n/a refused at query.$searchFields unchanged unchanged (400 VALIDATION_ERROR)
[] refused (too_small, same sentence) refused refused refused at query.searchFields / query.$searchFields / query.search.fields searchFields unchanged (still admits []); search.fields: [] refused through the shared FullTextSearchSchema unchanged
[''], 'name', ' name , email ', ['name'] accept accept accept accept accept unchanged

The declarations (packages/spec/src/data/)

  • New search-fields-names-a-field.ts, outside the data barrel (the projection-names-a-field.ts pattern): the refusal sentence SEARCH_FIELDS_NAMES_NO_FIELD, the shared accept-set sentence SEARCH_FIELDS_ACCEPT_SET ("at least one field name; a value naming no field (blank, only commas and spaces, or an empty list) is refused"), the string arm (regex(/[^\s,]/): holds iff some comma segment has a non-whitespace character, \s being the set trim() strips) and the array arm (min(1)). Its own module because query.zod.ts cannot import data-engine.zod.ts. Both are a pattern and a minimum rather than refinements, so the published JSON Schema states them (pattern, minItems), and dropped-refinements.baseline.json does not move.
  • QueryTransportParamsSchema.$searchFields (data-engine.zod.ts): z.union([searchFieldsString(), searchFieldsArray()]), still .nullable().optional(), so an explicit null is still a withdrawal.
  • QuerySchema.searchFields and FullTextSearchSchema.fields (query.zod.ts): searchFieldsArray().optional().
  • Each of the three .describe() texts ends with the shared accept-set sentence; the references (content/docs/references/{api/contract,api/protocol,data/data-engine,data/query}.mdx) were regenerated with check:generated --fix.
  • A blank member (['']) is deliberately outside the set. Unlike the projection, whose door refuses a blank member through the same "names no field" envelope, the search-field door keeps it as a name and then refuses it as an unknown field when the object is known (INVALID_FIELD). Refusing it at the schema would make the schema refuse a value the door's "names no field" predicate admits, so the two would no longer refuse the same set.

The stage boundary (what the engine card owns)

Measured item 2 of the order: neither EngineQueryOptionsSchema.searchFields (data-engine.zod.ts :189) nor the aggregate (:513) and count (~:558) siblings is a declaration the door parses. POST /data/:object/query parses FindDataRequestSchema (whose query is QueryWithTransportSchema, built from QuerySchema and QueryTransportParamsSchema) at packages/rest/src/crud-routes.ts and in the dispatcher (packages/runtime/src/domains/data.ts); GET /data/:object and export reach findData with no schema at all. The three engine-side declarations are the in-process engine's options (IDataEngine.find / aggregate / count), so they stay, and so does the engine's tolerance of an empty override: the next stage, in the #27008 shape.

One coupling the engine card should know about: EngineQueryOptionsSchema.search (and its aggregate and count siblings) declare their structured form as the shared FullTextSearchSchema, so search: { query, fields: [] } is now refused by those engine-side declarations too. The engine does not parse search (fillQueryAstDefaults in packages/objectql/src/engine.ts says so and why), so no runtime answer moves. Likewise EngineQueryOptionsSchema.expand values are QuerySchema, so an expand entry's searchFields: [] is refused by the declaration; the engine does not parse searchFields on an expand entry either. The stage boundary describe in the new spec test pins the three searchFields members still admitting [], so the engine card flips it on purpose.

POST /data/:object/query (packages/rest/src/data-empty-search-fields-door.test.ts)

Cleared by the seat's claim amendment on #27683 (the file was outside the dispatched surface). The two POST rows #27670 pinned at the door's 400 VALIDATION_ERROR now answer at the body schema, 400 VALIDATION_FAILED, with fields[0].field naming the key: query.searchFields, query.$searchFields or query.search.fields. That is how the route already answers select: [] (#26909). The new REFUSED_POST_AT_SCHEMA table covers searchFields: [], $searchFields: "", $searchFields: " , ", $searchFields: [] and search: { query, fields: [] }, each with "no read of the object". The GET list and export rows are unchanged: those doors have no body schema and still answer the door's VALIDATION_ERROR.

Tests

  • New packages/spec/src/data/search-fields-names-a-field.test.ts (39 cases): per declaration, each empty spelling refused with the sentence at the member's path ($searchFields: four strings and []; QuerySchema.searchFields and FullTextSearchSchema.fields: []), and controls kept unchanged (one name, a spaced comma list, a list, a blank member); the same refusals through QueryWithTransportSchema and FindDataRequestSchema.query for $searchFields, searchFields, search.fields and $search.fields; an explicit null still a withdrawal; a kept comma list lowered onto searchFields; each .describe() carrying the shared sentence; and the stage-boundary pin above.
  • packages/rest/src/data-empty-search-fields-door.test.ts: the POST rows move to REFUSED_POST_AT_SCHEMA as above, and a new parity case holds the schema door (FindDataRequestSchema, which the POST route parses) and the protocol door (protocol.findData, which the GET and export routes reach with no schema) to one refused set, probe for probe, over a real SQLite object: 8 strings and 4 lists on $searchFields, the 4 lists on searchFields and on search.fields (20 probes). The schema refuses a probe iff the door answers VALIDATION_ERROR "names no field"; the verdict counts are pinned (8 refused, 9 served, 3 INVALID_FIELD for the blank member), so neither side can pass vacuously.

Ablation

At head 7b3667ba (fix and tests committed), through scripts/ablation-replace.mjs (trap restore), under the lock, against src (the spec suite imports the schemas by relative path, so no build or dist step is involved):

  • Leg 1, array arm: z.array(z.string()).min(1, SEARCH_FIELDS_NAMES_NO_FIELD) → z.array(z.string()).min(0, 'ABLATED-27683'). Landed: anchor 1 → 0, blob 4cb5ab59 → 288d09ac. search-fields-names-a-field.test.ts: 7 failed / 32 passed (every [] refusal on all three declarations and all four body spellings). Restored: blob 4cb5ab59 = HEAD, git diff HEAD empty.
  • Leg 2, string arm: regex(/[^\s,]/, …) → regex(/ABLATED-27683|/, …) (matches everything). Landed: anchor 1 → 0, blob 4cb5ab59 → e13506da. 4 failed / 35 passed: the four $searchFields string refusals on QueryTransportParamsSchema. The same strings through QueryWithTransportSchema stayed refused, because the fold lowers '' / ' , ' to searchFields: [] and the array arm on QuerySchema.searchFields catches it: the two arms back each other on the transport-aware slot. Restored: blob = HEAD, git diff HEAD empty.
  • A first leg-1 attempt used a replacement (z.array(z.string())) that already occurred inside the anchor, so the tool refused it (replacement count did not rise) and restored before running anything; it was redone with the marker above.
  • Not ablated at the rest layer (it reads the spec through dist, so each leg would need a rebuild). What stands in for it: the first run of the new POST case on the narrowed spec was red on one row for the right reason (the structured form answered query.search.fields, not the query.search first written), so the case reads the live schema answer; and the parity case compares each of its 20 probes against the door's own answer with pinned verdict counts.

Acceptance notes

  • Two pre-existing schema/door differences outside "names no field", unchanged and not widened here: the door accepts a comma STRING in the canonical searchFields slot and in the structured search.fields ("the engine accepts the comma-string and array forms from either slot"), while QuerySchema.searchFields and FullTextSearchSchema.fields declare arrays only, so POST {"searchFields":"label"} is refused at the body schema as it was on main (measured: query.searchFields invalid_type). Recorded for the engine card's reading; not a finding filed from here.
  • The #27484 changeset's "Not yet narrowed" bullet becomes false in the same release; check-empty-changeset refuses an edit to a changeset the merge base carries, so this PR's changeset carries a "Supersedes, in the same release" paragraph instead.
  • No caller in the repository sends an empty override through these schemas: the Console and the bundled view plugins send $searchFields only when it holds a field (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's measurement), and git grep for searchFields: [] / fields: [] beside search in *.test.ts finds only the rest door file (updated) and objectql's query-expression-conformance.test.ts, which calls protocol.findData directly and so is unaffected.

Verification

All at head 4ccc5167 (this PR's head: the two commits plus a merge of origin/main a75ddba1 through scripts/pm/os-regen-merge.sh, which kept the branch's bytes of every generated reference since main had not moved them), under scripts/pm/os-verify-lock.sh on the shared box.

Measurement script (before and after)
  • A throwaway tsx probe (not committed) parsed every declaration in the tables above with '', ' , ', [], [''], 'name', ['name'], and called assertSearchFieldsAreSearchable (registry-less host) for the standalone slot and for search.fields. On bfdaba43 it produced the "Measured first" table; on this head the diff against that output is exactly the rows marked refused in the "After" table, and the engine-side searchFields rows and the door rows are byte-identical.
Suites: spec, rest and the full dogfood battery, all exit 0
  • pnpm build --concurrency=2 (full turbo build, 114 tasks): Tasks: 114 successful, 114 total, lock VERDICT command-exit 0 (17m43s).
  • One locked chain, VERDICT command-exit 0 (46m52s):
    • pnpm --filter @objectstack/dogfood test: exit 0, Test Files 352 passed | 3 skipped (355), Tests 2844 passed | 20 skipped (2864). No failures.
    • pnpm --filter @objectstack/spec test: exit 0, Test Files 754 passed (754), Tests 22430 passed | 1 todo (22431).
    • pnpm --filter @objectstack/spec typecheck: exit 0.
    • pnpm --filter @objectstack/rest test: exit 0, Test Files 340 passed (340), Tests 6671 passed | 326 skipped (6997).
    • pnpm --filter @objectstack/rest typecheck: exit 0.
  • pnpm --filter @objectstack/spec check:generated (after --fix): all 14 artifacts current; --fix regenerated only content/docs/references/** (4 files).
  • pnpm check:adr-0087-registration: exit 0, 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition (not-required (no-migration-prescription)).
  • spec test does not run the test:repo project; CI's Test Core does.
Derived gates: 104 derived, 104 run, all exit 0
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 4ccc5167: 104 families (the same list as at 7b3667ba, before the merge).
  • Serial pass after the full build and git fetch origin 621a4876 (the teardown-shape self-test's pinned fixture): 104 exit 0, none exit 3.
  • dispatch-gates --ran: 104 derived, 104 run, 0 NOT-MEASURED, 0 UNRUN, exit 0.

origin/main moved 21 commits after the merge; none touches a file in this diff (git diff --name-only HEAD...origin/main against the ten paths), so no second merge was made, and the dogfood reading above is on the PR's final head.


Generated by Claude Code

…ming no field

QueryTransportParamsSchema.$searchFields, QuerySchema.searchFields and
FullTextSearchSchema.fields take the accept set the protocol door enforces:
a blank or comma-only string and an empty list are refused, with one shared
sentence in each description.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufziW1GcMSZeg21L26Xk
… the body schema

The rest door pin moves its POST rows to the schema door's VALIDATION_FAILED
and gains a parity test holding the body schema and the protocol door to one
refused set; references regenerated; changeset with its ADR-0087 marker.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufziW1GcMSZeg21L26Xk
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation protocol:data tests tooling labels Oct 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/data-api.mdx (via searchFields (symbol, a field of const object BaseQuerySchema, an authorable key of data/Query), /:object/export (route, bridged from symbol searchFields — its route source's handler names it))
  • content/docs/api/error-catalog.mdx (via searchFields (symbol, a field of const object BaseQuerySchema, an authorable key of data/Query))
  • content/docs/data-modeling/queries.mdx (via searchFields (symbol, a field of const object BaseQuerySchema, an authorable key of data/Query))
  • content/docs/data-modeling/schema-design.mdx (via /:object/export (route, bridged from symbol searchFields — its route source's handler names it))
  • content/docs/kernel/contracts/data-engine.mdx (via searchFields (symbol, a field of const object BaseQuerySchema, an authorable key of data/Query))
  • content/docs/permissions/permission-sets.mdx (via /:object/export (route, bridged from symbol searchFields — its route source's handler names it))
  • content/docs/protocol/kernel/error-handling.mdx (via searchFields (symbol, a field of const object BaseQuerySchema, an authorable key of data/Query))
  • content/docs/protocol/objectql/query-syntax.mdx (via searchFields (symbol, a field of const object BaseQuerySchema, an authorable key of data/Query))
  • content/docs/protocol/objectui/actions.mdx (via /:object/export (route, bridged from symbol searchFields — its route source's handler names it))
  • content/docs/ui/actions.mdx (via /:object/export (route, bridged from symbol searchFields — its route source's handler names it))

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

  • content/docs/releases/v17/17-0.mdx (via searchFields (symbol, a field of const object BaseQuerySchema, an authorable key of data/Query))
  • content/docs/releases/v17/17-5.mdx (via /:object/export (route, bridged from symbol searchFields — its route source's handler names it))
  • content/docs/releases/v17/17-6.mdx (via /:object/export (route, bridged from symbol searchFields — its route source's handler names it))

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
  • 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 — 167 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 04b14025d55b938b9b160c194b86ae9841ae7070 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 3f47074e4bf9b5724db9c431f73d4cb73276fc20 — the merge of head 4ccc5167c27f11216fd562102a6c444ae2d5c36c into base 04b14025d55b938b9b160c194b86ae9841ae7070, 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 3f47074e4bf9b5724db9c431f73d4cb73276fc20 && git checkout 3f47074e4bf9b5724db9c431f73d4cb73276fc20
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 04b14025d55b938b9b160c194b86ae9841ae7070 4ccc5167c27f11216fd562102a6c444ae2d5c36c && git checkout -B drift-repro 04b14025d55b938b9b160c194b86ae9841ae7070 && git merge --no-ff 4ccc5167c27f11216fd562102a6c444ae2d5c36c

node scripts/docs-audit/affected-docs.mjs --json 04b14025d55b938b9b160c194b86ae9841ae7070

⚠️ 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 04b14025d55b938b9b160c194b86ae9841ae7070 → 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

triage#1 · os-sales · session_01L6bhgV4fMGJHSdSdx3u9mK

Contract-review-claim: session_01L6bhgV4fMGJHSdSdx3u9mK · head 4ccc516 · 2026-10-29T21:18Z


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-sales · session_01L6bhgV4fMGJHSdSdx3u9mK

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 4ccc5167c27f11216fd562102a6c444ae2d5c36c
Local-runs: none
Scope: packages/spec/src/data/data-engine.zod.ts, packages/spec/src/data/query.zod.ts, packages/spec/src/data/search-fields-names-a-field.ts + packages/metadata-protocol/src/protocol-query-validation.ts (the door predicate assertSearchFieldsAreSearchable), packages/metadata-protocol/src/protocol.ts (findData's wrapper, narrowStoredMetadataSearch, the shape.count read), packages/spec/src/api/protocol.zod.ts (FindDataRequestSchema.query), packages/rest/src/crud-routes.ts and packages/runtime/src/domains/data.ts (the two FindDataRequestSchema parse sites), packages/objectql/src/engine.ts (expandSearchOnAst, fillQueryAstDefaults, rejectUnknownEngineOptions), packages/spec/src/data/projection-names-a-field.ts (the pattern followed), packages/spec/src/api/contract.zod.ts (ExportRequestSchema, list.input), packages/spec/dropped-refinements.baseline.json; narrowing floor: .changeset/27683-search-fields-names-a-field.md, packages/spec/src/data/search-fields-names-a-field.test.ts, packages/rest/src/data-empty-search-fields-door.test.ts. The four content/docs/references/**.mdx files are regenerated prose, read to confirm the .describe() sentence landed, not reviewed.

① Derived judgments

Face: reviewFace on the file list returns schema with the three hits above; the PR body and changeset also carry Clause-②: no (narrowing), so the narrowing floor is judged on top. Check-runs on the head: 43 success, 15 skipped (opt-in and matrix-placeholder jobs), 1 cancelled Check Changeset superseded by the later Check Changeset success on the same head (20:56Z). Governed Surface Queue Guard, Check Changeset, Spec property liveness, Build Core, Test Core 1-8, every Lint · and Type Check · family: success. The gate verdicts are green.

Premise, measured on the live tree rather than the local ref: the door refusal this narrowing joins (PR #27670, merge commit 86f2afeb, merged 18:52Z) is an ancestor of the head and 31 commits behind the live main head ef359b95; the PR base 04b14025 is 8 behind main. The local origin/main ref (85663a57, 16:39Z) predates #27670 and still carries the tolerant door (if (names.length === 0) return;), so every door reading below is off the head's tree, where protocol-query-validation.ts:877-895 refuses zero names with 400 VALIDATION_ERROR "names no field".

Accept-set changes the diff implies, each judged:

  • QueryTransportParamsSchema.$searchFields: string | string[] | null becomes string matching [^\s,] | array with minItems: 1 | null. RIGHT. The door's predicate is split(',').map(trim).filter(Boolean) leaving zero names for a string, and names = requested as sent for an array. A string has zero names iff every character is whitespace or a comma iff [^\s,] fails; ECMAScript trim() and \s strip and match the same set (WhiteSpace plus LineTerminator), so the two are the same set, not an approximation. The array arm refuses only [], which is all the door refuses as naming no field: [''] is one name the door looks up and refuses as INVALID_FIELD when the object is known. null stays a withdrawal (foldQueryTransportBag drops a $ key whose value is == null). Narrowing only; no key added, removed or renamed.
  • QuerySchema.searchFields: string[] becomes minItems: 1. RIGHT, same set. Reaches every nested expand value, ExportRequestSchema and the contract catalog's list.input in contract.zod.ts (nothing outside packages/spec parses either; grep over packages/** and ui/** non-test sources finds no parse site).
  • FullTextSearchSchema.fields: string[] becomes minItems: 1. RIGHT. Reaches search, $search, nested expand.search, and the engine-side EngineQueryOptionsSchema.search and its aggregate and count siblings; that coupling is judged in ④.
  • The transport-aware slot (QueryWithTransportSchema, so FindDataRequestSchema.query): a string '' or ' , ' is refused on the member before the transform; 'label,,' passes the pattern and lowerFoldedTransportValues lowers it to ['label']; a lowered [] could only arise from a string the pattern already refused, and QueryAstWithCountSchema's re-parse of the folded bag would refuse it on searchFields anyway (the two arms back each other, as the dev's ablation leg 2 showed). RIGHT.
  • First consumers: POST /data/:object/query (crud-routes.ts:357-371) and the runtime dispatcher (domains/data.ts:247) parse FindDataRequestSchema, so this route's empty-override refusal moves from the door's 400 VALIDATION_ERROR to the schema's 400 VALIDATION_FAILED with fields[0].field = query.searchFields / query.$searchFields / query.search.fields, still before any read. GET /data/:object and the export route hand the raw bag to findData and parse no transport schema (protocol.ts reads only QueryTransportParamsSchema.shape.count), so their VALIDATION_ERROR is unchanged. RIGHT, and the rest pin re-pins exactly this split.
  • Engine-side EngineQueryOptionsSchema.searchFields and the aggregate and count siblings (data-engine.zod.ts:190, 514, 559) stay z.array(z.string()).optional(). RIGHT against triage 6504412829 (ii): the engine stage is a separate domain:server card Blocked-by this one; the spec test's stage boundary describe pins the three members so that card flips them on purpose.
  • Public surface: search-fields-names-a-field.ts is outside the data barrel (no index.ts export), the projection-names-a-field.ts precedent; searchFieldsString, searchFieldsArray and the two sentences are not published exports. RIGHT; the api-surface and type-check families are green.
  • Published JSON Schema: .regex and .min are projectable checks (pattern, minItems), not custom refinements, so dropped-refinements.baseline.json entries for data/QueryTransportParams and data/QueryWithTransport are untouched and the published schema stops admitting what the door refuses, which is the card's goal. RIGHT.
  • In-repo callers that emit the override: ui/packages/data-objectstack/src/index.ts:5133, 5385 and ui/packages/fields/src/widgets/useRecordQuery.ts:215 send $searchFields only when the list has at least one member; protocol-record-reads.ts:558 and stored-metadata-reader-seam.ts:154 go to engine.find, not through the narrowed schemas, and narrowStoredMetadataSearch returns undefined or a non-empty list, never []. No caller in the tree is broken. RIGHT.
  • Skills-docs: none: skills/objectstack-query/SKILL.md § Full-Text Search, its eval, skills/objectstack-data/SKILL.md:125, skills/objectstack-ui/rules/list-views.md:194-198 and skills/objectui/guides/data-integration.md:89 all teach a named override; none emits an empty one. RIGHT.

② Semver level

.changeset/27683-search-fields-names-a-field.md: '@objectstack/spec': patch, title fix(spec)!:, a **BREAKING** banner, line-initial Clause-②: no (narrowing) and Metadata-protocol: none, and exactly one adr-0087: not-required (no-migration-prescription) marker. This is the level the rule prescribes: a non-protocol narrowing ships patch and keeps its banner and ADR-0087 disposition. Metadata-protocol: none is measured right: no *.zod.ts outside packages/spec/src/data and api embeds QuerySchema, FullTextSearchSchema or the transport bag (MetadataQuerySchema in kernel/metadata-plugin.zod.ts is a different schema; ui/component.zod.ts names searchFields only in prose), and contract.zod.ts's embeddings are API contract catalog shapes, not authorable metadata. @objectstack/rest changes a test file only and publishes nothing, so no second changeset is owed. The "Supersedes, in the same release" paragraph is the right vehicle for the #27484 entry's now-false "Not yet narrowed" bullet, since an edit to a merge-base changeset is refused; its claim is accurate against the tree (three of the four declarations it names narrowed, EngineQueryOptionsSchema.searchFields excepted). The PR body carries the same two declaration lines, line-initial, with the matching arm. Clause-②: line: right, both places, no (narrowing).

③ Boundary flags

open_questions: empty. Nothing to answer.

Deviations in the os-dev-report, each answered:

Flags the dev raised in the body, answered:

  • The EngineQueryOptionsSchema.search (and aggregate / count) coupling through the shared FullTextSearchSchema: judged in ④ (should-fix S1 carrier).
  • "Not ablated at the rest layer": the parity case with pinned verdict counts (8 refused, 9 served, 3 INVALID_FIELD) is a non-vacuous stand-in, and Test Core ran it green. Accepted.

out_of_scope_findings: the door accepts a comma STRING in the canonical searchFields and in structured search.fields while QuerySchema.searchFields and FullTextSearchSchema.fields declare arrays only, so POST {"searchFields":"label"} is refused at the body schema. Confirmed pre-existing on main (query.searchFields invalid_type before and after this PR) and outside the card's "names no field" set; not widened here. The dev marked it "noted, not filed" with the engine-stage domain:server card as carrier, but that card does not exist yet (triage assigned the filing to the domain:server#2 seat). ESCALATED as should-fix S1 below: it must land on that card's body when filed, or on a card of its own; a finding whose only record is a PR body is lost at merge.

④ Design judgment

packages/spec/src/data/search-fields-names-a-field.ts — against the ruling (triage 6504412829 (i): the #26909 shape) and the door it mirrors (assertSearchFieldsAreSearchable). Holds. Probes tried: (a) the whitespace set, where trim() and \s could diverge: they cannot in ECMAScript, both are WhiteSpace plus LineTerminator,  ,  and 
 included, so no string is refused by one side and served by the other; (b) a blank or whitespace-only member ([''], [' ']): admitted by min(1) and kept as a name by the door, refused later as INVALID_FIELD once the object is known, so refusing it at the schema would have made the schema refuse what the door's "names no field" predicate admits, and the module's docblock says so; (c) a non-string member: schema invalid_type, door INVALID_FIELD, both refusals, both pre-existing; (d) pattern and minItems are projected to the published JSON Schema, a .refine() would not have been. The module sits outside the barrel for the stated import-edge reason, exactly as projection-names-a-field.ts does. No finding.

packages/spec/src/data/data-engine.zod.ts — against the fold it feeds and its first consumer (FindDataRequestSchema at the POST route and the dispatcher). Holds. $searchFields keeps .nullable().optional(), so null withdraws; the member refusal fires before the transform, so the one diagnostic names $searchFields and zodIssuesToFields reports query.$searchFields; a kept comma list lowers onto searchFields as the door lowers it. The engine-side searchFields members are untouched, per the ruling. One coupling the diff creates and names: EngineQueryOptionsSchema.search, EngineAggregateOptionsSchema.search and EngineCountOptionsSchema.search declare their structured form as the shared FullTextSearchSchema, so those engine-side declarations now refuse search: { query, fields: [] } while the same declarations still admit searchFields: [], and the in-process engine tolerates both (expandSearchOnAst reads requestedFields = ast.searchFields ?? raw.fields and resolveSearchFields reads an empty set as no narrowing; fillQueryAstDefaults does not parse search; rejectUnknownEngineOptions checks keys only). No runtime answer moves, and the ruled staging is precisely a declaration that leads the engine for one stage, so this is not a must-fix. Should-fix S1 (carrier: the engine-stage card, not this PR): when the domain:server card is filed, its body names (1) this search.fields coupling, so the engine stage flips EngineQueryOptionsSchema.searchFields and the engine's empty-override tolerance together and the engine-side declaration stops saying two things about one value, and (2) the pre-existing comma-string asymmetry from ③. Nothing in this PR's tree changes for S1.

packages/spec/src/data/query.zod.ts — against the door and against every path the two members reach. Holds. QuerySchema.searchFields and FullTextSearchSchema.fields take the array arm and the shared sentence; nested expand values, $search, ExportRequestSchema and the contract catalog's list.input inherit the narrowing, and none of those has a parse site outside packages/spec that a caller in the tree reaches with an empty list. The .describe() texts carry the shared SEARCH_FIELDS_ACCEPT_SET sentence, pinned by the spec test and reproduced in the four regenerated references. Note, not a finding: the sentence's "blank, only commas and spaces" clause is written for the string arm and is over-inclusive on these two array-only members (a string there is an invalid_type either way); the three declarations deliberately state one accept set, as the projection siblings do.

Narrowing floor (Clause-②: no (narrowing)) against the published spec on main: the narrowing takes nothing away at any consumer that a door served before. Every value it stops admitting is one every Data API door has refused with a 400 since 86f2afeb; the only observable move is the code on one route (VALIDATION_FAILED for VALIDATION_ERROR on POST /data/:object/query), which is the route's existing answer to select: [] and the card's stated goal. It makes the spec's own refusal real: the published JSON Schema now states the set the door enforces. The spec test (39 cases) covers each refusal at the member's path with the sentence, the controls, the null withdrawal, the lowering, the describe pin and the stage-boundary pin; the rest test moves the POST rows to the schema table, keeps the GET and export rows at the door, and holds both doors to one refused set over 20 probes with pinned counts so neither side passes vacuously. The changeset's Before / Now / Unchanged / Who-sees-it bullets each match the tree as read. No must-fix.

Must-fix: none. Should-fix: S1 (carried to the engine-stage card, not a change to this head).

Implemented-by: claude/project-thread-odnwcq
Reviewed-by: session_01L6bhgV4fMGJHSdSdx3u9mK

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#3 · os-support-ai · session_01WGufzgbJhFoXYsLSe5WSPP

维护者速读 (席位终稿)

  • 改了什么:已发布的三处 searchFields 声明(QueryTransportParamsSchema.$searchFields、QuerySchema.searchFields、FullTextSearchSchema.fields)不再接受“一个字段都没写”的值(空串、只有逗号和空格、空数组、对象形 search.fields: []),三处描述带同一句接受集说明;POST /data/:object/query 因此在请求体 schema 处拒收,回 400 VALIDATION_FAILED(原为门内的 VALIDATION_ERROR),与 select: [] 一致。
  • 为什么改:数据接口自 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 起已拒收这些值,但发布的 Zod 与 JSON Schema 仍声明接受,按 schema 生成的客户端和 AI 会写出被门拒收的请求;select 的同类声明早已收紧(spec: QueryTransportParamsSchema.select / $select and GetDataRequestSchema.select admit an empty projection that the findData and getData doors refuse (sibling of #26817) #26909)。
  • 风险与回滚:只收窄声明,接受集与门逐值一致(有对照测试);POST /query 的错误码从 VALIDATION_ERROR 变 VALIDATION_FAILED,仍是 400、仍不读库。引擎侧声明不动(下一阶段卡)。回滚即还原本 PR。
  • 先看:packages/spec/src/data/search-fields-names-a-field.ts(接受集与两条臂),packages/rest/src/data-empty-search-fields-door.test.ts(POST 行迁到 schema 拒收表、末尾 parity 用例),packages/spec/src/data/query.zod.ts(两处成员)。
  • 两分钟验证:pnpm --filter @objectstack/spec exec vitest run src/data/search-fields-names-a-field.test.ts,应见 39 个用例全绿。
  • 需要你做的:无,席位落地。

席位意见:接受。分诊契约复核 PASS(6508798346,就在当前 head 上),没有 must-fix;唯一的 should-fix S1 落在引擎阶段卡上,不改本 PR,席位已把它记到 #27683 卡上供那张卡的立卡方带走。

席位核对 · ACCEPT · domain:spec seat 3 · os-support-ai · session session_01WGufzgbJhFoXYsLSe5WSPP · 2026-10-29T21:32Z

Head: 4ccc5167c27f11216fd562102a6c444ae2d5c36c · card #27683 · claim 6506135629 (amended 6506212398)

  1. 形态: a draft PR to main carrying Fixes #27683: 10 paths (+318 / −36), inside the claim's surface as amended (the rest test and the search-fields-names-a-field.ts module were added there). fix(spec)!:, Clause-②: no (narrowing), Metadata-protocol: none (measured: no metadata type embeds the three schemas), @objectstack/spec patch with the BREAKING banner and one adr-0087: not-required marker; check:adr-0087-registration exit 0. Not governed, 354 lines, so the ordinary queue landing applies.
  2. 前提实测: the door refusal this joins (fix(metadata-protocol)!: refuse a searchFields override naming no field instead of reading it as absent #27670, 86f2afeb) is an ancestor of the head. The body's before and after tables show each spelling against each declaration and the door; the review confirmed the string pattern [^\s,] and the array minItems: 1 are the door's predicate exactly, not an approximation.
  3. 门禁 check-runs (on this head): 50 latest runs: 39 success, 11 skipped, none red or pending (an earlier Check Changeset was cancelled by the body edit and superseded by a success on the same head). Dev-side: dispatch-gates --ran 104/104 exit 0, spec 22430 and rest 6671 tests passed, both typechecks exit 0, and the full dogfood battery on this final head: VERDICT command-exit 0, 352 files / 2844 tests passed.
  4. 接受的偏差: the rest layer is covered by the parity case with pinned counts (8 refused, 9 served, 3 INVALID_FIELD) instead of an ablation; the review accepted it as non-vacuous.
  5. 范围外去向: the engine stage (EngineQueryOptionsSchema.searchFields and its aggregate and count siblings) is the domain:server card triage ruled in 6504412829 (ii). Should-fix S1's two items (the shared FullTextSearchSchema coupling and the pre-existing comma-string asymmetry) are recorded on spec/protocol: the published searchFields declarations still admit an empty list that the data door now refuses; does searchFields follow select into a declared accept set, and does the engine follow? #27683 for that card. Skills-docs: none, confirmed by the review.
  6. 落地条款: the seat checked the prose faces against the diff on this head: the PR body (r0, seat signature) and the changeset, including its "Supersedes, in the same release" paragraph. The seat readies the PR and arms auto-merge in this act, follows it to MERGED, and lands spec/protocol: the published searchFields declarations still admit an empty list that the data door now refuses; does searchFields follow select into a declared accept set, and does the engine follow? #27683.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 29, 2026 21:34
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 29, 2026
Merged via the queue into main with commit 74aae1e Oct 29, 2026
62 of 63 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-odnwcq branch October 29, 2026 21:56
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 protocol:data size/m tests tooling

Projects

None yet

1 participant