55 "budget": { "amount": 2000000, "unit": "USD", "decimals": 6 } # = $2.00
66
77and the resource meters every request against it: reserve the request's maximum
8- cost atomically, serve, commit the actual cost, release the difference. The
9- draft requires consumption to be aggregated atomically across all live auth
10- tokens for the key ``(iss, sub, aud)`` (§14.4), so the meter pools the grants
11- of a principal's live tokens and counts reservations + consumption against
12- that pool.
8+ cost atomically, serve, commit the actual cost, release the difference.
9+
10+ Two things are counted, against different keys (draft §Aggregation):
11+
12+ * the **cap** is per auth token — committed consumption plus outstanding
13+ reservations against the presented ``jti`` never exceed *its* ``budget``;
14+ no cross-token arithmetic, one token never draws on a sibling's grant;
15+ * the **ledger** is per person — consumption is posted to ``(iss, sub, aud)``
16+ for the consumption record and the usage counters. It is not a second
17+ ceiling: a request that fits its token's budget is never refused because
18+ of a per-person total.
19+
20+ (0.4 and earlier pooled a principal's live grants into one purse. That let a
21+ jti spend past its own grant, so the figure recorded against it could exceed
22+ what its issuer granted and the overflow — spent from a sibling's allocation —
23+ was never attributed. Dropped in 0.5.0; the ``required`` member makes a
24+ fragmented agent's re-authorization a calculation instead.)
1325
1426Everything here is framework-free; the FastAPI glue lives in
1527:mod:`regent_httpsig.fastapi` (``BudgetMiddleware``).
@@ -42,7 +54,7 @@ class InvalidBudgetClaim(ValueError):
4254
4355
4456class UnitMismatch (ValueError ):
45- """A grant's unit/decimals differ from the pool 's — one envelope, one unit."""
57+ """A grant's unit/decimals differ from the ledger 's — one envelope, one unit."""
4658
4759
4860@dataclass (frozen = True )
@@ -80,8 +92,8 @@ def parse(claims: Mapping[str, Any]) -> BudgetClaim | None:
8092
8193@dataclass (frozen = True )
8294class Reservation :
83- """An atomic hold on the pool for one in-flight request. Never revised —
84- committed (with the actual cost) or released, exactly once."""
95+ """An atomic hold on one token's budget for one in-flight request. Never
96+ revised — committed (with the actual cost) or released, exactly once."""
8597
8698 rid : int
8799 key : MeterKey
@@ -91,7 +103,8 @@ class Reservation:
91103
92104@dataclass (frozen = True )
93105class InsufficientBudget :
94- """Refusal: the request's maximum cost exceeds the pool's remaining balance.
106+ """Refusal: the request's maximum cost exceeds the presented token's
107+ remaining balance.
95108 ``exhausted`` distinguishes the draft's two reason tokens: an empty envelope
96109 (``budget-exhausted``) vs a too-expensive request (``insufficient-budget``)."""
97110
@@ -204,21 +217,25 @@ def _purge(self, key: MeterKey, now: float) -> _Pool | None:
204217 return pool
205218
206219 @staticmethod
207- def _remaining (pool : _Pool ) -> int :
208- live = sum (a for a , _ in pool .grants .values ())
209- spent = sum (pool .consumed .get (jti , 0 ) for jti in pool .grants )
210- held = sum (a for _ , a , _ in pool .reservations .values ())
211- return max (0 , live - spent - held )
220+ def _remaining (pool : _Pool , jti : str ) -> int :
221+ """The presented token's balance: its grant minus what was committed
222+ against it minus what is held for it. Sibling tokens of the same
223+ person do not enter — the cap is per token (§Aggregation)."""
224+ grant = pool .grants .get (jti )
225+ if grant is None :
226+ return 0
227+ held = sum (a for j , a , _ in pool .reservations .values () if j == jti )
228+ return max (0 , grant [0 ] - pool .consumed .get (jti , 0 ) - held )
212229
213230 # ── public interface (the BudgetMeter contract) ──────────────────────────
214231
215232 async def observe_grant (self , key : MeterKey , jti : str , claim : BudgetClaim ,
216233 exp : float , jkt : str = "" ) -> None :
217- """Register a token's envelope in the principal 's pool (idempotent per
218- ``jti``). ``jkt`` is the RFC 7638 thumbprint of the token's ``cnf`` key —
234+ """Register a token's envelope under the person 's ledger key (idempotent
235+ per ``jti``). ``jkt`` is the RFC 7638 thumbprint of the token's ``cnf`` key —
219236 recorded so consumption records can be scoped to the presenting agent
220237 (one agent must not learn about its siblings). Raises
221- :class:`UnitMismatch` if the pool already runs in a different unit —
238+ :class:`UnitMismatch` if the ledger already runs in a different unit —
222239 one envelope, one unit, no FX at the meter."""
223240 async with self ._lock :
224241 now = time .monotonic ()
@@ -229,7 +246,7 @@ async def observe_grant(self, key: MeterKey, jti: str, claim: BudgetClaim,
229246 key , _Pool (unit = claim .unit , decimals = claim .decimals ))
230247 if (pool .unit , pool .decimals ) != (claim .unit , claim .decimals ):
231248 raise UnitMismatch (
232- f"pool runs in { pool .unit } /{ pool .decimals } , "
249+ f"ledger runs in { pool .unit } /{ pool .decimals } , "
233250 f"grant is { claim .unit } /{ claim .decimals } " )
234251 pool .last_activity = now
235252 if jkt :
@@ -244,7 +261,7 @@ async def reserve(self, key: MeterKey, jti: str,
244261 pool = self ._purge (key , now )
245262 if pool is None or jti not in pool .grants :
246263 return InsufficientBudget (remaining = 0 , exhausted = True )
247- remaining = self ._remaining (pool )
264+ remaining = self ._remaining (pool , jti )
248265 if max_cost > remaining :
249266 return InsufficientBudget (remaining = remaining ,
250267 exhausted = remaining == 0 )
@@ -255,7 +272,7 @@ async def reserve(self, key: MeterKey, jti: str,
255272
256273 async def commit (self , res : Reservation , actual : int ) -> int :
257274 """Commit the actual cost (clamped to the reserved amount — reservations
258- are never revised upward) and return the pool 's remaining balance."""
275+ are never revised upward) and return the token 's remaining balance."""
259276 async with self ._lock :
260277 now = time .monotonic ()
261278 pool = self ._purge (res .key , now )
@@ -267,20 +284,22 @@ async def commit(self, res: Reservation, actual: int) -> int:
267284 pool .last_activity = now
268285 if cost > 0 :
269286 self ._record_usage (res .key , pool , res .jti , cost )
270- return self ._remaining (pool )
287+ return self ._remaining (pool , res . jti )
271288
272289 async def release (self , res : Reservation ) -> int :
273290 async with self ._lock :
274291 pool = self ._purge (res .key , time .monotonic ())
275292 if pool is None :
276293 return 0
277294 pool .reservations .pop (res .rid , None )
278- return self ._remaining (pool )
295+ return self ._remaining (pool , res . jti )
279296
280- async def remaining (self , key : MeterKey ) -> int :
297+ async def remaining (self , key : MeterKey , jti : str ) -> int :
298+ """The presented token's remaining balance (0 for an unknown or
299+ expired ``jti``)."""
281300 async with self ._lock :
282301 pool = self ._purge (key , time .monotonic ())
283- return 0 if pool is None else self ._remaining (pool )
302+ return 0 if pool is None else self ._remaining (pool , jti )
284303
285304 def _record_usage (self , key : MeterKey , pool : _Pool , jti : str ,
286305 amount : int ) -> None :
@@ -322,11 +341,25 @@ def metering_unit(self) -> tuple[str, int] | None:
322341 or ``None`` before the first commit."""
323342 return self ._metering_unit
324343
344+ async def consumed_record (self , key : MeterKey , jti : str ) -> dict [str , Any ] | None :
345+ """The consumption record for the resource token's ``budget_consumed``
346+ claim (draft §The Consumption Record): ``{"jti", "consumed"}`` for the
347+ PRESENTED token — its total metered so far — or ``None`` when nothing
348+ was metered against it. One record, the presented token's; the spend
349+ under a person's other tokens is the usage endpoint's to report."""
350+ async with self ._lock :
351+ pool = self ._purge (key , time .monotonic ())
352+ if pool is None :
353+ return None
354+ total = pool .consumed .get (jti , 0 )
355+ return {"jti" : jti , "consumed" : total } if total > 0 else None
356+
325357 async def consumed_records (self , key : MeterKey ,
326358 jkt : str | None = None ) -> list [dict [str , Any ]]:
327- """Per-token consumption for the resource token's ``budget_consumed``
328- claim: ``[{"jti": ..., "consumed": ...}, ...]``. Non-destructive — the
329- PS deduplicates by ``jti``, so reporting the same record twice is safe.
359+ """Audit view: per-token consumption under this ledger key,
360+ ``[{"jti": ..., "consumed": ...}, ...]``. Not what goes on the wire —
361+ the resource token carries :meth:`consumed_record` — but the figures a
362+ PS-side reconciliation or an operator wants to see.
330363
331364 When ``jkt`` is given, records are scoped to tokens bound to that key:
332365 the agent carrying the resource token sees only its OWN spending, never
0 commit comments