user_profile: add the pro grace period, and distinguish an unset auto-renewing flag - #124
Open
mpretty-cyro wants to merge 7 commits into
Open
Conversation
Clients sometimes need to know whether a Pro subscription is terminal or auto-renewing (e.g. "renews on X" vs "expires on X"). Store the backend's `auto_renewing` (from get_pro_status) as a presence-only config flag `A`: 1 when auto-renewing, absent otherwise (terminal / unknown / not Pro). Deliberately not tri-state: unlike blinded_msgreqs `M`, this is backend- derived fact, not a defaulted client preference, so there's no upgrade- default edge case that a distinct "unset" would guard. And no t/T bump -- it's synced pro state like E/I/R, not a user-initiated profile edit. Exposes get_/set_pro_auto_renewing (C++ bool; C 0/1) with unit + C-API coverage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the config side of session-foundation#121 against the Pro status-refresh spec, which asks for both `auto_renewing` and `grace` to be synced alongside `E`. session-foundation#121 ships `auto_renewing`; this adds the grace period. The backend folds the grace period into the stored expiry for auto-renewing subscriptions (`payment_expiry_at = expiry_at + grace if auto_renewing`) and sends that verbatim as `get_pro_status.expiry_ts`, so `E` is the end of coverage rather than the date a renewal is due. With `G` synced, any device recovers the paid-through instant as `E - G`; without it a config-only consumer cannot compute it at all. Not presence-checked, unlike `A`: the backend sends 0 whenever the subscription isn't auto-renewing, so an absent key and a stored zero describe the same account and both give `E - 0 == E`. There is no state a caller could act on differently. Clearing `E` clears `G` with it. A grace that outlived its expiry would pair with whatever wrote `E` next, and `set_pro_access_expiry` already clears `I` and `R` as side effects, so this follows the existing shape.
…sponse `generate_pro_proof` returns `account_expiry_ts` so a proof fetch can refresh the client's cached access expiry. The backend now sends the two values that qualify it -- `account_grace_period_duration` and `account_auto_renewing` -- and `parse_pro_proof` dropped both on the floor, so no client could reach them. That mattered because clients persist the account expiry into synced config from this response as well as from `get_pro_status`. Without the companions, a proof fetch wrote a fresh expiry beside a stale grace and a stale renewing flag. The flag is the sharp end: config stores it presence-only, so an account whose expiry has only ever been written by a proof reads back as terminal while it renews. Both are required on a successful proof, exactly like `account_expiry_ts`. A response that can't be paired with the values qualifying its expiry is treated as malformed rather than handing the caller a fresh expiry beside a defaulted grace and flag it would then persist -- and a defaulted `false` is not inert, because writing false to the config key ERASES it. They are zero/false on the failure outcomes that carry no proof, which is also the truthful value for `subscription_expired`, `not_subscribed` and `revoked`. Tests cover the pair round-tripping through the C and C++ parses with `E - G` recovering the paid-through instant, each field missing being a parse error, a wrong-typed flag being a parse error, and the non-auto-renewing account's genuine zero.
`set_pro_access_expiry(nullopt)` already clears `G` -- a grace is only meaningful as `E - G`, so it must not outlive its expiry. `A` has the same relationship and was not being cleared: a renewing flag with no expiry beside it describes a subscription that is not there. Every caller that clears `E` is handling an account with no entitlement -- a proof cleared, a proof revoked, or a non-positive `expiry_ts` -- and none of those is auto-renewing, so there is no state in which the flag should survive its expiry. Without this the three keys are coherent only because every *consumer* happens to test `E` before reading `A`. That is true today on all three clients and nothing enforces it; a new consumer inherits the obligation without knowing it has one. Maintaining the invariant on the write side is what removes that. Note this changes the lifecycle of `A`, which is session-foundation#121's key rather than mine -- raised deliberately as its own commit so it can be taken or dropped independently of the `G` work it sits beside.
mpretty-cyro
force-pushed
the
feature/pro-auto-renewing-tristate
branch
from
August 10, 2026 05:38
086a420 to
b066ba2
Compare
mpretty-cyro
marked this pull request as ready for review
August 10, 2026 06:56
Every comment describing `G` said the backend folds grace INTO the stored expiry, so that `E` was the end of coverage and `E - G` recovered the paid-through instant. That was true of the backend at the commit this branch was written against, and has not been true since Session-Pro-Backend PR session-foundation#15. The current model, from `subscription_coverage_end` and the `get_pro_status` handler: E the payment-due date -- the instant the term was paid through G how much longer service continues past it: the store's dunning window plus the backend's renewal-latency allowance; 0 when not auto-renewing coverage ends at E + G, and [E, E + G) is overdue-but-still-served No behaviour changes -- `G` is still a synced seconds value, still cleared with `E`, still omitted when zero. What changes is every explanation of what it is for, in both config headers, both pro_backend headers, the clear-pairing comment, and the tests that asserted the subtraction. The tests now assert `E + G` is the coverage end rather than `E - G` being the paid-through instant, which is the same property stated the right way round.
…odel
The previous commit corrected the proof-response fields and left the status-response
ones, which describe the SAME model for the endpoint that actually changed. The
header contradicted itself: one struct said grace is additive, the other said
expiry_at = subscription_expiry + grace_period_duration
and told the reader to subtract the grace to recover the subscription expiry. That
text predates this branch, but leaving it beside the corrected text made the more
detailed -- and wrong -- version the one a reader would trust.
`ProStatusResponse::expiry_at` is the payment-due date; entitlement runs to
`expiry_at + grace_period_duration`, and the backend judges `user_status` against
that sum. Stated at both fields, with the subtraction called out as the shape that
no longer means anything.
Also documents two things that were bare fields in the C header and are a live trap
for a binding author:
- `grace_period_duration` exists at BOTH levels with the same name and is a
different quantity at each. The account-level one adds the backend's
renewal-latency allowance and is gated on the account renewing; the payment-level
one is raw store data and is not gated, so a cancelled subscription can carry a
stale non-zero value. Reading the wrong one puts the end of entitlement days late.
- `expiry_ts` at both levels, for the same reason.
Comment-only; no signature or behaviour changes.
Found by pro-refresh-ios while flipping the client to the corrected model: the doc
pass had been checked by grepping for the phrasings that were corrected, which
cannot find the same claim stated in other words.
The zero-grace note read `E - 0 == E`, a subtraction in the one comment explaining that coverage is an addition. Same sign error the whole model correction was about, left in the prose describing it.
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.
user_profile: add the pro grace period (config key
G)Built on:
jagerman/pro-auto-renewing-config@8e5634b8— the head of #121, not the-pfsvariant (identical commit subject, different sha, sits on the PFS track).Branch:
feature/pro-auto-renewing-tristate· Commits:269f8b88,f197a0bd,b066ba27,1e8b521f,54e246b2,de208ff1· 8 files, +332 −41 (derived fromgit diff --shortstat 8e5634b8..HEAD, not computed)Completes the config side of #121 against the Pro status-refresh spec, which asks for both
auto_renewingandgraceto be synced alongsideE. #121 shipsauto_renewing; this addsthe grace period.
The key
Why it is needed rather than merely nice.
Eis the account's payment-due date — the instantthe term was paid through — and entitlement continues past it for the store's dunning window plus the
backend's renewal-latency allowance.
get_pro_statusreports that span asgrace_period_duration, andjudges
user_statusagainstexpiry_at + grace_period_duration(server.py:327,backend.py:512).So coverage ends at
E + G, andEalone cannot answer when. WithGsynced, any device computesit. Without it, a config-only consumer — notably each client's
user_expirywake, which must fire atcoverage end — has no way to reach that instant.
No companion presence check, deliberately. The backend sends
0whenever the subscription is notauto-renewing, so "unset" and "zero" describe the same account and both give
E − 0 == E. There isno state a caller could act on differently, so there is nothing for a presence check to disambiguate.
Clearing
EclearsG. A grace that outlived its expiry would pair with whatever wroteEnext.set_pro_access_expiryalready clearsIandRas side effects, so this follows the existing shaperather than introducing one.
Testing
All tests passed (25755864 assertions in 128 test cases), and specifically:a stale
testAllbinary reporting a previous build's result.user_profile_{get,set}_pro_grace_periodverified in the built artefact (libsession-config.a)alongside the shipping control symbol
user_profile_get_pro_access_expiry, not inferred from agreen compile.
clang-format— a green run taken before the format pass would havebeen a stale-binary claim.
CLANG_FORMAT_DESIRED_VERSION=19 ./utils/format.sh; the diff touches nothing unrelated.Tests pin that a zero or negative grace erases rather than storing, and that clearing
Eprevents alater
Ewrite from inheriting a stale grace.Known limitation
E + Gis only coherent ifEandGare written from the same response. Thegenerate_pro_proofresponse carriesaccount_expiry_ts, so a proof outcome writesE. A companionbackend change adds
account_grace_period_duration(andaccount_auto_renewing) to that response —Session-Pro-Backend,feature/proof-response-carries-grace. Without it,Gis a strictimprovement over having nothing but is not reliable across a proof-then-grace-transition sequence.
The clear-pairing above is insurance; the backend field is the fix.
Why there is no presence check on
AA predicate telling an unset
auto_renewingflag from a storedfalsecannot work under thepresence-only encoding:
set_nonzero_interases on false, soAis present if and only if itsvalue is 1, and any such predicate returns the same bit as the plain getter in every reachable state.
Making the distinction real would mean storing
0rather than erasing — a change to this key'sencoding and to a convention used across the config types (
+priority,ylegacy_blinding,epro_expiry).The client-side need is met instead by the proof response carrying
auto_renewing, so every writer ofEwritesAtoo.Relationship to the client work
Three client PRs (Android/Desktop/iOS Pro status-refresh unification) list this as a conditional
dependency: all three read
get_pro_auto_renewing()andget_pro_access_expiry(), which this PRleaves unchanged, so all three compile and behave correctly without it. It becomes a hard dependency
once a client needs the coverage-end instant — the
user_expirywake and the grace indicator both do.Per-platform glue cost: Android a JNI pair, Desktop ~6 lines in its
pro-auto-renewing-gluebranch(already open and already exposing
G), iOS free — its module map is generated by globbing theheader tree, so a new
LIBSESSION_EXPORTis visible to Swift immediately.6. Second commit —
f197a0bd: parse the two fields off the proof response4 files, +72.
pro_backend, notuser_profile— a separate subsystem, reviewable separately.generate_pro_proofreturnsaccount_expiry_tsso a proof fetch can refresh the client's cached accessexpiry. The backend also sends the two values that qualify it —
account_grace_period_durationandaccount_auto_renewing— andparse_pro_proofread neither, so no client could reach them.That mattered because clients persist the account expiry into synced config from this response as well as
from
get_pro_status. Without the companions, a proof fetch wrote a fresh expiry beside a stale grace anda stale renewing flag. The flag is the sharp end: config stores it presence-only, so an account whose
expiry has only ever been written by a proof reads back as terminal while it renews.
std::chrono::seconds account_grace_period{0}; bool account_auto_renewing{false};Both required on a successful proof, exactly like
account_expiry_ts. A response that cannot bepaired with the values qualifying its expiry is treated as malformed, rather than handing the caller a
fresh expiry beside a defaulted grace and flag it would then persist — and a defaulted
falseis notinert, because writing
falseto the config key erases it. Zero/false on the failure outcomes thatcarry no proof, which is also the truthful value for
subscription_expired/not_subscribed/revoked.Tests cover the pair round-tripping through the C and C++ parses with
E + Ggiving the coverage end,each field missing being a parse error, a wrong-typed flag being a parse error, and the
non-auto-renewing account's genuine zero.
All tests passed (25755877 assertions in 128 test cases), rebuilt and re-run afterclang-format. Theassertion count is checked to have moved on each addition — this repo's own guard against a stale
testAllbinary reporting a previous build's result.
7. Third commit —
b066ba27: clear the renewing flag with the expiry it describes2 files, +23.
user_profile. Deliberately separate, because it changes the lifecycle ofA, whichis #121's key rather than mine — take or drop it independently of the
Gwork.set_pro_access_expiry(nullopt)already clearedG: a grace is only meaningful asE + G, so it must notoutlive its expiry.
Ahas the same relationship and was not being cleared, so a revoked or lapsedsubscription kept a renewing flag describing a subscription that is not there.
Every caller that clears
Eis handling an account with no entitlement — a proof cleared, a proof revoked,or a non-positive
expiry_ts— and none of those is auto-renewing.The reason it is worth doing rather than documenting: without it the three keys are coherent only
because every consumer happens to test
Ebefore readingA. That was true on all three clients andnothing enforced it — each had arrived there independently, and a new consumer would inherit the
obligation without knowing it had one.