Skip to content

Treat Play's generic ERROR as a shrug, not as an answer - #3

Merged
StaticHumStudio merged 1 commit into
masterfrom
fix/gate-settles-on-transient-error
Aug 21, 2026
Merged

Treat Play's generic ERROR as a shrug, not as an answer#3
StaticHumStudio merged 1 commit into
masterfrom
fix/gate-settles-on-transient-error

Conversation

@StaticHumStudio

Copy link
Copy Markdown
Owner

What

The settle gate's retryable set was missing ERROR, so Play's generic "something
went 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 free
unlock 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 a
confusing 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 ERROR stayed missing through three review rounds.

So the classification moves to PurchaseGatePolicy, holding no Billing types, for the
reason PurchaseEvaluator already gives in its own header: the decision rules are the
part that must be exhaustively tested, and they should not need a Billing client, a
Play Store, or a network to run. 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.

Behavioural equivalence, since a refactor is where this breaks:
settles(c) = c !in RETRYABLE, so !settles(c) = c in RETRYABLE. Identical to the
original condition for every input, OK included.

Testing

427 tests, 0 failures. assembleDebug clean, 52 of 52 Gradle tasks executed.

Unit canaries, both directions, each 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.

Device canary on SM-S948U / Android 16, because a pure test cannot notice a call
site getting inverted by the refactor.

baseline ERROR forced
t=5s prompt is up no prompt, onboarding only
t=15s to 30s up no prompt
t=40s up prompt released by the 35s backstop

Logcat during the canary:

BillingManager: purchase query not answered (6), gate stays open

6 is ERROR. 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 35s
backstop still releases it, so holding longer cannot hang anyone.

Canary reverted, grep -rn CANARY app/src/ returns nothing, git diff HEAD empty.

Evidence bundle: /home/static/nine-lives-evidence/2026-08-21-gate-settles-on-error/

Declined from the same review

PaidEraClaimPrefs.markPrompted() ignores what commit() returns, so a failed write
plus 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() over apply() to harden that path.

🤖 Generated with Claude Code

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>
@StaticHumStudio
StaticHumStudio merged commit 266a9c3 into master Aug 21, 2026
1 check passed
@StaticHumStudio
StaticHumStudio deleted the fix/gate-settles-on-transient-error branch August 21, 2026 22:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant