Treat Play's generic ERROR as a shrug, not as an answer - #3
Merged
Conversation
The settle gate exists so the claim prompt never acts on a provisional reading of what a user owns. Its retryable set held SERVICE_DISCONNECTED, SERVICE_UNAVAILABLE and NETWORK_ERROR, and left out ERROR. ERROR reads like a hard failure and is not one. Google documents it as Play's generic "something went wrong on our side" and recommends retrying. So it belongs in the set the gate refuses to settle on, by that set's own stated definition: codes where Play said NOTHING, as opposed to saying "no". What it cost, concretely. An unlock owner reinstalls. The Play-grant cache is excluded from backup on purpose, so entitlement reads free until the first query answers. That query returns a transient ERROR, the gate settles on it, and the claim dialog offers them a free unlock code for the thing they already bought. Not an entitlement bug: revocation still demands a SUCCESSFUL query and PurchaseEvaluator's asymmetry is untouched. It costs them a confusing dialog and us a support email. This is the same finding an earlier review round raised. That round fixed the transport-failure case and built a set that was incomplete. WHY THERE IS A NEW FILE. The set had no test, which is how ERROR stayed missing through three review rounds. It could not have one where it lived: classifying a response code meant reaching through a suspend function that needs a BillingClient. So the classification moves to PurchaseGatePolicy, holding no Billing types, for the reason PurchaseEvaluator already gives in its own header. BillingManager keeps the behaviour and loses the untestable copy. The mirrored constants carry a drift guard. If Google renumbers a code, the test fails instead of silently reclassifying it. CANARIES, both observed failing before revert: - Removing ERROR from the set turned the two shrug tests red. - Setting NETWORK_ERROR to 99 turned the drift guard red. 427 tests, 0 failures, 52 of 52 tasks executed. assembleDebug clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The settle gate's retryable set was missing
ERROR, so Play's generic "somethingwent wrong on our side" was treated as a final answer about what a user owns.
Found by an independent review pass run after #2 merged, which is why this is a
follow-up rather than a comment on that PR.
The failure it allowed
An unlock owner reinstalls. The Play-grant cache is excluded from backup on purpose,
so entitlement reads free until the first query answers. That query returns a
transient
ERROR, the gate settles on it, and the claim dialog offers them a freeunlock code for the thing they already bought.
Sizing it honestly: this is not an entitlement bug. Revocation still demands a
SUCCESSFUL query and
PurchaseEvaluator's asymmetry is untouched. It costs aconfusing dialog and a support email, nothing more. But it is exactly the failure the
gate was built to prevent, and it is the same finding an earlier review round raised.
That round fixed the transport-failure case and built a set that was incomplete.
Why there is a new file
The set had no test. It could not have one where it lived, because classifying a
response code meant reaching through a suspend function that needs a
BillingClient.That is how
ERRORstayed missing through three review rounds.So the classification moves to
PurchaseGatePolicy, holding no Billing types, for thereason
PurchaseEvaluatoralready gives in its own header: the decision rules are thepart that must be exhaustively tested, and they should not need a Billing client, a
Play Store, or a network to run.
BillingManagerkeeps the behaviour and loses theuntestable copy.
The mirrored constants carry a drift guard. If Google renumbers a code, the test fails
instead of silently reclassifying it.
Behavioural equivalence, since a refactor is where this breaks:
settles(c) = c !in RETRYABLE, so!settles(c) = c in RETRYABLE. Identical to theoriginal condition for every input,
OKincluded.Testing
427 tests, 0 failures.
assembleDebugclean, 52 of 52 Gradle tasks executed.Unit canaries, both directions, each observed failing before revert. Removing
ERRORfrom the set turned the two shrug tests red. SettingNETWORK_ERRORto 99turned the drift guard red.
Device canary on SM-S948U / Android 16, because a pure test cannot notice a call
site getting inverted by the refactor.
ERRORforcedLogcat during the canary:
6isERROR. Before this change that line could not fire for code 6.That proves both halves: the gate holds open on
ERROR, and the consumer's 35sbackstop still releases it, so holding longer cannot hang anyone.
Canary reverted,
grep -rn CANARY app/src/returns nothing,git diff HEADempty.Evidence bundle:
/home/static/nine-lives-evidence/2026-08-21-gate-settles-on-error/Declined from the same review
PaidEraClaimPrefs.markPrompted()ignores whatcommit()returns, so a failed writeplus process death could re-show a one-time dialog. Declined: it needs a
SharedPreferences failure and process death, its failure mode is showing the dialog
once more (the direction this feature's documented asymmetry deliberately prefers),
and the suggested fix would trap a user in a modal on storage failure. The code
already chose
commit()overapply()to harden that path.🤖 Generated with Claude Code