Skip to content

Commit 1c4d2a0

Browse files
committed
budgets 0.5.0: cap per token (no pooling), one consumption record (#120)
Per draft-hardt-aauth-budgets editor's copy (Sept 2026): - InMemoryMeter caps each auth token at its own budget (§Aggregation); the (iss, sub, aud) key remains the per-person ledger for records and usage counters, never a second ceiling. 0.4 pooled a person's live grants, letting a jti spend past its own grant with the overflow unattributed to the sibling it came from. - Refusals carry ONE budget_consumed record — the presented token's {jti, consumed} (§The Consumption Record). New consumed_record(key, jti). - Breaking: remaining(key) → remaining(key, jti); resource_token_provider receives a single record (dict | None). - Vectors regenerated; 80 tests.
1 parent f646ae6 commit 1c4d2a0

9 files changed

Lines changed: 150 additions & 77 deletions

File tree

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,29 @@
11
# Changelog
22

3+
## 0.5.0
4+
5+
**AAuth Budgets — per-token cap, one consumption record** (draft-hardt-aauth-budgets
6+
editor's copy, September 2026; issue #120):
7+
8+
- **No more pooling.** `InMemoryMeter` caps each auth token at its own `budget`
9+
(§Aggregation): committed consumption plus outstanding reservations against
10+
the presented `jti` never exceed *its* grant, and a token never draws on a
11+
sibling's allocation. The `(iss, sub, aud)` key survives as the per-person
12+
**ledger** for records and usage counters — it is not a second ceiling.
13+
0.4 pooled a person's live grants into one purse, which let a jti spend past
14+
its own grant; the overflow, spent from a sibling's allocation, was never
15+
attributed and the sibling's remainder later released as unspent. The
16+
`required` member makes a fragmented agent's re-authorization a calculation,
17+
so the purse bought nothing worth that.
18+
- **One record on the wire.** Budget refusals carry a single
19+
`budget_consumed` object — `{"jti", "consumed"}` for the PRESENTED token —
20+
per §The Consumption Record. New `InMemoryMeter.consumed_record(key, jti)`;
21+
`consumed_records(key, jkt=…)` stays as the audit view.
22+
- **Breaking:** `InMemoryMeter.remaining(key)``remaining(key, jti)`;
23+
`resource_token_provider(key, records: list)``(key, record: dict | None)`.
24+
`InsufficientBudget.remaining` is now the presented token's balance.
25+
- Test vectors regenerated (`consumption_records` is one object).
26+
327
## 0.4.0
428

529
**AAuth Budgets — the August 20 editor's-copy additions** (allocation model,

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,10 @@ to the control plane. The middleware does the whole resource-side checklist:
116116
verify the token against your pinned PS, atomically reserve → commit →
117117
release per request, answer with `AAuth-Budget`, and refuse exhausted
118118
envelopes with a `401` + `AAuth-Requirement` (optionally carrying your signed
119-
resource token with the agent's own consumption records — scoped to its key,
120-
so one agent never learns about a sibling's spending):
119+
resource token with **one** consumption record — the presented token's
120+
`{jti, consumed}` — so one agent never learns about a sibling's spending).
121+
The cap is **per token**: a jti never draws on a sibling's grant; the
122+
`(iss, sub, aud)` key is only the per-person ledger behind records and usage:
121123

122124
```python
123125
from regent_httpsig import HttpsigConfig, HttpsigVerifier, InMemoryMeter

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "regent-httpsig"
7-
version = "0.4.0"
7+
version = "0.5.0"
88
description = "Verify and sign AI agent HTTP traffic in Python — RFC 9421 HTTP Message Signatures: Web Bot Auth (what OpenAI ships) and AAuth."
99
readme = "README.md"
1010
license = "Apache-2.0"

src/regent_httpsig/budget.py

Lines changed: 60 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,23 @@
55
"budget": { "amount": 2000000, "unit": "USD", "decimals": 6 } # = $2.00
66
77
and 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
1426
Everything here is framework-free; the FastAPI glue lives in
1527
:mod:`regent_httpsig.fastapi` (``BudgetMiddleware``).
@@ -42,7 +54,7 @@ class InvalidBudgetClaim(ValueError):
4254

4355

4456
class 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)
8294
class 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)
93105
class 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

src/regent_httpsig/fastapi.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ async def require_signature(request: Request) -> VerifiedSignature:
122122

123123
PriceFn = Callable[[Request], "int | None | Awaitable[int | None]"]
124124
ResourceTokenProvider = Callable[
125-
[MeterKey, "list[dict[str, Any]]"], "str | None | Awaitable[str | None]"
125+
[MeterKey, "dict[str, Any] | None"], "str | None | Awaitable[str | None]"
126126
]
127127

128128

@@ -158,9 +158,12 @@ class BudgetMiddleware(BaseHTTPMiddleware):
158158
``require=True`` refuses them with 401 + ``AAuth-Requirement``.
159159
- Error responses (4xx/5xx) release the reservation — nothing was served,
160160
the envelope is not charged.
161-
- ``resource_token_provider(key, consumed_records)`` (optional) mints the
162-
resource token embedded in budget-refusal responses so the agent can
163-
carry ``budget_consumed`` back to its PS for re-authorization.
161+
- ``resource_token_provider(key, record)`` (optional) mints the resource
162+
token embedded in budget-refusal responses so the agent can carry
163+
``budget_consumed`` back to its PS for re-authorization. ``record`` is
164+
the PRESENTED token's ``{"jti", "consumed"}`` (draft §The Consumption
165+
Record — one record, two members) or ``None`` when nothing was metered
166+
against it yet.
164167
"""
165168

166169
def __init__(
@@ -223,7 +226,7 @@ async def dispatch(
223226
reason = "budget-exhausted" if outcome.exhausted else "insufficient-budget"
224227
return await self._refusal_with_token(
225228
reason=reason, envelope=envelope, remaining=outcome.remaining,
226-
key=key, jkt=sig.keyid,
229+
key=key, jti=jti,
227230
# `required` rides only on insufficient-budget: what THIS
228231
# request needed, so the agent can lower its bound and retry.
229232
required=int(max_cost) if reason == "insufficient-budget" else None,
@@ -247,7 +250,7 @@ async def dispatch(
247250
# when `cost` is omitted — with `remaining` already net of the
248251
# hold, and commit when the stream completes. The agent recovers
249252
# the exact figure from the next response's `remaining`.
250-
remaining = await self._meter.remaining(key)
253+
remaining = await self._meter.remaining(key, jti)
251254
response.headers["AAuth-Budget"] = build_aauth_budget_header(
252255
remaining=remaining, reserved=int(max_cost),
253256
unit=envelope.unit, decimals=envelope.decimals,
@@ -311,14 +314,17 @@ async def _verified(self, request: Request) -> VerifiedSignature | None:
311314

312315
async def _refusal_with_token(
313316
self, *, reason: str, envelope: BudgetClaim,
314-
remaining: int, key: MeterKey, jkt: str | None = None,
317+
remaining: int, key: MeterKey, jti: str,
315318
required: int | None = None,
316319
) -> Response:
317320
token: str | None = None
318321
if self._resource_token is not None:
319322
try:
320-
records = await self._meter.consumed_records(key, jkt=jkt)
321-
token = await _maybe_await(self._resource_token(key, records))
323+
# One record, the presented token's (§The Consumption Record):
324+
# what THIS jti has cost so far. Siblings' spend never rides
325+
# home with this agent — the usage endpoint reports that.
326+
record = await self._meter.consumed_record(key, jti)
327+
token = await _maybe_await(self._resource_token(key, record))
322328
except Exception: # noqa: BLE001 — refusal must not fail on the extras
323329
logger.warning("resource_token_provider failed", exc_info=True)
324330
return self._refusal(reason=reason, envelope=envelope,

0 commit comments

Comments
 (0)