Repository navigation
docs(skills): objectstack-query teaches the served route for a related record's column, not the refused nested form - #20811
Conversation
…d record's column, not the refused nested form
The published query skill taught `{ relation: { field: value } }` under a
lookup as a working filter. The engine refuses that form on every driver
(`INVALID_FILTER` / 400) and names the route that works: filter the related
object first, then `$in` its ids (`$contains` per id on a multi-valued
lookup). The six sites that taught or pointed at the form now state the
refusal and the route; the rule section's rewrite is paid for inside
`rules/filters.md` by deleting three examples whose rule already lives in
`SKILL.md`.
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTZmMfzVzjNvyaLyQ8mHvg
…d (8 → 7) The deleted "Combining Logical Operators" example carried one `role: 'admin'` literal; `check:role-word` prescribes the ratchet-down and this is its `--update` output, one row. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KTZmMfzVzjNvyaLyQ8mHvg
Contract reviewServed-tier: Inputs. Card #20782: body and all three comments (triage 5905048525, claim 5906472951, Check-runs on ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
维护者速读(终稿)— PR #20811 · 查询技能:关联字段筛选改教可用的两步路线(#20782)skills 席 1 · 改了什么:改的是
为什么改:PR #20781 之后,所有驱动都拒绝这种写法。AI 照旧技能写,要么拿到 400,要么在内存驱动上静默返回 0 行。 风险与代价(含回滚):只改文档,不发布任何包。回滚方式是 revert。 席位意见:ACCEPT,建议批准。
你要做的:审阅后给 APPROVED,之后由本席位落地。 Generated by Claude Code |
…ter in where, its limits, and the two-step route past them (objectstack-ai#20902) Fixes objectstack-ai#20888 Clause-②: no ## What this changes Since PR objectstack-ai#20872 (`ca5408c62`, the engine half of ruling 5907789183 on objectstack-ai#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 objectstack-ai#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 objectstack-ai#20811 rewrote — and nothing about analytics, which objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#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): objectstack-ai#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; objectstack-ai#20876 carries it and quotes the section above. - The report's `api_writes` lists every relay write of this run. ## 维护者速读(草稿) **改了什么。** 发布的查询技能包 `skills/objectstack-query` 今天早些时候(PR objectstack-ai#20811)被改成「引擎拒绝在关联字段下直接写条件」;同一天晚些时候引擎侧 PR objectstack-ai#20872 落地,引擎已经支持这种写法(`where: { customer: { country: 'US' } }`)。本 PR 把同样的几处句子改成引擎现在的真实行为:支持,以及四条边界(只到一层、只能正向、只在 `where` 里、关联 id 最多 1000 条超出即拒绝),并且以调用者身份读关联对象(读不到的字段报 403,不会静默给空结果);超过上限或反向(用子记录条件筛父记录)仍走原来的两步 `$in`。规则文件里删掉了与 SKILL.md 重复的一节逻辑运算符示例,用来支付这段改写;整个包净减 28 行,每个文件的 token 上限都没动。 **为什么改。** 技能包是客户项目里 AI 的教材。裁决 5907789183(「20802 同意」)明确写了技能包在同一轮更新;不改,AI 作者会照着旧句子手写两步查询,正是裁决要去掉的模式。措辞与已落地的文档页(`data-engine.mdx`)、三份 changeset 和 `FilterCondition` 的注释逐句核对过,一致;objectstack-ai#20876 改 `query-syntax.mdx` 时引用本 PR 的句子,两处不会分叉。 **风险与代价(含回滚)。** 只改两份 markdown 与一行门禁基线(`role-word` 计数 7 → 2,门禁自己要求的下调),不改任何代码或发布包。风险在两点:一是措辞若与后续分析(objectstack-ai#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>
Fixes #20782
Clause-②: no
What this changes
The published skill
skills/objectstack-querytaught{ relation: { field: value } }beneath a lookup as a workingwhereform. Onmainthe engine refuses that form on every driver —INVALID_FILTER/ 400, in the words ofrelationWords()inpackages/objectql/src/no-operator-object-door.ts— and names the route it serves: filter the related object first, then match the relation column against the ids it returns ($inon a single-valued column;$containsper id, an$orof those for several, on amultiple: trueone, whose JSON column the SQL driver refuses$inon). The skill now states that refusal and that route at every site that taught or pointed at the form. It anticipates neither letter of the open v18 decision (#20802 is not addressed here): it states today's behaviour.Six landing sites, all in
skills/objectstack-query, measured onorigin/main96e72447:96e72447)SKILL.md:76, Removed-key rowquery.joinsexpand, or a nested relation filter"expand(display), or filter the related object and$inits ids"SKILL.md:88, rules indexSKILL.md:187-:194, subsection{ contact: { profile: { verified: true } } }exampleSKILL.md:334$inids from its own query"SKILL.md:343, Cross-Object rowrules/filters.md:132-:152, sectionThe
:343row was mislabelled. "Filter parent by child conditions" names the reverse direction (a parent by its children's fields), while the form it pointed at, even had it been served, expresses only the forward one (rows by their lookup target's column). The row is now two: "Filter rows by their lookup target's column" → query the target object, then{ lookup: { $in: ids } }($containsper id whenmultiple); "Filter parent by child conditions" → query the child withfields: [lookup], then{ id: { $in: those ids } }on the parent. Both routes are served: the engine's ownexpandbatch-loads with{ id: { $in } }(engine.ts, the "Batch-load related records using $in query" block), and the REST ingress admitsidas a filter key (protocol.tsresolveQueryFields,known.add('id')).No dotted alternative is taught.
'owner.region'is refused one door earlier by the #8371 dotted verdict (filter-comparand-shape.ts,INVALID_FIELD/ 400); a grep of the skill for a dottedwherepath finds none.Census.
grep -rn -i -E 'nested relation|relation filter|nested relations' skills/on96e72447finds exactly the six sites; a multi-line shape grep for awhereexample nesting a no-operator object under a relation key finds the same three (SKILL.md:193,rules/filters.md:138,:145) and nothing else inskills/**.Paying for it inside
rules/filters.mdThe file sat at 2148 / 2149 tokens. The section rewrite (881 bytes) is paid for by deleting three examples whose rule already has a home in the same package, so the file lands at 2149 / 2149 (headroom 0, ceiling untouched):
rules/filters.md## Implicit Equality (Shorthand)(:40-:51)SKILL.md"Implicit Equality (Shorthand)" (:94-:101), and the$eqrow of this file's Operator Reference### NOT(:93-:100)SKILL.mdLogical Operators,{ where: { $not: { status: 'closed' } } }(:183-:184)### Combining Logical Operators(:102-:113)SKILL.md"AND + OR combined" (:173-:181); sibling-keys-are-AND is this file's first Common MistakeThe
role: 'admin'literal in the deleted third example movedcheck:role-word's count for the file 8 → 7; the gate prescribes the ratchet-down ("run--updateand commit the baseline"), and commitb893787bis that--updateoutput: one row ofscripts/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'sceil(utf8 bytes / 4))96e72447)b893787b)SKILL.mdrules/filters.mdskills/objectstack-query(6 files)Line budget (PM-set, net 0 at most): −39. No untouched line was re-wrapped; no ceiling row moved.
Verification
Gates, all at head
b893787b, exit codes captured by redirect and verdict lines quoted from each log: the 31 familiesdispatch-gates --commands --repo objectstack-ai/objectstackderives from the change set, reconciled with--ran("31 derived, 31 run, 0 NOT-MEASURED, 0 UNRUN") — every one exit 0, includingcheck-skills-token-ratchet("54 authored bundle file(s) within their ceilings") and its--self-test(65 cases),check:role-wordafter the ratchet-down ("OK, no new occurrences of the reserved word"),check:corpus-claim-drift,check:skill-identifier-liveness,check:doc-authoring,check:nul-bytes,check:doc-formula-expressions(@objectstack/formulaand@objectstack/lintbuilt first), speccheck:skill-docs("Skill docs in sync") and speccheck:skill-refs("9 generated files in sync").check:skill-examplesdoes not apply: no block in this skill carries anos:checkmarker.Pin: the skill's example validation does not cover these snippets (no
os:checkmarker), so the pin is PR #20781's, onmain:packages/objectql/src/engine-nested-object-door.test.ts(the refusal envelope{ code: 'INVALID_FILTER', status: 400 }beneathlookup,master_detail,multiple: truelookup,userandtree, and the CONTROL that{ owner: { $in: [...] } },{ owners: { $contains: ... } }, its$or, and{ id: { $in: [...] } }reach the driver as written) andpackages/rest/src/data-nested-object-door.test.ts(400 overPOST /api/v1/data/:object/query; the routes answerd1,d3). Run first-hand here:pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 src/engine-nested-object-door.test.ts→ "Test Files 1 passed (1), Tests 15 passed (15)". No package is touched, so no package build or test suite is owed beyond that.Acceptance notes
content/docs/protocol/objectql/query-syntax.mdx(:602-:610, read-only here): the "Filtering Across Relationships" callout's headline is true (neither the nested form nor a dotted path is served), but its mechanism paragraph is stale — it saysSqlDriver.applyFilters()compiles the nested object as a single-column comparison and emits the dotted key verbatim to Knex, while onmainboth are refused at the engine before any driver (INVALID_FILTER/ 400 for the nested form,INVALID_FIELD/ 400 for the dotted path), and it names no served route. Noted, not filed: documentation drift under a true headline; no carrier known.check:pm-governed-mergesas the package script spells it is the--self-testalone (exit 0 here); the live sweep is CI's.api_writeslists every relay write of this run.维护者速读(草稿)
改了什么。 发布的查询技能包
skills/objectstack-query原先把「在关联字段下直接写条件」({ customer: { country: 'US' } })当作能用的过滤写法来教。现在六处教它或指向它的句子都改成平台今天的真实行为:这种写法被引擎拒绝(INVALID_FILTER/ 400),可行的路是先查关联对象拿到 id,再对关联字段用$in(多值关联用$contains逐个 id)。规则文件里删掉了三段在 SKILL.md 已有同样规则的重复示例,用来支付这段改写;整个包净减 39 行,每个文件的 token 上限都没动。为什么改。 AI 作者照着技能包写,写出来的查询在内存驱动上静默返回 0 行、在 SQL 驱动上报 400;PR #20781 合并后所有驱动都统一拒绝。技能包是客户项目里 AI 的教材,教错一句就是每个客户项目里的错误查询。本 PR 不预判 v18 决策卡(#20802)的任一方向,只陈述今天的行为。
风险与代价(含回滚)。 只改文档文字与一行门禁基线(
role-word计数 8 → 7,门禁自己要求的下调),不改任何代码或发布包。风险在措辞:若维护者裁定 v18 支持关联过滤,这几句还要再改一次(决策卡已注明)。回滚即 revert 本 PR 的两个提交。席位意见。
你要做的。 复核六处措辞与三处删除各有归宿;批准后由席位落地(Tier H)。
Generated by Claude Code