You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Script validation rules are silently skipped when their predicate fails to evaluate — fail-open is the wrong direction for a validation #4649
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:
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 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
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 …