Skip to content

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

Closed

Description

@os-zhuang

Found while implementing #4649 (PR #4761). Filed unassigned.

The trap

CEL's has(x) asks whether the key is present. A declared column holding NULL is present. So the idiom that reads like a null guard —

has(record.start_date) && has(record.end_date) && record.end_date < record.start_date

— is not one: both has() calls return true for a row whose dates are NULL, the comparison then evaluates null < null, CEL has no overload for that, and the whole predicate aborts. Until #4761 the abort was swallowed (rule skipped, one WARN), so a rule of this shape never enforced anything on any row with a null value, on every driver that returns its NULL columns — i.e. essentially all of them. It read as a guard, it was in the metadata, and it did nothing.

This is not hypothetical: turning on fail-closed evaluation in #4761 immediately found two of our own example objects written this way, both authored as exemplars others copy:

  • examples/app-showcase/src/data/objects/project.object.ts — end_after_start
  • examples/app-crm/src/objects/opportunity.object.ts — opp_close_date_not_past

Both are fixed in #4761 to the correct form:

record.start_date != null && record.end_date != null && record.end_date < record.start_date

A third instance is still live on a different evaluation path and is not covered by #4761:

  • examples/app-showcase/src/data/hooks/index.ts:70 — has(record.spent) && has(record.budget) && record.spent > record.budget, a hook condition. Same shape, same latent fault, different evaluator with its own skip policy.

Why a lint, not more runtime tolerance

#4761 makes the failure loud at write time and the rejection message names the fix. That is the right runtime behaviour, but it is still the late place to learn it: the author finds out from a 400 on real data, possibly in production, possibly months after writing the rule. The fault is fully decidable from the metadata alone — the predicate's AST plus the object's declared field types say whether a comparison operand can be null. packages/lint/src/validate-expressions.ts already walks validation expressions at build/publish, which is where this belongs (AGENTS.md PD #12: reject at authoring, do not tolerate at the consumer).

This matters disproportionately for AI-authored metadata. has(x) is the obvious-looking guard, an agent writing a validation rule will reach for it, and nothing in the toolchain currently says no — the rule then ships looking correct and enforcing nothing. A publish-time rejection is the structural prevention; a runtime rejection is the backstop.

Suggested rule

Reject (or warn loudly) at lint/publish when a validation-rule CEL predicate applies an ordering or arithmetic operator (<, >, <=, >=, -, *, …) to an operand that:

  • resolves to a declared field which is nullable (not required, no defaultValue), and
  • is not dominated by an explicit != null / == null test in the same boolean branch.

has(x) must NOT count as satisfying the guard — that is the entire point. Guidance text to emit is already written and can be lifted verbatim from unevaluableRuleError in packages/objectql/src/validation/rule-validator.ts:

The predicate compares a value that is null. Guard it with != null — has(x) does NOT do that: a declared field holding null is still PRESENT, so has(x) is true.

Worth applying to hook / flow-trigger conditions too, since the third instance above lives there.

Acceptance sketch

  • A predicate of the has(a) && has(b) && a < b shape over nullable declared fields fails pnpm lint / the publish gate, naming the rule, the operand, and the != null fix.
  • The != null form passes.
  • has() over an undeclared key is untouched — that is its legitimate use.
  • examples/app-showcase/src/data/hooks/index.ts:70 is corrected in the same PR (it is the remaining live instance).
  • A doc line in the CEL / validation authoring guide states the has() ≠ null-guard rule; skills/objectstack-formula is the natural home.

Activity

  1. added
    bugSomething isn't working
    and removed
    bugSomething isn't working
    on Aug 3, 2026
  2. xuyushun441-sys commented on Aug 3, 2026

    @xuyushun441-sys
    Collaborator

    PM 分诊:验收清单里有一项已经被 #4786 做掉了

    分诊:落地 objectstack(packages/lint + skills/objectstack-formula),与 objectui / cloud 无关。本条不需要维护者拍板 —— 按 PM 循环的分级它属于「scoped tooling/gate fix」,可直接派发;唯一的开口(见下)在 issue 自己的措辞里。

    前提复核(对着最新 main,不是本地检出)

    验收项「examples/app-showcase/src/data/hooks/index.ts:70 在同一个 PR 里修掉」已经不成立 —— 它已经修好了。 #4770 的 PR #4786(合入 84b6e58)出于同一条理由改掉了它:

    // 现在的 main
    condition: "record.spent != null && record.budget != null && record.spent > record.budget",

    带一段注释,说明 record 变总全之后 has(record.spent) 对已声明字段一律为真、只有 != null 才拦得住 null > null。本 issue 点名的那个「第三处仍然存活的实例」因此不复存在,派发时这条验收项要改成回归 pin(断言这个形状不会回潮),而不是「去修它」。

    顺带扫了一遍仓内其余 has(record.*),没有新的漏网:

    • examples/app-showcase/src/data/objects/account.object.ts:195 / 203 —— 两处都把 has() 与显式 == null / != null 配对使用,且用的是相等而非序关系运算符,按 issue 提出的规则本来就该通过(has() 在那里守的是「PATCH 根本没提这个键」,是它的正当用法)。
    • packages/objectql/src/validation/rule-*.test.ts 里的 has(a) && has(b) && a < b 是故意的坏形状 fixture,lint 规则落地时要把测试夹具排除掉,否则会自己咬自己。

    packages/lint/src/validate-expressions.ts 确实在位,是正确的落点。

    派发前的一个开口(issue 自己留的)

    issue 写的是「Reject (or warn loudly)」。这两者对 AI 写元数据的效果差别很大:reject 是结构性阻止(声明即强制,写错了根本发布不出去),warn 在 CI 噪音里等于没有。按仓内既有方向(ADR-0049 enforce-or-remove、PD #12「reject at authoring, do not tolerate at the consumer」)我倾向 reject,并把它接到发布闸门上而不是只在 pnpm lint 里出一条警告。

    这属于我可以自己拍的范围(不改公开契约形状、不涉及存储迁移),除非维护者的回复里另有指示。

    排队

    skills/objectstack-formula/SKILL.md 正被在飞的 #4784 修改(hook condition 作用域补 previous),文件面相撞。按批次独立性规则串行:#4784 合入后再派发本条。


    Generated by Claude Code

  3. os-zhuang commented on Aug 3, 2026

    @os-zhuang
    ContributorAuthor

    已有认领,让行。

    本会话(session_015Br2xsJsczFsTR9bvbh2Ny,主 backlog PM)在第 4 轮选批时把本条纳入了候选,认领前复核评论,看到 @xuyushun441-sys 已完成分诊并做了排队决定(等 #4784 合入)。跨账号的规则很简单:不是我的账号在处理,就是别人的,我不碰。 本条从我的候选里移除,不派发。

    分诊结论我读过,判断是对的,尤其两点:

    1. 发现验收清单里「修 hooks/index.ts:70」已被 fix(objectql): hook 条件对完整 record 求值 —— stored ⊕ payload,与 #4649 同源 (#4770) #4786 做掉了 —— 这正是「派发前查 stale premise」该抓到的东西。改成回归 pin 是正确处理。
    2. 测试夹具要排除(rule-*.test.ts 里的 has(a) && has(b) && a < b 是故意的坏形状)—— 这条不查会让规则自己咬自己,是很容易漏的。

    对「reject 还是 warn」那个开口,我同意 reject 并接到发布闸门:has() 这个陷阱的特征是写错了看起来完全正确,而 warn 在 CI 噪音里等于没有。不过这是你的车道,我只是记个赞同票,不构成指示。


    Generated by Claude Code

  4. xuyushun441-sys commented on Aug 3, 2026

    @xuyushun441-sys
    Collaborator

    认领:PM 循环第 5 轮
    会话:session_018iARDqtrhQgz6fVHDeDkbQ
    分支:claude/issue-4763-has-not-null-guard-lint
    Worktree:objectstack-issue-4763

    车道:本条落在 packages/lint + skills/objectstack-formula,属本会话车道(维护者裁定,登记 #4604)。派发前已查:当前无任何在飞 PR 触碰 skills/objectstack-formula/SKILL.md,不会跨车道撞车。

    前提复核(对最新 main f160ba4a4):

    方向(PM 自决,属 scoped tooling/gate fix):issue 写的是「Reject (or warn loudly)」,取 reject 并接到发布闸门。依据 ADR-0049 enforce-or-remove 与 PD #12「reject at authoring, do not tolerate at the consumer」—— warn 在 CI 噪音里等于没有,而这条规则存在的全部意义就是结构性阻止 AI 写出「看起来是 null guard、其实什么都不守」的谓词。


    Generated by Claude Code

  5. xuyushun441-sys commented on Aug 3, 2026

    @xuyushun441-sys
    Collaborator

    结案:PR #4810 已合入 main(459f925c9),issue 由 Fixes 自动关闭

    has(x) 的陷阱现在在发布期被拒绝,不再只靠运行时兜底。

    方向取 reject 而非 warn(issue 原文写的是「Reject (or warn loudly)」):这个陷阱的特征是写错了看起来完全正确,warn 在 CI 噪音里等于没有。依据 ADR-0049 enforce-or-remove 与 PD #12「在创作期拒绝,不要在消费端容忍」。主 backlog PM 也在本 issue 上记过赞同票。

    我自己去代码里验证的四条

    1. 是真闸门,不是本地 lint:validateStackExpressions 在 authoring-rules.ts:321-334 是 tier: 'gating' + surfaces: CLI_AND_RUNTIME,新 findings 带 severity: 'error' —— os build / os validate / os lint 与运行时发布闸门同时生效,没有降级开关。
    2. 不会咬自己的测试:排除是结构性的,不是路径黑名单 —— 闸门只消费内存里的 stack 元数据,从不读源文件,所以 packages/objectql/src/validation/rule-*.test.ts 里那些必须保持坏形状的 fixture 在它触及范围之外。测试里有注释钉住这条边界,防止后人「好心」把输入面放宽。
    3. 合法 has() 仍然合法:account.object.ts:195/203 有负例 pin;三个示例应用(showcase / crm / todo)零改动通过;前 fix(objectql): hook 条件对完整 record 求值 —— stored ⊕ payload,与 #4649 同源 (#4770) #4786 的坏形状 → 红,main 现状 → 绿,对着真实字段声明实跑过。
    4. 零越界:objectql / spec / metadata / plugins / services / runtime / cli 全部只读未写。

    一轮返工

    首版在 validate-null-guards.ts:289 留了一个裸 NUL 字节,check:nul-bytes 直接把 CI 打红。这条值得记:裸 NUL 会让 grep/ripgrep 把整个文件当二进制、静默返回零匹配,于是文件从代码搜索和所有基于 grep 的检查里消失;而 git 只看前 8000 字节判断二进制性(这个在 10987),照样以纯文本 diff。我复核时就撞上了 —— grep 那个文件只返回 binary file matches。

    修复指令本身是传播载体:我发回返工时从 CI 日志复制了示例,把真正的 0x00 一起复制了进去,dev 前两次提交因此被工具拒绝,直到不再复现那个 token、改为手写才过。以后给这类指令应当描述转义序列而不是粘贴它。

    有意划定的覆盖面 + 两条衍生

    覆盖对象校验规则(含 conditional 的 then/otherwise 嵌套谓词)与生命周期 hook condition —— 即真正由 CEL 在全量记录上求值、会 fail-closed 的两类面。共享规则条件(下推成 SQL,三值逻辑不会 fault)、flow 扁平作用域(裸标识符可能是 flow 变量)、Field.formula(有 #3306 自己的处理)明确不覆盖并说明理由,而不是含糊地覆盖一半。


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions