fix: convert a JSON null to null, not to the text "null" - #459
Conversation
`JsonElement.toPassableValue` matched `JsonNull` against its `is JsonPrimitive` branch, where every check failed and it fell to the catch-all that reads `content` - which for a null is the string "null". An audience filter asking `field == null` never matched, while `field == "null"` did. The sibling converter `convertFromJsonElement` already handles this, so the two now agree. Two existing assertions in JsonElementToPassableValueTest pinned the old behaviour and are updated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
ℹ️ No correctness issues found — one coverage suggestion below.
Reviewed changes — the JsonNull branch added to JsonElement.toPassableValue(), the three test assertions it changes, the changelog entry, and the automated coverage badge commit.
JsonNullno longer becomes the string"null"—SuperscriptEvaluator.kt:250-253addsis JsonNull -> PassableValue.NullValueahead of theis JsonPrimitivebranch. Branch order is required and correct:JsonNullis aJsonPrimitivewhoseisStringisfalseand whosecontentis"null", so it previously fell into the catch-allelse -> StringValue(this.content)at line 272.- Two tests that pinned the old behaviour are corrected —
JsonElementToPassableValueTest.kt:105and:138now expectNullValuefor a null inside aJsonObjectand inside aJsonArray. Both assertions genuinely fail without the fix, as does the newtest JsonNull conversion to PassableValueat:67-73. - Changelog + coverage badge —
CHANGELOG.mdentry under## Unreleased; thejacoco.svgchange is this repo's automatedUpdate coverage badge [skip ci]commit, matching the three prior badge commits ondevelop.
I checked the two things the fix rests on and both hold:
- The branch is reachable in production, not dead code.
setUserAttributesdoes no type conversion (cleanAttributesinPublicIdentity.ktonly strips$-prefixed keys) andIdentityLogic.mergeAttributes:40gates on Kotlinnull, which theJsonNullsingleton passes. Placement params reach it too:TrackingLogic.clean:144returnsis Map<*, *> -> valuewithout recursing, so aJsonNullnested in a map value survives to theis JsonElement ->branch atSuperscriptEvaluator.kt:221. The paths that don't need this branch —Enrichment.kt,DeviceTemplate.toDictionary, the RoomConverters— all convert to Kotlinnullfirst and are handled by the existing?: PassableValue.NullValuefallbacks. - The
NullValuewire shape is already load-bearing.PassableValue.NullValueencodes to{"type":"Null"}under theClassDiscriminatorMode.POLYMORPHIC/classDiscriminator = "type"config inDependencyContainer.kt:229-232, and the map/list branches atSuperscriptEvaluator.kt:193/204/216already emit it for Kotlin nulls today. This change routes more values onto an existing contract rather than introducing a new one.
ℹ️ Nothing tests the behaviour the changelog promises
Every added assertion stops at the Kotlin converter. The user-visible claim — that an audience filter checking field == null now matches — depends on the Rust supercel binary decoding {"type":"Null"} into a CEL null and comparing it equal to the null literal, which no test in the repo exercises. The risk is low because the existing Kotlin-null paths already emit NullValue, but a silent regression here degrades audience targeting for every Android user, and SuperscriptExpressionEvaluatorInstrumentedTest already has the harness to pin it.
Technical details
# Add an evaluator-level test for `== null` matching
## Affected sites
- `superwall/src/androidTest/java/com/superwall/sdk/paywall/presentation/rule_logic/expression_evaluator/SuperscriptExpressionEvaluatorInstrumentedTest.kt` — has `evaluatorFor(...)` plus a `test_happy_path_evaluator` case using `expressionCEL = "user.id == \"123\""`; no case covers a null-valued attribute.
- `superwall/src/test/java/com/superwall/sdk/paywall/presentation/rule_logic/JsonElementToPassableValueTest.kt:67-73` — covers the converter output only, so it cannot catch a Kotlin↔Rust wire mismatch.
## Required outcome
- A test that fails if `PassableValue.NullValue` stops round-tripping through the native evaluator as a CEL null — i.e. it drives a real `evaluateExpression` with an attribute whose JSON value is null and asserts the rule matches.
## Suggested approach (optional)
- Add a case alongside `test_happy_path_evaluator` with `expressionCEL = "params.foo == null"`, feeding `EventData(parameters = mapOf("obj" to mapOf("foo" to JsonNull)), ...)` (nested in a map, since `TrackingLogic.clean` drops a top-level `JsonElement` null) or a `RuleAttributeFactoryBuilder` variant that supplies a `JsonNull` attribute directly.
- Assert `TriggerRuleOutcome.match(rule = rule)`, and confirm the test fails when `SuperscriptEvaluator.kt:253` is reverted.
## Open questions for the human
- Is the `supercel` version pinned in `gradle/libs.versions.toml` (`supercel_version = "1.0.13"`) built from a `superscript` revision that handles the `Null` unit variant? I could not tie the published AAR back to a tagged Rust source revision, so an instrumented test is the only way to confirm it against the binary actually shipping.Claude Opus | 𝕏

What
JsonElement.toPassableValue()turned a JSON null into the string"null":JsonNullis aJsonPrimitivewhoseisStringisfalseand whosecontentis"null", so it falls past every check into the catch-all. An audience filter askingfield == nullnever matched, whilefield == "null"did.Fixed with an
is JsonNull -> PassableValue.NullValuebranch ahead of the primitive check (order matters —JsonNullis aJsonPrimitive).The sibling converter
convertFromJsonElementinConverters.ktalready does exactly this, so the two paths now agree.PassableValue.NullValuealready existed, and the map/list branches of theAny?overload already produce it for Kotlin nulls — only theJsonElementoverload was missing it.When this actually bites
Only when a value is already a kotlinx
JsonElementon its way intoAny.toPassableValue(). I traced where those can come from:toNullableTypedMapruns everything throughconvertFromJsonElementfirst, so they arrive as basic Kotlin types.JsonElementin either.Measured, for what a host app can actually store:
JsonObjectwith a nested null fieldStringValue("null")NullValueJsonNullStringValue("null")NullValuenullThe second row is the subtle one.
IdentityLogic.mergeAttributesdeletes an attribute whose value isnull, which is why a real null never gets this far. ButJsonNullis an object, sovalue != nullis true — it slips past that guard, gets stored, and reaches the broken branch. The one thing protecting against nulls is the thing that misses this one.So: a host app storing profile data built from an API response with a null field gets
attributes.profile.company == nullnever matching, while== "null"does.Heads up: two existing tests pinned the old behaviour
JsonElementToPassableValueTestasserted the bug in two places:Both now expect
NullValue. A repo-wide grep found nothing else depending on the string output, andPassableValueTestalready expectedNullValuefor the Kotlin-null path.Relationship to the iOS entitlement fix — none, despite appearances
This started while looking at Superwall-iOS#517, where a Purchase Controller's entitlement reported
willRenewas absent, the evaluator gave the missing key the type defaultfalse, and active subscribers matched a "will not renew" audience.That bug does not exist on Android, and this PR is not its counterpart. Android's
customer_infopath is already null-correct end to end:It resolves through
convertFromJsonElement, which has always handledJsonNull, and never enters the converter this PR fixes.For the record, since an earlier draft of this description got it wrong: entitlement filtering does work on Android.
device.activeEntitlements(ids) anddevice.activeEntitlementsObject(identifier + type) are available on every placement, and full entitlement detail —willRenew,state,offerType, dates — is reachable asparams.customer_infoon paywall-generated placements, viaPaywallInfo.eventParams(). The real difference from iOS is that iOS exposesdevice.customerInfoon every placement, so an audience written asdevice.customerInfo.entitlements...binds on iOS and silently doesn't on Android. That parity gap is not addressed here.Testing
./gradlew :superwall:testDebugUnitTest— BUILD SUCCESSFUL, full suite.test JsonNull conversion to PassableValue, plus the two corrected assertions covering a null inside aJsonObjectand inside aJsonArray.🤖 Generated with Claude Code