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 --> WFail-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 --> CachedRuling 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 |
|---|---|
| 2 | Readers of users.tier (limits, auth claims, dashboards) are unchanged; only the writer changes |
| 3 | Refresh (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 |
| 5 | early_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 |
| 6 | With TOLL configured, nothing writes users.tier except the sync |
| 7 | polar_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.ClientwithCheckout,Entitlements,TierFromCapabilities,ProductKey(purr-api#77). No lifecycle verbs (subscription, cancel, reactivate, portal, change_plan) yet; no caller.resolveTieralready readsusers.tieron 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.tierfrom 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
- Multi-Strategy Authentication in purr-api debugging 401 errors across different clients
- Rate Limiting: Edge per-IP, Origin per-Tier debugging 429 errors for specific users