Raise an error for unresolved variables instead of degrading to null (#4043)
Port of RevenueCat/purchases-ios#7460. The predicate fixtures are
byte-identical to the iOS ones.
### Motivation
A `var` lookup that found nothing returned `null`, per the JSON Logic
spec. That null coerces to `0` in a numeric comparison and `""` in a
string one, so a rule reading a dimension this SDK version never
supplied still produces an answer, often the wrong one. A missing `now`
turns `{">": [expires, now]}` into `expires > 0`, true for every
subscription that ever existed.
### Description
An absent variable means "unknown", which is not the same answer as
"no". The lookup now throws `UnresolvedVariable(path)` and the caller
decides what an unanswerable rule means. Both callers already drew that
distinction, so neither needed changing: `LocalRulesEvaluator` keeps
walking later rules and fails only if none matched, and
`CheckpointWorkflowResolverImpl` reports `CONFIGURATION_UNAVAILABLE`
rather than `NO_MATCH`.
- Rules that tolerate absence opt in via the spec's own `{"var":
["path", default]}`. A key present but holding `null` is a known value
and still resolves; `missing`/`missing_some` still probe absence.
- This diverges from `json-logic-js`, so the same rule that errors here
answers `false` on web.
<!-- CURSOR_SUMMARY -->
---
> [!NOTE]
> **High Risk**
> Changes core audience/checkpoint matching semantics so rules that
previously coerced missing variables to false/true may now fail
evaluation or map to CONFIGURATION_UNAVAILABLE instead of NO_MATCH.
>
> **Overview**
> **`var` / `rc.rootVar` no longer treat absent paths as `null`.** When
a path is missing and the rule does not supply `{"var": ["path",
default]}`, evaluation fails with
`EvaluationException.UnresolvedVariable(path)` instead of logging a
warning and returning `null` (which could silently make comparisons like
`expires > now` true via coercion).
>
> Explicit **`null` in scope still resolves normally**; **`missing` /
short-circuited branches** are unchanged. This **intentionally diverges
from `json-logic-js`**.
>
> **Callers already distinguish “unanswerable” from “no match”:**
checkpoint resolution now expects **`CONFIGURATION_UNAVAILABLE`** when a
required custom variable was omitted (including under negated
audiences), and local rule matching surfaces predicate errors instead of
treating missing dimensions as a ordinary non-match.
>
> Coverage is updated across **predicate JSON fixtures** (new
`unresolved_variable.json`, revised iteration-scope cases),
**conformance runner** (`unresolvedVariable` expected errors), and
**integration tests**.
>
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
ee8f1caddbaf48c06614acadd8e591bf84499f04. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
---------
Co-authored-by: Cursor <cursoragent@cursor.com> A
Antonio Pallares committed
84faa2be1b0c2ad8b33b7a73224036ecae094edb
Parent: 64609c7
Committed by GitHub <noreply@github.com>
on 8/25/2026, 8:08:15 AM