Skip to content

user_profile: add the pro grace period, and distinguish an unset auto-renewing flag - #124

Draft
mpretty-cyro wants to merge 2 commits into
session-foundation:devfrom
mpretty-cyro:feature/pro-auto-renewing-tristate
Draft

user_profile: add the pro grace period, and distinguish an unset auto-renewing flag#124
mpretty-cyro wants to merge 2 commits into
session-foundation:devfrom
mpretty-cyro:feature/pro-auto-renewing-tristate

Conversation

@mpretty-cyro

Copy link
Copy Markdown
Collaborator

user_profile: add the pro grace period, and distinguish an unset auto-renewing flag

Base: jagerman/pro-auto-renewing-config @ 8e5634b8 — the head of #121, not the
-pfs variant (identical commit subject, different sha, sits on the PFS track).
Branch: feature/pro-auto-renewing-tristate · Commit: 086a420e · 4 files, +214 −1

Completes the config side of #121 against the Pro status-refresh spec, which asks for both
auto_renewing and grace to be synced alongside E. #121 as it stands ships auto_renewing
only.


1. New key G — the account's grace period, in seconds

std::chrono::seconds UserProfile::get_pro_grace_period() const;
void                 UserProfile::set_pro_grace_period(std::chrono::seconds);

int64_t user_profile_get_pro_grace_period(const config_object*);   // seconds, 0 if unset
void    user_profile_set_pro_grace_period(config_object*, int64_t);

Why it is needed rather than merely nice. The backend folds the grace period into the stored
expiry for auto-renewing subscriptions — payment_expiry_at = expiry_at + grace if auto_renewing,
written to users.expiry_at and sent verbatim as get_pro_status.expiry_ts. So E is the end of
coverage, not the date a renewal is due
, and user_status is judged against that same value. The
backend states it plainly at base.py:158: "Grace periods added to a subscription's paid-through
instant."

With G synced, any device recovers the paid-through instant as E − G. Without it, a
config-only consumer — notably each client's cold-start status-fetch gate — cannot compute it at all.

Not optional, deliberately, unlike A. The backend sends 0 whenever the subscription is not
auto-renewing, so "unset" and "zero" describe the same account and both give E − 0 == E. There is no
state a caller could act on differently, so there is nothing for a presence check to disambiguate.

Clearing E clears G. A grace that outlived its expiry would pair with whatever wrote E next
— usually a proof outcome, which carries no grace of its own to correct it with. set_pro_access_expiry
already clears I and R as side effects, so this follows the existing shape rather than introducing
one. (Insurance rather than the load-bearing fix — see §4.)

2. auto_renewing: telling "unset" apart from "false"

std::optional<bool> UserProfile::get_pro_auto_renewing_opt() const;
int user_profile_has_pro_auto_renewing(const config_object*);   // 0/1

The flag is presence-only (set_nonzero_int), so false erases the key and the existing getter
answers 0 for both "not auto-renewing" and "never learned". A client deciding at startup whether
to call /get_pro_status needs those apart: an account entitled before this field existed reads as
false forever, and the fetch that would populate it is the one being skipped.

get_pro_auto_renewing() and set_pro_auto_renewing() are unchanged, so every existing caller
keeps the collapsing behaviour, which is right for anything that only asks "is it renewing".

Why a separate predicate and not a -1 sentinel on the getter

A sentinel was the first shape; the three clients killed it with platform evidence:

  • Androidjboolean is uint8_t. The natural binding, mirroring the accessor directly above
    it, does static_cast<jboolean>(-1) = 255 = JNI_TRUE. "Never stored" would reach Kotlin as
    "auto-renewing", silently, with no diagnostic — and that is the reading which makes a startup
    gate decline the fetch, so the state never corrects.
  • Desktop — the napi getter would return number; if (getProAutoRenewingOpt()) reads unknown as
    true in JS, and TypeScript would not catch it.
  • iOS — immune either way; Swift has no implicit int-to-Bool, so the compiler forces != 0.

Two of three exposed, and the one where it is silent is also the one where it fails toward the
dangerous answer.

3. Testing

All tests passed (25755870 assertions in 128 test cases), and specifically:

  • The assertion count moved on each addition (…856 → …866 with G → …870 with the clear-pairing
    test), which is this repo's own guard against a stale testAll binary reporting a previous build's
    result.
  • New symbols verified in the built artefact alongside the shipping control symbol
    user_profile_get_pro_access_expiry, not inferred from a green compile.
  • Rebuilt and re-run after clang-format — a green run taken before the format pass would have
    been a stale-binary claim.
  • CLANG_FORMAT_DESIRED_VERSION=19 ./utils/format.sh; the diff touches nothing unrelated.

Tests pin the behaviour that is easy to get wrong: that setting the flag false returns it to unset
rather than to a stored false, and that clearing E prevents a later E write from inheriting a stale
grace.

4. 🔴 Known limitations — please read before merging

(a) This is unknown vs true, not a genuine tri-state. set_pro_auto_renewing(false) erases the
key, so a stored false and an absent one remain the same state. It answers "has anyone ever written
this?"
and not "is this user explicitly not auto-renewing?". Making it a real tri-state means
storing 0 instead of erasing — a wire-encoding change, and a different decision.

(b) E − G is only coherent if E and G are written from the same response. The
generate_pro_proof response carries account_expiry_ts but no grace, so a proof outcome writes E
and leaves G untouched. A companion backend change adds account_grace_period_duration to that
response (Session-Pro-Backend, feature/proof-response-carries-grace). Without it, G is a strict
improvement over having nothing but is not reliable across a proof-then-grace-transition sequence.

The clear-pairing in §1 does not close this on its own — it is insurance; the backend field is the fix.

(c) The API shape is yours. The predicate-vs-sentinel choice was made on client evidence rather
than on preference, and it is worth confirming. If you would rather supply the change yourself, this
branch is disposable.

Note: This is based on #121

jagerman and others added 2 commits August 6, 2026 16:55
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>
…-renewing flag

Completes the config side of session-foundation#121 against the refresh spec, which asks for both
`auto_renewing` and `grace` to be synced alongside `E`.

- New key `G`: the account's grace period in seconds. The backend folds grace into
  the stored expiry for auto-renewing subscriptions, so `E` is the end of coverage
  rather than the date a renewal is due; `E - G` recovers the paid-through instant
  on any device. Clearing `E` clears `G` with it, since a grace that outlived its
  expiry would pair with whatever wrote `E` next.

- `get_pro_auto_renewing_opt()` / `user_profile_has_pro_auto_renewing()`: the flag
  is presence-only, so the existing getter answers 0 both for "not auto-renewing"
  and for "never learned". A client deciding at startup whether to fetch
  `/get_pro_status` needs those apart, since an account entitled before the field
  existed reads as false forever otherwise. A separate predicate rather than a -1
  sentinel on the getter: a negative int cast to an unsigned boolean is non-zero,
  so the natural JNI binding would silently read "unknown" as "auto-renewing".
@mpretty-cyro
mpretty-cyro requested a review from jagerman August 7, 2026 06:53
@mpretty-cyro mpretty-cyro self-assigned this Aug 7, 2026
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