Pro: separate the status and proof refresh loops, and make the freshness floor real - #2155
Open
mpretty-cyro wants to merge 28 commits into
Open
Conversation
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.
…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
marked this pull request as ready for review
August 10, 2026 06:56
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.
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.
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) — soneither 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_tswas grace-INCLUSIVE — coverage end, withthe renewal falling due a grace period earlier — and subtracted grace throughout. That reading was
traced against a
Session-Pro-Backendcheckout 70 commits behindorigin/dev, where the foldgenuinely existed. It has since been removed upstream.
The current contract, in the backend's own words at the site that builds the response:
So
expiryis the payment-due date, coverage ends atexpiry + grace, and the grace window is[expiry, expiry + grace).120e846dderemoves every subtraction. Net effect on the user-visibledates: the branch now renders them as the pre-branch code did. The subtraction was introduced and
removed within this branch and never shipped.
3901191c4b("days wrong"on the renewal date) and the grace-severity commits that refine it. They are wrong about the model, not
merely miscalibrated.
120e846ddesupersedes them and says so.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
falseerases, a read hoisted out of that branch would wipe a flagget_pro_statushad correctly learned. The protection is placement; the comment at the write site saysso.
feature/pro-backend-override-qa) — this branch is cut from its head445b74cc89, which carries the proof-acquisition floorb066ba27— one commit, containing#121(8e5634b8, theauto_renewingkey) plus the grace keyG, the proof-response parse for both advisory fields, and clearing the renewing flag with the expiryLibSession-Util-Androidrelease exposing dependency 2, plus thelibsessionUtilAndroidVersionbump ingradle/libs.versions.tomlMerge order is 2 → 3 → 1 → this.
A note on
auto_renewing, so nobody looks for a withdrawn accessorAis 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
Aispresent 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_renewingandaccount_grace_period_duration, andthis branch writes both beside the expiry.
Not the
-pfsbranch.JasonFork/pro-auto-renewing-config-pfshas an identical commit subject anda different sha, and is not a descendant — it rebases the change onto the PFS track.
gh pr view 121 --json headRefOidconfirms8e5634b8.What this changes
Entitlement is the proof; display is the status. They no longer drive each other.
in
manageProStatusRefreshScheduling). Keying a status fetch to proof timing meant a proof renewingearly or late dragged the status fetch with it.
Efrom the proofresponse, and that write fires the config-change trigger on its own.
FetchProStatusWorkerintoProStatusManager.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.
force→immediate, with a closed, documented caller list: manual/recover, the post-purchasepoll, and the bounded grace poll. Every routine trigger now goes through the floor.
sleeps until the renewal falls due, then polls once a minute until it lands, coverage ends, or the
screen closes.
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.
a persisted 24h minimum in its own attempt-stamped key. Non-subscribers and comfortably-active users
no longer fetch on every cold start.
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.
E + G), not at the payment date. The backend onlyreports
Expiredonce coverage has ended, so a 30-day window measured fromEwas30d − Glong.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 isuntouched — "your payment is due soon" is about the payment date, not coverage.
the account asserting a future access expiry with nothing behind it, and nothing corrected it — the
config-change trigger watches
Eand the prepaid marker, and that path touches neither.Eis notcleared locally: a revocation says the proof is void, not what the subscription is.
ProRefreshWindows). The 24h/7d/30d values were namedin the gate and bare literals in
HomeViewModel, so tuning the CTA window left the gate behind and thetwo then disagreed about the same instant — the one property they exist to share.
EandAbut neverG,so the expiry it stored paired with whatever grace a proof outcome last left, or with nothing.
Coverage is read as
E + Gdownstream, so the second wake and the settings grace poll's terminationbound were computed against a value this path never supplied.
toProStatusneverconstructs an unconfirmed
inGracePeriod = true, so a new reader inherits the protection instead ofhaving 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.
Loadingor
Loaded;Initis neither, so the check was skipped on exactly the path it exists for. It nowreads the persisted timestamp. Covered by
ProStatusFreshnessFloorTest.ProProofGenerationWorkergatedon the in-memory load state holding an ACTIVE status, but WorkManager persists its schedule across
process death and
loadStaterestarts atInit. It now asks libsession the same question thescheduler does.
HomeViewModel's Expired-CTA gate(
refreshState is State.Success) never enforced what its comment claims, because the mapping endedin a catch-all that swept up
Init. What actually suppressed the CTA was the unconditional startupfetch driving a
Loadingtransition — so gating startup would have removed it.ProStatusRepository.fetchedInThisProcessguarantees the first request of a process still reachesthe network; its comment records that it is load-bearing and why.
same catch-all also swept up a
Loadedrestored from WorkManager's persisted work state, so atprocess start, before asking anyone anything, the refresh state read as
Successagainst a cachedresponse. 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.Loadednowcarries 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
elseis what made two different non-successes indistinguishable.The floor's timestamp is stamped on attempt, not success, and needed a new
pro_statekey:pro_status_updated_atis written in the same statement as the response blob and is unreadablewithout it, so a failed fetch had nothing to record and a failing network was never throttled.
Deliberately not included
auto_renewingfrom synced config rather than the cachedget_pro_statusresponse(
ProDataMapper). The write side IS here — every fetch persists it — but the display still reads theresponse. 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.
the shipped strings already say "retrying soon" and that access "will remain active".
Testing
:app:testPlayDebugUnitTest— 225 tests, 0 failures, verified against result-file mtimes ratherthan counts alone (
TEST-*.xmlpersists across runs that never happened, so counts alone are notevidence of a run).
ProStatusFreshnessFloorTestcovers the floor decision as a pure function, including both exemptionsand their negative controls.
ProStartupGateTestcovers the gate's four rows and where theauto-renewing bound is anchored.
ProExpiredCoverageEndTestcovers the CTA anchor, asserting that windowlength 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
coverageEndedAtback 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, sincethe 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 9known failures on
devare fixed by59060641d2, so the suite is fully green here.