CRUMB a card from devarno-cloud

TOLL Sell-Through: users.tier as a Cache of Entitlements

smo1 advanced 8 min read

ELI5

TOLL will know who has paid. purr-api will keep reading its own users.tier column for limits and badges, but that column becomes a copy of what TOLL says rather than something purr-api decides. The copy is refreshed whenever someone touches billing, whenever they look at the billing page, and on a timer for anyone who is or recently was paying. If TOLL cannot be reached, the copy is left alone: nobody loses a plan because a network call failed.

Technical Deep Dive

Source: purr-api docs/decisions/2026-10-24-sell-through-toll.md rulings 1-7 (status accepted, countersigned flight@arno.host 2026-10-24) and docs/superpowers/plans/2026-10-24-sell-through-toll.md:7-18, 63-71 (plan, not yet executed); internal/billing/toll/client.go:18-140, internal/middleware/auth.go:603-626, internal/models/user.go:124-167 (origin/main bb6cfc2), read 2026-10-25. Everything below except the client and the tier readers is design, not code.

Refresh sources

flowchart LR
LA[Lifecycle action: change plan, cancel, reactivate] --> S[TierSync]
BI["GET /api/billing read"] --> S
SW["Periodic sweep: paid tier or billing activity in last 40 days"] --> S
S -->|"toll.entitlements, or expectedCapabilities after a plan change"| T[(TOLL)]
T --> D{"successful read ?"}
D -->|"no, TOLL error"| K[keep cached tier]
D -->|yes| R["business if smo1.business, else pro if smo1.pro, else free"]
R --> E{"current tier is early_adopter ?"}
E -->|yes| EA[replace only if the grant ranks higher]
E -->|no| W[write users.tier]
EA --> W

Fail-safe rule

stateDiagram-v2
[*] --> Cached
Cached --> Cached : TOLL unreachable, no change
Cached --> Paid : successful read grants smo1.pro or smo1.business
Cached --> Free : successful read grants no paid capability
Paid --> Cached
Free --> Cached

Ruling 4: “Fail safe, not fail open, and never fail paying users closed.” A downgrade happens only on a successful TOLL read that grants nothing paid. Errors never downgrade.

Rulings in one table

#Rule
2Readers of users.tier (limits, auth claims, dashboards) are unchanged; only the writer changes
3Refresh (a) after a lifecycle action, from the verb’s expectedCapabilities for a plan change, otherwise from a fresh toll.entitlements; (b) on every billing-info read; (c) by a periodic sweep of users whose cached tier is paid or who had billing activity in the last 40 days
5early_adopter is a local comp tier: the sync never lowers it; a pro or business grant replaces it only when it ranks higher by models.GetTierLimits
6With TOLL configured, nothing writes users.tier except the sync
7polar_customer_id, polar_subscription_id, subscription_*, payments, revoked_subscriptions stop being written; dropping them is a separate operator-gated migration

Plan specifics (not decision text)

The plan names a TierSync service and a TierSweep{interval} started from main.go, with TIER_SWEEP_INTERVAL defaulting to 10m, per-user errors logged and skipped. Product keys are TOLL spec keys smo1_pro_monthly, smo1_pro_yearly, smo1_business_monthly, smo1_business_yearly. It says not to start before the decision is countersigned (done) and TOLL#166 is merged (not checked here: UNVERIFIED).

The early_adopter ranking catch

GetTierLimits gives early_adopter 50 monthly links and 1,000 requests per hour, versus pro’s 100 links and 600 per hour. Neither dominates, so “ranks higher” needs a definition the decision defers to GetTierLimits without picking a field. Ruling 5’s own example (“if early_adopter’s limits are at least pro’s, a pro grant leaves it unchanged”) does not settle a split like this one. Flag it in review.

What exists on 2026-10-25

  • toll.Client with Checkout, Entitlements, TierFromCapabilities, ProductKey (purr-api#77). No lifecycle verbs (subscription, cancel, reactivate, portal, change_plan) yet; no caller.
  • resolveTier already reads users.tier on every authenticated request (auth.go:603-626), so the cache’s readers need no change.
  • No TierSync, no sweep, no removal of Polar.

Key Terms

  • Entitlement / capability — a TOLL grant string such as smo1.pro
  • TierSync — planned service that writes users.tier from TOLL under the fail-safe rules
  • TierSweep — planned periodic refresh of paid or recently active users
  • expectedCapabilities — what TOLL’s change-plan verb says the subject will hold after the change

Q&A

Q: When is the cached tier refreshed under the TOLL decision? A: Right after a lifecycle action (from expectedCapabilities for a plan change, otherwise a fresh toll.entitlements), on every billing-info read, and by a periodic sweep of users who are paid or had billing activity in the last 40 days.

Q: What happens to a paid user’s tier when TOLL is unreachable? A: Nothing. The cached tier is kept; only a successful TOLL read granting no paid capability can downgrade.

Q: How does a TOLL grant interact with early_adopter? A: The sync never lowers early_adopter. A pro or business grant replaces it only when it ranks higher by GetTierLimits; with today’s limits that comparison is ambiguous for pro.

Q: Which parts of this design exist in code on 2026-10-25? A: Only the thin TOLL client (checkout, entitlements, tier and product-key mapping) and the existing users.tier readers. The sync, the sweep, the lifecycle verbs and the Polar removal are unbuilt.

Examples

A business customer’s renewal fails at Polar. TOLL learns of it through Polar’s webhook into TOLL and revokes smo1.business. No one opens the billing page, so purr-api’s sweep (planned default every 10 minutes) calls toll.entitlements, gets no paid capability, and writes free. If TOLL had been down for that sweep, the user would stay business until the next successful read.

neighbors on the map