Skip to content

docs(skills): objectstack-query teaches the served route for a related record's column, not the refused nested form - #20811

Queued
objectstack-fleet[bot] wants to merge 2 commits into
mainfrom
claude/issue-20782-query-skill-relation-filter
Queued

objectstack-fleet[bot] wants to merge 2 commits into
mainfrom
claude/issue-20782-query-skill-relation-filter

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20782
Clause-②: no

What this changes

The published skill skills/objectstack-query taught { relation: { field: value } } beneath a lookup as a working where form. On main the engine refuses that form on every driver — INVALID_FILTER / 400, in the words of relationWords() in packages/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 ($in on a single-valued column; $contains per id, an $or of those for several, on a multiple: true one, whose JSON column the SQL driver refuses $in on). 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 on origin/main 96e72447:

Site (at 96e72447) Before After
SKILL.md:76, Removed-key row query.joins "expand, or a nested relation filter" "expand (display), or filter the related object and $in its ids"
SKILL.md:88, rules index "nested relations" "filtering by a related record"
SKILL.md:187-:194, subsection "Nested Relation Filters" with the { contact: { profile: { verified: true } } } example "Filtering by a related record": the refusal, the route, a pointer to the rule
SKILL.md:334 "use a nested relation filter" "$in ids from its own query"
SKILL.md:343, Cross-Object row "Filter parent by child conditions — Nested relation filter" two rows, one per direction (below)
rules/filters.md:132-:152, section "Nested Relation Filters", two ✅ examples of the refused form "Relation Filters": the refusal, one two-step example, the multi-valued spelling, the reverse direction

The :343 row 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 } } ($contains per id when multiple); "Filter parent by child conditions" → query the child with fields: [lookup], then { id: { $in: those ids } } on the parent. Both routes are served: the engine's own expand batch-loads with { id: { $in } } (engine.ts, the "Batch-load related records using $in query" block), and the REST ingress admits id as a filter key (protocol.ts resolveQueryFields, 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 dotted where path finds none.

Census. grep -rn -i -E 'nested relation|relation filter|nested relations' skills/ on 96e72447 finds exactly the six sites; a multi-line shape grep for a where example nesting a no-operator object under a relation key finds the same three (SKILL.md:193, rules/filters.md:138, :145) and nothing else in skills/**.

Paying for it inside rules/filters.md

The 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):

Deleted from rules/filters.md Home
## Implicit Equality (Shorthand) (:40-:51) SKILL.md "Implicit Equality (Shorthand)" (:94-:101), and the $eq row of this file's Operator Reference
### NOT (:93-:100) SKILL.md Logical 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 Mistake

The role: 'admin' literal in the deleted third example moved check:role-word's count for the file 8 → 7; the gate prescribes the ratchet-down ("run --update and commit the baseline"), and commit b893787b 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 (96e72447) After (b893787b) Δ
SKILL.md 401 lines · 3990 tokens (ceiling 5552) 399 lines · 4055 tokens −2 lines · +65 tokens
rules/filters.md 251 lines · 2148 tokens (ceiling 2149) 214 lines · 2149 tokens −37 lines · +1 token
Whole package skills/objectstack-query (6 files) 1184 lines · 10461 tokens 1145 lines · 10527 tokens −39 lines · +66 tokens

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 families dispatch-gates --commands --repo objectstack-ai/objectstack derives from the change set, reconciled with --ran ("31 derived, 31 run, 0 NOT-MEASURED, 0 UNRUN") — every one exit 0, including 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 ("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/formula and @objectstack/lint built first), spec check:skill-docs ("Skill docs in sync") and spec check:skill-refs ("9 generated files in sync"). check:skill-examples does not apply: no block in this skill carries an os:check marker.

Pin: the skill's example validation does not cover these snippets (no os:check marker), so the pin is PR #20781's, on main: packages/objectql/src/engine-nested-object-door.test.ts (the refusal envelope { code: 'INVALID_FILTER', status: 400 } beneath lookup, master_detail, multiple: true lookup, user and tree, and the CONTROL that { owner: { $in: [...] } }, { owners: { $contains: ... } }, its $or, and { id: { $in: [...] } } reach the driver as written) and packages/rest/src/data-nested-object-door.test.ts (400 over POST /api/v1/data/:object/query; the routes answer d1, 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 says SqlDriver.applyFilters() compiles the nested object as a single-column comparison and emits the dotted key verbatim to Knex, while on main both 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-merges as the package script spells it is the --self-test alone (exit 0 here); the live sweep is CI's.
  • The report's api_writes lists 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

…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
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: b893787bfd1323878680c47487e47c8ef6b47130
Local-runs: none

Inputs. Card #20782: body and all three comments (triage 5905048525, claim 5906472951, os-dev-report 5906976148). PR #20811: body, file list (3 files: scripts/role-word-baseline.json +1/−1, skills/objectstack-query/SKILL.md +9/−11, skills/objectstack-query/rules/filters.md +14/−51), and the diff fetched fresh from the API (+24/−63, identical to git diff origin/main...b893787b; merge-base 96e72447). PR issue comments: none. Decision card #20802 read as context only. Code read with git show / git grep at origin/main 72f8c382 and at the head.

Check-runs on b893787b (filter=latest, 35 runs): 24 success, 11 skipped, 0 failure / cancelled / neutral / timed_out / action_required, 0 in_progress. Nothing red, nothing running. The seven required contexts: Lint & Repo Gates success (completed 08:32:43Z), TypeScript Type Check success, Test Core success (shards 1/6..6/6 all success), Dogfood Regression Gate success, Governed Surface Queue Guard success, Build Core skipped, Temporal Conformance (live PG + MySQL) skipped. The two skipped required jobs sit in the same check suite as the filter job (success) and started and completed at the same instant, 08:04:59Z: paths-filter skips on a diff that touches no package, not runs that failed to finish. The other skipped runs: Auto Label, Check Changeset, Check PR Size (each has a second, success run), Build Docs, Console Pin Gate, Dogfood Verify CLI, two Packed-tarball smoke (opt-in), and Dogfood Regression Gate (${{ matrix.shard }}/3) (an unexpanded matrix name, a workflow quirk not of this PR's making).

① Derived judgments

  1. The refusal — right. REFERENCE_VALUE_TYPES (packages/spec/src/data/field-value.zod.ts:154-:156) is exactly lookup, master_detail, user, tree; noOperatorObjectColumnKind() answers relation for that set, single or multiple, and relationWords() writes the route the skill now teaches ($in on a single-valued column, $contains per id with an $or for several on a multi-valued one). The arm runs inside the engine's one filter walk (number-comparand-declared-type-door.ts:445-:523) before any driver is resolved, so "INVALID_FILTER / 400 on every driver" is right by construction. Envelope { code: 'INVALID_FILTER', status: 400 } is pinned for all five field shapes at engine.find with zero driver reads (packages/objectql/src/engine-nested-object-door.test.ts) and at POST /api/v1/data/:object/query over a real SqlDriver (packages/rest/src/data-nested-object-door.test.ts, SQLite cell always, PG/MySQL cells env-gated). Since the merge-base only 157baa75f touched the engine pin (a having case); the [finding] 仓内存在 5 个独立的过滤器→谓词编译器,每次语义裁决成本 ×5 —— 值得立「谓词编译收敛」调查程序(#5298 成本清单副产品) #5930 seam cfa931535 touched engine.ts but the door is still wired at origin/main 72f8c382, so the text is true of main today. No relation type is omitted or wrongly included: the four names are the closed set; file/media, formula and unknown types are never judged and the text does not name them.

  2. The two-step route — right, and it is right NOT to name a limit. No door applies a default or maximum row count when the caller sets none: the spec declares limit: z.number().optional() with no .default() (packages/spec/src/data/query.zod.ts:555); fillQueryAstDefaults fills only orderBy / expand and its docblock states limit carries no default (engine.ts:11324-:11345); SqlDriver emits LIMIT only if (query.limit !== undefined) (sql-driver.ts:7603, :11206, :11332); the memory driver slices only on presence (memory-driver.ts:662-:665); findData forwards options to engine.find unchanged and says "Without a limit the full result set is returned" (protocol.ts:11453-:11458); GET /data/:object hands req.query to findData (rest-server.ts:8639 block) and POST /data/:object/query validates the body against FindDataRequestSchema and forwards it (:8818 block) — neither adds a limit. So the first step returns every matching id and the taught route is exact; a limit written on it would be the truncation class the decision card measured in hotcrm (top: 5000), and the text correctly carries none. No $in list-length cap exists on the data path (grep over objectql, driver-sql, metadata-protocol, rest: none), so a very large id set reaches the backend as one statement; that ceiling is the backend's and outside this diff.

    • engine.find('customer', { where: …, fields: ['id'] }) is the skill's own Calling Convention spelling (SKILL.md:25: object first, option bag second). Right.
    • fields: ['id'] is legal: fields is in ENGINE_FIND_OPTION_KEYS (engine.ts:550) and find adds id to its known projection set (engine.ts:11467), so id survives the unknown-column filter and the example's .map over c.id reads it. Right.
  3. The multiple: true claim — right. SqlDriver refuses $in / $eq / $nin / $ne aimed at a JSON-stored multi-value column with INVALID_FILTER / 400 and prescribes $contains, an $or of $contains for any-of (sql-driver.ts:3385-:3450, jsonColumnOperatorError). The REST suite's header table records { owners: { $in: ['u1'] } } 400 on both SQL cells and { owners: { $contains: 'u1' } } answering d1, d3 on all three; its SQLite cell pins the single $contains and the $or; the engine pin's CONTROL shows both shapes reach the driver as written. The memory driver accepts $in there (header table, measured, not pinned — the closed driver-memory census), so scoping the refusal to "the SQL driver" is precise. Right.

  4. The parent-by-child row — legal on every door, served on every driver. { id: { $in: […] } } reaches the driver as written (engine pin CONTROL); id is admitted by find's projection set (engine.ts:11467) and by the REST ingress (packages/metadata-protocol/src/protocol.ts:9746, resolveQueryFields, known.add('id')); the engine's own expand batch-load issues exactly { id: { $in: uniqueIds } } (engine.ts:10792-:10800), the shape every driver already serves. fields: [the lookup] on the child projects a declared column. Right.

  5. The deleted content — every rule keeps a home at the head. Implicit equality: SKILL.md:94-:101 ("Implicit Equality (Shorthand)") and the $eq row of rules/filters.md:9. $not: SKILL.md:183-:184 inside Logical Operators. Combining: SKILL.md:173-:181 ("AND + OR combined", explicit $and over an $or), plus the sibling-keys-are-AND rule at rules/filters.md:44-:62 and its Common Mistake at :119-:122; the deleted example's exact form (a bare sibling key beside a top-level $or) is no longer shown anywhere, but the rule it illustrated is stated twice, so no rule is lost. "(see Logical Operators above)" at rules/filters.md:122 still resolves: ## Logical Operators stands at :40 with AND and OR beneath it.

  6. The renamed anchor — nothing else links to the old heading. git grep at origin/main for Nested Relation Filters / nested relation filter finds only the skill's own six sites and one test title in service-analytics (where-source-field-gate.test.ts:625, not a link). objectstack-formula/SKILL.md:424 and objectstack-ui/SKILL.md:226 link to rules/filters.md for the token list, which is untouched. build-skill-docs.ts reads only the frontmatter (:143-:155), so no generated page mirrors the body — content/docs/ai/skills-reference.mdx:51 and skills/README.md:42 carry the unchanged description, consistent with check:skill-docs green on the head. SKILL.md's own "filter rules → Relation Filters" resolves to ## Relation Filters at rules/filters.md:98.

  7. Decision [Decision] v18:查询能否直接按关联记录的字段筛选(例:「客户行业 = 科技」的商机) #20802 — not anticipated. The text states the refusal as today's behaviour and the route that works; it neither says the form will be served (letter A) nor that the two-step is the contract's only form (letter B). Context, not a finding: the card was ruled letter A at 08:59Z (record 5907789183), after this PR opened at 08:04Z; the ruling routes the skill's re-sync onto the v18 card, and the refusal still holds at origin/main 72f8c382, so the text stays true of the 17.x line the skill declares (compatibility: Requires @objectstack/spec 17.x).

  8. scripts/role-word-baseline.json 8 → 7 — right and the gate's own prescription. scripts/check-role-word.mjs (WORD = /\brole(?:s)?\b/gi) reds a baselined file whose count DECREASED and prescribes --update; counting with that regex over rules/filters.md gives 8 at 96e72447 and 7 at the head — the one { role: 'admin' } in the deleted example. --update rewrites the whole ledger from the tree and the diff moves exactly one row, so nothing else in content/docs or skills/ moved. The gate is in Lint & Repo Gates (lint.yml:2762), success on the head.

  9. The body's readings reproduce. SKILL.md 399 lines, 16219 bytes → 4055 tokens (ceiling 5552, check-skills-token-ratchet.mjs:366); rules/filters.md 214 lines, 8594 bytes → 2149 tokens (ceiling 2149, :497); base 3990 and 2148. No ceiling row moved; rules/filters.md now has headroom 0.

② Semver level

skip-changeset matches what the diff publishes: nothing. The touched paths are skills/** and one gate ledger; no packages/*/package.json files[] names skills (grep: none), no released package is touched, no .changeset/*.md exists on the branch, and Check Changeset is success on the head. Clause-②: no is on the PR body with no (widening) / (narrowing) arm, and the diff narrows no authorable surface — it is doc text plus a ratchet row. Consistent.

③ Boundary flags

  • Landing tier. skills/** is Tier H (Prime Directive feat: Comprehensive CRM example demonstrating all ObjectStack protocol features #14, 人合). This record is the Tier S instrument; a PASS here lifts nothing on a Tier H path — the landing waits for an authorized APPROVED review, as the body's 维护者速读 says: 「批准后由席位落地(Tier H)」.
  • Dev deviation 1 — scripts/role-word-baseline.json. Outside the claim's surface (SKILL.md, rules/filters.md, the token ratchet only on a ceiling change). Consequential to the deletion, gate-prescribed, one row, declared in body and report. Answered: accepted.
  • Dev deviation 2 — commit trailers. Both commits (1afde54f, b893787b) carry the model-free pair AGENTS.md mandates (Co-authored-by: Claude + Claude-Session:); the harness reminder's model-named trailer is what the pre-push hook refuses. Answered: the repository rule wins.
  • content/docs/protocol/objectql/query-syntax.mdx:602-:610, noted with carrier: none. Verified at origin/main: the callout's headline is true; its mechanism sentences (:605-:610: SqlDriver.applyFilters() compiles the nested object as a single-column comparison, the dotted key is emitted verbatim to Knex) are stale since 4b4ee88f — both forms are refused at the engine first (INVALID_FILTER, INVALID_FIELD). One correction to the dev's note: the page DOES name the served route — :612-:623 shows the two-query fields: ['id'] then $in example — so only the mechanism paragraph is wrong. Rightly not fixed in this PR: the claim (5906472951) held content/docs/** read-only and said any other hit "is reported for the seat to card". Not filing it as a defect is defensible under Prime Directive chore: version packages #10 (true headline, right prescription: neither a trap nor a contract violation). But carrier: none is wrong: the carrier is the seat's card, by the claim's own instruction, and the fix is a one-paragraph docs-only PR on a hand-written tree (Documentation Guardrails). Escalated to the seat: card it as documentation drift with the dev's dedupe words.
  • Substring semantics of the taught $contains on a multiple: true lookup (out of scope; no change asked of this diff). SqlDriver lowers $contains to LIKE '%v%' over the JSON serialization and calls that "incidental rather than designed" (sql-driver.ts:3415-:3419, driver-sql: a declared operator on a multiple: true (JSON array) column silently answers wrong — $in/$eq always zero rows, $nin returns the rows it was asked to EXCLUDE #7398 ask 2 open); the memory driver lowers it to a regex. An id that is a prefix of another id would over-match. The skill teaches exactly the platform's own prescription — the engine's refusal words and the driver's — so this is the platform's open question, not the diff's.
  • Decision [Decision] v18:查询能否直接按关联记录的字段筛选(例:「客户行业 = 科技」的商机) #20802 was ruled A after the PR opened. The body's "the open v18 decision" is now stale wording; the diff itself needs no change. When the v18 card lands the served form, the skill changes again — the ruling names 「skills/objectstack-query([finding] skills/objectstack-query teaches the nested relation filter { relation: { field: value } } as a working form; no data-path driver serves it, and PR #20781 makes the engine refuse it #20782)同步」.
  • Headroom 0 on rules/filters.md. The next edit to that file must pay for itself inside it or lower a ceiling row; a note for the next author, not a defect.
  • CI names. Dogfood Regression Gate (${{ matrix.shard }}/3) appears on the head as a skipped run under an unexpanded matrix name — a workflow quirk on main, not introduced here; the required Dogfood Regression Gate is success.

Implemented-by: claude/issue-20782-query-skill-relation-filter
Reviewed-by: session_01KTZmMfzVzjNvyaLyQ8mHvg

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

维护者速读(终稿)— PR #20811 · 查询技能:关联字段筛选改教可用的两步路线(#20782)

skills 席 1 · session_01KTZmMfzVzjNvyaLyQ8mHvg · 2026-09-30T14:08Z · 所审 head b893787b

改了什么:改的是 skills/objectstack-query 下两个文件,另加 scripts/role-word-baseline.json 一行,技能包净 −39 行。

  • 技能原来有 6 处把 { 关联字段: { 字段: 值 } } 当作能用的筛选写法来教。
  • 现在改为:这种写法会被引擎以 INVALID_FILTER / 400 拒绝;可用的路线是先查关联对象拿到 id,再用 $in 匹配;多值关联改用 $contains。
  • 「按子记录条件筛父记录」拆成正、反两个方向,各给一条可用路线。

为什么改:PR #20781 之后,所有驱动都拒绝这种写法。AI 照旧技能写,要么拿到 400,要么在内存驱动上静默返回 0 行。

风险与代价(含回滚):只改文档,不发布任何包。回滚方式是 revert。

席位意见:ACCEPT,建议批准。

  • 契约复核 PASS(评论 5912934502),由隔离的 fable 子代理出具。复核在代码层确认:engine.find 与各驱动都没有默认行数上限,所以两步路线不会被悄悄截断。
  • 删掉的三个示例,在 SKILL.md 里都另有出处。
  • 一处相关文档 query-syntax.mdx 的机制说明已经过时,不在本 PR 改,席位另立卡交给 docs 车道。
  • 决策 [Decision] v18:查询能否直接按关联记录的字段筛选(例:「客户行业 = 科技」的商机) #20802 已裁 A(v18 支持这种写法)。届时技能还要再改一次;在那之前,本文对 17.x 是对的。
  • CI 全绿。

你要做的:审阅后给 APPROVED,之后由本席位落地。


Generated by Claude Code

This branch has not been deployed

No deployments
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/s skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

3 participants