Repository navigation
fix(validations): make every validation predicate total so rules stop being silently skipped - #634
Merged
Conversation
… being skipped
A validation rule is evaluated against `{...previous, ...data}`, and the engine
fills absent fields with `null` only on insert. On update `previous` is whatever
the driver returned, and a driver that stores only the columns a row was written
with hands back a record with the key absent. Strict CEL then aborts the whole
predicate with `No such key`, and the engine's response to a predicate that
fails to evaluate is to SKIP the rule — no error, no failed save, just a rule
that reads as enforced and requires nothing.
23 of 24 script validations were exposed. Every `record.x` read now carries a
`has(record.x)` guard: `(!has(record.f) || isBlank(record.f))` for "f is empty",
`has(record.f) && record.f <op> …` for "f holds a value". `!= null` does not
substitute — measured on an absent key it aborts exactly like `record.f < 0`
does; it guards the separate `dyn<null> < int` hazard, so numeric comparisons
carry both.
Measured per driver: driver-sql and driver-sqlite-wasm are column-complete, so
rules already fired there; driver-memory and driver-mongodb return only what was
written, so the rules were inert on those. The house rule, the full measurement
table and why this route was chosen over making the in-memory test driver
column-complete are recorded in test/object-validation-predicates.test.ts and
summarised in AGENTS.md.
Enforcement is two tests: a structural check that no predicate reads a field
without a guard, and a run of every predicate through the engine's own
`evaluateValidationRules` against a record with no keys at all, failing on any
"predicate failed to evaluate" warning. An end-to-end case drives a real
ObjectQL over InMemoryDriver and performs the exact operation from the issue.
Refs #630
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SS7C5SXpniKeCApxgARyf
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
os-zhuang
marked this pull request as ready for review
August 2, 2026 14:41
This was referenced Aug 2, 2026
A
decision node's singular config.condition is never evaluated — 24 flows author inert metadata
#650
Closed
This was referenced Aug 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #630
先说结论:issue 的前提成立,但只成立了一半
PM 要求的第一步是实测「SQL 驱动的
previous确实列完备,所以只有内存驱动暴露」。实测确认前提成立,P1 的定级是对的 —— 但同时发现了 issue 里没有提到的第四个驱动。用一个只有
record.status == "closed" && isBlank(record.reason)规则的探针对象,插入一条不写reason的记录,再 update 成closed:@objectstack/driver-sql(better-sqlite3)reason: null,键在@objectstack/driver-sqlite-wasmreason: null,键在@objectstack/driver-memory@objectstack/driver-mongodbSQL 家族列完备的原因也核对了:引擎读
previous用的是{ object, where: { id }, limit: 1 },不带字段投影,所以是整行;SELECT *对未写入列返回 NULL。needsPriorRecord()对任何script规则都返回 true,所以单条和批量 update 两条路径都会去取 prior row —— 不存在「SQL 上也拿不到 previous」的漏网路径。issue 没提到的那条:
@objectstack/driver-mongodb的find/findOne返回的是存储的文档去掉_id,不做任何字段补齐(projection: { _id: 0 },没有补 null 的步骤),行为和内存驱动一致。它是平台的一等公民驱动、同版本线发布。HotCRM 是 marketplace app,不选择宿主用哪个数据源 —— 所以「只有测试替身暴露、生产没事」这个说法对 SQLite/Postgres 成立,对 Mongo 宿主不成立。这一条没有把本件推回 P0(HotCRM 自己的 dev/demo/生产配置走的是 SQL 家族,已实测执行),但它把「家规」的选择从两难变成了单选题,理由见下。家规:选择「谓词必须是全函数」,不选「把内存驱动改成列完备」
PM 给的两条路是 (a) 每处字段引用都带
has()守卫,(b) 让内存测试驱动列完备。选 (a),理由不是它便宜,是 (b) 在这个仓库里做不到它承诺的事:@objectstack/driver-memory是已发布的平台包,本仓库不改平台代码。HotCRM 能做的只是一个测试侧的包装器 —— 而包装器只对「记得用它」的测试有效。这正是 PM 对 (a) 的那条反对意见(「每个作者都得记住,忘了是静默的」),只是 (b) 的遗忘模式更糟:新测试直接new InMemoryDriver()就悄悄退回陷阱,而且没有任何东西会检查。test/lead-duplicate-management.test.ts今天就是这么写的。(a) 的代价 —— 「靠人记」—— 由测试付掉,见下。决定和被否路线的代价写在
test/object-validation-predicates.test.ts顶部的 HOUSE RULE 块里(不是只写在 PR 描述里),AGENTS.md 的约束清单里加了一条摘要 + 一节速查表。改了什么
24 条 script validation 里 23 条暴露(唯一幸存的是 #629 里已经带
has()守卫的crm_lead.duplicate_disqualification_requires_survivor)。两种书写形状:(!has(record.f) || isBlank(record.f))has(record.f) && record.f < 比较运算符 > …!= null不能替代has():实测在缺键上record.f != null和record.f < 0一样 abort。它守的是另一个坑(dyn< null > < int,#514 item 3),所以数值比较两个守卫都要带。coalesce(record.f, "")也 abort —— 参数在调用前求值,所以has()是唯一的全函数访问器。一处语义需要单独说明:
crm_opportunity.close_date_future里stage用的是(!has(record.stage) || (…))而不是前置has(record.stage) &&。缺键意味着「没有记录 stage」,而没有记录的 stage 不是已关闭的 stage,所以该告警应当照常发出;写成前置has()会把告警吞掉 —— 那就是换了个方式静默。测试
三层,item 2 是 PM 指定必做的那条:
validations[].condition+requiredWhen/readonlyWhen/visibleWhen+ optionvisibleWhen,任何一处record.x没有配套has(record.x)就失败,并在断言消息里给出正确写法。evaluateValidationRules(obj, {}, 'update', { previous, logger }),断言 logger 里没有任何failed to evaluate。三种记录形状:全部键缺失、全部字段 present-as-null、以及逐个字段单独缺失(后者能抓住「只是因为相邻子句短路了才没炸」的假守卫)。这层是量出来的而不是 grep 出来的,正则骗不过去。ValidationError在这里算通过 —— 会开火的规则说明它给出了裁决,只有 abort 导致的沉默才是缺陷。InMemoryDriver,复现 issue 里的原始操作:插入一条不带completed_date的 task,再 updatestatus: 'completed'。反向验证过这些测试确实有牙:把
crm_task.completed_date_required单独改回未守卫的写法,4 个测试立刻红,其中包括端到端那条(promise resolved instead of rejecting—— 也就是 issue 描述的那次静默放行)。用户可见的行为变化
SQL / SQLite 宿主上没有变化 —— 那里规则本来就在执行。内存和 Mongo 宿主上,受影响的规则从「什么都不做」变成「按声明执行」,所以以前能存下的记录现在可能被正确拒绝(例如把 task 改成 Completed 却不给完成日期)。changeset 里写明了这一点。
范围之外
has()都没有(9 条 sharing rule 全中,9 条 flow 条件全中)。但它们拿到的记录形状和 abort 后的后果都与 validation 路径不同(sharing rule 若 fail-open 就是信息泄露,fail-close 只是更严),没有实测就不该动手,也不属于本 issue 的范围 —— 已另开 Flow start conditions carry no has() guards — measure whether the abort-and-skip class of #630 reaches them #633(未指派),把已量到的清单和还需要量什么写清楚了。🤖 Generated with Claude Code
https://claude.ai/code/session_019SS7C5SXpniKeCApxgARyf