Skip to content

hook 的 condition 求不出值时:全局 fail loud —— 抛错并中断该次操作(方案 B 已拍板;Blocked-by #4770) #4775

Description

@xuyushun441-sys

背景

#4770 的修法 2。修法 1(合并 ctx.previous ⊕ input.data,让条件求值对着一条对已声明字段总全的 record)已按 #4649 / 50185a8 的现成语义派发实现中,分支 claude/issue-4770-hook-condition-merged-record。

修法 1 消灭掉的是「字段存在但本次没改」这一类,也就是眼下 showcase 刷屏的那一类。它不会消灭「求不出值」本身 —— 修法 1 之后仍然会有求不出值的条件:条件里写了一个未声明的 key(拼错、路径写错、引用了个已被退役的字段),或者表达式在运行期因别的原因 fault。#4649 的 materialise 是故意只覆盖已声明字段的,好让拼错的 key 依然炸出来、依然可报告 —— 所以「炸出来之后怎么办」这个问题是被有意留下的,不是被顺手解决的。

现状(packages/objectql/src/hook-wrappers.ts:86):求不出值 → logger.warn → return false → hook 不触发。

具体问题

「表达式求不出值」和「表达式求出 false」被压成了同一个结果,而这两件事对不同 hook 意味着相反方向的风险:

  • 对 guard 型(before*,语义是"满足条件就拦下")—— 吞成 false = 放行一次本该被拦的写入;
  • 对 审计型(after*,语义是"满足条件就留痕")—— 吞成 false = 漏记一条本该有的审计。

一个 hook 声明了 condition,平台却在自己算不出这个 condition 时安静地当它是 false —— 这正是 declared ≠ enforced:声明摆在元数据里、Studio 里看得见,运行时什么都不兑现,而且只留一条默认日志级别下会被当噪音划过去的 warn。#4649 对 validation 谓词已经拒绝了这个形状(选了 fail closed:谓词算不出就拒写,并点名规则和出问题的 key)。hook 这条路径要不要、以及怎样跟上,是本 issue 要定夺的。

为什么不由 PM 代拍:这不是"恢复既有不变量"那一类(那类我直接派发了),它要在几种语义上互斥的方向里选一个,其中两个会改变写入的运行时行为、一个会给 spec 增加一个新的可声明键 —— 属于公开契约形状,AGENTS.md / ADR / 现有代码惯例都没有决定答案。

可选方案

A. 维持现状(求不出值 → warn + false)

不改。

B. 全局 fail loud:求不出值 → 抛错、中断该次操作(与 #4649 对齐)

点名 hook 与出问题的 key,写入被拒。

  • 长远合理性:好。一条规则 = 一个答案,整个仓对"谓词算不出"只有一种处理;Script validation rules are silently skipped when their predicate fails to evaluate — fail-open is the wrong direction for a validation #4649 已经付过这条路的设计成本,复用它没有新的概念。
  • 防 AI 写错:最好。契约收紧、错误响亮,拼错的 key 在第一次跑到时就炸,而不是变成一年后才发现的审计空洞。
  • 代价(要如实看):一个坏掉的 after 型 hook 条件会连带打挂一次本来会成功的写入。validation 那边 fail closed 是自洽的(validation 的职责本就是拒写);审计 hook 的职责不是拒写,让它拒写是把失败爆炸半径扩大到了它本来管不着的地方。此外这是破坏性变更:任何现在靠"条件算不出就静悄悄跳过"苟着的存量 hook 会开始拒写。

C. 按 hook 类别分方向(before 型 fail closed / after 型 fail loud 但不阻断)

before* 求不出值 → 拒写;after* 求不出值 → 以 error 级别上报(并按 onError 走既有的 abort/continue),不额外阻断写入。

  • 长远合理性:中上。它按"这个 hook 有没有拦截职责"分方向,理由是能讲清楚的;代价是平台多了一条"要看 event 类别才知道失败方向"的隐性规则,读代码的人和写文档的人都要额外记一条。
  • 防 AI 写错:好。两个方向都不再是静默 —— AI 拼错的 key 在两类 hook 上都会响,只是响的方式不同。比 B 温和,比 A 严格得多。
  • 代价:「不额外阻断」到底等于什么,取决于既有 onError(abort / continue)怎么复用,需要写清楚,否则会长出第三套语义。

D. 变成可声明的:给 hook 加一个 onConditionError: 'skip' | 'abort' | 'run',默认非 skip

把方向交给声明这个 hook 的人。

  • 长远合理性:差 —— 这是"用一个新的可声明键换掉一次拍板"。ADR-0049 enforce-or-remove 的教训正是"每加一个可声明键就多一处 declared 与 enforced 可能对不上的地方";这里平台有能力自己定出正确方向(B 或 C),把它外包给 app 作者是把决定权交给了最不该承担它的人。
  • 防 AI 写错:最差之一,而且是隐蔽的最差。多一个键 = 多一个 AI 会写错的地方,而且 AI 极可能为了"让它别报错"而写 skip —— 于是 A 的静默行为原地复活,只不过这次它是被"声明"出来的,连 warn 的立场都没有了。宽容的消费端正是 AI 批量犯错被掩盖的温床。
  • 只有当出现真实用例证明同一个平台内确实存在两种都正当的期望时,D 才值得重新考虑;目前没有这样的用例。

我的建议

C,并把 B 作为可接受的次选。

两条轴的判断:长远合理性上 B 最干净(一条规则一个答案),但它把审计 hook 的声明错误变成用户写入失败,爆炸半径超出了那个 hook 的职责边界 —— 这不是"温和一点",而是归因错误:一次写入失败会指向一个跟它无关的 hook。C 保留了"错误必须响亮"这条真正重要的性质(两条轴里防 AI 犯错那条要的是不静默,不是必阻断),同时让失败留在它该在的地方。两轴在 B 与 C 之间的冲突就是这一点,如实摆在这里由你拍板。

A 和 D 我认为都不应选:A 是 #4649 已判过死刑的形状,D 是把平台该负的责任外包给最容易写错的一方,并且给静默行为发了一张许可证。

无论选哪个,有两点建议一并定下来:

  1. 报错文案必须点名 hook、点名求不出的 key(Script validation rules are silently skipped when their predicate fails to evaluate — fail-open is the wrong direction for a validation #4649 的错误形状可直接复用),否则响亮也没用;
  2. 若选 B 或 C,这是破坏性变更,需要 changeset 标 major 并在退役/迁移说明里写清楚"存量靠静默跳过苟着的 hook 会开始报错" —— 这恰恰是该发现的东西,Script validation rules are silently skipped when their predicate fails to evaluate — fail-open is the wrong direction for a validation #4649 用同一招当场揪出了两条我们自己从没生效过的示例规则。

关联

Activity

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions