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.
Found while implementing #4649 (PR #4761). Filed unassigned.
The trap
CEL's
has(x)asks whether the key is present. A declared column holdingNULLis present. So the idiom that reads like a null guard —— is not one: both
has()calls returntruefor a row whose dates are NULL, the comparison then evaluatesnull < 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_pastBoth are fixed in #4761 to the correct form:
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.tsalready 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:required, nodefaultValue), and!= 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 fromunevaluableRuleErrorinpackages/objectql/src/validation/rule-validator.ts:Worth applying to hook / flow-trigger conditions too, since the third instance above lives there.
Acceptance sketch
has(a) && has(b) && a < bshape over nullable declared fields failspnpm lint/ the publish gate, naming the rule, the operand, and the!= nullfix.!= 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).has()≠ null-guard rule;skills/objectstack-formulais the natural home.