Skip to content

fix(validations): make every validation predicate total so rules stop being silently skipped - #634

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-630-validation-predicate-totality
Aug 2, 2026
Merged

os-zhuang merged 1 commit into
mainfrom
claude/issue-630-validation-predicate-totality

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Aug 2, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #630

先说结论:issue 的前提成立,但只成立了一半

PM 要求的第一步是实测「SQL 驱动的 previous 确实列完备,所以只有内存驱动暴露」。实测确认前提成立,P1 的定级是对的 —— 但同时发现了 issue 里没有提到的第四个驱动。

用一个只有 record.status == "closed" && isBlank(record.reason) 规则的探针对象,插入一条不写 reason 的记录,再 update 成 closed:

驱动 未写入列读回来是什么 update 时规则
@objectstack/driver-sql (better-sqlite3) reason: null,键在 ENFORCED(抛 ValidationError)
@objectstack/driver-sqlite-wasm reason: null,键在 ENFORCED
@objectstack/driver-memory 键不存在 SKIPPED(update 静默成功)
@objectstack/driver-mongodb 键不存在 暴露(见下)

SQL 家族列完备的原因也核对了:引擎读 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) 在这个仓库里做不到它承诺的事:

  1. (b) 没法在源头做。 @objectstack/driver-memory 是已发布的平台包,本仓库不改平台代码。HotCRM 能做的只是一个测试侧的包装器 —— 而包装器只对「记得用它」的测试有效。这正是 PM 对 (a) 的那条反对意见(「每个作者都得记住,忘了是静默的」),只是 (b) 的遗忘模式更糟:新测试直接 new InMemoryDriver() 就悄悄退回陷阱,而且没有任何东西会检查。test/lead-duplicate-management.test.ts 今天就是这么写的。
  2. (b) 会把这一类 bug 藏起来,而不是修掉。 内存驱动的稀疏行是整个技术栈里唯一会产出「merged 记录缺键」形状的地方。把替身改得跟 SQL 一致,测试会全绿,而每一个 Mongo 宿主继续静默失去保护。这个稀疏的替身是资产,不是缺陷 —— 代码注释里明确写了「不要去修它」,并加了一条断言:如果哪天平台升级让内存驱动变成列完备,那条断言会红,提醒后人下面几个测试已经不再证明任何东西。
  3. (b) 让谓词的正确性挂在环境属性上。 「存储层碰巧返回 NULL」不是契约,是巧合;一次数据源切换就能让规则集体失效。全函数性是规则自身的属性,契约该待的地方。

(a) 的代价 —— 「靠人记」—— 由测试付掉,见下。决定和被否路线的代价写在 test/object-validation-predicates.test.ts 顶部的 HOUSE RULE 块里(不是只写在 PR 描述里),AGENTS.md 的约束清单里加了一条摘要 + 一节速查表。

改了什么

24 条 script validation 里 23 条暴露(唯一幸存的是 #629 里已经带 has() 守卫的 crm_lead.duplicate_disqualification_requires_survivor)。两种书写形状:

  • 「f 没有值」 → (!has(record.f) || isBlank(record.f))
  • 「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 指定必做的那条:

  1. 结构检查 —— 扫全部 validations[].condition + requiredWhen/readonlyWhen/visibleWhen + option visibleWhen,任何一处 record.x 没有配套 has(record.x) 就失败,并在断言消息里给出正确写法。
  2. 真引擎上的全函数性 —— 把每个对象的谓词交给引擎自己的 evaluateValidationRules(obj, {}, 'update', { previous, logger }),断言 logger 里没有任何 failed to evaluate。三种记录形状:全部键缺失、全部字段 present-as-null、以及逐个字段单独缺失(后者能抓住「只是因为相邻子句短路了才没炸」的假守卫)。这层是量出来的而不是 grep 出来的,正则骗不过去。ValidationError 在这里算通过 —— 会开火的规则说明它给出了裁决,只有 abort 导致的沉默才是缺陷。
  3. 端到端 —— 真 ObjectQL + InMemoryDriver,复现 issue 里的原始操作:插入一条不带 completed_date 的 task,再 update status: 'completed'。

反向验证过这些测试确实有牙:把 crm_task.completed_date_required 单独改回未守卫的写法,4 个测试立刻红,其中包括端到端那条(promise resolved instead of rejecting —— 也就是 issue 描述的那次静默放行)。

Test Files  40 passed (40)
     Tests  899 passed | 1 skipped (900)

pnpm typecheck  ✓
pnpm validate   ✓ Validation passed (941ms)
pnpm hygiene    ✓ source hygiene clean
pnpm build      ✓ Build complete — dist/objectstack.json
pnpm lint       1 warning(既有的 crm_campaign_member field-group-shadowed,与本改动无关)

用户可见的行为变化

SQL / SQLite 宿主上没有变化 —— 那里规则本来就在执行。内存和 Mongo 宿主上,受影响的规则从「什么都不做」变成「按声明执行」,所以以前能存下的记录现在可能被正确拒绝(例如把 task 改成 Completed 却不给完成日期)。changeset 里写明了这一点。

范围之外

🤖 Generated with Claude Code

https://claude.ai/code/session_019SS7C5SXpniKeCApxgARyf

… 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
@vercel

vercel Bot commented Aug 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
hotcrm Ignored Ignored Aug 2, 2026 2:29pm

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation ci/cd CI plumbing and the verification pipeline metadata Declarative metadata — schema, security posture, UI surfaces labels Aug 2, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 2, 2026 14:41
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 2, 2026
Merged via the queue into main with commit 0f72853 Aug 2, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd CI plumbing and the verification pipeline documentation Improvements or additions to documentation metadata Declarative metadata — schema, security posture, UI surfaces

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Script validations are silently SKIPPED when the merged record lacks the field key (update path)

2 participants