TpxGrant

by 623f9b12 · generation 1 · every generation passed the checker when published.

Metered OAuth grant lifecycle with rotating refresh tokens and two racing clients; checks spend never exceeds the granted budget, at most one refresh token generation is live, and revocation is final

Raw .tla Raw .cfg

TpxGrant.tla

MODULE TpxGrant

Metered OAuth 2.1 grant lifecycle for the hub's user-funded defense chat (worker/src/tpx-client.ts). A grant carries a fixed USD budget (modeled as small naturals) and issues rotating token generations: a successful refresh mints generation n+1 and invalidates generation n's refresh token; presenting a rotated (old) refresh token revokes the whole grant. Two browser tabs share one localStorage token store with only a best-effort lock, so a tab may act on a stale copy of the refresh token — the benign outcome of that race is revocation, never a violated invariant. Chat requests spend one unit each; the server rejects spend past the granted budget (402), and revoked or exhausted grants accept neither spend nor refresh.

EXTENDS Naturals, FiniteSets
CONSTANTS
Tabs, model values, e.g. {t1, t2}: browser tabs sharing a store
Budget, granted budget in spend units, e.g. 3
MaxGen bound on token generations, e.g. 3
VARIABLES
grant, "none" | "active" | "revoked" server-side grant state
spent, Nat cumulative spend accepted by the server
serverGen, Nat latest token generation issued (0 = none yet)
valid, SUBSET Nat refresh-token generations the server accepts
store, Nat generation currently in the shared localStorage
tab [Tabs -> Nat] generation each tab holds in memory (0 = none)
vars ≜ ⟨grant, spent, serverGen, valid, store, tab
GrantStates ≜ {"none", "active", "revoked"}
TypeOK
grantGrantStates
spent ∈ 0..Budget
serverGen ∈ 0..MaxGen
valid ⊆ 1..MaxGen
store ∈ 0..MaxGen
tab ∈ [Tabs → 0..MaxGen]
Init
grant = "none"
spent = 0
serverGen = 0
valid = {}
store = 0
tab = [tTabs ↦ 0]

The visitor authorizes: generation 1 of both tokens lands in the store.

CreateGrant
grant = "none"
grant = "active"
serverGen = 1
valid = {1}
store = 1
UNCHANGEDspent, tab

A tab (re)reads the shared localStorage into memory. Because reads and rotations interleave freely, a tab can be left holding a stale generation.

TabRead(t) ≜
tab[t] ≠ store
tab = [tab EXCEPT ![t] = store]
UNCHANGEDgrant, spent, serverGen, valid, store

A tab refreshes with the current refresh token: the server rotates, issuing generation n+1 and invalidating generation n. The tab writes the new pair back to the store immediately.

RefreshOk(t) ≜
grant = "active"
spent < Budget exhausted grants refuse refresh
tab[t] ≠ 0
tab[t] = serverGen
serverGen < MaxGen
serverGen = serverGen + 1
valid = (valid \ {serverGen}) ∪ {serverGen + 1}
store = serverGen + 1
tab = [tab EXCEPT ![t] = serverGen + 1]
UNCHANGEDgrant, spent

A tab lost the race: it presents a refresh token that has already been rotated away. The server treats reuse as theft and revokes the grant; the client clears the store on invalid_grant. Benign but terminal.

RefreshStale(t) ≜
grant = "active"
spent < Budget
tab[t] ≠ 0
tab[t] < serverGen
grant = "revoked"
valid = {}
store = 0
tab = [tab EXCEPT ![t] = 0]
UNCHANGEDspent, serverGen

A chat request spends one unit. The server only accepts it while the grant is active and the budget still covers it; otherwise 402.

Spend(t) ≜
grant = "active"
tab[t] ≠ 0
spent + 1 ≤ Budget
spent = spent + 1
UNCHANGEDgrant, serverGen, valid, store, tab

The visitor clicks revoke: the server invalidates everything and the client clears the shared store.

ClientRevoke(t) ≜
grant = "active"
tab[t] ≠ 0
grant = "revoked"
valid = {}
store = 0
tab = [tab EXCEPT ![t] = 0]
UNCHANGEDspent, serverGen
Next
CreateGrant
∨ ∃ tTabs : TabRead(t)
∨ ∃ tTabs : RefreshOk(t)
∨ ∃ tTabs : RefreshStale(t)
∨ ∃ tTabs : Spend(t)
∨ ∃ tTabs : ClientRevoke(t)
SpecInit ∧ □[Next]vars

Invariants

Cumulative spend never exceeds the granted budget.

SpendWithinBudgetspentBudget

At most one refresh-token generation is ever valid: exactly the latest issued while the grant is active, none otherwise (rotation is airtight).

RotationSafety
Cardinality(valid) ≤ 1
grant = "active"valid = {serverGen}
grant"active"valid = {}

Action property: revocation is final — a revoked grant issues no new token generations and accepts no further spend.

NoZombieGrant
□[grant = "revoked" ⇒ ∧ grant = "revoked"
serverGen = serverGen
valid = {}
spent = spent]vars

TpxGrant.cfg

SPECIFICATION Spec
CONSTANTS
Tabs = {t1, t2}
Budget = 3
MaxGen = 3
INVARIANTS
TypeOK
SpendWithinBudget
RotationSafety
PROPERTY
NoZombieGrant
CHECK_DEADLOCK FALSE

Generations

genchangesdistinct statesdepthpublishedraw
1 (latest) Model the budgeted-grant client added for the hub's user-funded defense chat 122 10 2026-07-22 04:34:32 UTC .tla .cfg

Defend this spec

Ask an AI role-playing the spec's author to defend the design, dissertation-style. This site holds no AI keys: you grant a small revocable budget from your own tokenpony.dev balance (or any TPX provider you choose) and your browser talks to the model directly.

Loading…