You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat(lint,docs): reject un-guarded nullable comparisons in CEL predicates — has(x) is not a null guard (#4763)
CEL's `has(x)` asks whether the KEY is present. Since #4649 every predicate
reads a record that is total over the object's declared fields, so a declared
column holding NULL is still present and `has(record.end_date)` is uniformly
true. The idiom that reads like a guard —
has(record.start_date) && has(record.end_date) && record.end_date < record.start_date
— therefore reaches `null < null`, CEL has no overload, and the whole predicate
aborts. Before #4761 the abort was swallowed, so a rule of this shape enforced
NOTHING on exactly the rows it was written to catch. The fault is fully
decidable from the metadata alone, so per PD #12 it belongs at authoring, not
at a 400 on production data.
New gate (error, no warn-mode escape hatch), in `packages/lint`:
- `validate-null-guards.ts` — the decision procedure. Parses the predicate with
cel-js and rejects an ordering (`< <= > >=`) or arithmetic (`+ - * / %`,
unary `-`) operator applied to an operand that resolves to a declared
NULLABLE field (no `required: true`, no `defaultValue`, no default option,
not autonumber) and is not dominated by an explicit `!= null` / `== null` /
`!isBlank()` test in the same boolean branch. `has()` deliberately does not
count. Guard propagation follows `&&` left-to-right, `||` short-circuit,
`!` polarity and ternary branches.
- Wired into `validateStackExpressions`, already a `gating` authoring rule on
`os build` / `os validate` / `os lint` AND the runtime publish gate.
- Scope: object validation rules (including predicates nested in a
`conditional` rule's `then`/`otherwise`) and lifecycle hook `condition`s —
the surfaces CEL actually evaluates over a total record. Sharing rules
(compiled to a SQL filter, three-valued, never faults), flattened flow
conditions (a bare id may be a flow variable) and `Field.formula` (its own
#3306 handling) are deliberately out, not half-covered.
- Message names the rule, the operand and the fix, and closes with the sentence
lifted verbatim from `unevaluableRuleError` in `rule-validator.ts`, so the
publish-time and runtime rejections read identically.
`has()` over an UNDECLARED key is untouched — that is its legitimate use.
All three example apps pass the new gate unchanged; the pre-#4786 showcase hook
shape is pinned as a regression test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018iARDqtrhQgz6fVHDeDkbQ
Copy file name to clipboardExpand all lines: content/docs/data-modeling/validation.mdx
+4Lines changed: 4 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -59,6 +59,10 @@ export const Order = ObjectSchema.create({
59
59
Condition expressions are **CEL** (evaluated by `@objectstack/formula`), not Salesforce-style formulas. Reference the incoming record via `record.<field>`, use `==`/`!=`, `&&`/`||`, and helpers like `isBlank(x)` and `has(record.field)`. A string condition is accepted as authoring shorthand and normalized to `{ dialect: 'cel', source }` at build time.
60
60
</Callout>
61
61
62
+
<Callouttype="warn">
63
+
**`has(x)` is not a null guard.** Predicates see a record that is *total* over the object's declared fields, so `has(record.end_date)` is true even when the value is `NULL` — `has(a) && has(b) && a < b` then reaches `null < null`, CEL has no overload, and the whole rule aborts (the write is rejected fail-closed). Write `record.start_date != null && record.end_date != null && record.end_date < record.start_date` instead. Since #4763 the `has()` form is **rejected at build/publish**: an ordering or arithmetic operator over a declared nullable field needs a real `!= null` guard. `has()` over an *undeclared* key — "was this in the PATCH at all?" — is untouched.
0 commit comments