背景
#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,写入被拒。
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 是把平台该负的责任外包给最容易写错的一方,并且给静默行为发了一张许可证。
无论选哪个,有两点建议一并定下来:
报错文案必须点名 hook、点名求不出的 key (Script validation rules are silently skipped when their predicate fails to evaluate — fail-open is the wrong direction for a validation #4649 的错误形状可直接复用),否则响亮也没用;
若选 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 用同一招当场揪出了两条我们自己从没生效过的示例规则。
关联
背景
#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 意味着相反方向的风险:
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,写入被拒。
C. 按 hook 类别分方向(before 型 fail closed / after 型 fail loud 但不阻断)
before*求不出值 → 拒写;after*求不出值 → 以 error 级别上报(并按onError走既有的 abort/continue),不额外阻断写入。onError(abort/continue)怎么复用,需要写清楚,否则会长出第三套语义。D. 变成可声明的:给 hook 加一个
onConditionError: 'skip' | 'abort' | 'run',默认非skip把方向交给声明这个 hook 的人。
skip—— 于是 A 的静默行为原地复活,只不过这次它是被"声明"出来的,连 warn 的立场都没有了。宽容的消费端正是 AI 批量犯错被掩盖的温床。我的建议
C,并把 B 作为可接受的次选。
两条轴的判断:长远合理性上 B 最干净(一条规则一个答案),但它把审计 hook 的声明错误变成用户写入失败,爆炸半径超出了那个 hook 的职责边界 —— 这不是"温和一点",而是归因错误:一次写入失败会指向一个跟它无关的 hook。C 保留了"错误必须响亮"这条真正重要的性质(两条轴里防 AI 犯错那条要的是不静默,不是必阻断),同时让失败留在它该在的地方。两轴在 B 与 C 之间的冲突就是这一点,如实摆在这里由你拍板。
A 和 D 我认为都不应选:A 是 #4649 已判过死刑的形状,D 是把平台该负的责任外包给最容易写错的一方,并且给静默行为发了一张许可证。
无论选哪个,有两点建议一并定下来:
关联
claude/issue-4770-hook-condition-merged-record)50185a8(随 PR fix(objectql): 校验规则谓词求值失败时 fail closed,并把合并记录补全为声明形状 (#4649) #4761 落地)—— validation 谓词 fail closed + merged record totalhas(x)reads as a null guard and is not one — a publish-time lint should reject un-guarded nullable comparisons in CEL predicates #4763(has(x)读起来像 null guard 但不是,publish 期 lint 拒绝未加保护的 nullable 比较)—— 同属"CEL 谓词写错了该在哪一层被抓住"的问题面,若本 issue 选 B/C,两者的错误信息风格应保持一致