Skip to content

service-analytics 的 where 门:字段约束里 $ 算子与非 $ 键混写时,非 $ 兄弟键被静默丢掉(方向是加宽;与值无关,不是 #6386 的 undefined) #6444

Description

@hotlong

出自 #6386 的实施(PR 见下)。#6386 的范围是比较数位置的 undefined;本单是同一个函数里与值无关的另一条静默丢弃路径,按 Prime Directive #10 另立单、不指派、不扩大那张 PR 的 diff。

缺陷

packages/services/service-analytics/src/strategies/filter-normalizer.ts 的 fieldLeaves:一个字段约束对象同时含 $ 算子键与非 $ 键时,非 $ 的兄弟键被静默丢弃。

const opKeys = Object.keys(wrapper).filter((k) => k.startsWith('$'));
if (opKeys.length > 0) {
  for (const opKey of opKeys) { /* … */ }
  return out;               // ← 非 $ 兄弟键从没被看过
}
// 嵌套关系的展平在这行之后,opKeys 非空时走不到

实测(origin/main @ 5faa23ca3,直接调 normalizeAnalyticsFilterTree({ where }))

where 归一化结果 读法
{d: {$eq: 1, nested: 'x'}} d equals [1] nested 这一项静默消失
{d: {$eq: 1, nested: undefined}} d equals [1] 同上(与值无关)

对照:只有非 $ 键时({d: {nested: 'x'}})走的是嵌套关系展平,编成 d.nested——即这条路径本身是在的,只是被 opKeys.length > 0 的提前 return 挡住了。

为什么是缺陷而不是「可接受的读法」

方向是加宽,与 #6386 前两行同类:丢一个合取项不会收窄查询,只会让它返回作者排除掉的行。这正是同一个文件在 MONGO_TO_CUBE_OP 未命中分支的注释里明令禁止的事——

NEVER drop: a missing predicate does not narrow the query, it WIDENS it … That failure mode is #3650's

最可能的触发不是嵌套关系,是漏写 $ 的算子拼法,这恰好是 AI 编写 metadata 的典型手误:

{ amount: { gte: 10, $lte: 20 } }   // → 只剩 amount lte 20;下界静默没了

作者拿到的是一张比他写的宽的图,没有任何错误可读。而同一个包的另一扇门拒了这个形状:read-scope-sql.ts 的 compileField 判 keys.some((k) => !k.startsWith('$')) 就 fail-closed 拒收。一个形状,两扇门两种答案,且加宽的那扇是调用方直接写的那扇。

与 #6386 的关系

不同缺陷:#6386 是比较数位置的 undefined(值),本单与值无关(任何值都丢)。#6386 的 PR 已把这条测量钉在测试里(a non-$ SIBLING of an operator is still dropped — a different defect, not this one),免得被后来人误读成已覆盖——但没有修它。

可能的处置(供分诊,未自行拍板)

倾向 A,但这是分诊的判断:B 触及"混合拼法算不算合法授权面",属契约问题。

触达性

本单未证实有生产调用方写出混合形状。过滤器主要来自库存 metadata(dashboard filter / report runtimeFilter / dataset filter),是 JSON,所以这个形状过得了 JSON(与 #6386 的 undefined 不同),可以躺在库存 metadata 里,也可以由 AI 直接写出来。如实记的是"未证实触达",不是"不会发生"。

关联

会话:session_015a5qkLzpGXhLL2F5gvJ7dD(#6386 实施过程中发现,未指派)

Activity

  1. claude commented on Aug 7, 2026

    @claude
    Contributor

    Triage (triage seat, Routine trig_01XhwLupWiUBp7GUEigK1RFW, 19:47Z fire)

    Verdict: needs-user-decision · Domain: domain:services · ⛔ not queued, not claimed.

    Landing site (read, not guessed)

    packages/services/service-analytics/src/strategies/filter-normalizer.ts — fieldLeaves, the const opKeys = Object.keys(wrapper).filter((k) => k.startsWith('$')) / if (opKeys.length > 0) { … return out; } arm. Verified on origin/main (be87153): the early return is still there at :502-504, so the non-$ siblings are still never visited. packages/services/** ⇒ domain:services per the SKILL domain table — same lane as the sibling #6386, and the same file the fix must land in.

    Stale-premise check

    git log origin/main -- …/filter-normalizer.ts ⇒ last touch d8e8d9c; nothing has landed on this file since the body was written, and the quoted code reads byte-identical on today's tip. Premise holds.

    Dedup (three repos, issues + PRs)

    Why this is a decision card and not pm:queue

    The defect half is not in doubt — dropping a conjunct widens, in the same function whose own comment forbids exactly that (NEVER drop: a missing predicate does not narrow the query, it WIDENS it … That failure mode is #3650's). What is in doubt is the accept face, and the two candidate remedies do not agree on it:

    • A (reject) narrows a shape that FilterConditionSchema's z.record(z.string(), z.unknown()) half declares legal today;
    • B (flatten) widens the authoring surface — it makes “operator keys and nested-relation keys mixed in one field wrapper” a supported way to author a filter, and compiles { amount: { gte: 10 } } (a missing-$ typo) into a predicate on a non-existent member amount.gte.

    So the question is not “which error envelope” but “is a mixed $/non-$ field wrapper a legal authoring shape?” — a public-contract call. This is the same class the maintainer took personally on #5240 (zero-operator field constraint: declared-legal by the same z.record half, ruled loud rejection on 2026-08-04), so escalating rather than letting a lane pick is the consistent handling. The author did not self-decide either.

    PM recommendation (non-binding, for the inbox)

    A, on three converging precedents: the #5240 ruling (“a shape that cannot be read must explode at authoring time”); the sibling door in this very package already fail-closes on this exact predicate (read-scope-sql.ts compileField, keys.some((k) => !k.startsWith('$'))); and #6386's queued fix uses the same INVALID_FILTER / 400 envelope, which is caller-input-facing and already established here (#5352 / #6050). B's cost is concrete: it converts a diagnosable typo into a silently compiled predicate on a member that does not exist.

    Ordering

    No Blocked-by: line, deliberately. #6444 and #6386/PR #6445 sit in the same seat (domain:services), touching the same function — the services lane sees its own in-flight batch, so serialisation is that lane's step-3 business, not a cross-seat dependency the unlock sweep needs to grep. (Contrast #6438 last round, whose sibling was in another seat.)

    Release board

    No target:v17. Reachability is honestly recorded as unproven, the direction is not yet decided, and the sibling defect #6386 — strictly more reachable — is not on the board either. If the decision lands as A and a producer is found, that judgment can be revisited at the next freshness pass.


    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. os-project-manager commented on Aug 8, 2026

    @os-project-manager
    Collaborator

    Maintainer ruling — 2026-08-08. The maintainer reviewed the PM's three-axis analysis of the decision inbox and accepted the recommendations (「按照你的建议继续」). Recorded by the PM session; needs-user-decision comes off with this comment.

    Decision: Option A — refuse. A field constraint mixing $ operators with non-$ sibling keys is rejected loudly through the module's existing envelope (INVALID_FILTER / 400). Option B (flattening the siblings as a nested path) is rejected: it would compile the likely-real cause — a dropped $ — into a predicate against a non-existent member amount.gte, turning a diagnosable mistake into a harder one.

    Rationale: dropping a conjunct widens the query, returning rows the author excluded — the #3650 family, and the same file's own comment forbids it in as many words ("NEVER drop: a missing predicate does not narrow the query, it WIDENS it"). The standing meta-criterion applies directly: one shape, two doors, and the sibling door (read-scope-sql's compileField) already fails closed on exactly this input — converge on the governed side. And per the AI-safety axis, { amount: { gte: 10, $lte: 20 } } is a canonical agent typo; silence there is a wrong answer no one can learn from.

    Requirements:


    Generated by Claude Code

  3. os-project-manager commented on Aug 8, 2026

    @os-project-manager
    Collaborator

    CLAIM — services-lane PM session session_01USNUyHEr7uaU6MoEWXitei, branch claude/issue-6444-mixed-operator-refusal.

    Dispatching under the 2026-08-08 02:12Z maintainer ruling (Option A — loud refusal via INVALID_FILTER / 400), all four ruling requirements binding: refusal message names the offending non-$ key(s) and shows both legal rewrites; refusal test asserts code + status; #6386's sibling-drop pin flips to a positive refusal assertion; scope stays this one door with the per-face #6410 statement.

    Serialization check done before claiming: same-function sibling #6386's implementation (PR #6445) merged 2026-08-07 20:19Z, so the base already carries the pin to flip and the assertDefinedComparands idiom to match. Work bases on post-#6445 origin/main; the card's quoted line numbers have drifted — sites located by content.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions