Commit 4957ee5
docs(skills): objectstack-query states the served nested-relation filter in where, its limits, and the two-step route past them (#20902)
Fixes #20888
Clause-②: no
## What this changes
Since PR #20872 (`ca5408c62`, the engine half of ruling 5907789183 on
#20802) the engine serves `{ relation: { field: value } }` in `where`,
lowered at its filter seam
(`packages/objectql/src/relation-filter-lowering.ts`, `engine.ts`
`lowerRelationConditions`): the related object is read through the
engine's own `find` with `fields: ['id']` and `limit:
RELATION_FILTER_ID_CAP + 1` in the caller's execution context, and the
ids become `{ relation: { $in: ids } }` on a single-valued relation or
an `$or` of one `$contains` per id on a `multiple: true` one. The
published skill `skills/objectstack-query`, rewritten by PR #20811
(`1d202453`) the same day and before that landing, still told AI authors
the form is refused (`INVALID_FILTER` / 400) and that the two-step `$in`
route is the only way. This PR states the served form, its limits and
the two-step route past them, at every site PR #20811 rewrote — and
nothing about analytics, which #20887 carries.
Every limit is stated from the code and its pins, read at `origin/main`
`00a92e18d`:
| Limit | Where it lives | Code / status |
|:--|:--|:--|
| One level: every key a field the related object declares; a relation
condition or a dotted key inside is refused before any read |
`admitRelationCondition` (`second-level`, `dotted-key`,
`undeclared-key`, `empty`, `unregistered-target`), pinned in
`engine-nested-relation-lowering.test.ts` | `INVALID_FILTER` / 400, "one
level only" |
| Forward only: the condition sits beneath a relation field of the
queried object; a parent by its children is not served |
`relation-filter-lowering.ts` header ("The reverse form (a parent
filtered by its children) is not served here at all") | the two-step
route stays |
| `where` only: an aggregation's own `filter` and `having` refuse the
form | `no-operator-object-door.ts` `relationWords` ("which the engine
serves in 'where' and not in an aggregation's 'filter'" / "not in
'having'") | `INVALID_FILTER` / 400 |
| At most 1000 related ids; past that refused, never truncated |
`RELATION_FILTER_ID_CAP = 1000`, `relationFilterCapError` ("a cut-off id
list would silently drop matching rows") | `INVALID_FILTER` / 400 |
| As the caller: the related object's row scope and field permissions
apply; a field the caller cannot read is refused, never an empty result
| `packages/rest/src/data-nested-relation-permission.test.ts` (the
security layer's filter-oracle guard) | `PERMISSION_DENIED` / 403 |
Landing sites, all in `skills/objectstack-query`, before (`00a92e18d`)
and after (`b73023a34`):
| Site | Before | After |
|:--|:--|:--|
| `SKILL.md:76`, Removed-key row `query.joins` | "`expand` (display), or
filter the related object and `$in` its ids" | "`expand` (display), or
`{ relation: { field: value } }` in `where` (filter)" |
| `SKILL.md:189-:191`, "Filtering by a related record" | the refusal
(`INVALID_FILTER` / 400) and the two-step route | served in `where`,
read as the caller; the five limits by name and the two-step route past
them, by pointer to the rule (now `:189-:192`) |
| `SKILL.md:331`, the `search` paragraph's last sentence | "To *filter*
by a related record's column, `$in` ids from its own query" | "`where: {
relation: { column: value } }`" (now `:332`) |
| `SKILL.md:340`, Cross-Object row "Filter rows by their lookup target's
column" | query the target, then `{ lookup: { $in: ids } }` | the served
form in `where` up to 1000 related ids; past the cap the two-step route,
`$contains` per id when `multiple` (now `:341`) |
| `rules/filters.md:98-:115`, `## Relation Filters` | the refusal, one
two-step example, the multi-valued spelling, the reverse direction | the
served form with one ✅ example, one limits paragraph, the two-step route
past the cap, the multi-valued spelling and the reverse direction
unchanged |
Re-read against the code and left as they are: `SKILL.md:88` (the rules
index, "filtering by a related record"); `SKILL.md:326-:327` and `:342`
("`search` never traverses": `searchFields` names are judged exactly at
the ingress, `protocol.ts` "Names are judged EXACTLY (no dotted-head
tolerance)", so a dotted name is still refused and the mirror-field
route still holds); `SKILL.md:341` "Filter parent by child conditions"
(the reverse form, not served, stays two-step). The same-day rows `:26`
and `:38-:44` (PR #20776) are untouched.
**Census.** `grep -rn -i -E 'nested.relation|two steps|two-step|\$in its
ids|never traverses|relation.*INVALID_FILTER|filter the related object'
skills/` at `00a92e18d` finds the sites above plus
`evals/filters-pagination-search.json:37` (a `search` case: the mirror
field, still true) and, outside this skill,
`objectstack-ui/rules/list-views.md:235` and
`objectstack-data/SKILL.md:120` (searching by a related record's title —
still true). No other sentence in `skills/**` states the refusal or the
two-step route as the only way. The evals carry no relation-filter case,
so nothing there asserts the refusal; no block in the skill carries an
`os:check` marker, so `check:skill-examples` does not apply.
## Sentences for #20876 to quote
`rules/filters.md` `## Relation Filters`, verbatim at `b73023a34`:
A condition on a related record's fields beneath a relation field
(`lookup`, `master_detail`, `user`, `tree`) is served in `where`: the
engine reads the related object with it **as the caller**, then matches
the field against the ids it returns (`$in`; any member when `multiple:
true`).
```typescript
// ✅ Orders whose customer is in the US
where: { customer: { country: 'US' } }
```
Limits — one level: every key a field the related object declares, no
relation or dotted key inside; forward only: never a parent by its
children; `where` only: an aggregation's `filter` and `having` refuse
it, `INVALID_FILTER` / 400; at most 1000 related ids, refused past that,
`INVALID_FILTER` / 400, never truncated; as the caller: the related
object's row scope and field permissions apply, so a field the caller
cannot read is refused, `PERMISSION_DENIED` / 403, never an empty
result. Past the cap, run the two steps yourself — filter the related
object, then `$in` its ids:
```typescript
const us = await engine.find('customer', { where: { country: 'US' }, fields: ['id'] });
where: { customer: { $in: us.map((c) => c.id) } }
```
On a `multiple: true` lookup match each id with `$contains` (an `$or` of
those for several); the SQL driver refuses `$in` there. A parent by its
children's fields is the same two steps reversed: query the child with
the condition and `fields: [the lookup]`, then `{ id: { $in: … } }` on
the parent.
These sentences agree with the first landed wording of the fact —
`content/docs/kernel/contracts/data-engine.mdx` (PR #20872), the
`FilterCondition` docblock form 4, and the changesets
`20802-nested-relation-filter-served.md` /
`20802-dotted-relation-route.md` / `20802-nested-relation-prose.md` —
each read against the code; none contradicts it, and the skill
contradicts none of them. The `where` dotted path (`{
'account.industry': 'tech' }`) stays refused `INVALID_FIELD` / 400 and
its words now name the nested form; the skill teaches no dotted `where`
path, so no sentence changes for it.
## Paying for it inside `rules/filters.md`
The file sat at 2149 / 2149 tokens (headroom 0). The section rewrite
(881 → 1463 bytes) is paid for by deleting the `## Logical Operators`
section (579 bytes, 41 lines), whose two rules already have a home in
the same package; the file lands at 2148 / 2149, ceiling untouched.
| Deleted from `rules/filters.md` | Home |
|:--|:--|
| `### AND (implicit)` — sibling keys are AND-combined; explicit `$and`
| `SKILL.md` Logical Operators (`:173-:181`, the `$and` example) and
this file's first Common Mistake ("sibling keys always are" AND) |
| `### OR` — `$or`, and `$in` as the one-field equivalent | `SKILL.md`
Logical Operators (`:170-:171`, the `$or` example) and `$in` (`:123`,
`:128`); this file's Operator Reference `$in` row |
The Common Mistake's pointer "(see **Logical Operators** above)" now
reads "(SKILL.md, **Logical Operators**)". The five `role: …` literals
in the deleted examples moved `check:role-word`'s count for the file 7 →
2; the gate prescribes the ratchet-down ("run `--update` and commit the
baseline"), and commit `b73023a34` is that `--update` output: one row of
`scripts/role-word-baseline.json`. That file is outside the claim's
declared surface; declared here and in the report.
## `skills/**` readings (lines and tokens; tokens are the ratchet's
`ceil(utf8 bytes / 4)`)
| | Before (`00a92e18d`) | After (`b73023a34`) | Δ |
|:--|--:|--:|--:|
| `SKILL.md` | 399 lines · 4055 tokens (ceiling 5552) | 400 lines · 4109
tokens | +1 line · +54 tokens |
| `rules/filters.md` | 214 lines · 2149 tokens (ceiling 2149) | 185
lines · 2148 tokens | −29 lines · −1 token |
| Whole package `skills/objectstack-query` (6 files) | 1145 lines ·
10527 tokens | 1117 lines · 10580 tokens | −28 lines · +53 tokens |
| Whole catalog `skills/**` (every file) | 13375 lines | 13347 lines |
−28 lines |
Line budget (PM-set, net +4 at most): −28. No untouched line was
re-wrapped; no ceiling row moved.
**Changeset.** `skills/**` ships in no package's `files[]`: of the 82
tracked `package.json` files, 69 declare `files[]` and 0 entries name
`skills` (measured at `b73023a34`); the catalog ships from `main` via
`npx skills add`. Nothing published moves, so `skip-changeset` is the
seat's to apply; this PR writes no label.
## Verification
Gates at head `b73023a34`, each exit code captured by redirect and the
verdict line quoted from its log: `node scripts/pm/dispatch-gates.mjs
--commands --repo objectstack-ai/objectstack` (no paths; "change set
derived from git — 3 path(s) vs merge base 00a92e1") derives 31
commands, six families more than the dispatch's list because the
baseline row adds `scripts/**`; reconciled with `--ran`: "31 derived
famil(ies) accounted for — 31 run, 0 NOT-MEASURED (a DERIVED zero — all
31 recorded an exit code and none of them is 3)". Every one exit 0:
`check-skills-token-ratchet` ("54 authored bundle file(s) within their
ceilings") and its `--self-test` (65 cases), `check:role-word` after the
ratchet-down ("Ledger: 44 baselined file(s) still carrying it (117
occurrence(s))"), `check:corpus-claim-drift`,
`check:skill-identifier-liveness` ("Leg 1: 457 citation(s) over 53
published file(s)"), `check:doc-authoring`, `check:nul-bytes`,
`check:skill-compatibility`, `check:skill-frame-sync`,
`check:agent-test-spelling`, `check:cross-package-test-inputs`,
`check:driver-memory-census`, `check:gitlink-declared`,
`check:pm-governed-merges` (the `--self-test`, as the script spells it),
`check:refd-timer-probe`, `check:watch-hint-literal`,
`check-ci-filter-parity` (+ `--self-test`),
`check-closing-keyword-parity` (+ `--self-test`),
`check-comment-mask-corpus` ("7664 files, 0 disagree"),
`check-doc-route-spelling --advisory` (+ `--self-test`),
`check-scripts-symbol-anchors` (+ `--self-test`), `check:bash32-floor`,
`check:cli-command-ids`, `check:entry-guard`, `check:parse-guard`,
`check:pnpm-filter-targets`, `@objectstack/lint`
`check:doc-formula-expressions` ("22 record-scoped formula example(s)
across 458 files / 1380 TS blocks judged clean", after building
`@objectstack/lint...` under `os-verify-lock.sh`, `VERDICT command-exit
0`), spec `check:skill-docs` ("Skill docs in sync") and spec
`check:skill-refs` ("9 generated files in sync"). The two ratchet
families were re-run after the final commit at `b73023a34` (both exit 0,
verdicts as quoted).
Pins of the served form, run first-hand under `os-verify-lock.sh` after
building each package's dependency closure (`VERDICT command-exit 0` on
both): `pnpm --filter @objectstack/objectql exec vitest run
--maxWorkers=2 src/engine-nested-relation-lowering.test.ts` → "Test
Files 1 passed (1), Tests 13 passed (13)" (the served rows on every
relation type, the any-member `$or` of `$contains`, the cap refusal with
the two-step route, the one-level / dotted / undeclared refusals, the
`aggregations[i].filter` and `having` refusals naming `where`, the
caller's context on the related read); `pnpm --filter @objectstack/rest
exec vitest run --maxWorkers=2
src/data-nested-relation-permission.test.ts
src/data-nested-object-door.test.ts` → "Test Files 2 passed (2), Tests 9
passed | 12 skipped (21)" — the 12 are the PostgreSQL and MySQL cells of
the door test, named skips (`OS_TEST_POSTGRES_URL` / `OS_TEST_MYSQL_URL`
unset here); the memory and SQLite cells and the 403 pin ran. No package
is touched, so no package build or test suite is owed beyond that.
## Acceptance notes
- `SKILL.md`'s `compatibility: Requires @objectstack/spec 17.x` is left
as is: the catalog states `main`'s behaviour (as PR #20811 did) and
`main` is 17.5.0 with the served form; the published 17.5.0 engine still
refuses it, and which release carries `ca5408c62` is the ruling's
sequencing, not this card's. Noted, not filed.
- The skill says nothing about analytics (the cube read and the read
scope): #20887 is open, and the sentences above name `where` on the
engine and the data door only.
- `content/docs/protocol/objectql/query-syntax.mdx` is read-only here;
#20876 carries it and quotes the section above.
- The report's `api_writes` lists every relay write of this run.
## 维护者速读(草稿)
**改了什么。** 发布的查询技能包 `skills/objectstack-query` 今天早些时候(PR
#20811)被改成「引擎拒绝在关联字段下直接写条件」;同一天晚些时候引擎侧 PR #20872 落地,引擎已经支持这种写法(`where: {
customer: { country: 'US' } }`)。本 PR
把同样的几处句子改成引擎现在的真实行为:支持,以及四条边界(只到一层、只能正向、只在 `where` 里、关联 id 最多 1000
条超出即拒绝),并且以调用者身份读关联对象(读不到的字段报 403,不会静默给空结果);超过上限或反向(用子记录条件筛父记录)仍走原来的两步
`$in`。规则文件里删掉了与 SKILL.md 重复的一节逻辑运算符示例,用来支付这段改写;整个包净减 28 行,每个文件的 token
上限都没动。
**为什么改。** 技能包是客户项目里 AI 的教材。裁决 5907789183(「20802 同意」)明确写了技能包在同一轮更新;不改,AI
作者会照着旧句子手写两步查询,正是裁决要去掉的模式。措辞与已落地的文档页(`data-engine.mdx`)、三份 changeset 和
`FilterCondition` 的注释逐句核对过,一致;#20876 改 `query-syntax.mdx` 时引用本 PR
的句子,两处不会分叉。
**风险与代价(含回滚)。** 只改两份 markdown 与一行门禁基线(`role-word` 计数 7 →
2,门禁自己要求的下调),不改任何代码或发布包。风险在两点:一是措辞若与后续分析(#20887)落地后的行为有出入,分析那一半另改;二是已发布的
17.5.0 引擎仍拒绝这种写法,技能包描述的是 `main`。回滚即 revert 本 PR 的两个提交。
**席位意见。**
**你要做的。** 复核五条边界的措辞与「逻辑运算符」一节的删除归宿;批准后由席位落地(Tier H)。
---
_Generated by [Claude
Code](https://claude.ai/code/session_01KTZmMfzVzjNvyaLyQ8mHvg)_
---------
Co-authored-by: objectstack-fleet[bot] <332303061+objectstack-fleet[bot]@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>1 parent d7b9817 commit 4957ee5
3 files changed
Lines changed: 26 additions & 54 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
39 | 39 | | |
40 | 40 | | |
41 | 41 | | |
42 | | - | |
| 42 | + | |
43 | 43 | | |
44 | 44 | | |
45 | 45 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
73 | 73 | | |
74 | 74 | | |
75 | 75 | | |
76 | | - | |
| 76 | + | |
77 | 77 | | |
78 | 78 | | |
79 | 79 | | |
| |||
186 | 186 | | |
187 | 187 | | |
188 | 188 | | |
189 | | - | |
190 | | - | |
191 | | - | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
192 | 193 | | |
193 | 194 | | |
194 | 195 | | |
| |||
328 | 329 | | |
329 | 330 | | |
330 | 331 | | |
331 | | - | |
| 332 | + | |
332 | 333 | | |
333 | 334 | | |
334 | 335 | | |
| |||
337 | 338 | | |
338 | 339 | | |
339 | 340 | | |
340 | | - | |
| 341 | + | |
341 | 342 | | |
342 | 343 | | |
343 | 344 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
37 | 37 | | |
38 | 38 | | |
39 | 39 | | |
40 | | - | |
41 | | - | |
42 | | - | |
43 | | - | |
44 | | - | |
45 | | - | |
46 | | - | |
47 | | - | |
48 | | - | |
49 | | - | |
50 | | - | |
51 | | - | |
52 | | - | |
53 | | - | |
54 | | - | |
55 | | - | |
56 | | - | |
57 | | - | |
58 | | - | |
59 | | - | |
60 | | - | |
61 | | - | |
62 | | - | |
63 | | - | |
64 | | - | |
65 | | - | |
66 | | - | |
67 | | - | |
68 | | - | |
69 | | - | |
70 | | - | |
71 | | - | |
72 | | - | |
73 | | - | |
74 | | - | |
75 | | - | |
76 | | - | |
77 | | - | |
78 | | - | |
79 | | - | |
80 | | - | |
81 | 40 | | |
82 | 41 | | |
83 | 42 | | |
| |||
97 | 56 | | |
98 | 57 | | |
99 | 58 | | |
100 | | - | |
101 | | - | |
102 | | - | |
103 | | - | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
104 | 63 | | |
105 | 64 | | |
106 | | - | |
107 | 65 | | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
108 | 79 | | |
109 | 80 | | |
110 | 81 | | |
| |||
119 | 90 | | |
120 | 91 | | |
121 | 92 | | |
122 | | - | |
| 93 | + | |
123 | 94 | | |
124 | 95 | | |
125 | 96 | | |
| |||
0 commit comments