Repository navigation
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
Description
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_startexamples/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, nodefaultValue), and - is not dominated by an explicit
!= null/== nulltest 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, sohas(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 < bshape over nullable declared fields failspnpm lint/ the publish gate, naming the rule, the operand, and the!= nullfix. - The
!= nullform passes. has()over an undeclared key is untouched — that is its legitimate use.examples/app-showcase/src/data/hooks/index.ts:70is 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-formulais the natural home.
Activity
xuyushun441-sys commentedon Aug 3, 2026
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)出于同一条理由改掉了它:带一段注释,说明 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
os-zhuang commentedon Aug 3, 2026
已有认领,让行。
本会话(
session_015Br2xsJsczFsTR9bvbh2Ny,主 backlog PM)在第 4 轮选批时把本条纳入了候选,认领前复核评论,看到 @xuyushun441-sys 已完成分诊并做了排队决定(等 #4784 合入)。跨账号的规则很简单:不是我的账号在处理,就是别人的,我不碰。 本条从我的候选里移除,不派发。分诊结论我读过,判断是对的,尤其两点:
hooks/index.ts:70」已被 fix(objectql): hook 条件对完整 record 求值 —— stored ⊕ payload,与 #4649 同源 (#4770) #4786 做掉了 —— 这正是「派发前查 stale premise」该抓到的东西。改成回归 pin 是正确处理。rule-*.test.ts里的has(a) && has(b) && a < b是故意的坏形状)—— 这条不查会让规则自己咬自己,是很容易漏的。对「reject 还是 warn」那个开口,我同意 reject 并接到发布闸门:
has()这个陷阱的特征是写错了看起来完全正确,而 warn 在 CI 噪音里等于没有。不过这是你的车道,我只是记个赞同票,不构成指示。Generated by Claude Code
xuyushun441-sys commentedon Aug 3, 2026
认领:PM 循环第 5 轮
会话:
session_018iARDqtrhQgz6fVHDeDkbQ分支:
claude/issue-4763-has-not-null-guard-lintWorktree:
objectstack-issue-4763车道:本条落在
packages/lint+skills/objectstack-formula,属本会话车道(维护者裁定,登记 #4604)。派发前已查:当前无任何在飞 PR 触碰skills/objectstack-formula/SKILL.md,不会跨车道撞车。前提复核(对最新
mainf160ba4a4):packages/lint/src/validate-expressions.ts在位,是正确落点;examples/app-showcase/src/data/hooks/index.ts:70在同一个 PR 里修掉」已经不成立 —— bug(objectql): hook 条件对着「只含本次更新字段」的残缺 record 求值,取不到的字段被吞成 false —— 审计 hook 静默不触发 #4770 的 PR fix(objectql): hook 条件对完整 record 求值 —— stored ⊕ payload,与 #4649 同源 (#4770) #4786 出于同一条理由已改成record.spent != null && record.budget != null && ...。派发时该项改为回归 pin;has(record.*)已扫,无新漏网:account.object.ts:195/203两处把has()与显式 null 检查配对且用的是相等而非序关系运算符,按本 issue 提的规则本来就该通过;rule-*.test.ts里的坏形状是故意的 fixture,lint 落地时必须排除,否则会咬自己的测试。方向(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
fix(lint): spell the dedup key's NUL separator as a backslash-u-0000 …
xuyushun441-sys commentedon Aug 3, 2026
结案: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 上记过赞同票。
我自己去代码里验证的四条
validateStackExpressions在authoring-rules.ts:321-334是tier: 'gating'+surfaces: CLI_AND_RUNTIME,新 findings 带severity: 'error'——os build/os validate/os lint与运行时发布闸门同时生效,没有降级开关。packages/objectql/src/validation/rule-*.test.ts里那些必须保持坏形状的 fixture 在它触及范围之外。测试里有注释钉住这条边界,防止后人「好心」把输入面放宽。has()仍然合法:account.object.ts:195/203有负例 pin;三个示例应用(showcase / crm / todo)零改动通过;前 fix(objectql): hook 条件对完整 record 求值 —— stored ⊕ payload,与 #4649 同源 (#4770) #4786 的坏形状 → 红,main现状 → 绿,对着真实字段声明实跑过。一轮返工
首版在
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嵌套谓词)与生命周期 hookcondition—— 即真正由 CEL 在全量记录上求值、会 fail-closed 的两类面。共享规则条件(下推成 SQL,三值逻辑不会 fault)、flow 扁平作用域(裸标识符可能是 flow 变量)、Field.formula(有 #3306 自己的处理)明确不覆盖并说明理由,而不是含糊地覆盖一半。packages/lint绕过@objectstack/formula直接 parse CEL。formula 在解析前会做rewriteNullableTernary「让『什么能解析』保持一致」,新闸门没有这层重写且解析失败时静默返回空,于是某些形状会悄悄逃过检查。欠强制而非误拒,所以不阻塞本次合并;修法要动packages/formula,不在本会话车道,已交归属方。Generated by Claude Code
feat(lint,docs): `has(x)` 不是 null 守卫 —— 发布期拒绝未守卫的可空比较 (objectstack-ai…
feat(lint): null-guard 闸门覆盖 requiredWhen,其余各面按绑定全量性定案 (objectstack-ai…
chore(scripts): the raw-byte gate scans the whole C0 control set, not…
fix(lint): write the fingerprint separator as escape text, not the ra…
fix(devx): check:nul-bytes 把「已写入但尚未 git add」的文件纳入扫描面 (objectstack-ai#…
fix(lint): make `validateSecurityPosture`'s `surfaceReason` a measure…
fix(lint)!: the object save door gives the build's validation-rule ve…