Skip to content

fix(objectql): $search emits no text clause over a column that can never hold text - #29502

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-sek37l
Nov 2, 2026
Merged

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

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

domain:server#2 · os-steve · session_01ADHkmQxwoGb5fSnrFkpxmt

Requested by Steve · project thread

Fixes #29394

Clause-②: no
Metadata-protocol: none

Clause-② basis (contract review 6583544380): nothing the contract refuses is newly accepted. The declared-branch contract, in the resolveSearchFieldResolution docblock of packages/spec/src/data/search-fields.ts, admits a declared json column as the author's explicit choice ("executed by the engine as a $contains over the stored JSON text"). #4254 puts the loudness for the search set at the ingress, which judges the same set before and after this change. Ruling 6573569376 letter A admits a field marked searchable: true "of any type". The 400 INVALID_FILTER that a json field caused was a driver mis-refusal of a declared, admitted request, so turning it into a 200 is not a widening.

维护者速读

  • 改了什么:$search 展开成"每个可搜索字段一个模糊文本匹配"时,不再为 number / boolean / 日期时间 / json 这类永远存不了文本的字段生成匹配;文本、选项、关联、文件字段照旧。
  • 为什么改:对象的 searchableFields 里只要有一个 json 字段,该对象上的每一次搜索都会 400 失败(实测 PostgreSQL、SQLite、内存驱动都一样);内存驱动还会按日期的文本命中(搜 2026 搜到 2026 年的日期),和 SQL 驱动不一致。
  • 风险与回滚:不拒绝任何请求;只由非文本字段组成的搜索集合现在返回 0 行(不报错,也不返回全部)。数值/日期在 SQL 驱动上本来就匹配不到,结果不变。回滚即 revert 本 PR。
  • 先看:① packages/objectql/src/search-filter.ts 的 neverHoldsText 与 fieldClausesForTerm 第一行;② packages/rest/src/data-search-non-text-column-door.test.ts(真实 SQLite 驱动)。
  • 两分钟验证:在已构建的工作区跑 pnpm --filter @objectstack/rest exec vitest run src/data-search-non-text-column-door.test.ts,预期 6/6 通过:$search=qq9 返回 200 和两行,不再是 400 INVALID_FILTER。
  • 需要你做的:无,席位落地

What changes

Tests

  • packages/objectql/src/engine-search-non-text-column.test.ts (new, dialect-neutral pin on the AST the driver receives). It declares a search set over one field per FieldType (the spec's TEXT_OPERATOR_DOOR_FIXTURE) and asserts that the door's own walk (findTextOperatorOverNonTextField) finds nothing in the emitted search. Its CONTROL is that f_text, f_select, f_lookup and f_tags are still scanned. Further cases: a mixed name / qty / born / active / meta set scans only name. The terms 42, 2026 and true match nothing. A multi-term search carries only name in each term's $or. A set of only non-text fields reaches the driver as { $or: [] } and answers 0 rows on find and count, and a blank term still answers every row. A searchFields naming qty answers no rows with no refusal, and qty,name matches through name. CONTROL: an override outside the set is still 400 INVALID_FIELD. Auto branch: with no searchableFields, flagged searchable: true number / date / json fields get no clause, the unflagged text name is still scanned (rows r1 and r2), and naming a flagged field answers { $or: [] }, no rows, with no refusal.
  • packages/rest/src/data-search-non-text-column-door.test.ts (new) runs the Data API door, GET and POST, over a real SqlDriver (SQLite), on the harness of data-search-fields-credential-door.test.ts. It covers the auto branch too: a flagged number / date / json field with no searchableFields answers 200 through the text column, and $searchFields naming the flagged json field is accepted and matches nothing. CONTROL: the caller's own { qty: { $contains: '4' } } is still 400 INVALID_FILTER from the [Decision] refuse a text operator ($contains family) over a field whose DECLARED type is not textual — INVALID_FILTER 400 at the engine's field-aware door (option C of #14079); the textual-type vocabulary is the question #15661 door.
  • No forced test edits.

Acceptance notes

  1. The card's premise, measured. The card read a Postgres type error (SQLSTATE 42883) for number / boolean / date columns. On a live PostgreSQL 16 that does not happen on this base. driver-sql's compile-time type gate (driver-memory's reference matcher answers $notContains NO for every valued NON-STRING row — the live mingo path answers YES #14079 / text operator over a temporal column: SQLite matches the stored ISO text, live Postgres refuses with SQLSTATE 42883, and the JS faces answer by stored representation — no face declares the cell #15683) already turns those clauses into no-match. The real failure is json: the SQL family and driver-memory both refuse a text operator on a JSON column, so one json field in the set fails every search on the object. The other divergence is driver-memory, which matched a date stored as text by ISO substring. Readings are below.
  2. Postgres harness. The repo's live-dialect matrix (OS_TEST_POSTGRES_URL) is wired in CI only for driver-sql and metadata-protocol, and an engine-level live cell would need the isolation scaffolding of cascade-delete-multivalue-lookup-real-driver.integration.test.ts. So the committed pins are the dialect-neutral AST pin and the real SQLite door. The Postgres readings come from a one-off, uncommitted engine plus SqlDriver script run against a local PostgreSQL 16 in this container.
  3. The 400 that becomes a 200. A search that answered 400 INVALID_FILTER (a json field in the set) now answers 200. No request is refused that was accepted. The 400 was a driver refusal of a predicate the caller never wrote, so the claim's Clause-②: no is kept. The seat may re-grade it.

Verification

Head 18f7a010436b8d069ccf78e579e05691fecd85ae, a clean merge with origin/main at c88467d4 (it includes #29385). Round 2 (contract review 6583544380) is test-only: it adds the auto-branch pins and changes no source.

Red first on main (7b1ae33), then green on the fix commit bc2269f (the head's full suites are below)
main  objectql engine-search-non-text-column.test.ts       Tests 6 failed | 1 passed (7)   (the passing one is the override-refusal control)
main  rest     data-search-non-text-column-door.test.ts    Tests 4 failed | 1 passed (5)   400 INVALID_FILTER "...aims a scalar comparison or text operator at a multi-value or JSON field..."; the #15661 control passed
bc2269fe objectql (10 search / door files)                 Test Files 10 passed (10) · Tests 198 passed
bc2269fe rest     data-search-non-text-column-door.test.ts    Tests 5 passed (5)
round 2  objectql engine-search-non-text-column.test.ts     Tests 8 passed (8)   (+1: auto branch)
round 2  rest     data-search-non-text-column-door.test.ts  Tests 6 passed (6)   (+1: auto branch)
Live readings: engine + SqlDriver on PostgreSQL 16 and SQLite, and the in-memory driver (one-off script, not committed)

Rows: r1 has name qq9-alpha, qty 42, born 2026-01-05, active true and meta {k:'qq9'}; r2 has name other. Each object declares searchableFields: ['name', X].

                       before (skip removed, rebuilt)          after (head)
pg     name+json  qq9   400 INVALID_FILTER                      1 row [r1]
pg     name+json  42    400 INVALID_FILTER                      0 rows
pg     all five   qq9   400 INVALID_FILTER                      1 row [r1]
pg     name+qty   qq9   1 row [r1]   (no 42883: type-gated)     1 row [r1]
pg     name+born  2026  0 rows                                  0 rows
sqlite name+json  qq9   400 INVALID_FILTER                      1 row [r1]
sqlite all five   qq9   400 INVALID_FILTER                      1 row [r1]
memory name+json  qq9   400 INVALID_FILTER                      1 row [r1]
memory name+born  2026  1 row [r1]  (ISO substring of born)     0 rows
any    only qty/born/active  42 / 2026   0 rows                 0 rows

The before column for pg and sqlite was taken under the ablation build below, because the first main run's SQL inserts were refused for a missing organization row and so held no rows. The 400 cells read the same with rows absent. The memory before cells come from the first main run, where the memory inserts landed.

Ablation: the skip removed, rebuilt, proven in dist, then restored

scripts/ablation-replace.mjs --hold replaced if (neverHoldsText(meta)) return []; with a guard that never fires, carrying the string marker ABLATION_29394_SKIP_OFF (anchor 1 → 0, blob 0c1d38a4 → 8ede2261). After pnpm --filter @objectstack/objectql build, ablation-dist-preflight.mjs found the marker in 4 built files.

round 1  objectql engine-search-non-text-column.test.ts       Tests 6 failed | 1 passed (7)
round 1  rest     data-search-non-text-column-door.test.ts    Tests 4 failed | 1 passed (5)
round 2  objectql engine-search-non-text-column.test.ts       Tests 7 failed | 1 passed (8)   (the auto-branch pin reds too)
round 2  rest     data-search-non-text-column-door.test.ts    Tests 5 failed | 1 passed (6)   (the auto-branch pin reds too)

Every control stayed green in both rounds. Restore (each round): the blob after restore equals HEAD (0c1d38a4) and git diff HEAD is empty. After a rebuild, --absent found the marker absent from all 14 built files and the tree clean.

Suites, typecheck, lint and derived gates on the head 18f7a01
turbo run build --filter=./packages/** (after merging main)          exit 0
@objectstack/objectql test        Test Files 535 passed (535) · Tests 10399 passed (10399)
@objectstack/objectql typecheck   exit 0
@objectstack/rest search files (9: data-*search* incl. data-search-field-searchable-flag-door, discovery-search, list-search-total, meta-object-search-companion, search-clone)
                                  Test Files 9 passed (9) · Tests 54 passed | 22 skipped
@objectstack/rest typecheck       exit 0
eslint --no-inline-config packages/objectql packages/rest (round 1) and the two edited test files (round 2)   exit 0
dispatch-gates --commands (63 derived) then --ran:
✓ dispatch-gates --ran: 63 derived famil(ies) accounted for — 63 run, 0 NOT-MEASURED (a DERIVED zero — all 63 recorded an exit code and none of them is 3)

Generated by Claude Code

…le column

The #15661 text-operator door judges only the caller's own where, before
expandSearchOnAst runs, so the expansion's $icontains over a declared
number / boolean / date / json search field reached the driver unjudged.
One json field in a declared searchableFields failed every search on the
object with 400 INVALID_FILTER (PostgreSQL, SQLite, memory alike), and
driver-memory matched a date by ISO substring where the SQL faces answer
no-match.

fieldClausesForTerm now skips a field whose declared type gets the door's
own door-refusal verdict (textOperatorDoorVerdict, read from the spec, no
second list). The resolved set is unchanged, so the #24284 / #28120
refusals and the $searchFields door are unmoved; a set of only such
fields expands to an $or with no arm, FALSE: no rows, never an error.

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

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 79 pages)
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 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; 101 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 a92277937217769906d70fc86591b457d7f052b5 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json a92277937217769906d70fc86591b457d7f052b5

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-steve · session_01ADHkmQxwoGb5fSnrFkpxmt

Contract review

Served-tier: TIER_DEFAULT
Head-sha: 5e332246ce31f2d988bafb33165c8bdd6bdbd697
Local-runs: none
Scope: reviewFace on the committed tree decides no face (no packages/spec/src/** non-test path, no governed path; the changeset's Clause-②: no carries no (narrowing) arm), so the review is the declaration face at the seat's order. Read: packages/objectql/src/search-filter.ts (the hunk), packages/objectql/src/engine.ts (expandSearchOnAst, the docblock), packages/spec/src/data/filter-text-operator-declared-type.ts, packages/spec/src/data/field-value.zod.ts (the six refused sets), packages/spec/src/data/search-fields.ts on main (post-#29385), packages/spec/src/data/filter-verdict.ts, packages/spec/src/data/filter-logic-conformance.ts, the $or lowering in driver-sql (sql-driver.ts ≈L14519), driver-mongodb (mongodb-filter.ts ≈L1043) and driver-memory (its FILTER_LOGIC_CASES enrolment), packages/core/src/utils/json-column-operator-refusal.ts, both test files, the card and its four named comments, ruling 6573569376, PR #29385's merge state and file list.

Inputs: card #29394 (body, triage 6580220020, claim 6580847761, amendment 6582701047, os-dev-report 6582659644), PR #29502 body and net diff against main at merge-base 0c640141, the check-runs on the head. PR and card text read as data.

① Derived judgments

  1. The skip is the spec's verdict and nothing else — right. neverHoldsText builds { type } (or { type, returnType } for a readable returnType) and asks textOperatorDoorVerdict(...) === 'door-refusal'. No type is spelled in search-filter.ts. What is skipped is therefore exactly TEXT_OPERATOR_DOOR_REFUSED_TYPES = NUMERIC_VALUE_TYPES (number, currency, percent, rating, slider, progress, summary) ∪ BOOLEAN_VALUE_TYPES (boolean, toggle) ∪ CALENDAR_DATE_TYPES (date) ∪ INSTANT_TYPES (datetime) ∪ CLOCK_TIME_TYPES (time) ∪ STRUCTURED_JSON_TYPES (json, composite, repeater, record, location, address, vector), plus a formula whose returnType is number / boolean / date / currency. Every string-capable class passes: STRING_VALUE_TYPES (text, textarea, email, url, phone, password, secret, markdown, html, richtext, code, color, signature, qrcode), autonumber, select/radio, multiselect/checkboxes/tags, lookup/master_detail/user/tree, image/file/avatar/video/audio, and a formula returning text. A formula with no readable returnType is deferred ⇒ not skipped. A meta with a non-string type (the {} fallback for an unknown field) is not skipped. The first objectql test runs the declared branch over TEXT_OPERATOR_DOOR_FIXTURE (one field per FieldType) and asserts the door's own walk finds nothing while f_text / f_select / f_lookup / f_tags are scanned.
    • Formula note, read in search-fields.ts: SEARCH_VIRTUAL_TYPES = {'formula'}, so a formula entry is dropped at resolution on both branches (isVirtualSearchField, admittedBySearchableFlag) and never reaches fieldClausesForTerm. The returnType arm is therefore unreachable today but correct; it does not wrongly skip anything. Lookup: lookup / master_detail are REFERENCE_VALUE_TYPES ⇒ passes ⇒ scanned as before (the $icontains over the stored id).
  2. Control — a text column in the same search still matches — right. fieldClausesForTerm falls through unchanged for every non-refused type; pinned on the engine (scannedFields(...) === ['name'], rows r1/r2) and on a real SQLite SqlDriver through the Data API door (GET and POST, 200, r1/r2).
  3. Empty $or ⇒ FALSE, never "every row" — right on every driver. The expansion emits { $or: [] } per term (or { $and: [ {$or: []}, … ] }), and the engine merges it as { $and: [where, searchFilter] }. The reduction is the spec's: reduceFilterKeyVerdict (filter-verdict.ts reduceKey) answers 'false' for a $or with no true and no clause member, and FILTER_LOGIC_CASES row "empty $or is FALSE — the OR identity" (空组合子在同仓有两个对立答案:五个后端归约成布尔单位元,service-analytics 的两个编译器 fail-closed 抛错 —— #5239 的一致性表四条因此进不了表 #5322) pins it as the contract. Each face: driver-sql applyFilterCondition reduces the whole node first and calls applyFalseConstant (1 = 0) on 'false'; driver-mongodb translateCondition pushes matchNothing() on a 'false' key verdict (so an empty $or never reaches MongoDB, which would refuse it); driver-memory is enrolled on FILTER_LOGIC_CASES (memory-driver-filter-logic-conformance.test.ts), and driver-odata too. The real-driver pin here is SQLite (the rest test: only-non-text set ⇒ 200, no rows; no search ⇒ all 3 rows). Not every row, not an error.
  4. 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 / runtime: the stored-metadata reader seam still reads an in-process searchFields: [] as "names none" and serves the narrowed default set #28120 refusals and the $searchFields door are byte-unchanged — right. The net diff touches two source files: search-filter.ts (the skip and docblocks) and engine.ts (a 6-line docblock only; git diff shows no code hunk). resolveSearchFields / resolveSearchFieldResolution (spec) and assertSearchFieldsAreSearchable (metadata-protocol) are not in the diff. The skip runs after resolution and after withholdUnqueryable, so searchFields.length === 0 (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 null ⇒ emptySearchSetError) is judged on the same set as before; a non-empty all-non-text set is not that case and answers { $or: [] }. The runtime: the stored-metadata reader seam still reads an in-process searchFields: [] as "names none" and serves the narrowed default set #28120 override refusal is judged on resolveSearchFieldResolution(expandOptions).allowed before the expansion, unchanged. Pinned: searchFields: ['nope'] ⇒ 400 INVALID_FIELD; $searchFields=meta ⇒ 200, no rows.
  5. The card's premise, corrected by the dev — accepted as read. The 400 is the [finding] a per-aggregation filter $nin on a multi-valued field counts the rows it was asked to exclude, and $in counts none, where the same where is refused 400: the aggregation evaluator has no JSON-column equality gate #21007/[finding] $startsWith / $icontains on a multi-valued lookup answer 500 on PostgreSQL and a wrong count on SQLite: the text operators other than $contains reach a JSON column unrefused and unruled #21009 JSON-column operator refusal (packages/core/src/utils/json-column-operator-refusal.ts, printed by driver-sql, driver-memory and the engine's per-aggregation filter), not SQLSTATE 42883; the SQL faces type-gate numeric/boolean/temporal (driver-memory's reference matcher answers $notContains NO for every valued NON-STRING row — the live mingo path answers YES #14079 / text operator over a temporal column: SQLite matches the stored ISO text, live Postgres refuses with SQLSTATE 42883, and the JS faces answer by stored representation — no face declares the cell #15683) to no-match. That reading matches the cited code. The PR body's live readings are not relied on.
  6. Both branches flow through fieldClausesForTerm — right, but the "not merged" sentence is stale. expandSearchToFilter maps every field of the resolved set (declared or auto, as resolveSearchFields answers it) through fieldClausesForTerm with opts.fields[f]. On main today, autoDefaultFields admits a searchable: true field of any type except secret/password/vector and formula, so a flagged number / date / json field reaches the same line and is skipped. PR feat(spec): a field marked searchable: true is searched when its object declares no searchableFields #29385 merged at 2026-11-02T16:02:19Z (merge commit 29c70cacf), before the os-dev-report (16:40Z). The head's merge-base 0c640141 does not contain it, so the statement is true of the PR's base and false of main. No semantic conflict: feat(spec): a field marked searchable: true is searched when its object declares no searchableFields #29385's rest and spec tests flag only code / html / secret / formula fields, and resolution is untouched here. The auto-branch pin is still owed (should-fix 1).
  7. $searchFields naming a declared non-text column is accepted and matches nothing — consistent with ruling 6573569376 (letter A: the override allow-list is the resolved set; no refusal of searchable: true on a non-text field). The ruling is not departed from.
  8. Red-first and ablation are plausible from the tests. On main the six engine cases that read the emitted AST or the row answer must fail (the walk finds $icontains over qty/born/active/meta; 42/2026/true match through the value-keyed fake; { $or: [] } expectations) while the override-refusal control (case 7) passes ⇒ 6/1. On the rest file the json entry in both declared sets makes every search 400 ⇒ the four search cases fail and the [Decision] refuse a text operator ($contains family) over a field whose DECLARED type is not textual — INVALID_FILTER 400 at the engine's field-aware door (option C of #14079); the textual-type vocabulary is the question #15661 control passes ⇒ 4/1. The ablation (skip line replaced by a never-firing guard) reproduces exactly those counts; the controls are independent of the skip. Consistent.
  9. CI on the head — see ② below for the counts (written from the API after every run completed).

② Semver level

③ Boundary flags

④ Design judgment

Not owed on the declaration face (no hits); recorded at the seat's order, on the search-filter.ts hunk against the code it cites.

  • Skip vs cast: skip is the right answer against filter-text-operator-declared-type.ts (a text operator over these classes is refused by declaration; a cast would reintroduce a dialect-dependent text rendering that text operator over a temporal column: SQLite matches the stored ISO text, live Postgres refuses with SQLSTATE 42883, and the JS faces answer by stored representation — no face declares the cell #15683 ruled out for the temporal classes).
  • The skip sits after resolution and withholding, and before the companion clause: companionWithinSearchFields requires every companion source to be in the set, and the sources are the display/name text columns, so an all-non-text set cannot gain a companion arm and stays { $or: [] }.
  • Attempted breaks: a searchFields: ['qty'] override over a mixed set ⇒ { $or: [] } ⇒ 0 rows, no refusal (pinned); a blank term ⇒ not a search, every row (pinned); a multi-term search ⇒ one $or per term, each FALSE (pinned); an unknown field in opts.fields ⇒ {} ⇒ not skipped, unchanged behaviour. None breaks the design.
  • Should-fix 1 — auto-branch pin. main (with feat(spec): a field marked searchable: true is searched when its object declares no searchableFields #29385) admits a searchable: true non-text field to the auto set; the card asked for both branches and the code covers both, but no test pins the auto branch. One case (fields: { name: text, qty: { type: 'number', searchable: true } }, no searchableFields, search 42 ⇒ ['name'] scanned, no rows) after merging main into the branch, on this PR or as a delta commit; not a FAIL because the code path is the same line and is read to hold.
  • Should-fix 2 — the declared-branch sentence in packages/spec/src/data/search-fields.ts ("a json or lookup column declared here is executed by the engine as a $contains over the stored JSON text … a scan that CAN match") now describes lookup only: a declared json column is admitted and contributes no clause. It was already false before this PR (the JSON-column refusal), and the order forbids packages/spec/** here, so a domain:spec follow-up card to reword the two sentences, not a change on this PR.
  • Should-fix 3 — carry the Clause-② citation. The seat's ACCEPT (or a PR-body edit) should quote the search-fields.ts sentence and the REST 读路径:searchFields / groupBy / aggregations 指向不存在的字段时被静默降级(#4226 收口后剩下的三条轴) #4254 ingress acceptance beside Clause-②: no, so the no is self-evidencing per execution-duties.

CI on the head (check-runs, read from the API at 2026-11-02T17:18Z): 47 check-runs — 33 completed/success, 13 completed/skipped (the UI, docs, dogfood, temporal-conformance and opt-in lanes, path-filtered off this diff), 1 in_progress (Test Core (6/8)), 0 failure. No red. The one open shard is the lane seat's all-green landing condition (contract-review ②), not this record's.

Implemented-by: claude/project-thread-sek37l
Reviewed-by: session_01ADHkmQxwoGb5fSnrFkpxmt

VERDICT: PASS

…true)

#29385 landed: with no searchableFields, a field marked searchable: true
joins the auto set of any type. A flagged number / date / json field is
admitted to the set and still gets no text clause; the text control is
scanned, and naming a flagged field is accepted and matches nothing. On
the engine (AST) and the SQLite Data API door.

Claude-Session: https://claude.ai/code/session_01ADHkmRnyoAJD6oToQ2QZUK
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-steve · session_01ADHkmQxwoGb5fSnrFkpxmt

席位核对

ACCEPT · domain:server seat 2 · os-steve · session_01ADHkmQxwoGb5fSnrFkpxmt · 2026-11-02T18:19Z · head 18f7a010 · claimed by seat 2 on #29394 (claim 6580847761, amendment 6582701047)

There is no maintainer ruling on this card; triage graded it p2 (6580220020). It does not depart from the context ruling 6573569376 (letter A on #29008, searchable of any type).

  1. Shape: a draft against main with Fixes #29394. Clause-②: no and Metadata-protocol: none, with the citation on its own Clause-② basis line. objectql ships a patch. check-governed-merges --pr 29502: NOT governed. No packages/spec, no ui/**.
  2. Contract review: PASS (6583544380) at 5e332246. This head adds only a merge of origin/main and one test-only commit, and the seat read it: git diff 5e332246 18f7a010 touches neither search-filter.ts nor engine.ts. The PR's own diff is still the same 5 files. The reviewer ruled on two points the seat adopts:
  3. Should-fix 1 is done in this round. feat(spec): a field marked searchable: true is searched when its object declares no searchableFields #29385 landed, and the auto branch (searchable: true, no searchableFields) is now pinned on the engine and on the SQLite door. The pins go red under ablation (7/8 and 5/6) and restore clean.
  4. Evidence:
    • objectql: 535 files, 10399 tests passed, typecheck 0.
    • rest search files: 9/9.
    • 63 of 63 gates exit 0.
    • CI on this head: 35 success, 13 skipped, 0 failed, all completed.
  5. Follow-up: should-fix 2 needs domain:spec wording. search-fields.ts still calls a declared json column "a scan that CAN match". The seat files it as a card.
  6. Landing: pr_ready + automerge_enable through the relay.

Generated by Claude Code

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

1 participant