Skip to content

Script validation rules are silently skipped when their predicate fails to evaluate — fail-open is the wrong direction for a validation #4649

Closed

Description

@os-zhuang

Reported from the HotCRM side (objectstack-ai/hotcrm#630), where it produced a rule that read as enforced and required nothing. Filing here because the behaviour is entirely in the engine's validation path; the app-side mitigation works but should not be the only line of defence.

What happens

A script validation's predicate is evaluated against the merged record {...previous, ...data}.

  • On insert, fields absent from the payload are materialised as null, so a predicate referencing them evaluates normally.
  • On update, they are not. previous is whatever the driver returns, and a driver that stores only written columns returns a record that does not carry the key at all.

Strict CEL then aborts the whole predicate with No such key, and the engine's handling of a predicate that failed to evaluate is to skip the rule, leaving only a WARN:

WARN Validation rule 'duplicate_disqualification_requires_survivor'
     predicate failed to evaluate (…) — skipped

So the rule is declared, appears in the metadata, shows up in any "what protects this object" listing — and enforces nothing, on exactly the records whose shape triggers it.

Why this is worth changing

For most engine features, skipping something unevaluable is a reasonable robustness choice. For a validation it inverts the guarantee: the rule exists precisely to reject a write, and the failure mode ships the write through. The observable signal is one WARN in a log nobody reads during normal operation — not a failing request, not a failing test.

Concretely, in HotCRM this let a lead be closed as a duplicate while naming no surviving record, and it did so on the in-memory driver the entire test suite runs on, so no test could have caught it.

Suggested direction

Either would remove the trap; the first is the smaller change:

  1. Fail closed for validations specifically — an unevaluable validation predicate rejects the write with a clear error naming the rule and the missing key, rather than passing it. Other rule categories can keep whatever policy they have.
  2. Make the merged record total — materialise declared-but-absent fields as null on update as well as insert, so a predicate written against the object's declared shape always has a value to read. This also makes insert and update behave the same way, which is what an author would assume from the docs.

A third, weaker option is to keep skipping but promote the WARN to an error-level event that a health check or a test harness can assert on. That still leaves running deployments unprotected.

App-side mitigation (already shipped, not a substitute)

HotCRM now wraps every field reference in its validation predicates with has(...), making each predicate a total function that returns a verdict for any record shape rather than aborting on some of them. That is correct authoring practice and we are keeping it, but an author who forgets it gets a rule that silently does nothing, and nothing in the toolchain tells them.

Activity

  1. self-assigned this
    on Aug 3, 2026
  2. os-zhuang commented on Aug 3, 2026

    @os-zhuang
    ContributorAuthor

    🔒 认领 —— 第 2 轮(发布窗口 bug 批)

    • 会话:session_015Br2xsJsczFsTR9bvbh2Ny(PM 循环)
    • 分支:claude/issue-4649-validation-fail-closed
    • Worktree:objectstack-issue-4649

    ⚠️ 共用身份,session ID 不符即本单已被占,请另选。

    方向的 PM 裁定(维护者可否决):正文方案 2 + 方案 1 组合,缺一不可:

    1. 方案 2(根修):update 时把已声明但缺席的字段物化为 null,与 insert 对齐 —— 对着声明形状写的谓词永远有值可读,insert/update 行为一致(作者从文档得到的预期)。
    2. 方案 1(兜底):物化之后仍求值失败的谓词(引用未声明键 = 作者笔误),fail closed —— 拒绝写入,错误点名规则名与缺失键。校验规则的存在意义就是拒绝写入,失败模式放行写入是方向反了;declared = enforced,与 未知键静默剥离仍是全仓默认:把 #3405 的 strict 收紧从一个 schema 推广到整个可授权面(ADR-0078 完整性闸门) #4001 同轴。方案 3(只提级日志)否 —— 在运行的部署上仍是裸奔。

    为什么不是只做其一:只做 1,常见形态(驱动只回写入列)会让合法谓词大面积 422,爆炸半径不可接受;只做 2,笔误谓词回到静默跳过 —— 陷阱换了位置没有消失。

    行为变更的边界要在 PR 里写清:物化 null 后,以前因缺键被跳过的规则现在会真的执行 —— 存量部署里可能有一直没生效过的规则开始拒绝写入。这不是回归,是声明开始被兑现;changeset 必须把这一点写成显著条目(它正是本单的目的)。

    约束:只动 validation 类规则的求值路径,其他规则类别的跳过策略不碰;HotCRM 的 has(...) 缓解层是 app 侧的事,不依赖它;⛔ packages/spec/** 零改动;⛔ content/docs/releases/ 不碰;资源纪律照旧。注意 objectql/src/engine.ts 刚被 PR #4730(合并队列中)动过 ~5340 区域,起分支后若它已合并,rebase 时两处改动不相干,别误并。

    验收:HotCRM 场景(update 时谓词引用未写入列)下规则执行而非跳过;笔误键谓词 → 422 点名规则与键;insert 行为回归不变;正文那条 WARN 路径有测试钉住不再放行写入。


    Generated by Claude Code

  3. os-zhuang commented on Aug 3, 2026

    @os-zhuang
    ContributorAuthor

    实现完成 —— 草稿 PR #4761

    会话 session_015Br2xsJsczFsTR9bvbh2Ny,分支 claude/issue-4649-validation-fail-closed。按裁定实现了方案 2 + 方案 1 组合。

    一个必须请维护者知悉的发现(不是新增决策,是既有事实被 fail-closed 照出来了):

    has(x) 问的是键是否存在,而一个已声明的列即使值为 NULL 也算存在 —— 所以 has(a) && has(b) && a < b 这类"看起来是空值守卫"的谓词,在 null < null 上照样 fault。也就是说:这类规则在任何会回 NULL 列的驱动上,从来就没有拦过任何东西,只是 fault 被吞掉了。

    这一点直接检验了裁定里"别让缓解层反被破坏"那条:

    • HotCRM 缓解层用的是相等/空值判断(x == null / x == ""),物化后仍然正确工作 —— 已在 rule-fail-closed.test.ts 里用 4 条用例钉住 has() 对 null 的语义。缓解层不受影响,不需要撤销。
    • 真正受影响的是用 has() 守卫大小比较的形状。fail-closed 一开就抓出仓库自己的两条示例规则(showcase_project.end_after_start、crm_opportunity.opp_close_date_not_past),dogfood 两个 suite 立刻变红 —— 它们本来就是坏的。两条已改成 != null 守卫,拒绝消息也把这个区别讲清楚了。

    顺带做的一处连带修正:conditional 只要声明了 when 就计入"需要 previous"。when 是对着 merged 记录求值的,没有 previous 等于把 PATCH 当整条记录读;不修这一处,fail-closed 会误拒合法的部分更新。

    范围外发现,已按 PD #10 立单(均未指派):

    ⛔ packages/spec/** 零改动;⛔ content/docs/releases/ 未碰。


    Generated by Claude Code

  4. os-zhuang commented on Aug 3, 2026

    @os-zhuang
    ContributorAuthor

    ✅ PM 验收通过 —— PR #4761,CI 全绿(18 success + 2 skipped,0 失败),已转 ready 并入合并队列

    复核对着 GitHub:

    • 裁定的 2+1 组合逐条落实:声明字段物化 null(update 与 insert 对齐,previous 绑定同样物化且在副本上做,不污染 hookContext);残余求值失败 fail closed,点名规则与缺键;severity 仍管拦不拦,advisory 规则不升格
    • 物化边界准确:只覆盖已声明字段,笔误键保持不可求值 —— 兜底没有被物化抵消(这是本单最容易做错的一处)
    • 机制立刻自证:fail-closed 上线当场抓出仓内两条从未生效的示例规则(dogfood 两 suite 红 → 修正),HotCRM 场景 20 条新用例钉住
    • conditional 的 when 计入需要 previous —— fail-closed 成立的前提,不修会误拒合法部分更新
    • 全仓 turbo run test 133/133、typecheck 122/122;两条原本断言 fail-open 的既有用例有意识地翻转为 fail-closed

    open_question 裁定:has() 语义选 A(维护者可否决)

    保持 CEL 规范语义(has() 问存在,不问可用),不 fork 方言。理由采纳 dev 的两轴分析:B 把修正藏在错误会繁殖的那一层(同一谓词搬去 filter/flow 又坏),且 fork 一个跨消费者共享的 CEL 宏是错层的 workaround(PD #5/#12);A 把修正放在最响亮处 —— 拒绝消息教 != null 写法 + #4763 的 publish-time lint 在创作时拒绝该形状。暴露面如实声明:携带 has() 守卫大小比较的存量部署会见到新 400,但那些规则在任何回 NULL 的驱动上本来就没拦过东西 —— 是暴露,不是回归;changeset 已显著记载。

    场外发现处置(均已入队,本会话暂停不派)


    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

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions