Skip to content

A revoked proof keeps the synced access expiry, and a lapse stores the dates it returns - #2198

Merged
mpretty-cyro merged 2 commits into
session-foundation:devfrom
mpretty-cyro:fix/revoked-keeps-access-expiry
Aug 28, 2026
Merged

A revoked proof keeps the synced access expiry, and a lapse stores the dates it returns#2198
mpretty-cyro merged 2 commits into
session-foundation:devfrom
mpretty-cyro:fix/revoked-keeps-access-expiry

Conversation

@mpretty-cyro

Copy link
Copy Markdown
Collaborator

Android is the reference for this rule — its ProStatusManager comment is what iOS and Desktop were changed to match — but its proof worker contradicted it three files away.

Two changes:

  1. ProProofGenerationWorker lumped REVOKED in with NOT_SUBSCRIBED and SUBSCRIPTION_EXPIRED under notEntitled and cleared E for all three. REVOKED now keeps it.

  2. SUBSCRIPTION_EXPIRED now sets E, G and A from the response instead of clearing. The old comment said the returned expiry was "redundant with the get_pro_status horizon" — it is not: it is the only thing distinguishing a lapsed subscriber from one who never subscribed, and the backend supplies it deliberately. This needed the API layer to carry the parsed body onto a failure (Failure : ProApiResponse<Nothing> becoming generic), which ripples through every ProApi subclass.

The post-revocation status fetch is also now immediate rather than floored.

One rule, applied to every generate_pro_proof failure, and a fetch afterwards.

response config then
revoked keep E (the response carries no expiry) fetch status, immediate
subscription_expired set E, G, A from the response fetch status, immediate
not_subscribed clear E (no user row exists) fetch status, immediate

The principle underneath: E reflects what the backend last said. Clear it only when the backend says
there is nothing to say — never as a side effect of a local decision.

Why keeping E on revoked matters

A revocation says this proof is void; it does not say what the subscription is. A revocation with
revoke_payments=false is a rotation — the account stays paid and re-provable — and no client can tell
that apart from a refund locally. Android already said so in its own comment:

"E is not cleared 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 the server knows which."

E is also synced config, not device-local: a client that clears it pushes that clear to every other
device, erasing the shared record that the user ever subscribed. Both mobile clients seed display status
from E plus the proof, so clearing it makes "lapsed subscriber" indistinguishable from "never subscribed".
That shipped on iOS as a refunded subscriber being offered "Recover Pro Access" and "Upgrade to" instead of
"Renew".

Why the trio travels together

Verified in libsession's parser (src/pro_backend.cpp, parse_pro_proof) — all three fields are populated
inside the FAILURE branch when the slug is subscription_expired, with the comment:

"They must travel together: coverage ends at account_expiry + grace, and an expiry has typically NOT
changed on this failure (it is a lapse/cancellation) while the grace and flag have — refreshing only the
expiry would leave a stale grace claiming coverage the backend has stopped honouring."

The backend sends all three unconditionally on that slug, so "absent" cannot arise from a current backend.

Why immediate, not floored

Neither E choice gives a correct display on its own: after a refund E is still the OLD, FUTURE expiry, so
keeping it seeds "active" just as clearing it seeds "never". Only the status fetch fixes it — the backend
keeps the user row and writes expiry_at = now, returning user_status: "expired" with a real PAST
expiry_ts, which every client mirrors into E.

A floored fetch is dropped whenever a status fetch ran inside the floor, which is the normal case because
launch fetches. Measured on iOS: adding a FLOORED fetch changed nothing — 5 runs out of 5 still wrong.

That mirrored past E is also what TERMINATES the acquire loop: libsession's pro_renewal_target acquires
only while access && *access > now. Flooring the terminator is the wrong place to economise.

Verification

Desktop 8/8 revocation specs. Android 15/15 revocation plus the wider ProApi surface, and 12/12 pin specs.
iOS pin 13/14 across two builds, refund 5/5, recipient 3/3.

subscription_expired has no spec on any platform — the slug only fires once
expiry + grace + RENEWAL_LATENCY_ALLOWANCE has passed, that allowance is a hard-coded hour, and
/dev/add_payment validates duration positive. Nothing can reach it in a test. Recorded as a blocked row
in the coverage table; a dev expire hook would make it reachable in seconds.

The proof worker treated revoked, not_subscribed and subscription_expired as one
answer and cleared the access expiry for all three. Revoked is not the same
answer as the other two.

Revoked says this PROOF is void. It says nothing about the subscription, and
carries no expiry to say it with -- a revocation that does not revoke payments is
a rotation, leaving the account paid and re-provable, and locally we cannot tell
that from a refund. Clearing the expiry answers a question the backend did not
answer.

It also propagates. The expiry is synced config, so one device's clear reaches
every other device and erases the shared record that the user ever subscribed:
with no expiry and no proof, the seeded display status reads "never subscribed" --
a confident claim rather than an absence -- and a refunded subscriber is offered
"Upgrade" where they should see "Renew".

This is what ProStatusManager already does on the revocation-list path, and its
comment there says why; the worker was undoing it three files away.

not_subscribed still clears, because no account row exists and there is genuinely
nothing to record. The defunct credential is still dropped either way, guarded so
a proof another device just landed survives.

Keeping the expiry leaves the acquire loop running, since libsession's renewal
target fires on a future expiry with no proof. That is bounded, not unbounded --
the dark backoff widens to a 15-minute floor -- and it ends when a status fetch
writes a past expiry. No extra limiter added: a third guard would suppress the
symptom of a loop that already terminates.
…after every denial

Two changes on the proof worker's entitlement-denied path, on top of revoked no
longer clearing the access expiry.

First, subscription_expired now stores what it returns. The backend sends the
account expiry, grace period and renewing flag with that slug precisely so a
client can persist them and read them back later -- offline, at cold start, on
another device. We were discarding them and clearing the expiry instead, which
left a lapsed subscriber indistinguishable from one who never subscribed.

All three, not the expiry alone. On a cancellation the expiry is typically the
value that did NOT change while the grace and the flag did, so writing only the
expiry leaves a stale grace claiming coverage the backend has stopped honouring;
coverage end is derived as expiry + grace. The write is gated on the slug, and
that gate does real work: libsession populates those fields for this slug only
and otherwise leaves them at 0/false, which are indistinguishable from genuine
zeroes -- and writing false to a presence-only key erases it, which would wipe a
flag get_pro_status had correctly learned.

Carrying those values required the API layer to stop discarding the parsed body
on failure. Some error slugs are informative rather than merely negative, so
Failure now holds the parsed response alongside the error. Reaching that branch
means the envelope parsed, which is what makes reading it safe at all.

Second, every denial now fetches the account status immediately. Change the local
record, then ask the server. Immediate rather than floored because a floored
request is dropped whenever a fetch already ran inside the floor -- the ordinary
case, since launch fetches -- so flooring drops exactly the fetch that matters:
it is the acquire loop's terminator, and only a response writing a past expiry
stops the loop.

This deliberately makes the proof loop a source of status fetches, which the
success path avoids. The difference is that the success path has another trigger
in its own expiry write, whereas revoked writes nothing -- so without this the
terminator would arrive only via the revocation-list path, a dependency between
two paths that neither of them states.
@mpretty-cyro
mpretty-cyro marked this pull request as ready for review August 28, 2026 06:28
@mpretty-cyro
mpretty-cyro merged commit 00827b6 into session-foundation:dev Aug 28, 2026
5 checks passed
@mpretty-cyro
mpretty-cyro deleted the fix/revoked-keeps-access-expiry branch August 28, 2026 06: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