Skip to content

一个被静默丢弃的排序键对外部调用方仍然是静默的:direction 该 400 还是继续被丢掉(#4674 第 4 项) #4721

Description

@os-zhuang

从 #4674 的第 4 项拆出来——那一项写的是「值得单独决定」,这就是那个单独决定。#4720 修了两个内部站点,没有动这一类。

类修复保护的不是同一批人

#4674 提了两条路:

  1. 让 QueryAST 的排序 schema 在这条轴上拒绝未知键(SortNodeSchema 加 .strict()),direction 变成 400;
  2. 或者让 normalizer 专门识别 direction,以一条点名 order 的消息拒绝它。

两条都住在入站 findData normalizer 上。而 #4674 自己的「为什么没被抓住」第 2 条已经确认:那条路径对这次的两个站点根本不运行——它们是协议直接调 this.engine.find,在自己的守卫内侧而非后面。

所以这个类修复不会抓住催生它的那个 bug。这不构成放弃它的理由,但它把要解决的问题换了一个:

调用方 被执行的渠道 现状
内部(协议、插件、包内代码) tsc — EngineQueryOptions.orderBy 是 SortNodeSchema[] #4720 已恢复(两处 as any 删除)
外部(REST / RPC 上的 orderBy) normalizer 仍然静默丢弃 direction

对内部调用方,真正的教训是「别擦除类型」,而不是「加个运行时闸」。本单只关心第二行。

决定点

SortNodeSchema 不是 .strict(),所以任何未知键都被丢掉,而不是被标记。给外部调用方发一个 { field: 'updated_at', direction: 'desc' },他们得到的是一个看起来成功的升序响应——带 limit 时还意味着返回了另一批行。没有任何信号。

两个选项的代价不同:

(a) SortNodeSchema 加 .strict() —— 覆盖面完整,但这是对已发布线协议的破坏性变更:今天任何在排序节点里多带一个键的客户端会开始收到 400。而且它把所有未知键一视同仁,而 direction 是唯一一个有已知正确译法的。

(b) 专门识别 direction,以点名 order 的消息拒绝 —— 窄,不破坏无关的多余键,并且送达一条处方而不只是一个拒绝。这正是 retiredKey() 的做法(通过一次 parse 把修复建议交到调用方手里),区别在于 direction 从来不是我们这条轴上的键,所以「墓碑」不是准确的词——它是一条外来词汇提示。

倾向 (b),但 (a) 的「未知键一律拒绝」在别处是这个仓的既有姿态(#4371 让引擎拒绝未声明的选项键——只是在顶层,没有递归进排序节点),所以这里存在一致性论据,值得一并权衡。

为什么值得做

direction 不是一个凭空的拼写错误,它是同一个概念的两套活词汇:

  • SortNodeSchema — { field, order } — QueryAST / EngineQueryOptions(query.zod.ts:12)
  • IReportService.orderBy — { field, direction? } — 一份真正不同的契约(report-service.ts:29)

plugin-auth/objectql-adapter.ts:536 已经显式地在两者间翻译。也就是说这个翻译是已知必需的,只是没有在任何地方被强制;调用方忘了翻译时,结果是一个静默的错误答案而不是一个错误。这就是 ADR-0049 的形状。

顺带一提:一个更便宜的内部护栏

如果要收紧内部这一侧,能想到的最小手段是一条 lint 规则,禁止对引擎查询选项做 as any / : any —— 是类型擦除而不是缺少运行时闸让 #4674 溜过去的。这是否值得单开一单,取决于树里这种擦除还有多少。

关联:#4674、#4720、#4363、#4371、ADR-0049

Activity

  1. os-zhuang commented on Aug 3, 2026

    @os-zhuang
    ContributorAuthor

    裁决(随 #4001 维护者「必要的全收」裁决,搭 v17 breaking 窗口):方案 (a)+(b) 结合 ——

    1. (a) SortNodeSchema .strict() 化:未知键静默丢弃正是 未知键静默剥离仍是全仓默认:把 #3405 的 strict 收紧从一个 schema 推广到整个可授权面(ADR-0078 完整性闸门) #4001 全仓战役在杀的形状,[P2] A direct engine call silently drops sort/select/skip/populate — declared query contract, zero enforcement #4371 已让顶层拒绝未声明选项键,排序节点不该是缺口;线协议 breaking 搭 v17 major 窗口合法;
    2. (b) 在 strict 拒绝之上,对 direction 键给点名 order 的专门错误消息(外来词汇提示,送达处方)——direction 是唯一有已知正确译法的外来键(IReportService 词汇),泛化的 "unrecognized key" 不给处方,AI 和外部调用方拿到 400 后仍不知道改成什么。

    单独只做 (b) 会留下「顶层 strict、排序节点宽容」的不一致;单独只做 (a) 错失唯一能给处方的键。与 #4001 同批派发。

    顺带项(lint 禁对引擎查询选项 as any)由执行 agent 评估树里残余擦除量后决定是否单独立项。


    Generated by Claude Code

  2. os-zhuang commented on Aug 3, 2026

    @os-zhuang
    ContributorAuthor

    维护者裁决(2026-08-03):方案 A —— 把 SortNodeSchema 拎出 blanket open 单独定级,strictObject + aliases,两扇门同一个 change 关

    ⚠️ 先更正本单原裁决(06:25Z)引用的一条前提 —— 它已被 #4001 重测实测推翻:

    #4371 已让顶层拒绝未声明选项键,排序节点不该是缺口

    不成立。实测:#4371 的检查是 packages/objectql/src/engine.ts 里手写的 rejectUnknownEngineOptions allowlist,只在顶层迭代 Object.entries(bag);而 QuerySchema 本身不是 strict —— 对照探针 QuerySchema.safeParse({object:'sales', nonsenseKey:1}).success === true。

    但结论反而更该成立,理由换成实测事实:

    SortNodeSchema.parse({ field, direction: 'desc' })  →  { field, order: 'asc' }
    

    静默排反方向。带 limit 时返回的是另一批行,且无任何信号。

    三轴

    ① 实际业务需求 —— direction 不是拼写错误,是活着的竞争词表:IReportService.orderBy 用 {field, direction},plugin-auth/objectql-adapter.ts:536 已经在手工翻译两者。外部调用方拿到的是「看起来成功的升序数据」—— 静默错数据是对业务最贵的一类错。

    ② 平台长远 —— query.zod.ts 的 blanket open 判给的是过滤表达式(谓词值里流用户数据),而 SortNodeSchema 是封闭的两键元组 {field, order},没有任何用户数据面。按文件整体定级才是那个不精确的东西,不是 strict 化。

    方案 B(只修 normalizer)是 #4001 战役文档点名的反模式 —— 第六个「只守一扇门的 bespoke 守卫」:SortNodeSchema.parse 经 defineStack、data-engine.zod.ts 的 orderBy: z.array(SortNodeSchema)、以及直接 parse QueryAST 三条路都可达,normalizer 只拦 REST 一条。

    ③ 防 AI 犯错 —— 编辑距离永远够不到 direction(它不是 typo),只有 strictObject 的 aliases: { direction: 'order' } 能把处方送到手上。这正是本单原裁决 (b) 想要的「点名 order 的专门错误」,用战役已有机制实现,不另造守卫。

    执行约束(硬性)

    1. 两扇门必须同一个 change 关:SortNodeSchema strict 化 且 normalizeSortNodes(protocol.ts:1102)点名拒绝 direction。只关 schema 会重演 Object-level workflows: [...] (and any unknown ObjectSchema key) is silently stripped at build — no error/warning (ADR-0032 'no silent failure', metadata layer) #1535 object 守卫、feat(spec)!: ObjectSchema 在 parse 路径上收紧,而不只是 create() —— #1535 的奠基例子一直是活的(#4001 批 3a) #4522 才发现的那个门不对称。
    2. wire 面 breaking → v17 窗口内。
    3. QuerySchema 顶层不 strict 这个新发现不在本单扩权 —— 记进 未知键静默剥离仍是全仓默认:把 #3405 的 strict 收紧从一个 schema 推广到整个可授权面(ADR-0078 完整性闸门) #4001 战役地图,按批次走。

    并入 #4001 首批执行,不单独派 dev。


    Generated by Claude Code

  3. xuyushun441-sys commented on Aug 3, 2026

    @xuyushun441-sys
    Collaborator

    🔒 认领:PM 循环第 1 轮,按 11:41Z 维护者裁决(方案 A)并入 #4001 首批执行
    会话:session_01Ehu85kCXvMcr… → 完整 ID session_01Ehu85kbvMcrNTUJjwxvLJ9
    分支:claude/issue-4721-sortnode-strict
    Worktree:objectstack-issue-4721
    域:domain:engine × domain:spec(跨域单认领,文件面全量声明如下)
    文件面:packages/spec/src/data/query.zod.ts、packages/metadata-protocol/src/protocol.ts(normalizeSortNodes)、packages/metadata-protocol/src/protocol.orderby-vocabulary.test.ts、spec 台账行与生成物

    继承的实测前提(已在本单 11:41Z 裁决评论就地更正,派发词照此奠基):QuerySchema 不是 strict;#4371 是 engine.ts 里手写的顶层 allowlist,不递归进 orderBy[]。结论不变,依据为实测:SortNodeSchema.parse({field, direction:'desc'}) → {field, order:'asc'},静默排反方向。

    硬约束照裁决:两扇门同一个 change 关;QuerySchema 顶层不 strict 不在本单扩权;v17 窗口内落地(changeset major)。

    派发前快检:origin/main = 89d2a4e,query.zod.ts 的 SortNodeSchema 仍为裸 strip(实查);protocol.ts 今日被 #4841 改过,已写进派发词。与在飞 #4636(metadata-protocol/src/plugin.ts)文件不相交。


    Generated by Claude Code

  4. xuyushun441-sys commented on Aug 3, 2026

    @xuyushun441-sys
    Collaborator

    ✅ 验收通过 —— PR #4922(draft,等 CI)

    对照实际 diff 复核(10 文件),裁决三条硬约束逐条落实:

    1. 两扇门同一 change:SortNodeSchema → strictObject + aliases: {direction: 'order'}(query.zod.ts);normalizeSortNodes 对良构节点上的 direction 以 400 INVALID_SORT 点名拒绝(新 invalidSortDirectionKeyError 挂既有错误族),测试断言engine 未被调用——拒绝真的发生在门口。
    2. v17 窗口:spec + metadata-protocol 双 major changeset,带 FROM→TO 与五行行为对照表。
    3. 不扩权:QuerySchema 顶层原样,且加了范围 pin 测试(nonsenseKey 探针保持 true)——后来者想 blanket strict 这个文件必须有自己的裁决,不能顺手带走。

    奠基更正落实:PR body 以三条 origin/main 实测探针开篇,明文退休「#4371 同一不变量」论证,正当性只建立在「静默排反方向」实测上——与本单 11:41Z 裁决评论的更正完全一致。

    质量亮点:{field: direction} map 形式(列名恰叫 direction)刻意放行 + 回归测试——避掉了镜像 bug;strip 时代的 pin 注释标注退休而非删除;台账把「per-file blanket 与 per-schema 发现冲突时先怀疑 blanket」写成可复用结论。

    验证:spec 7367 / metadata-protocol 288 / objectql 1742 全过,十道 check 全绿,三示例 validate exit 0,ADR-0087 载荷扫描零 direction: 命中。⛔ releases/ 零触碰。

    衍生:#4918(engine 查询选项 as any 擦除,实测 36 处非测试代码,按 PD#10 立项)、#4917(VS Code 片段被 #4001 关掉的键弄失效)——均已实核存在,进下轮分诊。

    落地排程:等 CI 绿后入队,串行在 #4909(已在队)之后;两 PR 生成物零交集,台账不同 section,预期干净。


    Generated by Claude Code

  5. added a commit that references this issue on Aug 10, 2026
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