Repository navigation
Commit 29c70ca
feat(spec): a field marked searchable: true is searched when its object declares no searchableFields (#29385)
director#1 · zhuangjianguo · session_01QsR2jV5sBSzCYNEDgpYLJw
Ruled: 6580271401 · letter A · 2026-11-02T14:58Z
<!-- claude-projects-attribution: {"github_login":"os-steve"} -->
_Requested by **Steve** · [project
thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9Uq4LmxqZBkQTT4zD3bmPrk)_
Fixes #29008
Clause-②: yes (widening)
Metadata-protocol: semantic
Skills-docs: follow-up owed — skills/objectstack-data/SKILL.md,
skills/objectstack-query/SKILL.md,
skills/objectstack-ui/rules/list-views.md (the content/docs pages are
updated here)
## 维护者速读
- **改了什么**:对象没有声明 `searchableFields` 时,标了 `searchable: true` 的字段现在会被
`$search` 扫描,`$searchFields` 点名它也会被接受,不再返回 400;`secret` / `password` /
`vector` 即使标了也永远不搜。声明了 `searchableFields` 的对象完全不变。
- **为什么改**:这个开关以前能写、有默认值、schema 里也返回 `searchable:
true`,但没有任何代码读它:作者标了却搜不到,点名还被 400 拒绝。按裁决记录 6573569376 的 A
案(维护者已同意)补上读取方。
- **风险与回滚**:这是放宽。已经标了 `searchable: true` 的非文本字段会开始被搜;平台对象实测有 6 个对象的 7
个用户 id 类 lookup 字段因此被搜到(匹配的是存储的 id,不是关联记录的标题)。回滚即 revert 本
PR;单个对象也可以删掉标记,或声明 `searchableFields` 把集合钉死。
- **先看**:① `packages/spec/src/data/search-fields.ts` 的
`admittedBySearchableFlag` 与 `autoDefaultFields` 第一行(标记先于 hidden /
系统字段名排除判断);②
`packages/metadata-protocol/src/protocol-query-validation.ts` 的 400 文案;③
`packages/lint/src/validate-searchable-fields.ts` 的 lint 文案。
- **两分钟验证**:在已构建的工作区跑 `pnpm --filter @objectstack/rest exec vitest run
src/data-search-field-searchable-flag-door.test.ts`,预期 4/4 通过:卡片的三个请求里
QQCODE、QQHTML 都能找到那一行,`$searchFields=snippet` 返回 200。
- **需要你做的**:无,席位落地
## What changes
- **The resolver** (`packages/spec/src/data/search-fields.ts`). The auto
branch of `resolveSearchFieldResolution` now admits a field marked
`searchable: true` unless its type is one of the new
`SEARCH_FLAG_EXCLUDED_TYPES` (`secret`, `password`, `vector`) or it is a
virtual `formula` with no stored column. The flag is asked before the
auto-default's own exclusions, so a flagged `hidden` field and a flagged
field named like a system column join too, which is the ruling's "every
field marked `searchable: true`, of any type". The declared branch is
unchanged and reads no flag. `SearchFieldMeta` gains the optional
`searchable`.
- **Every reader moves with it.** The #4254 ingress gate
(`assertSearchFieldsAreSearchable`), the engine's `$search` expansion,
the lint rule and the ui readers (the fields search offer and
`ListView`) all call this one resolver. Measured: the protocol and the
ui pass the registry's field map unprojected, so no `domain:server` half
was needed. The one projection that dropped `searchable` was the lint
rule's `indexObjectSearchTargets`; it now carries the flag.
- **The describe** on `FieldSchema.searchable`
(`packages/spec/src/data/field.zod.ts`) says the ruling's sentence
instead of "Is searchable", and the reference pages are regenerated
(`content/docs/references/data/field.mdx`, `data/object.mdx`,
`system/migration.mdx`, plus `api-surface` / `export-origins` for the
new export).
- **Messages.** For a field outside the auto-default, the ingress `400
INVALID_FIELD` and the `searchable-field-unsearchable` lint verdict now
name the missing `searchable: true` and offer it as the fix, where they
used to say the type cannot be scanned. For the three hard-excluded
types they keep the old remedy and say the flag does not admit them. The
`os explain` text for the rule says the same.
- **Docs.** `content/docs` pages that described the auto-default as
type-only now name the flag (data-api, queries, query-syntax,
schema-design, and the `searchable` rows in field-types and fields).
- **Liveness.** The `searchable` row in
`packages/spec/liveness/field.json` now points at the real reader. Its
old evidence was a dashboard widget's own `searchable` key, not this
field.
- **Changeset.** `@objectstack/spec`, `@objectstack/metadata-protocol`
and `@objectstack/lint` are minor (widening, new export), with the
claim's two lines at line start.
## Tests
- `packages/spec/src/data/search-fields.test.ts`, new describe
"field.searchable: true joins the auto-default set — never a declared
one". It covers the card's reproduction (`code` and `html` fields marked
searchable resolve into the set) and an override naming a flagged field
narrowing to it. It also covers these CONTROLS: an unflagged field
resolves as before, each of `secret` / `password` / `vector` stays out
even when flagged, a virtual `formula` stays out, a declared
`searchableFields` ignores the flag, and absent and `false` give one
answer. It also pins that every other `FieldType` is admitted when
flagged, that a flagged hidden field and a flagged system name join
(with unflagged controls), that a field with no readable type is not
admitted, and that the display field still only orders. A second
describe pins `SEARCH_FLAG_EXCLUDED_TYPES`: exactly the three types,
covering every masked-on-read type, and a subset of the auto-default's
own exclusions.
- `packages/lint/src/validate-searchable-fields.test.ts`, new describe
for the flag. A view narrowing to flagged `code` / `html` fields lints
clean. An unflagged one is refused with the exact new hint. A flagged
`vector` is refused with the hard-exclusion verdict and the old hint. A
declared set ignores the flag. `indexObjectSearchTargets` carries the
flag.
- `packages/rest/src/data-search-field-searchable-flag-door.test.ts`
(new) runs the card's three requests end to end over the Data API door
on a real `SqlDriver` (SQLite). Its controls: an unflagged `code` field
is not scanned and naming it is `400` before any driver read, a flagged
`secret` is `400` before any read, and an object that declares
`searchableFields` keeps the declaration as the whole set.
- **Forced test edit (named):**
`packages/lint/src/validate-searchable-fields.test.ts`, the skill-parity
pin for a `lookup` outside the auto-default. It expected the verdict to
say "of type 'lookup', which 'search' cannot scan". It now expects "of
type 'lookup' and not marked `searchable: true`". The old sentence is
false after this change, because a marked lookup is now scanned.
## Acceptance notes
1. **What already matches more, measured on the platform's own
objects.** Seven lookup fields on six objects are already marked
`searchable: true` with no `searchableFields`. These are
`sys_session.user_id`, `sys_activity.actor_id` and `environment_id`,
`sys_audit_log.user_id`, `sys_comment.author_id`,
`sys_notification_preference.user_id` and `sys_presence.user_id`. They
now join `$search` on their stored ids, not on the user's name. This is
a static census of the source, not a runtime reading.
2. **Flagged types with no text column.** The ruling admits a flagged
field "of any type", so a flagged `number`, `boolean`, `date`,
`datetime`, `time` or `json` field now joins the set, and the engine
emits `$icontains` for it. That is NOT MEASURED on Postgres in this PR.
The same shape already exists today for a declared `searchableFields`
naming such a column, so it is not new to this PR. It is listed as out
of scope for `domain:server`, with a one-line narrowing offered in the
report.
3. **ADR-0061 D2** still says field-level `searchable` "stays pruned".
That sentence predates ruling 6573569376, and the code docblock says so.
An amendment note is owed on a governed path (Tier H). It is not in this
PR, so this PR stays ungoverned. The seat has a draft.
4. Left unchanged on purpose: the engine's empty-search-set refusal
still names `searchableFields` as its only remedy, and the field form's
help text "Include in global search results" is now accurate as written.
5. **The ui readers agree with the server without a ui edit.** They pass
the full field meta to the same resolver, and their tests are green
(below).
## Verification
Head `85ec00115a6b5179a99240050a1d9509638e2656`, merge base `2f7977cd2`,
a clean merge with `origin/main` at `57cde8cb`.
<details>
<summary>Consumer suites (each through os-verify-lock, VERDICT
command-exit 0)</summary>
```text
turbo build ./packages/** Tasks 78 successful, 78 total
@objectstack/spec test Test Files 791 passed (791) · Tests 23409 passed | 1 todo
@objectstack/spec typecheck exit 0 (check:test-typecheck clean)
@objectstack/lint test Test Files 189 passed (189) · Tests 7835 passed
@objectstack/lint typecheck exit 0
@objectstack/metadata-protocol test Test Files 301 passed | 4 skipped (305) · Tests 22127 passed | 26 skipped
@objectstack/metadata-protocol typecheck exit 0
@objectstack/objectql search files (search-filter, search-companion, engine-empty-search-fields-refusal, query-expression-conformance, filter-comparand-shape)
Test Files 5 passed (5) · Tests 331 passed
@objectstack/rest search files (data-search*, list-search, meta-object-search, search-clone, discovery-search, list-view-grouping-query-door, rest-data*)
Test Files 15 passed (15) · Tests 425 passed | 22 skipped
@objectstack/rest typecheck exit 0
ui: fields searchOffer, RecordPickerDialog.searchlessTarget-12966, ListView.searchlessObject-12966, ListView.searchWithheldTreeChart-10326
Test Files 4 passed (4) · Tests 39 passed
```
</details>
<details>
<summary>Ablation: the flag line removed, rebuilt, proven in dist, then
restored</summary>
The line `if (meta && admittedBySearchableFlag(meta)) return true;` was
replaced with a marker that never matches (`ablation-replace.mjs
--hold`). After a rebuild, `ablation-dist-preflight.mjs` found the
marker in 2 built files of `packages/spec/dist`.
```text
spec search-fields.test.ts Tests 5 failed | 45 passed (50) (the reproduction, the override, every-other-type, hidden/system-name, display-field order)
lint validate-searchable-fields.test.ts Tests 1 failed | 72 passed (73) (flagged code/html lints clean)
rest data-search-field-searchable-flag-door Tests 1 failed | 3 passed (4) (the card's three requests)
```
Every control stayed green with the flag unread. Restore: blob
`d45c69a6` equals HEAD, `git diff HEAD` empty. After rebuilding,
`--absent` preflight found the marker absent from all 588 built files
and the working tree clean.
</details>
<details>
<summary>Derived gates (dispatch-gates --commands, then --ran)</summary>
At head `85ec0011`, 21 changed paths against the merge base. Each
command ran with stdin closed, and its exit code was recorded before any
pipe.
```text
derived 106 · run 106 · exit 0 on 106 · NOT-MEASURED 0 · UNRUN 0
✓ dispatch-gates --ran: 106 derived famil(ies) accounted for — 106 run, 0 NOT-MEASURED
```
One command first answered exit 3, a prerequisite and not a verdict:
`node scripts/check-plugin-teardown-shape.mjs --self-test` could not
read its positive control pinned at commit `621a4876` in this shallow
clone. After fetching that one commit it answered exit 0 (48 cases
pass).
Offline `node scripts/check-changeset-no-major.mjs --base origin/main`
was run against this body as the event and answered exit 0. It found no
major, and on the level axis it read `Clause-②: yes (widening)` and
`Metadata-protocol: semantic` with no moved package graded patch.
</details>
---
_Generated by [Claude
Code](https://claude.ai/code/session_01ADHkmSR2TvJTmoYyVtcQv9)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 8ca9dd7 commit 29c70ca
21 files changed
Lines changed: 559 additions & 33 deletions
File tree
- .changeset
- content/docs
- api
- data-modeling
- protocol/objectql
- references
- data
- system
- packages
- lint/src
- metadata-protocol/src
- rest/src
- spec
- api-surface
- export-origins
- liveness
- src/data
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
23 | 23 | | |
24 | 24 | | |
25 | 25 | | |
26 | | - | |
| 26 | + | |
27 | 27 | | |
28 | 28 | | |
29 | 29 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1023 | 1023 | | |
1024 | 1024 | | |
1025 | 1025 | | |
1026 | | - | |
| 1026 | + | |
1027 | 1027 | | |
1028 | 1028 | | |
1029 | 1029 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
453 | 453 | | |
454 | 454 | | |
455 | 455 | | |
456 | | - | |
| 456 | + | |
457 | 457 | | |
458 | 458 | | |
459 | 459 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
538 | 538 | | |
539 | 539 | | |
540 | 540 | | |
541 | | - | |
| 541 | + | |
| 542 | + | |
542 | 543 | | |
543 | 544 | | |
544 | 545 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
87 | 87 | | |
88 | 88 | | |
89 | 89 | | |
90 | | - | |
| 90 | + | |
| 91 | + | |
91 | 92 | | |
92 | 93 | | |
93 | 94 | | |
| |||
102 | 103 | | |
103 | 104 | | |
104 | 105 | | |
105 | | - | |
| 106 | + | |
| 107 | + | |
106 | 108 | | |
107 | 109 | | |
108 | 110 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1186 | 1186 | | |
1187 | 1187 | | |
1188 | 1188 | | |
1189 | | - | |
| 1189 | + | |
| 1190 | + | |
1190 | 1191 | | |
1191 | 1192 | | |
1192 | 1193 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
57 | 57 | | |
58 | 58 | | |
59 | 59 | | |
60 | | - | |
| 60 | + | |
61 | 61 | | |
62 | 62 | | |
63 | 63 | | |
| |||
0 commit comments