MeteredGrant

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

Budget-scoped grant lifecycle for a metered API, styled as an OAuth 2.1 profile: single-use authorization codes (replay revokes the minted grant), rotating refresh tokens (reuse revokes the grant), and a per-grant spend budget. Checks that spend never exceeds the budget, each code mints at most one grant, live grants trace to redeemed codes, and revocation is immediate and final.

Raw .tla Raw .cfg

MeteredGrant.tla

MODULE MeteredGrant

Budget-scoped grant lifecycle for a metered API, in the style of an OAuth 2.1 profile.

Models the safety core of the flow: - authorization codes are single-use; replay revokes the minted grant - refresh tokens rotate; reuse of a rotated token revokes the grant - the budget is a hard damage cap on spend under a grant - revocation is immediate: a revoked grant never spends again

Abstractions: PKCE, proof-of-possession, discovery, and token expiry are trusted plumbing and not modeled; access tokens are collapsed into "the grant is active" since revocation invalidates them all at once. Refresh-token generations are modeled as a counter; presenting any generation below the current one is a reuse.

EXTENDS Naturals
CONSTANTS
Codes, authorization code ids
Grants, grant ids
NoGrant, model value: "no grant minted from this code"
MaxBudget, largest budget a consent screen can approve, in credits
Costs, possible charges for one metered call
MaxRefresh bound on refresh-token rotations per grant (finiteness)
ASSUME NoGrantGrants
VARIABLES
codePhase, code -> "unissued" | "pending" | "redeemed" | "expired"
codeBudget, code -> budget approved at consent
codeGrant, code -> grant minted from it, or NoGrant
grantPhase, grant -> "unminted" | "active" | "revoked"
budget, grant -> granted budget
used, grant -> total credits debited so far
rt grant -> current refresh-token generation
vars ≜ ⟨codePhase, codeBudget, codeGrant, grantPhase, budget, used, rt
TypeOK
codePhase ∈ [Codes → {"unissued", "pending", "redeemed", "expired"}]
codeBudget ∈ [Codes → 0..MaxBudget]
codeGrant ∈ [CodesGrants ∪ {NoGrant}]
grantPhase ∈ [Grants → {"unminted", "active", "revoked"}]
budget ∈ [Grants → 0..MaxBudget]
used ∈ [Grants → 0..MaxBudget]
rt ∈ [Grants → 0..MaxRefresh]
Init
codePhase = [cCodes"unissued"]
codeBudget = [cCodes ↦ 0]
codeGrant = [cCodesNoGrant]
grantPhase = [gGrants"unminted"]
budget = [gGrants ↦ 0]
used = [gGrants ↦ 0]
rt = [gGrants ↦ 0]

Consent: the user approves budget b (possibly lower than requested) and the authorization server issues authorization code c.

Approve(c, b) ≜
codePhase[c] = "unissued"
codePhase = [codePhase EXCEPT ![c] = "pending"]
codeBudget = [codeBudget EXCEPT ![c] = b]
UNCHANGEDcodeGrant, grantPhase, budget, used, rt

Codes expire unredeemed after a short window.

Expire(c) ≜
codePhase[c] = "pending"
codePhase = [codePhase EXCEPT ![c] = "expired"]
UNCHANGEDcodeBudget, codeGrant, grantPhase, budget, used, rt

Token endpoint, authorization_code grant: redeem code c, mint grant g with the consented budget and its first refresh token.

Exchange(c, g) ≜
codePhase[c] = "pending"
grantPhase[g] = "unminted"
codePhase = [codePhase EXCEPT ![c] = "redeemed"]
codeGrant = [codeGrant EXCEPT ![c] = g]
grantPhase = [grantPhase EXCEPT ![g] = "active"]
budget = [budget EXCEPT ![g] = codeBudget[c]]
UNCHANGEDcodeBudget, used, rt

An attacker replays a redeemed code: the server revokes the grant issued from it and mints nothing.

ReplayCode(c) ≜
codePhase[c] = "redeemed"
codeGrant[c] ≠ NoGrant
grantPhase = [grantPhase EXCEPT ![codeGrant[c]] = "revoked"]
UNCHANGEDcodePhase, codeBudget, codeGrant, budget, used, rt

refresh_token grant with the current token: rotate to a new generation. Fresh access tokens are implicit in the grant staying active.

Refresh(g) ≜
grantPhase[g] = "active"
rt[g] < MaxRefresh
rt = [rt EXCEPT ![g] = rt[g] + 1]
UNCHANGEDcodePhase, codeBudget, codeGrant, grantPhase, budget, used

An attacker presents a rotated-away refresh token: detected reuse revokes the whole grant. rt > 0 means an older generation exists.

RefreshReuse(g) ≜
grantPhase[g] = "active"
rt[g] > 0
grantPhase = [grantPhase EXCEPT ![g] = "revoked"]
UNCHANGEDcodePhase, codeBudget, codeGrant, budget, used, rt

A metered call debits its charge against the grant; the server refuses spend past the budget.

Spend(g, k) ≜
grantPhase[g] = "active"
used[g] + kbudget[g]
used = [used EXCEPT ![g] = used[g] + k]
UNCHANGEDcodePhase, codeBudget, codeGrant, grantPhase, budget, rt

Revocation by the user or the client app (RFC 7009).

Revoke(g) ≜
grantPhase[g] = "active"
grantPhase = [grantPhase EXCEPT ![g] = "revoked"]
UNCHANGEDcodePhase, codeBudget, codeGrant, budget, used, rt
Next
∨ ∃ cCodes, b ∈ 1..MaxBudget : Approve(c, b)
∨ ∃ cCodes : Expire(c) ∨ ReplayCode(c)
∨ ∃ cCodes, gGrants : Exchange(c, g)
∨ ∃ gGrants : Refresh(g) ∨ RefreshReuse(g) ∨ Revoke(g)
∨ ∃ gGrants, kCosts : Spend(g, k)
SpecInit ∧ □[Next]vars

The budget is a hard damage cap: spend under a grant never exceeds it.

BudgetIsHardCap ≜ ∀ gGrants : used[g] ≤ budget[g]

Authorization codes are single-use: no two codes mint the same grant, so a replayed code can never produce a second spending credential.

CodeMintsAtMostOneGrant
c1, c2Codes :
(codeGrant[c1] ≠ NoGrantcodeGrant[c1] = codeGrant[c2]) ⇒ c1 = c2

Every live grant traces back to exactly one redeemed authorization code.

GrantsComeFromCodes
gGrants :
grantPhase[g] ≠ "unminted"
cCodes : codeGrant[c] = gcodePhase[c] = "redeemed"

Revocation is immediate and final: a revoked grant never spends again and is never resurrected.

NoSpendAfterRevoke
□[∀ gGrants :
grantPhase[g] = "revoked"
used[g] = used[g] ∧ grantPhase[g] = "revoked"]vars

MeteredGrant.cfg

SPECIFICATION Spec
CONSTANTS
Codes = {c1, c2}
Grants = {g1, g2}
NoGrant = NoGrant
MaxBudget = 3
Costs = {1, 2}
MaxRefresh = 2
INVARIANT TypeOK
INVARIANT BudgetIsHardCap
INVARIANT CodeMintsAtMostOneGrant
INVARIANT GrantsComeFromCodes
PROPERTY NoSpendAfterRevoke
CHECK_DEADLOCK FALSE

Generations

genchangesdistinct statesdepthpublishedraw
1 (latest) Initial publication of the grant lifecycle and budget metering model. 7393 15 2026-07-20 16:46:31 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…