Repository navigation
Description
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.
previousis 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:
- 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.
- Make the merged record total — materialise declared-but-absent fields as
nullon 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
os-zhuang commentedon Aug 3, 2026
🔒 认领 —— 第 2 轮(发布窗口 bug 批)
session_015Br2xsJsczFsTR9bvbh2Ny(PM 循环)claude/issue-4649-validation-fail-closedobjectstack-issue-4649方向的 PM 裁定(维护者可否决):正文方案 2 + 方案 1 组合,缺一不可:
null,与 insert 对齐 —— 对着声明形状写的谓词永远有值可读,insert/update 行为一致(作者从文档得到的预期)。为什么不是只做其一:只做 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
os-zhuang commentedon Aug 3, 2026
实现完成 —— 草稿 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 被吞掉了。这一点直接检验了裁定里"别让缓解层反被破坏"那条:
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 立单(均未指派):
formatrule with an invalid regex, and ajson_schemarule ajv cannot compile, still fail OPEN — the same trap #4649 closed, one rule type over #4762 ——format的坏regex/json_schema编译不了的 schema 仍然 fail open,同一个陷阱换了规则类型。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 —— 建议在 publish/lint 期就拒掉"未加!= null守卫的可空比较",把has()陷阱在作者时刻挡掉(对 AI 编写的元数据尤其重要);showcase 的 hook 条件里还剩一处同形状实例。⛔
packages/spec/**零改动;⛔content/docs/releases/未碰。Generated by Claude Code
os-zhuang commentedon Aug 3, 2026
✅ PM 验收通过 —— PR #4761,CI 全绿(18 success + 2 skipped,0 失败),已转 ready 并入合并队列
复核对着 GitHub:
previous绑定同样物化且在副本上做,不污染 hookContext);残余求值失败 fail closed,点名规则与缺键;severity仍管拦不拦,advisory 规则不升格conditional的when计入需要 previous —— fail-closed 成立的前提,不修会误拒合法部分更新turbo run test133/133、typecheck 122/122;两条原本断言 fail-open 的既有用例有意识地翻转为 fail-closedopen_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 已显著记载。场外发现处置(均已入队,本会话暂停不派)
formatrule with an invalid regex, and ajson_schemarule ajv cannot compile, still fail OPEN — the same trap #4649 closed, one rule type over #4762 — format 坏 regex / json_schema 编译失败仍 fail-open(同类陷阱隔一个规则类型;本 PR 已用测试钉住现状让缺口可见)→ bug 入队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 — publish-time lint 拒绝无!= null支配的可空排序/算术比较 → tooling 入队Generated by Claude Code
fix(objectql): fail closed on unevaluable validation predicates, and …
fix(objectql): hook 条件对完整 record 求值 —— stored ⊕ payload,与 objectstack…
fix(objectql,skills): hook `condition` 的 CEL 作用域补上 `previous` —— 过渡语义…
feat(lint,docs): `has(x)` 不是 null 守卫 —— 发布期拒绝未守卫的可空比较 (objectstack-ai…
feat(objectql)!: hook 的 condition 求不出值时 fail loud —— 抛错并中断该次操作 (objec…
docs: v17 docs sweep run 5 — rc.2 catch-up (objectstack-ai#4881)
fix(objectql,lint): enforce parent-scoped `readonlyWhen` on the server (
fix(lint): regex/JSON Schema 编译不过的校验规则改在发布期拒绝 —— 声明了却什么都不拦的两个静态产物 (ob…
fix(objectql,lint)!: `json_schema` 规则的 `format` 关键字真正生效(注册 ajv-format…
fix(objectql,lint): 服务端为 `requiredWhen` 绑定 parent 作用域,并把构建期硬闸扩到同一格 (o…
fix(objectql): gate by-id update/delete on record existence — 404 REC…
fix(objectql)!: a field-level requiredWhen / readonlyWhen that cannot…
docs(trigger-record-change): re-anchor the dead tracker citations to …