Skip to content

Pro: separate the status and proof refresh loops, and make the freshness floor real - #2155

Open
mpretty-cyro wants to merge 28 commits into
session-foundation:devfrom
mpretty-cyro:feature/pro-status-refresh-unification
Open

Pro: separate the status and proof refresh loops, and make the freshness floor real#2155
mpretty-cyro wants to merge 28 commits into
session-foundation:devfrom
mpretty-cyro:feature/pro-status-refresh-unification

Conversation

@mpretty-cyro

@mpretty-cyro mpretty-cyro commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Pro: separate the status and proof refresh loops, and make the freshness floor real

Separates the two Pro backend loops — the proof (entitlement) and get_pro_status (display) — so
neither drives the other's timing, and makes the status freshness floor actually apply. Desktop and iOS
carry equivalent changes.

Read before merging

A correction landed inside this branch and a reviewer should not have to reconstruct it. Mid-branch
this work adopted the reading that get_pro_status.expiry_ts was grace-INCLUSIVE — coverage end, with
the renewal falling due a grace period earlier — and subtracted grace throughout. That reading was
traced against a Session-Pro-Backend checkout 70 commits behind origin/dev, where the fold
genuinely existed. It has since been removed upstream.

The current contract, in the backend's own words at the site that builds the response:

expiry_ts + grace_period_duration is exactly when we stop serving

So expiry is the payment-due date, coverage ends at expiry + grace, and the grace window is
[expiry, expiry + grace). 120e846dde removes every subtraction. Net effect on the user-visible
dates: the branch now renders them as the pre-branch code did. The subtraction was introduced and
removed within this branch and never shipped.

⚠️ Two commit messages in this branch argue for the withdrawn model3901191c4b ("days wrong"
on the renewal date) and the grace-severity commits that refine it. They are wrong about the model, not
merely miscalibrated. 120e846dde supersedes them and says so.

⚠️ The proof path writes the renewing flag and the grace period beside the access expiry, and those
two writes are only safe inside the success branch — nothing enforces that.
libsession's proof parser
returns on the failure path before filling them, so every non-OK outcome yields grace 0 and renewing
false from struct defaults — no presence flag, non-nullable type. Since both go into presence-only
config keys where false erases, a read hoisted out of that branch would wipe a flag
get_pro_status had correctly learned. The protection is placement; the comment at the write site says
so.

⚠️ Merge dependencies — this PR cannot land alone

# Dependency State
1 session-android #2153 (feature/pro-backend-override-qa) — this branch is cut from its head 445b74cc89, which carries the proof-acquisition floor open
2 libsession-util b066ba27 — one commit, containing #121 (8e5634b8, the auto_renewing key) plus the grace key G, the proof-response parse for both advisory fields, and clearing the renewing flag with the expiry open / unmerged
3 A LibSession-Util-Android release exposing dependency 2, plus the libsessionUtilAndroidVersion bump in gradle/libs.versions.toml not started

Merge order is 2 → 3 → 1 → this.

A note on auto_renewing, so nobody looks for a withdrawn accessor

A is presence-only — set(false) erases the key. A predicate distinguishing "stored false" from
"never written" was proposed and then withdrawn upstream as vacuous: under this encoding A is
present if and only if its value is 1, so such a predicate returns the same bit as the plain getter in
every reachable state.

The real gap was that the proof-success path wrote the access expiry without the renewing flag, so an
account whose expiry came only from a proof read back as terminal while it was renewing. That is fixed at
the source: the proof response carries account_auto_renewing and account_grace_period_duration, and
this branch writes both beside the expiry.

Not the -pfs branch. JasonFork/pro-auto-renewing-config-pfs has an identical commit subject and
a different sha, and is not a descendant — it rebases the change onto the PFS track. gh pr view 121 --json headRefOid confirms 8e5634b8.

What this changes

Entitlement is the proof; display is the status. They no longer drive each other.

  • Deleted the status fetch that was scheduled ~1h before proof expiry (one of the merged sources
    in manageProStatusRefreshScheduling). Keying a status fetch to proof timing meant a proof renewing
    early or late dragged the status fetch with it.
  • Removed the proof-success → status refresh. The worker already writes E from the proof
    response, and that write fires the config-change trigger on its own.
  • Proof scheduling moved out of FetchProStatusWorker into ProStatusManager.manageProofRenewalScheduling,
    which watches the three config inputs to libsession's pro_renewal_target — the stored proof, E,
    and the prepaid marker. Proof renewal no longer depends on a status fetch having happened.
  • forceimmediate, with a closed, documented caller list: manual/recover, the post-purchase
    poll, and the bounded grace poll. Every routine trigger now goes through the floor.
  • Pro settings freshness: refresh on entering the screen (floored), plus a bounded grace poll that
    sleeps until the renewal falls due, then polls once a minute until it lands, coverage ends, or the
    screen closes.
  • Grace-warning debounce: the in-settings "renewal unsuccessful" label requires a completed fetch
    at or after the renewal became due, so a snapshot predating the crossing cannot raise an alarm about an
    event it could not have observed. One message covers the whole window — the shipped strings already say
    "retrying soon" and that access "will remain active", so no second, more urgent message is needed
    late in a long grace.
  • Startup fetch gated on whether a home CTA could plausibly fire, computed from synced config, with
    a persisted 24h minimum in its own attempt-stamped key. Non-subscribers and comfortably-active users
    no longer fetch on every cold start.
  • A confirmed status now means confirmed by THIS process — see the fourth bug below.
  • The startup gate's auto-renewing row is bounded from coverage end, not from the payment date.
    This is the one place grace does any work in the gate, and it decides two things: an account inside a
    multi-day grace is never mistaken for a long-dead one, and an account whose renewing flag was never
    cleared stops fetching on every cold start forever.
  • The Expired CTA is anchored at coverage end (E + G), not at the payment date. The backend only
    reports Expired once coverage has ended, so a 30-day window measured from E was 30d − G long.
    Scope, stated narrowly on purpose: this changes behaviour for Apple accounts whose dunning window ran
    out
    , because Apple states its retry window separately and it arrives as grace. It is legitimately
    inert for Play, which applies grace by extending the expiry it reports, and for cancellations, where
    coverage genuinely does end at E. Pre-existing (44ab07f2eac), ruled in scope. The Expiring window is
    untouched — "your payment is due soon" is about the payment date, not coverage.
  • A revocation-list clear now asks the server what the state is. Clearing a revoked own-proof left
    the account asserting a future access expiry with nothing behind it, and nothing corrected it — the
    config-change trigger watches E and the prepaid marker, and that path touches neither. E is not
    cleared locally: a revocation says the proof is void, not what the subscription is.
  • The CTA and gate windows live in one place (ProRefreshWindows). The 24h/7d/30d values were named
    in the gate and bare literals in HomeViewModel, so tuning the CTA window left the gate behind and the
    two then disagreed about the same instant — the one property they exist to share.
  • The status path now writes the grace period beside the expiry. It wrote E and A but never G,
    so the expiry it stored paired with whatever grace a proof outcome last left, or with nothing.
    Coverage is read as E + G downstream, so the second wake and the settings grace poll's termination
    bound were computed against a value this path never supplied.
  • The grace warning is gated where it is produced, not at each consumer: toProStatus never
    constructs an unconfirmed inGracePeriod = true, so a new reader inherits the protection instead of
    having to know about it.

Bugs found and fixed along the way

Four defects in this path had one cause: a state meaning "we have not confirmed anything" being
compared against an enum that does not enumerate it
, so it fell into whichever branch happened to
catch it. Twice it fell outside the compared cases and disabled a check; twice it fell inside a
success-shaped default and satisfied one.

  • The freshness floor never applied on a cold start. It asked whether the load state was Loading
    or Loaded; Init is neither, so the check was skipped on exactly the path it exists for. It now
    reads the persisted timestamp. Covered by ProStatusFreshnessFloorTest.
  • A proof renewal that came due while the app was dead never ran. ProProofGenerationWorker gated
    on the in-memory load state holding an ACTIVE status, but WorkManager persists its schedule across
    process death and loadState restarts at Init. It now asks libsession the same question the
    scheduler does.
  • The false-expired protection was resting on an accident. HomeViewModel's Expired-CTA gate
    (refreshState is State.Success) never enforced what its comment claims, because the mapping ended
    in a catch-all that swept up Init. What actually suppressed the CTA was the unconditional startup
    fetch driving a Loading transition — so gating startup would have removed it.
    ProStatusRepository.fetchedInThisProcess guarantees the first request of a process still reaches
    the network; its comment records that it is load-bearing and why.
  • A "confirmed" status could come from a previous process — this one was live in production. The
    same catch-all also swept up a Loaded restored from WorkManager's persisted work state, so at
    process start, before asking anyone anything, the refresh state read as Success against a cached
    response. A renewal that happened while the app was closed therefore splashed the Expired CTA off
    the stale cache on the next launch — reported in the wild by the architect. LoadState.Loaded now
    carries whether the fetch landed in this process, and the catch-all is gone: the mapping is
    exhaustive, so the next state added has to declare which it is. That last part is the durable fix —
    the else is what made two different non-successes indistinguishable.

The floor's timestamp is stamped on attempt, not success, and needed a new pro_state key:
pro_status_updated_at is written in the same statement as the response blob and is unreadable
without it, so a failed fetch had nothing to record and a failing network was never throttled.

Deliberately not included

  • Reading auto_renewing from synced config rather than the cached get_pro_status response
    (ProDataMapper). The write side IS here — every fetch persists it — but the display still reads the
    response. Switching the read is what lets a linked device show the right state from another device's
    fetch, and it is a display change on a path all three clients share, so it is not being made
    unilaterally.
  • A second, more urgent message near the end of a long grace window. One message covers the window;
    the shipped strings already say "retrying soon" and that access "will remain active".

Testing

:app:testPlayDebugUnitTest225 tests, 0 failures, verified against result-file mtimes rather
than counts alone (TEST-*.xml persists across runs that never happened, so counts alone are not
evidence of a run).

ProStatusFreshnessFloorTest covers the floor decision as a pure function, including both exemptions
and their negative controls. ProStartupGateTest covers the gate's four rows and where the
auto-renewing bound is anchored. ProExpiredCoverageEndTest covers the CTA anchor, asserting that window
length is independent of grace across grace = 0, 1, 16, 29 and 45 days rather than pinning one
store's number.

All three are mutation-checked, and one of the mutants is worth stating because it shows what the
suites do and do not claim: collapsing coverageEndedAt back to the payment date kills 3 of 4 tests,
and the survivor is zero grace leaves the payment date as coverage end — which must survive, since
the anchors are equal there. A suite where all four died would mean the no-op case was never pinned.

Note for reviewers: this branch also retires a stale baseline — ConversationViewModelTest's 9
known failures on dev are fixed by 59060641d2, so the suite is fully green here.

Lets a QA Pro backend be targeted without rebuilding, matching the iOS
customProBackendUrl/customProBackendPubkey launch variables.

Both values are required together: a QA URL paired with the production signing
key reads every QA-signed proof as invalid and silently strips Pro content.
…ation

Reading a SessionProtocol constant runs System.loadLibrary("session_util"), so
doing it from the companion's initialiser made the class impossible to
initialise wherever the native library is absent -- every JVM unit test. Mockito
could not instrument it, and the nine tests constructing a ConversationViewModel
failed with NoClassDefFoundError.

The constants stay single-sourced from libsession; they are just read on first
use rather than on class load.
A successful generate force-refreshes get_pro_status, which asks libsession for a
renewal target, and `proofExpiry - PRO_RENEWAL_LEAD` is permanently in the past for
any proof living less than the 60-minute lead -- so the worker rescheduled itself
immediately and looped.

Mirrors iOS SessionProManager.reconcileProofRenewal and Desktop, constants included:
60s while covered, 15s * attempt capped at 900s while dark, and re-arming rather than
dropping the work, since `target <= now` is also the normal renewal-due signal.

The state is in-memory as it is on the other two platforms; a process restart costs
one extra request rather than a loop.
The comment here asserts the backend judges status against expiry + grace_period_duration.
It does not — it folds grace into the stored expiry before sending it, so the wire expiry is
coverage end, not paid-through. Two consequences below it: inGracePeriod sits in a branch
that requires now <= expiry so it is unreachable, and the renewal date renders one grace
period late (an hour on Apple, operator-configured days on Google Play).

Comment only. Desktop and iOS encode the same premise in different wordings, so correcting
the logic is a three-client change and is with the architect. The superseded text is kept
verbatim because the other clients' comments echo it and it needs to stay greppable.
Two couplings removed. It refreshed get_pro_status after minting a proof, which is redundant
— the access-expiry write it already makes is a config change that fires the status trigger
on its own. And it gated on the in-memory load state holding an ACTIVE status, which is wrong
in a way that only shows after a restart: WorkManager persists the proof schedule across
process death but that state restarts empty, so a renewal falling due while the app was dead
read as "not Pro, no purchase" and returned without renewing.

It now asks libsession the same question the scheduler asks — pro_renewal_target, from config.
…startup gate

Two new name/value rows, no migration. Both stamp the ATTEMPT rather than the success: a
request that reached the server costs it the same whether or not it succeeded, and stamping
on success makes a client retry hardest exactly when the server is least able to take it.

They are separate keys on purpose. A routine refresh must not consume the gate's 24h budget,
and a startup fetch from twenty hours ago must not satisfy the 60s floor — one value cannot
answer both. Neither can reuse pro_status_updated_at, which is written in the same statement
as the response blob and is unreadable without it, so a failed fetch has nothing to record
there.
…an unconfirmed status as confirmed

One commit because force -> immediate renames a parameter every one of these files calls, so
they cannot compile apart.

- Gate the startup get_pro_status fetch on whether a home CTA could plausibly fire, from
  synced config, with a persisted 24h minimum. Every cold start used to fetch, including for
  users who have never subscribed, and mobile cold-starts constantly.
- Make the 60s floor apply. It asked whether the load state was Loading or Loaded; a cold
  start begins at Init, which is neither, so it was skipped on exactly the path it exists for
  — and every background caller passed force=true anyway. It now reads the persisted attempt
  timestamp, and a process that has never fetched is exempt so the floor cannot become the
  "is one already running" mutex.
- force -> immediate, reserved to three callers named in the KDoc.
- Only report a confirmed status when THIS process confirmed it. The state mapping ended in a
  catch-all that swept up both Init and a Loaded restored from WorkManager's persisted work
  state, so at launch the refresh state read Success against a cached response and a renewal
  that happened while the app was closed splashed the Expired CTA off stale data. Reported in
  the wild. The catch-all is gone: the mapping is exhaustive, so the next state added has to
  declare which it is.
- Pro settings: refresh on open, and while open poll once a minute from when the renewal falls
  due until it lands, coverage ends or the screen closes. The grace label now needs a completed
  fetch at or after the crossing — a snapshot predating it cannot have seen the failure it warns
  about.
- Persist auto_renewing into config alongside the access expiry, unconditionally: libsession
  short-circuits a no-change write on a clean config, and a presence-based guard would be wrong
  because the key is erased rather than stored when false.

Two gate rows are deliberately built wrong and pinned by tests that will fail when the
decisions land: row 1 cannot see the real grace window (the expiry is grace-inclusive), and
the not-auto-renewing-but-active row declines to fetch while auto_renewing is presence-only.
Both match Desktop and iOS; the PR description carries them.
@mpretty-cyro mpretty-cyro self-assigned this Aug 7, 2026
…e renewal is actually due

The access expiry get_pro_status sends is grace-INCLUSIVE — the backend folds grace in before
sending it and judges "active" against that same value, and its own test subtracts grace from
the wire value to recover the store's date. So the expiry is coverage end, and the renewal
falls due a grace period earlier.

Three things were wrong as a result:

- The renewal date rendered a whole grace period late.
- The grace indicator was unreachable: it tested now >= expiry inside a branch that requires
  now <= expiry, so only a single instant satisfied both.
- The startup gate's first row declined to fetch for the whole grace window, because that
  window lies inside "now < coverage end" — the gate was blind to exactly the state this
  rework exists to surface.

Subtracting is unconditional and needs no provider branching: the wire sends grace = 0
whenever the subscription is not auto-renewing.

Not cosmetic where it mattered. Apple configures no grace, so the backend's ~1h stand-in is
the whole of it. Google's is the operator-configured base-plan value in DAYS, fetched exactly
when the subscriber enters grace — so the date was days wrong on the screen whose purpose is
that date, and most wrong precisely when someone was looking at it.

Also moves the grace warning's "a completed fetch at or after the renewal fell due" condition
into toProStatus, where inGracePeriod is produced, instead of applying it at each consumer.
Five call sites read that flag; a sixth would have inherited no protection.

Needs the grace key in synced config, so the wrapper's submodule pin moves with it.
The proof response refreshes the cached access expiry, and the two fields that must travel with
it cannot be written yet. The intended lines are in the source, commented, with the blocker
named at the site.

Both incoherences are live today rather than introduced here:

- auto-renewing goes stale: an account whose expiry only came from a proof reads back as
  not-renewing while it is renewing, because the key is presence-only and nothing on this path
  sets it.
- grace pairs with the wrong expiry: paid-through is derived as E - G everywhere, so a fresh E
  beside a G from an older response is wrong by the difference. Worst where it matters, since
  Google reports its real multi-day grace only once the subscriber enters grace — a proof
  landing first pairs a new coverage end with the old stand-in.

The hole is in the middle of the chain: the backend sends both account_auto_renewing and
account_grace_period_duration, but parse_pro_proof reads only account_expiry_ts, so neither
reaches the response type or the wrapper. A core commit lands first.

No client-side workaround: reading the raw response behind libsession, or sourcing the flag
from anywhere other than this outcome, are both worse than the staleness.
… the access expiry

A proof response refreshes the cached access expiry, and the two fields that must travel with
it are now written alongside: the renewing flag, and the grace period the expiry has folded
into it. Without them an account whose expiry only ever came from a proof read back as
terminal while it was renewing, and `E - G` -- which everything now uses for the paid-through
instant -- was wrong by the difference between two responses. Worst where it matters: Google
reports its real multi-day grace only once the subscriber enters grace, so a proof landing
first paired a new coverage end with the ~1h stand-in.

Written ONLY when the backend actually sent them. Absent means "did not say", not false or
zero, and collapsing that is destructive rather than merely lossy: both config keys are
presence-only, so writing false or zero erases them, and a backend predating these fields
sends neither. A `?: false` in this path would make every proof fetch wipe a value correctly
learned from get_pro_status.

Needs the wrapper's matching commit, which re-pins libsession to the parse.
The single user_expiry wake fired 30s after the access expiry the backend sends. That value is
grace-inclusive, so it is the instant coverage ENDS -- the renewal became overdue a whole grace
period earlier, and the wake fired after the window it exists to catch had already closed. It
was the last trigger still keyed to the pre-correction reading of that field.

Now wakes 30s after the renewal falls due. Subtracting grace is a no-op for a non-auto-renewing
account, where the wire sends zero.

Note what this does NOT add: nothing wakes at the end of coverage once grace has been entered.
A failed renewal is learned at the renewal date, and after that the account is covered by the
startup gate -- which now fetches for exactly that state -- plus on-enter and the while-open
poll. Whether a second wake at coverage end is wanted is an open cross-client question.
…y comment

Trigger #6 becomes two instants: 30s after the renewal falls due, and 30s after coverage ends.
The second is guarded on the two coinciding, which is the case for every non-auto-renewing
account, where grace is zero.

It looks redundant with the proof loop and isn't. The backend issues a proof good until roughly
an hour past coverage end and the renewal target sits ~1h before that, so a proof attempt lands
near coverage end and its config write fires the config-change trigger -- but only when E
actually MOVES. A succeeded renewal advances E and is covered; a FAILED one leaves E unchanged
and nothing fires. The uncovered branch is the one the grace warning exists for.

No wake handles to leak: transformLatest cancels the whole body when E moves, so a wake armed
against a superseded expiry cannot outlive it. A scheduler holding ids would need a collection
for two instants -- holding one and scheduling two orphans a timer every period, while both
wakes still fire correctly.

Also corrects the severity claim in the grace comment. The multi-day operator-configured value
is written only on Google's IN_GRACE_PERIOD notification and reset to the ~1h stand-in on
RECOVERED/RENEWED, so it exists only during the grace window. A healthy auto-renewing
subscriber has grace of about an hour on both stores, so the renewal date was an hour late, not
days. The unreachable grace state was the real defect, and the gate's blindness to the window
is the consequence that genuinely spans days.
… test backend

Both user_expiry wake emits reach the network through the floored path, so when the grace period
is shorter than the 60s freshness floor the second wake lands inside the floor the first one just
armed and its fetch is dropped. The wake fires; the fetch does not, which looks exactly like the
wake was never scheduled.

Only reachable on a compressed testing backend -- google_play/mule.py overrides grace with
testing_grace_period_duration_ms = 10s. Production grace is either the backend's ~1h stand-in or
an operator-configured value in days, both far outside the floor.

Documented in two places because the two readers arrive from opposite directions and neither has
reason to open the other's file: at the floor constant, for someone tuning or removing it, and at
the wake, for someone debugging why it "didn't fire".

Left alone deliberately. If Pro UI-test work needs to exercise the coverage-end wake, the
sanctioned escape hatch is an env-var override of the floor, owned by that work. Not an immediate
fetch for a scheduled trigger -- that is what force -> immediate was introduced to stop.
libsession now REQUIRES both fields on a successful proof rather than treating them as optional,
so there is no "the backend did not say" state left to guard against and the presence checks come
out.

That is safe for a specific reason rather than by simplification: a missing or malformed field
fails the parse, so we never reach this write. Writing false to a presence-only config key ERASES
it, so a defaulted false would have been destructive rather than inert -- requiring the field is
what makes an unconditional write correct here. Inside the success branch both values are
truthful.

Needs the wrapper's matching commit, which re-pins libsession to 799f1972.
The write is already inside the success branch and its behaviour is unchanged. The comment
justifying it was wrong: it said a missing or malformed field fails the parse so the write is
never reached. That is not what protects it.

libsession's parse_pro_proof returns on the failure path BEFORE filling the renewal flag and the
grace period, so on every non-OK outcome they hold struct defaults of false and zero. There is no
presence flag on the C struct and the Kotlin type is non-nullable, so a read outside the success
branch gets false and cannot tell it from a backend that genuinely said "not renewing" -- and
writing false to a presence-only config key ERASES it.

Erasing is truthful for subscription_expired, not_subscribed and revoked. For a protocol error, a
stale request or a transport failure it would wipe a flag get_pro_status had correctly learned.

So the comment now says the protection is PLACEMENT, not the parse and not the type, and not to
hoist the two writes out of the success branch. Needs the wrapper's matching commit.
@mpretty-cyro
mpretty-cyro marked this pull request as ready for review August 10, 2026 06:56
mpretty-cyro and others added 13 commits August 11, 2026 10:05
The backend no longer folds grace into the stored expiry — that fold was
removed upstream, and `grace_period_duration` now reports how much longer
we serve PAST the expiry sent. The backend states it directly: "expiry_ts
+ grace_period_duration is exactly when we stop serving".

So every subtraction goes away and the display gets simpler:

  - the renewal date is `expiry`, rendered as sent
  - the grace window is `[expiry, expiry + grace)`
  - coverage ends at `expiry + grace`
  - trigger #6 wakes at `expiry`, then again at `expiry + grace`

Grace now enters the startup gate in exactly one row, and earns its place
there: the auto-renewing row is bounded from COVERAGE end rather than the
payment date, so an account inside a multi-day grace is never mistaken
for a long-dead one, and an account whose renewing flag was never cleared
stops fetching on every cold start forever.

The earlier reading was traced against a checkout 70 commits behind
`origin/dev`, which is the whole lesson: the staleness trap already
documented for the client repos was never applied to the backend.
The status fetch wrote E and A but never G, so the expiry it stored paired
with whatever grace a proof outcome last left — or with nothing at all.
Everything downstream reads coverage as `E + G`, so the second wake at
coverage end and the settings grace poll's termination bound were both
computed against a grace this path never supplied.

Takes the ACCOUNT-level field, not `latestPayment.gracePeriod`: they share
a name and answer different questions — how much longer we serve, versus
what a store declared about one transaction.
The arithmetic changed in 120e846; these are the prose explanations of
why it used to be the other way. A confidently-worded wrong explanation is
worse than none, so they are rewritten rather than patched around.
Four comments described what the backend or its test harness currently
says rather than what the protocol guarantees: the grace direction was
explained via "an earlier reading had the fold, it was removed upstream",
and the floor gap cited a QA mule's specific override value.

Nothing in this repo can notice when a claim like that goes stale — CI
never builds that file and no test touches it — and it documents a state
that will not exist once both sides have moved on. Each now states the
durable fact: grace runs forward from the expiry, and the floor gap needs
only "grace shorter than the floor", which production grace never is.

The citations that support durable contract facts stay: those are claims
about the wire, and if the wire changes this code changes with it.
Clearing the proof leaves the account asserting a future access expiry
with nothing behind it, and nothing else corrects that: the config-change
trigger watches E and the prepaid marker, and this path touches neither by
design. The user sat on a stale expiry until some unrelated trigger fired.

Asks the server rather than deciding locally. A revocation says this proof
is void, not what the subscription is now — the account may be fine and
re-provable, or genuinely gone, and only one party knows which. So E is
not cleared here.

Gated on the clear having actually happened. The collector can fire for a
hash that is no longer the stored proof, and a refresh triggered by
someone else's revocation is a request with no reason behind it.

Floored, not immediate — nobody is waiting on a screen — and requested
after the config mutation closes rather than inside it.
Nine comments in this branch narrated the change rather than the contract:
"it previously ended in", "this used to read", "every client used to
fetch", "it used to have a second job". A reader in a year never saw the
change, so none of that is actionable — and where the narration carried
the real warning, the warning was only implicit in it.

Each now states the invariant directly and imperatively, which is both
shorter and stronger:

  - the exhaustive `when` says do not add an `else`, and which two states
    a catch-all would sweep up
  - the proof worker says ask config, never loadState, because WorkManager
    outlives the process and loadState does not
  - the load-bearing per-process flag says what deleting it costs: the
    settings screen spins until the floor expires

Also drops the claim that Desktop nearly shipped the spinning screen.
Whether that stayed true is not observable from this repo, and the
mechanism it was evidence for is stated here directly instead.
The row table was introduced as replacing a spec test that "double-counted
grace and was unimplementable when written". A reader of this function has
no access to that document and nothing to do with its history.

The durable half is that no row keys off coverage end, and why: a cold
start does not need to know when coverage ends, and testing it would
double-count grace against the payment date the rows already turn on. That
is now stated as a property of the rows.
The backend only reports EXPIRED once coverage has ended, and coverage ends
a grace period after the payment-due date it sends. Measuring the 30-day
CTA window from the payment date therefore yields a window of 30d - G, and
none at all once G reaches 30 days. Google Play grace is operator-
configurable up to 30 days, so the empty case is reachable in production;
an account with 16 days of store grace got 14 days of CTA.

    Expired  CTA fires while  E + G <= now < E + G + 30d
    Expiring CTA fires while  E - 7d <= now < E
    The displayed date stays E everywhere.

The Expiring window is deliberately untouched: "your payment is due soon"
is a statement about the payment date, not about coverage.

`expiredAt` keeps meaning the payment-due date, which is the value the
display model says we show, and the grace period travels beside it.
Coverage end is derived on the type rather than at the consumer, so a
second reader cannot pick the other anchor. Naming it after what it is also
makes the CTA deadline visibly the same instant as the startup gate's
bound; the two disagreed by G.

Pre-existing, not from this rework — 44ab07f.
The backend derives the grace it sends from the same coverage-end instant
it judged the status against, so the two describe one moment together and
none apart. Config is not an equivalent source for either: not every status
branch writes G there, and the branches that clear E cascade G away with
it, so a config read would pair this response's expiry with a grace period
from a different one.

Comment only — both values already came off the response.
…from

Five comments had the two stores the wrong way round, saying Apple's grace
is ~1h and Google's is the operator-configured multi-day value.

It is the other way. Play applies grace by extending the expiry it reports,
so the backend deliberately stores no separate number for it and a Play
account's grace is just the ~1h renewal-latency allowance. Apple states its
dunning window separately, so that is the one arriving as a multi-day
grace.

Consequences for the reader, which is why this is worth correcting rather
than leaving as trivia: the Expired-CTA re-anchor changes behaviour for
Apple accounts whose dunning ran out, and is legitimately inert for Play,
where the expiry already contains the grace. A reader told the opposite
would look for the effect on the wrong population and conclude the anchor
was broken.

The window-length tests sweep grace as a parameter, so they were asserting
the invariant rather than either store's number and did not change.
Two changes, in one commit because they touch the same lines.

Q6 — the 24h, 7d and 30d windows move to `ProRefreshWindows`, and the wake
slack becomes `WAKE_SLACK`. They were named in the gate's companion and
bare literals in HomeViewModel, so tuning the CTA window left the gate
behind and the two then disagreed about the same instant — the one property
they exist to share. A shared object rather than widened visibility because
the gate is not their only consumer and they should not read as its
internals.

Comments — cuts the argument around the invariants rather than the
invariants: how a conclusion was reached, what was believed before,
rhetorical emphasis, and the same rule restated at three sites.

The floor/poll-cadence equality now lives only at GRACE_POLL_INTERVAL_MS,
which owns it; the QA-backend interaction only at MIN_UPDATE_INTERVAL_SECONDS.
Their former call-site copies point instead. Two more leftovers of the
withdrawn Google grace story went with them.

Kept in full: the E/G model where it is the contract, the presence-only
erase trap on the proof path, the ordering dependency between the two wakes
and the floor, and the cross-client constants preamble.

618 -> 439 comment lines of 1001 added (52% -> 44%).
The expiry/grace contract was restated at eight sites. It belongs at
`toProStatus`, which is where the response is read and where the
don't-subtract trap has to live; the wake derivation, the gate's parameter
doc and two test headers only use it, so they now name what they use and
point.

The contract itself is unchanged and still stated in full where it is the
contract.
renewingAt is now E (the payment/renewal-due date), not E adjusted for grace;
Expired.gracePeriod is coverage-past-expiry for both stores (the backend keeps
the reported expiry at paid-through and carries Play's extension as grace);
and the freshness floor persists pro_status_last_attempt_at, not the old
pro_status_updated_at 'nothing to persist' claim.
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.

2 participants