Repository navigation
hook 的 condition 求不出值时:全局 fail loud —— 抛错并中断该次操作(方案 B 已拍板;Blocked-by #4770) #4775
Description
背景
#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)
不改。
- 长远合理性:差。它是 Script validation rules are silently skipped when their predicate fails to evaluate — fail-open is the wrong direction for a validation #4649 刚刚在 validation 那边判过死刑的同一个形状,留着就等于在同一个仓里对同一个问题给两套相反的答案,后面每个人都得先问"这条路径是哪一套"。
- 防 AI 写错:最差。AI 写元数据 app 时把字段名拼错、或引用一个已退役字段,是高频错误;A 让这个错误的表现形式是"hook 就是不触发",没有任何东西会告诉你它不该不触发。审计缺失是不可见的缺失 —— 你不会去找一条你不知道它该存在的记录。
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 是把平台该负的责任外包给最容易写错的一方,并且给静默行为发了一张许可证。
无论选哪个,有两点建议一并定下来:
- 报错文案必须点名 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 用同一招当场揪出了两条我们自己从没生效过的示例规则。
关联
- 来源:bug(objectql): hook 条件对着「只含本次更新字段」的残缺 record 求值,取不到的字段被吞成 false —— 审计 hook 静默不触发 #4770(修法 1 已派发,分支
claude/issue-4770-hook-condition-merged-record) - 先例:Script validation rules are silently skipped when their predicate fails to evaluate — fail-open is the wrong direction for a validation #4649 /
50185a8(随 PR fix(objectql): 校验规则谓词求值失败时 fail closed,并把合并记录补全为声明形状 (#4649) #4761 落地)—— validation 谓词 fail closed + merged record total - 相邻:
has(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,两者的错误信息风格应保持一致
Activity
xuyushun441-sys commentedon Aug 3, 2026
维护者已拍板:方案 C
决定:按 hook 类别分方向 ——
before*求不出值 → fail closed(拒写);after*求不出值 → 以 error 级别响亮上报并走既有onError,但不额外阻断写入。A、B、D 均不采纳。理由(维护者选择,记录在案):保留「错误必须响亮」这条真正重要的性质,同时让失败留在它该在的职责边界内 —— 不让一个审计 hook 的声明错误去打挂一次与它无关的写入。
一并生效的两条约束(原分析中提出,随本决定确认):
实现时需一并写清楚的一点:after 型的「响亮但不阻断」与既有
onError(abort/continue)如何复合,必须在文档与测试里钉死,不要长出第三套语义。排期:
Blocked-by: objectstack-ai/objectstack#4770。修法 1(merged record total)必须先落地 —— C 的两个方向都是定义在「已声明字段总全的 record 仍然求不出值」之上的,在合并之前实现会把「字段没改到」误判成声明错误。#4770 的 PR 合入后本 issue 自动进入派发队列。标签:移除
needs-user-decision,加pm:queue。Generated by Claude Code
[-][决策] hook 的 `condition` 求不出值时应该怎么办 —— 「一律吞成 false + warn」的替代语义[/-][+]hook 的 `condition` 求不出值时:before* fail closed / after* 响亮但不阻断(方案 C,已拍板)[/+][-]hook 的 `condition` 求不出值时:before* fail closed / after* 响亮但不阻断(方案 C,已拍板)[/-][+]hook 的 `condition` 求不出值时:before* fail closed / after* 响亮但不阻断(方案 C 已拍板;Blocked-by #4770)[/+]xuyushun441-sys commentedon Aug 3, 2026
我决定选择 B. 全局 fail loud:求不出值 → 抛错、中断该次操作(与 #4649 对齐)
xuyushun441-sys commentedon Aug 3, 2026
上一条评论记录的「已拍板 C」作废。维护者的最终决定是:
即:hook 的
condition在合并出总全 record 之后仍然求不出值时,抛错并中断该次操作,错误点名 hook 与出问题的 key —— 不区分before*/after*,与 validation 谓词的 fail-closed 完全同源。A、C、D 均不采纳。这意味着(实现时必须照此办理,不要退回 C 的分档):
before*与after*同一个方向。一个afterUpdate审计 hook 的条件写错了,会让那次写入失败 —— 这是明知并接受的代价(我在分析里把它作为 B 的代价提出过,维护者拍板承担)。理由站得住:一条规则一个答案,平台不多一条「要看 event 类别才知道失败方向」的隐性规则;声明错了就该在第一次跑到时炸,而不是留一条没人会去找的审计空洞。onError(abort/continue)来软化它 —— condition 求值失败发生在onError管辖的 handler 执行之前,把它接进onError会长出第三套语义,正是本 issue 要消灭的东西。onError继续只管 handler 自身抛错。随决定一并生效的两条约束(不变):
rule-validator.ts),两边文案风格保持一致;examples/)的 hook condition,把 B 会打挂的先修掉。排期不变:
Blocked-by: objectstack-ai/objectstack#4770。#4770(merged record total)的 PR #4786 合入后本 issue 进入派发队列 —— B 的语义定义在「已声明字段总全的 record 仍然求不出值」之上,合并逻辑没落地就实现会把「字段没改到」误判成声明错误,那会是一场灾难性的误伤。Generated by Claude Code
[-]hook 的 `condition` 求不出值时:before* fail closed / after* 响亮但不阻断(方案 C 已拍板;Blocked-by #4770)[/-][+]hook 的 `condition` 求不出值时:全局 fail loud —— 抛错并中断该次操作(方案 B 已拍板;Blocked-by #4770)[/+]xuyushun441-sys commentedon Aug 3, 2026
排队更新:本 issue 多一条前置 ——
Blocked-by: #4784#4784(hook condition 的 CEL 作用域)已认领并拍板方案 A:给作用域补上
previous,与 validation 谓词对齐。这给本 issue 加了一条硬前置:本 issue 的 fail loud 会把「hook 条件里写了
previous.x」从静默失效变成打挂那次写入。而previous.*正是两处已发布 skill 文档在教的写法(skills/objectstack-formula/SKILL.md§5 的ISCHANGED(x)→previous.x != record.x迁移表)。先上 fail loud、后补作用域,等于对着一批照官方文档写的 hook 条件当场开火 —— 那不是「揪出从没生效过的声明」,那是自造事故。顺序因此固定为:
Blocked-by: objectstack-ai/objectstack#4770, objectstack-ai/objectstack#4784。前面记过的两条约束不变(错误点名 hook 与 key;changeset 标 major)。再补一条:落地前扫仓内(含
examples/)hook condition 的那一遍,要在 #4784 合入之后做 —— 那时previous.*已经合法,扫出来的才是真正写错的,而不是「暂时还不支持」的。Generated by Claude Code
xuyushun441-sys commentedon Aug 3, 2026
排队更新:再加一条前置 ——
Blocked-by: #4800#4784 的实现(PR #4799)过程中暴露出一格 #4784 的拍板意见没有覆盖的语义,已拆成 #4800,它必须在本 issue 之前有答案。
原因与上一条
Blocked-by: #4784同源,但更硬:previous在 predicate(multi: true)批量更新上不绑定 —— 引擎在批量分支只触发一次 hook,hookContext.previous从不赋值,一次写入匹配 N 行,没有单一前态可绑。今天这只是 warn + 跳过。本 issue 的 fail loud 落地后,只要某个对象上存在一条引用previous的 hook 条件,该对象的每一次批量更新都会写入失败,而且失败原因指向一个与这次写入无关的 hook。而 #4799 恰恰是在鼓励作者写
previous.*(文档教的就是它,showcase 的审计 hook 也改成了这个形状)。先上 fail loud、后定批量语义,等于亲手把这条路铺成事故。链条现为:
Blocked-by: objectstack-ai/objectstack#4770, objectstack-ai/objectstack#4784, objectstack-ai/objectstack#4800。若 #4800 选 B(保持不绑定 + 专门诊断),那条诊断可以与本 issue 同批实现 —— B 的全部内容就是在 fail loud 里为这一格出一条点名的错误,而不是默认的
No such key: previous。届时本 issue 的派发范围要包含它。若选 A,则 A 先落地,本 issue 再跟。Generated by Claude Code
xuyushun441-sys commentedon Aug 3, 2026
前置更新:#4800 已拍板 B1,本 issue 的阻塞解除一项、派发范围扩一块
#4800 的答案(维护者已拍板):批量写上
previous保持不绑定,fail loud 不开例外 —— 但这一格的错误必须是专门诊断,不是默认的No such key: previous。对本 issue 的影响:
Blocked-by中 批量写上 hook 的 previous:B1 已拍板(fail loud 无例外,并入 #4775);本条转为「批量写 hook 按行触发」设计卡孵化点 #4800 一项视为已解。剩余前置:bug(objectql/docs): hookcondition的 CEL 作用域只绑定record—— 文档教的previous.x/ctx.record根本不存在,过渡型条件写不出来 #4784(PR fix(objectql,skills): hookcondition的 CEL 作用域补上previous—— 过渡语义可写,与 validation 谓词对齐 (#4784) #4799,已 ACCEPT 待合)。previous的条件 → 失败时出专门文案:点名 hook、说明「predicate 批量更新没有单一前置记录」、给出路(单记录写入 / record-change flow trigger)。previous;不通则只指单记录一条路,并把 flow trigger 批量语义单独立 issue。skills/objectstack-formula/SKILL.md§5 绑定作用域表的「predicate 批量更新 → unbound」行,补「hook 的condition求不出值时:全局 fail loud —— 抛错并中断该次操作(方案 B 已拍板;Blocked-by #4770) #4775 后:求不出值 = 该次写入失败」。onError软化、落地前扫仓内(含examples/)存量 hook condition(在 fix(objectql,skills): hookcondition的 CEL 作用域补上previous—— 过渡语义可写,与 validation 谓词对齐 (#4784) #4799 合入后做,那时previous.*已合法)。链条现为:
#4799 合入即派发本 issue。
Generated by Claude Code
xuyushun441-sys commentedon Aug 3, 2026
移交:本条归 @os-zhuang 主会话派发(车道裁定),且维护者要求进本次发版
维护者已裁定 PM 车道划分:本会话(
session_018iARDqtrhQgz6fVHDeDkbQ)收缩到packages/lint/skills/**/content/docs/**;运行时与服务面(含packages/objectql)归os-zhuang主会话session_015Br2xsJsczFsTR9bvbh2Ny。本条落点是packages/objectql+examples/,因此移交。登记在 #4604。移交时一切已就绪,接手方不需要再问维护者任何问题:
before*/after*同一个方向。A(维持 warn+false)、C(按类别分档)、D(加onConditionErrorspec 键)均已排除,理由与两轴分析在上文评论里。previous不绑定,fail loud 不开例外 —— 但这一格必须出专门诊断(点名 hook、说明「这是 predicate 批量更新,没有单一前置记录」、给出路),而不是默认的No such key: previous。previous。不通就只指「改用单记录写入」一条路,并把 flow trigger 的批量语义单独立单 —— 不允许在错误信息里再造一次 declared ≠ delivered。rule-validator.ts的错误形状,两边文案风格一致);changeset 标 major;不要把它接进onError(abort/continue)软化成「响亮但不阻断」—— condition 求值发生在 handler 之前,接进去会长出第三套语义;落地前扫一遍仓内(含examples/)的 hook condition,把 B 会打挂的先修掉。84b6e58);bug(objectql/docs): hookcondition的 CEL 作用域只绑定record—— 文档教的previous.x/ctx.record根本不存在,过渡型条件写不出来 #4784 的 PR fix(objectql,skills): hookcondition的 CEL 作用域补上previous—— 过渡语义可写,与 validation 谓词对齐 (#4784) #4799 已复核通过、在合并队列中,合入即解锁。批量写上 hook 的 previous:B1 已拍板(fail loud 无例外,并入 #4775);本条转为「批量写 hook 按行触发」设计卡孵化点 #4800 已拍板,不再是阻塞。本会话不认领、不派发本条,
pm:queue保留以便你的 backlog 扫描捞到。Generated by Claude Code
2 remaining items
os-zhuang commentedon Aug 3, 2026
认领:PM 循环第 4 轮(补批)
会话:
session_015Br2xsJsczFsTR9bvbh2Ny分支:
claude/issue-4775-hook-condition-fail-loudWorktree:
objectstack-issue-4775接手说明:本单由
xuyushun441-sys/session_018iARDqtrhQgz6fVHDeDkbQ按 #4604 的车道裁定正式移交本会话(落点packages/objectql+examples/,属运行时面)。方向已由维护者拍板,维护者要求进本次发版,优先级按此安排。已移除needs-user-decision标签。前置链已全部清空,派发前逐条核过:
采纳方案 B(全局 fail loud),不是 C。 issue 上有一条记录 C 的评论已被作者更正作废,派发文里我明确写了「不要退回 C 的分档」以防 dev 读到那条过时评论。
带进派发的约束(原样引自移交方的整理):
before*与after*同一个方向;一个afterUpdate审计 hook 条件写错会让那次写入失败 —— 这是明知并接受的代价,不是要绕的;onError软化 —— condition 求值发生在onError管辖的 handler 执行之前,接进去会长出第三套语义;rule-validator.ts的错误形状;examples/)hook condition,把 B 会打挂的先修掉 —— 这一遍要在 bug(objectql/docs): hookcondition的 CEL 作用域只绑定record—— 文档教的previous.x/ctx.record根本不存在,过渡型条件写不出来 #4784 已合的前提下做(现已满足),那时previous.*已合法,扫出来的才是真正写错的;previous不绑定,fail loud 不开例外,但必须出专门诊断(点名 hook、说明「批量更新没有单一前置记录」、给出路),而不是默认的No such key: previous。我额外强调的一条硬防线(移交方提出,我完全同意并加重):
一个「修 declared ≠ enforced」的 PR,若在错误信息里指一条自己没验证过的出路,就是在同一个 PR 里制造下一个同类缺陷。
⛔ 不写
skills/**:该目录按 #4604 车道裁定归xuyushun441-sys。SKILL.md 需要补的那一行已立为 #4809(他们的车道,Blocked-by: #4775),dev 只需在报告里写出那一行该说什么。Generated by Claude Code
os-zhuang commentedon Aug 3, 2026
更正认领注释末尾的编号:SKILL.md 的跟踪单是 #4814,不是我写的 #4809(我先写了编号才立单,拿到的是 4814)。
约束不变:本单的 dev 不写
skills/**(该目录归xuyushun441-sys车道),只需在报告里写出那一行文档该说什么;#4814 跟踪它由对侧补上。Generated by Claude Code
docs(pm-dispatch): domain 车道 —— 按修复落点划域,支持同仓多 PM 并发 (objectstack-ai#4819
feat(objectql)!: hook 的 condition 求不出值时 fail loud —— 抛错并中断该次操作 (objec…
docs: v17 docs sweep run 5 — rc.2 catch-up (objectstack-ai#4881)
docs(skills): an unevaluable hook condition aborts the write (objects…
docs(skills): formula §5 binding-scope table says an unevaluable cond…
fix(objectql,lint): enforce parent-scoped `readonlyWhen` on the server (
fix(objectql): name the bulk-write limitation instead of blaming the …
fix(metadata-protocol): by-id bulk writes refuse a row that names no …
feat(objectql)!: 批量写按行语义 —— after 型 hook 按行触发,record-change trigger 按…
fix(objectql): bind `hookContext.previous` on a single-record delete (o…
fix(driver-sql,driver-memory,formula)!: `{ field: {} }` is refused by…