Skip to content

Commit 4957ee5

Browse files
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

File tree

‎scripts/role-word-baseline.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
"skills/objectstack-data/references/data-hooks.md": 3,
4040
"skills/objectstack-data/rules/relationships.md": 1,
4141
"skills/objectstack-platform/SKILL.md": 2,
42-
"skills/objectstack-query/rules/filters.md": 7,
42+
"skills/objectstack-query/rules/filters.md": 2,
4343
"skills/objectstack-ui/rules/list-views.md": 1,
4444
"skills/objectstack-ui/rules/navigation.md": 1,
4545
"skills/objectstack-upgrade/references/examples-upgrade.md": 1

‎skills/objectstack-query/SKILL.md‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ both are given. Writes take only the trailing argument.
7373
| Removed key | Live replacement |
7474
|:--|:--|
7575
| `query.cursor` | keyset paging — `where` on the sort key + `orderBy` + `limit` |
76-
| `query.joins` | `expand` (display), or filter the related object and `$in` its ids |
76+
| `query.joins` | `expand` (display), or `{ relation: { field: value } }` in `where` (filter) |
7777
| `query.distinct` | `groupBy` the fields — each unique combination is one row |
7878
| `query.windowFunctions` | report/dashboard metadata (**objectstack-ui**), or rank / accumulate in app code |
7979
| aggregation `distinct: true` | `count_distinct` |
@@ -186,9 +186,10 @@ Combine conditions with `$and`, `$or`, and `$not`:
186186

187187
### Filtering by a related record
188188

189-
`{ customer: { country: 'US' } }` beneath a lookup is refused, `INVALID_FILTER` /
190-
400: filter the related object first, then `$in` its ids —
191-
**[filter rules → Relation Filters](./rules/filters.md)**.
189+
`{ customer: { country: 'US' } }` beneath a lookup is served in `where`: the
190+
engine reads the related object as the caller and matches its ids. The limits
191+
(one level, forward only, `where` only, 1000 ids, the caller's permissions) and
192+
the two-step route past them: **[filter rules → Relation Filters](./rules/filters.md)**.
192193

193194
### Cross-field comparisons
194195

@@ -328,7 +329,7 @@ that set, never widen it: over the REST/protocol ingress a name outside it is
328329
Mirror the related record's title into a **stored** field on the queried object
329330
and search that; the field, the write hooks and the lint wording are
330331
**objectstack-data → Search Fields (`searchableFields`)**. To *filter* by a
331-
related record's column, `$in` ids from its own query; to *display* it, `expand`.
332+
related record's column, `where: { relation: { column: value } }`; to *display* it, `expand`.
332333

333334
## Common Patterns
334335

@@ -337,7 +338,7 @@ related record's column, `$in` ids from its own query; to *display* it, `expand`
337338
| Scenario | Use |
338339
|:---------|:----|
339340
| Load lookup fields for display | `expand` |
340-
| Filter rows by their lookup target's column | Query the target object, then `{ lookup: { $in: ids } }` — `$contains` per id when `multiple` |
341+
| Filter rows by their lookup target's column | `{ lookup: { column: value } }` in `where`, up to 1000 related ids; past the cap query the target, then `{ lookup: { $in: ids } }` — `$contains` per id when `multiple` |
341342
| Filter parent by child conditions | Query the child with `fields: [lookup]`, then `{ id: { $in: those ids } }` on the parent |
342343
| **Keyword-search by a related record's title** | **Mirror the title into a stored field on this object and search that** — `search` never traverses |
343344
| Paginate/sort a parent's related records | Query the related object directly |

‎skills/objectstack-query/rules/filters.md‎

Lines changed: 18 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -37,47 +37,6 @@ allowlist that omits them and refuse — `Unsupported filter operator`,
3737
`INVALID_FILTER` / 400 — rather than approximating. A pattern ending in a lone
3838
unpaired backslash is refused by every face.
3939

40-
## Logical Operators
41-
42-
### AND (implicit)
43-
44-
All top-level conditions are AND-combined by default:
45-
46-
```typescript
47-
// ✅ Implicit AND — all conditions must match
48-
where: {
49-
status: 'active',
50-
role: 'admin',
51-
age: { $gte: 18 }
52-
}
53-
54-
// ✅ Explicit $and — same result
55-
where: {
56-
$and: [
57-
{ status: 'active' },
58-
{ role: 'admin' },
59-
{ age: { $gte: 18 } }
60-
]
61-
}
62-
```
63-
64-
### OR
65-
66-
```typescript
67-
// ✅ Find admins OR managers
68-
where: {
69-
$or: [
70-
{ role: 'admin' },
71-
{ role: 'manager' }
72-
]
73-
}
74-
75-
// ✅ Equivalent using $in
76-
where: {
77-
role: { $in: ['admin', 'manager'] }
78-
}
79-
```
80-
8140
## Field References
8241

8342
> ✅ **Enforced.** `{ $field: '...' }` compares two columns of the same row.
@@ -97,14 +56,26 @@ reference there.
9756

9857
## Relation Filters
9958

100-
A plain object with no `$` operator beneath a relation field (`lookup`,
101-
`master_detail`, `user`, `tree`) is refused, `INVALID_FILTER` / 400 on every
102-
driver: the column stores the related record's id, and no driver follows it into
103-
the related object. Filter the related object first, then `$in` its ids:
59+
A condition on a related record's fields beneath a relation field (`lookup`,
60+
`master_detail`, `user`, `tree`) is served in `where`: the engine reads the
61+
related object with it **as the caller**, then matches the field against the
62+
ids it returns (`$in`; any member when `multiple: true`).
10463

10564
```typescript
106-
// ❌ Refused: where: { customer: { country: 'US' } }
10765
// ✅ Orders whose customer is in the US
66+
where: { customer: { country: 'US' } }
67+
```
68+
69+
Limits — one level: every key a field the related object declares, no relation
70+
or dotted key inside; forward only: never a parent by its children; `where`
71+
only: an aggregation's `filter` and `having` refuse it, `INVALID_FILTER` / 400;
72+
at most 1000 related ids, refused past that, `INVALID_FILTER` / 400, never
73+
truncated; as the caller: the related object's row scope and field permissions
74+
apply, so a field the caller cannot read is refused, `PERMISSION_DENIED` / 403,
75+
never an empty result. Past the cap, run the two steps yourself — filter the
76+
related object, then `$in` its ids:
77+
78+
```typescript
10879
const us = await engine.find('customer', { where: { country: 'US' }, fields: ['id'] });
10980
where: { customer: { $in: us.map((c) => c.id) } }
11081
```
@@ -119,7 +90,7 @@ fields is the same two steps reversed: query the child with the condition and
11990
### ❌ Wrong: expecting sibling keys to be an OR
12091

12192
`where: { role: 'admin', status: 'active' }` is an AND — sibling keys always
122-
are. For OR, wrap them in a `$or` array (see **Logical Operators** above).
93+
are. For OR, wrap them in a `$or` array (SKILL.md, **Logical Operators**).
12394

12495
### ❌ Wrong: Using string operators on non-string fields
12596

0 commit comments

Comments
 (0)