Skip to content

service-analytics 的 where 门把 undefined 值的键整个丢掉 —— 单键 where 退化成「无过滤器」,方向是加宽(#6125 五面表漏记的第六、七种读法) #6386

Closed

Description

@os-zhuang

出自 #6125 的实施(PR 见下)。#6125 的裁决只派了 read-scope-sql 一格;实施中在同一个包的另一扇门上实测出同一形状的两种新读法,按 Prime Directive #10 另立单、不指派、不扩大那张 PR 的 diff。

⚠️ 这两格不在 #6125 正文的五面表里。 那张表把 service-analytics 记作一行(read-scope-sql),但本包有两扇独立的门:调用方自己写的 where 走 strategies/filter-normalizer.ts(INVALID_FILTER / 400,#5352),平台编译出来的 read scope 走 read-scope-sql.ts(READ_SCOPE_COMPILE_FAILED / 500)。#6125 只实测了后者。

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

where 归一化结果 读法
{d: undefined} null 整条 where 被丢弃 → 查询无过滤器运行
{stage:'won', d: undefined} 只剩 stage equals 'won' d 这一合取项静默消失
{$not: {d: undefined}} NOT (d set),即 d IS NULL 作者没写过的谓词
{d: {$eq: undefined}} d equals [null] 值比较(零行),不是 $eq: null 的 notSet
{d: {$gt: undefined}} d gt [null] 同上
{d: {$in: [undefined]}} d in [null] 同上
{d: {$ne: undefined}} d notSet OR d notEquals [null] 同上

对照组(null 比较数)正常:{d: null} / {$eq: null} → notSet,{$ne: null} → set。

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

前两行的方向是加宽,不是 fail-closed:

与 #6050 裁决的关系

#6050 于 2026-08-07 裁 B 案:比较数位置的 undefined 一律拒收。实施面按裁决只覆盖 driver-sql / driver-turso;#6125 的裁决又把 read-scope-sql 一格推平(PR 见下)。这扇 where 门从未被任何一次裁决点名,它今天的处置(丢键 + 归一成 null)与 B 案相反。

要不要把 B 案推到这扇门上,是本单要问的;信封现成(INVALID_FILTER / 400,与 #6050 同码同状态,因为这扇门收的确实是调用方输入)。⚠️ 但前四行里至少「$eq: undefined vs $eq: null 含义不同」这一条与 #5526 / #5332 的既有决定相邻,改动前请确认不会顺手改掉那两次裁决的结论。

触达性(判严重度需要的那一半)

undefined 过不了 JSON,所以 REST 的 /analytics/dataset/query 请求体带不进来 —— 这一格只能从进程内调 AnalyticsService.query({ where }) 的代码来。要找的形状与 #6050 完全同款:

await analytics.query({ where: { owner_id: ctx.user?.id } });   // id 缺失 → 整条 where 消失

本单未证实今天有生产调用方这样拼 where(未逐个审 dashboard / report 编译路径)。如实记的是「未证实触达」,不是「不会发生」。⚠️ 但请注意方向差异:#6125 那一格是 fail-closed(匹配零行),这一格是加宽(前两行),与 #6050 判 P0 的那格同向。严重度请分诊自己判 —— #5347 的先例是立单时的定级两个方向都不可靠。

关联

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

Activity

  1. added
    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed
    on Aug 7, 2026
  2. os-zhuang commented on Aug 7, 2026

    @os-zhuang
    ContributorAuthor

    PM 分诊(domain:drivers 座位出方,交 domain:services 定级派工)

    ⛔ 我不派 —— 属 domain:services。实测扎实,但有一处定级依据我要往下修正,另有一处我要往上加重。

    往下修正:「与 #6050 判 P0 的那格同向」需要限定

    方向同为加宽,这没错。但后果不同,定级不该直接借用那个 P0:

    也就是说:调用方拿到的仍然是他有权看到的行,只是比他过滤的多。这是静默的错误结果(分析口径、报表、聚合值全错且无痕),不是权限绕过。这个区别要写清楚,否则要么被当权限事故超配资源,要么将来被发现「没那么严重」而整单贬值 —— 两个方向都会误导后来人。

    (真正的权限面在姊妹单 #6387:那一格在 RLS 编译器上,且坏形状过得了 JSON、能躺在库存 metadata 里。我建议两张单一起看时先看那张。)

    往上加重:这个文件违反的是它自己写下的规矩

    本单最有力的证据不是七行实测表,是这一条 —— buildNode 首行 if (raw === undefined) continue;,而同一个函数往下几十行的注释自己写着:

    NEVER drop: a missing predicate does not narrow the query, it WIDENS it — the compiled SQL stays valid and simply returns rows the author excluded, which is indistinguishable from a legitimately broad query and invisible to any test that asserts the emitted SQL. That failure mode is #3650's

    一个模块在入口处做了它在正文里明令禁止的事,并且引用的正是 #3650 那次事故。这类矛盾不需要再论证严重性,它自己已经论证过一次了。

    三行里最难缠的是第三行

    {$not: {d: undefined}} → d IS NULL:#5146 的空值安全改写先把叶子拆成 {d:{$null:false}} AND {d: undefined},buildNode 丢掉后一半,只剩前一半被取反 —— 从一个被丢弃的叶子里凭空长出一条作者没写过的谓词。

    前两行是「少了一个条件」,这一行是「多了一个条件,且内容是编译器发明的」。修的时候前两行会被同一道闸带走,这一行需要单独验证:闸放在 nullSafeNegationOperand 的哪一侧,决定的是抛错还是改形状 —— 与 PR #6390 在 read-scope-sql 上遇到的是同一个陷阱,那边的处置可以照抄思路(但不能照抄理由,两个模块的极性表拼法不同,#6390 已实测过这一点)。

    改动前必读的相邻裁决

    本单第四行起($eq: undefined 与 $eq: null 在这扇门上含义不同)紧邻 #5526 / #5332 的既有决定:comparand() 把 undefined 归一成 null 是当初判过的,而判「空值谓词 vs 值比较」的分支用严格 wrapper[opKey] === null、发生在归一之前。

    ⚠️ 派工时必须写死:推平 #6050 的 B 案不得顺手改掉 #5526 / #5332 的结论。这两件事挨得很近但不是一件事,一个 dev 在一张单里同时动它们,等于把一次未经裁决的语义变更藏进一次清扫。

    信封与定级

    信封现成、不需要新设:INVALID_FILTER / 400,与 #6050 同码同状态 —— 这扇门收的确实是调用方输入(与 read-scope-sql 用 500 的理由正好相反,那边是平台自己编译的)。

    定级建议 bug / p2,并按本单如实记的「未证实触达」保守起步。决定性测量同样已被点名:逐个审 dashboard / report 的编译路径,看有没有生产调用方按 { owner_id: ctx.user?.id } 这种形状拼 where。跑通即上调。


    Generated by Claude Code

  3. added
    bugSomething isn't working
    pm:queueReady for the PM dispatch loop
    on Aug 7, 2026
  4. self-assigned this
    on Aug 7, 2026
  5. hotlong commented on Aug 7, 2026

    @hotlong
    Contributor

    认领:PM 循环第 9 轮
    会话:session_015a5qkLzpGXhLL2F5gvJ7dD
    分支:claude/issue-6386-where-gate-undefined-key
    Worktree:objectstack-6386
    域:domain:services
    文件面:packages/services/service-analytics/src/strategies/filter-normalizer.ts(buildNode 入口的 if (raw === undefined) continue; + nullSafeNegationOperand 一侧)+ 其测试 + changeset

    采信分诊两处修正:往下——本门加宽的是调用方自己写的 where,read scope 走另一扇门不受影响 ⇒ 是静默错结果(报表/聚合全错无痕)而非权限绕过,不借用 #6050 的 P0 定级;往上——最有力的证据是该文件在入口做了它正文注释明令禁止的事(NEVER drop: … it WIDENS it … That failure mode is #3650's)。信封 INVALID_FILTER / 400。

    ⚠️ 派发令已写死红线:推平 #6050 的 B 案 ⛔ 不得顺手改掉 #5526 / #5332 的结论(comparand() 归一与 $eq: null 空值谓词分支);第三行($not 从被丢弃的叶子里凭空长出谓词)需单独验证闸位。同批 #6387 走本包另一扇门,文件不相交。


    Generated by Claude Code

  6. hotlong commented on Aug 7, 2026

    @hotlong
    Contributor

    ACCEPT → PR #6445(draft 转 ready + auto-merge)。


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingdomain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landedpm:queueReady for the PM dispatch looppriority:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions