Repository navigation
[Decision] v18:查询能否直接按关联记录的字段筛选(例:「客户行业 = 科技」的商机) #20802
Description
Ruled: 5907789183 · letter A — v18 serves { relation: { field: value } } at the #5930 seam (one level, forward, as the caller, a loud cap); this card carries the work · 2026-09-30T08:59Z
Unblocked: #5930 step 2 (the engine seam) landed as cfa931535 on 2026-09-30T08:00Z; released to pm:queue by domain:engine#1, record 5907891818.
Blocked-by: #20887
Filed by the triage seat (objectstack-wide, seat post #6015, session_01AavokzJ5DndAwitDXvKy4U) at the maintainer's request 「开一张 v18 的决策卡」. ⛔ Not a claim, ⛔ not a dispatch, ⛔ never dispatched while needs-user-decision is on. Graded: enhancement · priority:p3 · domain:engine · area:api · target:v18.
一句话问题
作者想查「客户行业是科技的商机」,最自然的写法是把条件写在关联字段下面。平台的类型接受这个写法,但数据查询会报 400;而同样的写法放到分析看板里却能查出结果。v18 要真的支持它,还是把这个写法从协议里去掉?
背景
- [finding] a no-operator object under a lookup, master_detail or json field answers per driver: the declared nested-relation filter returns no rows on memory and a 400 on SQL, and a json object comparand deep-equals on memory and is refused on SQL #20745 实测:
{ account: { industry: 'tech' } }这类「关联字段下挂无运算符对象」的写法,内存驱动静默返回 0 行,SQL 驱动返回 400。 - PR fix(objectql)!: a no-operator object beneath a relation, structured-JSON or undeclared id column is refused INVALID_FILTER / 400 on every driver (#20745) #20781(
4b4ee88fb)先把今天的答案统一成响亮拒绝:所有驱动都返回INVALID_FILTER/ 400,报错里指明可行的路(先查关联对象,再对关联字段用$in匹配它返回的 id)。文档里教这个写法的示例也删了。 - 当时 triage 把「要不要真正支持」留给维护者([finding] a no-operator object under a lookup, master_detail or json field answers per driver: the declared nested-relation filter returns no rows on memory and a 400 on SQL, and a json object comparand deep-equals on memory and is refused on SQL #20745 的定级
5902573237;[finding] 仓内存在 5 个独立的过滤器→谓词编译器,每次语义裁决成本 ×5 —— 值得立「谓词编译收敛」调查程序(#5298 成本清单副产品) #5930 上的指针5902585544)。[finding] a no-operator object under a lookup, master_detail or json field answers per driver: the declared nested-relation filter returns no rows on memory and a 400 on SQL, and a json object comparand deep-equals on memory and is refused on SQL #20745 已关,这个问题没有自然的挂靠处,所以单开此卡。
Governing text
- spec 声明:
packages/spec/src/data/filter.zod.ts的FilterConditiondocblock,第 4 种形态「Nested relations:{ relation: { field: value } }」。它写明类型和 schema 接受、查询引擎拒绝。
re-check:git grep -n "4. Nested relations" origin/main -- packages/spec/src/data/filter.zod.ts→ 1 - 裁决基准(pm-dispatch 技能的「基本裁决原则」):spec 声明 > 实现 > 文档面;声明了却不兑现是实现缺口,只能补实现或退役。所以「一直维持现状」不是选项,只是等裁决期间的过渡状态。
- seam 架构(ADR-0053 修订第 5 条,即 [finding] 仓内存在 5 个独立的过滤器→谓词编译器,每次语义裁决成本 ×5 —— 值得立「谓词编译收敛」调查程序(#5298 成本清单副产品) #5930 裁决的 D4 (b)):驱动收到的是已经在 seam 下译好的过滤条件,驱动本身不改。
协议声明、是否改协议
- A 改的是 docblock 的描述:从「引擎拒绝」改成「引擎服务」。类型不收窄,属于扩大,
Clause-②: no。 - B 要收窄
FilterCondition:Clause-②: yes,是 BREAKING 的minor,存量过滤条件需要一条 ADR-0087 处置。
前提(每条带 re-check,均已在 origin/main 96e724475c 跑过)
- CRUD 数据口今天拒绝这个写法,点号路径(
'owner.region')也在更早一道门被 [finding] The FILTER axis has no DOTTED-path verdict —where: { project_id.name: 'x' }rides its head segment past both doors, where SORT refuses the same spelling (#4256) #8371 拒掉。
git grep -n "the #8371 dotted verdict" origin/main -- packages/objectql/src/no-operator-object-door.ts→ 1 - 分析看板那一面今天是支持的:cube 过滤把它拍平成点号成员(
profile.verified),靠 cube 声明的 join 解析。
git grep -n "Nested relation (e.g." origin/main -- packages/services/service-analytics/src/strategies/filter-normalizer.ts→ 1 - 分析的读权限范围那一面拒绝它。
git grep -n 'non-$ key means a nested relation' origin/main -- packages/services/service-analytics/src/read-scope-sql.ts→ 1 - 引擎里已有同形的机制:
expand批量加载关联记录,用的就是对关联 id 的$in查询。
git grep -n 'Batch-load related records using $in' origin/main -- packages/objectql/src/engine.ts→ 1 - 业务拉动(实测):hotcrm 有 4 个 hook 手写同一段两步查询:先查成员行拿到线索 id,再对线索用
id: { $in: leadIds },而且第一步手挑了top: 5000,超过 5000 条会静默截断。
cd hotcrm && git grep -n 'id: { $in: leadIds }' origin/main -- src→ 4
这 4 处是反向(父找子),不是本卡的正向写法。hotcrm 的 271 行filter/where中,按正则扫描,正向嵌套写法为 0。
objectui 按关键字检索(nested relation|relatedField|related field|cross-object filter)为 0 命中;这是关键字检索,⛔ 不是 UI 走查。
具体问题
v18 的平台契约里,「按关联记录的字段筛选」要么是服务的能力(A),要么不存在(B)。不裁时,今天的响亮拒绝继续有效,不影响 17.x 发版。
选项 × 真实代价
| 选项 | 做什么 | 客户可感知的后果 |
|---|---|---|
| A 支持(v18,排在 #5930 收敛的 seam 之后) | 引擎在一个 seam 上把 { 关联: { 字段: 值 } } 下译成「以调用者身份查关联对象 → 对关联字段 $in 这些 id」,驱动不改(D4 (b))。首版只做一层、只做正向(子 → 父)。多值关联按「任一匹配」处理。 |
列表视图、API、AI 写的查询都能直接写「客户行业 = 科技」。CRUD 与看板对同一写法给同一个答案。多一次内部查询;关联集合很大时按上限响亮拒绝,⛔ 不截断。 |
| B 退役(v18 收窄类型) | FilterCondition 不再接受这个写法,在发布、保存时就报错,只剩「两步 + $in」一条路。分析看板的嵌套写法同时退役,点号成员写法保留。 |
作者和 AI 在保存时就被拦下,不用等到运行时。但这个能力要靠手写两步查询,像 hotcrm 那 4 个 hook 一样,会重复写、会截断。 |
业务含义直译
- A = 像 Salesforce 那样,列表筛选可以直接写「客户.行业 = 科技」。平台替你做两步查询,并且按你的权限做。
- B = 平台明说「不能跨对象筛选」,想做就在代码里自己先查一遍。像早期只支持单表筛选的轻量表格工具。
四轴论证(从业务立场)
- 项目长远合理性:两年后,一个元数据 CRM 平台应当能在列表和查询里按父记录字段筛选。主流做法都是服务它:Salesforce SOQL 的父关系字段 WHERE(
Account.Industry)、Dataverse FetchXML 的link-entity过滤、ServiceNow 的 dot-walking。A 把它落在一个 seam 上,和 [finding] 仓内存在 5 个独立的过滤器→谓词编译器,每次语义裁决成本 ×5 —— 值得立「谓词编译收敛」调查程序(#5298 成本清单副产品) #5930 的收敛方向(一处下译,驱动不改)一致,并让 CRUD 与看板对齐。B 让契约更小,但这个能力大概率会以新拼写回来,届时要再迁一次。 - 实际业务拉动:正向写法今天零作者(hotcrm 为 0,objectui 关键字检索为 0)。相邻的需求是实测存在的:hotcrm 4 处手写两步查询,而且带静默截断。这说明「跨对象筛选」被需要,但需要的是反向,A 的首版不覆盖。
- 防 AI 犯错:AI 最自然写出的正是这个嵌套写法。
- A:写出来就对,而且以调用者权限执行。出错面在权限:若内部查询不套关联对象的读权限,就成了静默泄露(能按看不见的字段值筛出记录),所以这条是 A 的硬约束。
- B:保存时响亮拒绝,结构上最难写错,但 AI 会转去手写两步查询,把截断之类的错误带进应用代码。
- 创业阶段不扩散:B 最省,删一个声明面,不留永久义务。A 新增一个要长期维护的能力,含权限、上限、多值语义。A 的成本被 [finding] 仓内存在 5 个独立的过滤器→谓词编译器,每次语义裁决成本 ×5 —— 值得立「谓词编译收敛」调查程序(#5298 成本清单副产品) #5930 的 seam 摊薄,但仍是新义务。
os-decision-facets
- ① 项目长远合理性:A 与主流平台和 [finding] 仓内存在 5 个独立的过滤器→谓词编译器,每次语义裁决成本 ×5 —— 值得立「谓词编译收敛」调查程序(#5298 成本清单副产品) #5930 的单点下译一致,并消除 CRUD 与看板的分叉;B 缩小契约,但能力大概率日后以新拼写回归。
- ② 实际业务拉动:正向写法实测零作者;hotcrm 有 4 处反向两步查询(带静默截断),A 首版不覆盖。
- ③ 防 AI 犯错:B 在保存时响亮拒绝,结构最硬;A 让自然写法直接可用,但必须以调用者读权限执行,否则是静默泄露。
- ④ 创业阶段不扩散:B 删除声明面,零后续义务;A 新增权限、上限、多值三项长期义务。
Prior rulings read: nested relation / relation filter in docs/adr → 0 hits (only content/docs/references/api/contract.mdx maxQueryDepth); ADR-0053 amendment item 5 (D4 (b)); thread: #20745 grade 5902573237, #5930 ruling 5902355785, #20546 grade 5882227960 (which kept this form as an unjudged control).
推荐
推荐 A:v18 支持,排在 #5930 收敛落地 seam 之后;首版一层、正向、以调用者读权限执行、超上限响亮拒绝。
- 回退:B。 如果维护者按 ②④ 从紧(零正向拉动 → 不扩散),B 是干净的另一端。
- 自检: 只看①选 A;②③④ 是否翻转:否。② 的零正向拉动和 ④ 的从紧只调时序与范围(排在 seam 之后,限一层、正向),不翻字母。
- 置信缺口: 看不到租户库里存量视图和过滤条件是否带这个写法(自 PR fix(objectql)!: a no-operator object beneath a relation, structured-JSON or undeclared id column is refused INVALID_FILTER / 400 on every driver (#20745) #20781 起它们都会 400,但没有计数);大关联集合下「先取 id 再
$in」的实际开销没测过;objectui 没做 UI 走查。
裁后执行(维护者只裁方向)
- A → triage 开一张
domain:engine·target:v18卡,Blocked-by:[finding] 仓内存在 5 个独立的过滤器→谓词编译器,每次语义裁决成本 ×5 —— 值得立「谓词编译收敛」调查程序(#5298 成本清单副产品) #5930 落地 seam 的那一步。卡上写明:- 下译位置在引擎 seam,驱动不改;
- 内部查询以调用者身份执行,套用关联对象的行权限和字段权限;对调用者不可读字段的条件响亮拒绝,⛔ 不静默返回空;
- id 集合设上限,超限响亮拒绝,⛔ 不截断;
- 多值关联按「任一匹配」;
- docblock 第 4 条改为「服务」,恢复
data-engine.mdx示例,skills/objectstack-query([finding]skills/objectstack-queryteaches 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)同步; - pins:[finding] a no-operator object under a lookup, master_detail or json field answers per driver: the declared nested-relation filter returns no rows on memory and a 400 on SQL, and a json object comparand deep-equals on memory and is refused on SQL #20745 的表在 memory / SQLite / PostgreSQL 上翻成返回
d1、d3,外加一条权限 pin 和一条上限 pin。 - 反向(父找子,hotcrm 那 4 处)另议,⛔ 不并入首版。
- B → triage 开一张
domain:spec卡:收窄FilterCondition,Clause-②: yes,BREAKINGminor;存量过滤条件按 ADR-0087 处置,带指引拒绝;分析看板的嵌套写法同时退役,点号成员写法保留;[finding]skills/objectstack-queryteaches 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 的 skill 文案改成「这是唯一的路」。 - 不裁 → 维持 PR fix(objectql)!: a no-operator object beneath a relation, structured-JSON or undeclared id column is refused INVALID_FILTER / 400 on every driver (#20745) #20781 的响亮拒绝,不影响发版。
相关单与 PR
#20745(已关,PR #20781)· #20546 / PR #20744 · #5930(v18 过滤器收敛,裁决 5902355785)· #20782(skill 教 $in)· #8371(点号路径拒绝)· ADR-0053 修订第 5 条 · ADR-0049 · ADR-0087
Activity
objectstack-fleet commentedon Sep 30, 2026
os-dev-report
{
"issue": 20802,
"status": "done",
"branch": "claude/issue-20802-relation-filter-lowering",
"pr": "#20872",
"session": "session_01DEvba2nBuD4tWzfq8r8NFY",
"premise_still_valid": true,
"summary": "The engine half of ruling 5907789183 (letter A), as Part of #20802. The nested-relation form { relation: { field: value } } is served in where, on find / findOne / count / aggregate / update / delete and judgeFilter. It is lowered at the #5930 seam, between token resolution and lowerFilterCondition. The engine reads the related object with its own find, as the caller (fields ['id'], limit cap+1), and hands every driver $in (single-valued) or an $or of $contains per id (multi-valued, any member). No driver file changed. No second walk: the door and both lowering passes use the one walkCondition. Kept refusals: a second level, a dotted inner key, an undeclared inner key, {}, an unregistered related object, json object comparands, the dotted path (INVALID_FIELD), and the form at aggregations[i].filter / having. The cap RELATION_FILTER_ID_CAP = 1000 is exported, and past it the filter is refused INVALID_FILTER / 400 with the two-step route. Changes beside the lowering: the spec docblock item 4, the data-engine.mdx example restored, and both dotted-relation refusals (objectql and metadata-protocol), whose 'a filter reaches only columns' claim this change made false; they now name the nested route. The permission refusal reuses the existing check (403 PERMISSION_DENIED), not the ruling's parenthetical INVALID_FILTER; see open_questions.",
"tests": "All on 56da9b6 (a merge of origin/main 660a9b2). objectql test 345 files / 6786 passed; typecheck exit 0 (check:test-typecheck OK). rest test with OS_TEST_POSTGRES_URL set to a local PostgreSQL 16.13: 237 files / 4706 passed / 35 skipped; typecheck exit 0. metadata-protocol test 191 files passed, 3 skipped / 2801 passed, 19 skipped; typecheck exit 0. spec test 578 files / 17066 passed / 1 todo; typecheck exit 0; check:generated 15/15 up to date. Downstream: plugin-security 149 files / 3227 passed, 23 skipped; driver-memory 65 / 1470; driver-sql 201 files passed, 11 skipped / 3254 passed, 188 skipped. New pins: objectql engine-nested-relation-lowering.test.ts (13 tests); rest data-nested-object-door.test.ts rewritten (sqlite 6 + live pg 6 passed, mysql skipped); rest data-nested-relation-permission.test.ts (3 tests, real SecurityPlugin). Ablation A (lowering disabled, anchor 'if (sites.length === 0) return where;', blob 9237c3dfc995 -> f9994599356f, marker ablated_20802_lowering in 4 built files after an objectql rebuild): predicted red, observed red, objectql pins 10 failed / 235 passed, rest pins 11 failed / 4 passed / 6 skipped. Ablation B (related read as isSystem, blob -> b0c1748c5b70, marker ablated_20802_caller in 4 built files): predicted red, observed red, objectql 1 failed / 244 (the as-the-caller pin), rest 2 failed / 13 passed (the permission pin answered rows instead of 403; the row-scope pin returned d4). A first B run left execCtx unused, and its DTS step failed on TS6133 (the JS carried the marker); it was re-run type-clean and those numbers are the ones above. Restore: blob == HEAD 9237c3dfc995, git diff HEAD empty; after a rebuild both markers were --absent from 14 built files and the whole tree was clean; pins green again (245 passed; 15 passed / 6 skipped). Lint: eslint --no-inline-config --format json over the 15 changed .ts files at 56da9b6 gave 15 files / 0 errors / 0 warnings; isPathIgnored false for 15; parserOptions.project and projectService unset for 15, so no type-aware lint and untouched files cannot move.",
"gates": "node scripts/pm/dispatch-gates.mjs --commands at 56da9b6 (fresh, --repo holds) derived 120. All 120 run. --ran with exit codes: 120 derived / 120 run / 0 NOT-MEASURED / 0 UNRUN, all exit 0. check:skill-examples, check:dual-build-cjs-loads and check:type-check-debt first exited 3 (PREREQUISITE NOT MET) and were re-run green after turbo run build --filter='./packages/' --filter='./packages//'. The first derivation at a70adfc said STALE TREE (doc-authoring-prose-id.baseline.json changed on main), so main was merged and the list re-derived before the run.",
"line_budget": "No skills/** or governed surface touched (not Tier S/H). Changed lines 1819 (+1611 / -208) over 20 files, under the 5,000-line queue class. check:skill-line-ratchet not derived.",
"files_changed": [
".changeset/20802-dotted-relation-route.md (+7)",
".changeset/20802-nested-relation-filter-served.md (+28)",
".changeset/20802-nested-relation-prose.md (+7)",
"content/docs/kernel/contracts/data-engine.mdx (+30 -8)",
"packages/metadata-protocol/src/protocol.ts (+22 -2)",
"packages/objectql/src/engine-nested-object-door.test.ts (+22 -39)",
"packages/objectql/src/engine-nested-relation-lowering.test.ts (+363)",
"packages/objectql/src/engine.ts (+204 -27)",
"packages/objectql/src/filter-comparand-shape.ts (+22 -3)",
"packages/objectql/src/index.ts (+5)",
"packages/objectql/src/no-operator-object-door.ts (+31 -13)",
"packages/objectql/src/number-comparand-declared-type-door.ts (+164 -21)",
"packages/objectql/src/protocol-explicit-filter-field-gate.test.ts (+7 -5)",
"packages/objectql/src/query-expression-conformance.test.ts (+31 -11)",
"packages/objectql/src/relation-filter-lowering.ts (+304)",
"packages/rest/src/data-nested-object-door.test.ts (+126 -62)",
"packages/rest/src/data-nested-relation-permission.test.ts (+202)",
"packages/rest/src/data-no-operator-object-door.test.ts (+4 -3)",
"packages/spec/src/data/filter.zod.ts (+25 -14, prose only)",
"scripts/adr-anchors/packages__objectql__src__relation-filter-lowering.ts.json (+7)"
],
"deviations": [
"Surface beyond the claim's list, named in the PR body: packages/metadata-protocol/src/protocol.ts. Its dotted-relation refusal said 'a filter reaches only columns of the object itself', which this change made false; it now names the nested route in the engine door's words. There are also the rest pins (packages/rest/src/.test.ts, where #20745's SQLite/PG table lives), one new objectql export (RELATION_FILTER_ID_CAP) and one ADR anchor JSON. No driver file, no packages/services/, no skills/ was touched.",
"Three changesets, 20802-*: objectql minor, spec patch, metadata-protocol patch. The order named one objectql file; the other two cover the spec JSDoc and the protocol words.",
"Permission refusal envelope: 403 PERMISSION_DENIED from the one existing check, not the ruling's parenthetical INVALID_FILTER. See open_questions.",
"H2 widened: the form is served under every REFERENCE_VALUE_TYPES kind (user and tree too), not only lookup / master_detail.",
"Positions: served at where only. aggregations[i].filter and having keep the refusal, with words updated to say where serves it.",
"Measurement scaffolding, removed and never committed: two probe test files (packages/rest/src, packages/plugins/plugin-security/src); a private PostgreSQL 16 on 127.0.0.1 under /var/lib/postgresql/issue-20802-pg, started and stopped by this run and its directory deleted; one chmod o+x on /tmp/claude-0, which the environment reset.",
"Main moved again after the final verification (72f8c38, rest security/explain). It was not merged, so the verified head 56da9b6 stays the PR head."
],
"mcp_calls": "0",
"api_writes": "3 relay dispatches as objectstack-fleet[bot] (each POST /repos/objectstack-ai/objectstack/dispatches executed by fleet-write.yml): (1) pr_create -> POST /repos/objectstack-ai/objectstack/pulls, #20872 draft, body read back 15976/15976 bytes identical; (2) label-write --assign os-support-ai -> POST /repos//issues/20872/assignees, read back matches; (3) this os-dev-report comment -> POST /repos//issues/20802/comments. git push is not a REST write. REST reads only otherwise.",
"open_questions": [
{
"question": "The ruling says a condition on a related field the caller cannot read is refused loudly '(INVALID_FILTER in the engine's words, naming the field)'. Measured (H3): a direct filter on that field is already refused by the security layer's filter-oracle guard, 403 PERMISSION_DENIED naming the field and the object. The related read reaches that same check, so the nested form answers the same 403, loudly and never empty. Keep that envelope, or translate it?",
"options": [
"A: keep the reused check's 403 PERMISSION_DENIED (this PR). One rule, one answer for one violation, direct or nested; no second copy of a permission rule.",
"B: catch the security layer's field_predicate_denied refusal in the lowering and re-raise INVALID_FILTER / 400. objectql would couple to a plugin's reason string, and the same violation would answer two ways (direct 403, nested 400).",
"C: an engine-side readable-field check that raises INVALID_FILTER. This is a second copy of the permission rule, which the order forbids."
],
"recommendation": "A, on the four axes. (1) Long-term: one permission rule lives in the security layer; B/C fork it. (2) Business pull: zero forward authors today (the ruling's own reading), so no caller depends on the 400 spelling. (3) Preventing AI mistakes: the ruling's operative guarantees hold (loud, names the field, never an empty result; the ablation proves a system read would have leaked), and one answer per violation is harder to get wrong than two. (4) Startup scope: A adds nothing; B adds a translation seam to maintain. The ruling's parenthetical reads as an assumption that the engine would own the check, and the measurement shows it does not."
}
],
"out_of_scope_findings": [
"class: a (with a b half) · reach: POST /api/v1/data/:object/query with aggregations [{count, alias n}, {count, alias m, filter: { owners: { $contains: 'u1' } }}] over a multiple lookup answers m: 0 on SQLite, PostgreSQL 16 and InMemoryDriver where where answers d1, d3 (2 rows); measured on origin/main 688ddef (probe, not committed) · evidence: the engine's own evaluator for the per-aggregation filter (having-filter.ts matchesAggregationFilter) fails $contains on any non-string value and compares $in lists by whole value, so a stored array never matches; FILTER_OPERATORS' $contains docblock declares membership over a stored array · Seam: spec:FILTER_OPERATORS $contains (membership over a stored list) → runtime:objectql having-filter.ts matchesAggregationFilter · dedupe words: per-aggregation filter $contains multiple lookup count 0 · matchesAggregationFilter stored array membership · aggregation filter any member",
"class: b · reach: POST /api/v1/data/:object/query on an InMemoryDriver deployment, where { owners: { $contains: 'u1' } } over a row storing ['u10'] matches it (measured: memory d1, d3, d5 vs SQL d1, d3), and the nested-relation lowering inherits it on memory for a multi-valued relation · evidence: spec FILTER_OPERATORS $contains docblock: 'driver-memory — DOES NOT ANSWER IT YET … matches a stored array by substring PER ELEMENT'; the tracking card it names (#17286) answers 404 · Seam: spec:FILTER_OPERATORS $contains docblock → runtime:driver-memory memory-driver.ts $contains arm · dedupe words: driver-memory $contains stored array substring per element · memory multiple lookup contains u10 · stored-array axis membership",
"carrier: none · noted, not filed: @objectstack/lint validate-list-view-field-refs.ts hint for a dotted relation filter path says 'Filter on a column of … itself'; an instruction, not a claim made false, left unchanged (PR Acceptance notes)",
"carrier: none · noted, not filed: the related read runs at stage 2, before the outer verb's own middleware, so a caller without read access to the queried object still gets the outer 403 but the related read (as that caller) has already run; it reads nothing the caller could not read directly (PR Acceptance notes)"
]
}
objectstack-fleet commentedon Sep 30, 2026
Seat answer — #20802's open question (PR #20872): A, keep the reused check's
403 PERMISSION_DENIED; open to the maintainer's and the director's vetodomain:engine#1·session_01DEvba2nBuD4tWzfq8r8NFY· 2026-09-30T14:05Z. Answersopen_questions[0]ofos-dev-report5912874377. The seat answers in-seat. ⛔ Not a re-ruling of letter A, its scope or its sequencing.INVALID_FILTERin the engine's words, naming the field), ⛔ never a silent empty result". It names the leak it guards against: "filtering by a value the caller may not see".403 PERMISSION_DENIED, naming the field and the object. The nested form's inner read runs as the caller and reaches that same check, so it answers the same 403: loud, naming the field, never empty. Ablation B (the inner read asisSystem) turned the permission pin from 403 into rows, which proves the check is what holds the line.field_predicate_deniedand re-raiseINVALID_FILTER/ 400) couplesobjectqlto a plugin's reason string, and makes one violation answer two ways (direct 403, nested 400).Generated by Claude Code
objectstack-fleet commentedon Sep 30, 2026
ACCEPT — PR #20872 @
56da9b6d5(the engine half of ruling 5907789183; the card stays open)domain:engine#1·session_01DEvba2nBuD4tWzfq8r8NFY· 2026-09-30T14:30Z. The seat is the reviewer of record. Everything below was read on GitHub and onorigin/main, not taken from the report.Part of #20802, thenClause-②: yes (widening).@objectstack/objectqlminor(the accept set widens, plus one export,RELATION_FILTER_ID_CAP),@objectstack/specpatch(docblock item 4), and@objectstack/metadata-protocolpatch(a dotted-path refusal whose claim this change made false).os-support-ai. 20 files, +1611/-208; not governed.{ relation: { field: value } }inwhereonfind,findOne,count,aggregate,update,deleteandjudgeFilter, across every reference kind.lowerFilterCondition: an innerfindof the related object as the caller (fields: ['id'],limitcap+1), then$infor a single-valued relation or an$orof$containsper id for a multi-valued one.RELATION_FILTER_ID_CAP(1000) the filter is refusedINVALID_FILTER/ 400 with the two-step route, ⛔ never truncated.{}, an unregistered related object,jsonobject comparands, the dotted path, and this form ataggregations[i].filterandhaving. Their words now point towhere.isSystem) leaked rows, so that context is load-bearing.403 PERMISSION_DENIED, naming the field. That is the seat's in-seat answer A (5912908152) to the dev's open question, open to the maintainer's and the director's veto; the ruling's operative guarantees (loud, names the field, never empty) hold.Local-runs: none).$containson a stored array by substring per element (u1matches a row storingu10), where the SQL drivers answer membership; the spec docblock records the gap against a card that answers 404 #20874. The in-memory driver answers$containson a stored array by substring per element, so on memory a multi-valued nested filter for an id that prefixes another (u1,u10) matches both. The review judged it a filed follow-up, not a FAIL, and the objectql changeset states the caveat.filterwith$containson a multiple lookup counts 0 on every driver while the samewherefinds the rows: the engine's aggregation evaluator never matches a stored array #20873. A per-aggregationfilterwith$containson a multiple lookup counts 0 on every driver. This is pre-existing and not reachable through this diff.success, 2skipped, all rostered.Landing: ready and auto-merge in this act. The card's second half (the analytics cube read and the read-scope refusal) waits on #5930 step 3 (#20810).
skills/objectstack-query(#20782) is told after landing.Generated by Claude Code
objectstack-fleet commentedon Sep 30, 2026
Landed — PR #20872 as
ca5408c62(the engine half; the card stays open)domain:engine#1·session_01DEvba2nBuD4tWzfq8r8NFY· 2026-09-30T15:20Z.main:ca5408c62is a squash with one parent (0803a8b87) and an ancestor oforigin/main.packages/objectql/src/relation-filter-lowering.tsexists at the squash and not at its parent. 20 files, +1611/-208, as reviewed.pr_readyandautomerge_enableas two separate relay acts at the reviewed head56da9b6d5(PASS 5913169570). The 2 skipped checks were rostered.added_to_merge_queueat 14:49Z; merged by the queue at 15:19Z.mainnow:{ relation: { field: value } }is served inwhereon every engine verb and on the REST query doors, across every reference kind.$in, or$containsper id for a multi-valued relation.403 PERMISSION_DENIED(seat answer A, 5912908152, open to veto).where/ preview door, the read scope) and the memory cube face's door, with the F5 / F11 output vocabulary #20810) landed as793fb839, so nothing waits. That half ispackages/services/service-analytics, thedomain:serviceslane's, and goes to a per-layer child filed in this act. This card goespm:blockedon it.skills/objectstack-query([finding]skills/objectstack-queryteaches 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, PR docs(skills): objectstack-query teaches the served route for a related record's column, not the refused nested form #20811,1d202453) landed at 14:43Z teaching the engine's refusal, 36 minutes before this made the form served. It now states a refusal the engine no longer gives, so a skills-lane card is filed in this act.query-syntax.mdx) gets a pointer.filterwith$containson a multiple lookup counts 0 on every driver while the samewherefinds the rows: the engine's aggregation evaluator never matches a stored array #20873 (per-aggregation$containson a stored array) and [finding] driver-memory answers$containson a stored array by substring per element (u1matches a row storingu10), where the SQL drivers answer membership; the spec docblock records the gap against a card that answers 404 #20874 (memory$containssubstring per element).Generated by Claude Code
test(driver-memory,driver-sql): pin $contains membership on every mem…
objectstack-fleet commentedon Oct 1, 2026
Triage: blocker landed. #20887 closed
completed(PR #20916). This card can resume or be releasedTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-01T03:03Z. ⛔ Not a claim, ⛔ not a dispatch.@os-support-ai: this card is
pm:blockedon #20887 and assigned to you (domain:engine#1). Since your landing note5914262637, the blocker has landed: PR #20916, the analytics half, merged at 2026-10-01T01:08Z.pm:dispatched) under your claim, or release with a line-startRelease:line so the card returns topm:queue.pm:blockedwith an assignee is not something the dispatcher can act on, and triage does not change the assignee.Generated by Claude Code
objectstack-fleet commentedon Oct 1, 2026
Release: session
session_01DEvba2nBuD4tWzfq8r8NFY(domain:engine#1,os-support-ai) · reason: the maintainer's order relayed by seat 2 on #6367 (5922029579: 「seat 1 只处理在手任务,你接管 engine 车道。」, in seat 2's chat), and the maintainer's stand-down order in this seat's own chat (「当前任务处理完,合并后就下班」) · to:domain:engine#2(seat post #20966), viapm:queuedomain:engine#1· 2026-10-01T04:35Z. The assignee is cleared in this act, andpm:blocked→pm:queue.ca5408c62(ACCEPT 5913372850, landing record 5914262637).{ relation: { field: value } }is served inwhereat the [finding] 仓内存在 5 个独立的过滤器→谓词编译器,每次语义裁决成本 ×5 —— 值得立「谓词编译收敛」调查程序(#5298 成本清单副产品) #5930 seam.domain:services): the cube read and the analytics read scope answer{ relation: { field: value } }as the engine seam now serves it — as the caller, capped, one answer on every face #20887 (the analytics half,domain:services) closed ascompletedat 01:08Z today.403.mainwhether anything remains beyond the two halves (for example, the publishedobjectstack-queryskill text, [finding]skills/objectstack-querynow teaches that the engine refuses{ relation: { field: value } }(PR #20811), but since PR #20872 (ca5408c62) the engine serves it; the published skill states a refusal that no longer happens #20888). If nothing does, the card closes ascompleted.Generated by Claude Code
objectstack-fleet commentedon Oct 1, 2026
Unlock, verified, and closed
completed: every item of ruling 5907789183 (A) is onmainc27404f0a9domain:engine#1·session_01DEvba2nBuD4tWzfq8r8NFY· 2026-10-01T05:32Z.Provenance: the maintainer, in this seat's chat: 「#20680 和 #20731 的阻塞卡关闭,你负责解锁」. This card was in the same state: its blocker #20887 closed, and triage asked on it (5923885559). This seat released it (5924822255) before it ran the unlock it owed. This act runs that unlock and supersedes the release's "for the next claimer" line.
The three unlock duties:
domain:services): the cube read and the analytics read scope answer{ relation: { field: value } }as the engine seam now serves it — as the caller, capped, one answer on every face #20887 closedcompletedtoday at 01:08Z, through PR fix(service-analytics)!: the nested-relation filter gets the engine's answer on every analytics face — the related object read as the caller, capped (#20887) #20916 →8d329f02ee.RELATION_FILTER_ID_CAP, matching any member of a multi-valued relation;filterand the SQL echo refuse it, as the engine refuses it at an aggregation'sfilter;read-scope-sql.ts) is in the diff.main:ca5408c62, one level, forward, as the caller, with a loud cap and drivers untouched. Pins: [finding] a no-operator object under a lookup, master_detail or json field answers per driver: the declared nested-relation filter returns no rows on memory and a 400 on SQL, and a json object comparand deep-equals on memory and is refused on SQL #20745's table, a permission pin and a cap pin (ACCEPT 5913372850).FilterCondition's docblock item 4 now says the engine "SERVES it inwhere" (packages/spec/src/data/filter.zod.ts, read now).content/docs/references/data/data-engine.mdx("Nested relation filter",account: { industry: 'tech' }).skills/objectstack-queryteaches 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 and [finding]skills/objectstack-querynow teaches that the engine refuses{ relation: { field: value } }(PR #20811), but since PR #20872 (ca5408c62) the engine serves it; the published skill states a refusal that no longer happens #20888 are closedcompleted.Open to veto, unchanged: the in-seat answer 5912908152 (A). An unreadable related field answers the security layer's
403, not the ruling's parenthetical400. PR #20916 carried the same403to the analytics faces.Closing as
completed.pm:queueis removed in this act.Generated by Claude Code
docs(skills): objectstack-query teaches the served route for a relate…
feat(objectql): serve the nested-relation filter in where — lowered a…
docs(skills): objectstack-query states the served nested-relation fil…
fix(service-analytics)!: the nested-relation filter gets the engine's…