diff --git a/.cspell/custom-words.txt b/.cspell/custom-words.txt index ce73c361..81c8bf6b 100644 --- a/.cspell/custom-words.txt +++ b/.cspell/custom-words.txt @@ -3,6 +3,7 @@ absl achatassistant ACMRTUXB Adyen +aeoess agentic Agentic agenticpayments @@ -26,6 +27,7 @@ Connor contentnegotiation credman Crossmint +crosswalked cryptographical CYGPATTERN Dafiti @@ -60,6 +62,7 @@ generativeai genproto glog gofmt +goodmeta gopkg gradletasknamecache gradlew @@ -148,6 +151,7 @@ ROOTDIRSRAW ropeproject RPCURL Rulebook +runcycles screenreaders setlocal sharedpref diff --git a/code/samples/python/scenarios/cross-merchant-budget/README.md b/code/samples/python/scenarios/cross-merchant-budget/README.md new file mode 100644 index 00000000..89807d08 --- /dev/null +++ b/code/samples/python/scenarios/cross-merchant-budget/README.md @@ -0,0 +1,98 @@ +# Cross-Merchant Budget Enforcement + +Demonstrates the cross-merchant budget enforcement gap described in +[#207](https://github.com/google-agentic-commerce/AP2/issues/207) and a +solution using an external budget authority. + +## The Problem + +The AP2 `BudgetEvaluator` checks cumulative spend against a budget limit, but +each merchant only sees its own transaction history. When an agent shops at +multiple merchants under the same mandate, each merchant evaluates the budget +independently: + +```text +Agent mandate: $100 budget + +Merchant A: BudgetEvaluator(total_amount=0, new_spend=60) → pass +Merchant B: BudgetEvaluator(total_amount=0, new_spend=60) → pass + +Total spent: $120. Budget: $100. Overspent. +``` + +Neither merchant knows about the other's transaction. The `MandateContext` +that feeds `total_amount` is local to each merchant. + +## The Fix + +An external budget authority that both merchants call before accepting payment. +The authority maintains a single ledger and exposes a verb interface. + +### Canonical verb set + +The interface converges with the [Cycles](https://runcycles.io) protocol +(`reserve / commit / release`) and the broader budget-authority layer being +crosswalked in [`aeoess/agent-governance-vocabulary`][vocab]. Canonical six +verbs: + +| Verb | Purpose | In sample | +| --------------------------------------------------- | ------------------------------------ | --------- | +| `reserve(mandate_id, amount, idempotency_key)` | Atomically check budget + hold | yes | +| `commit(reservation_id)` | Confirm after successful payment | yes | +| `release(reservation_id)` | Return unspent reservation (pre-commit) | yes | +| `refund(reservation_id, amount)` | Reverse a committed amount (post-commit) | no | +| `query_budget(mandate_id)` | Snapshot of budget state | yes | +| `query_reservation(reservation_id)` | Per-reservation lookup | no | + +`release` and `refund` are deliberately separate: `release` returns an +unspent reservation before commit; `refund` reverses an already-committed +amount. Audit trails need both states. + +### Decision shape + +`reserve` returns a canonical three-value decision: + +| Decision | Meaning | Exercised here | +| ---------------- | ---------------------------------------------------- | -------------- | +| `ALLOW` | Full requested amount approved. | yes | +| `ALLOW_WITH_CAPS`| Partial budget remains; approved for less than requested. | no — see note below | +| `DENY` | No budget remains, or other error. | yes | + +`ALLOW_WITH_CAPS` is canonical for divisible / metered budgets (API credits, +streaming, stablecoin micropayments) where partial fulfillment is meaningful. +This retail sample does not exercise it — a $60 indivisible product cannot be +partially fulfilled at $40. See [Cycles](https://runcycles.io) for an +implementation that emits `ALLOW_WITH_CAPS`. Callers that do not handle +partial fulfillment MUST treat `ALLOW_WITH_CAPS` as `DENY` (safe default). + +```text +Agent mandate: $100 budget + +Merchant A: authority.reserve(mandate, 60) → ALLOW (remaining: 40) +Merchant B: authority.reserve(mandate, 60) → DENY (40 < 60) +Merchant B: authority.reserve(mandate, 35) → ALLOW (retry, remaining: 5) + +Total spent: $95. Budget enforced. +``` + +The `reserve` call is atomic: it decrements the budget and returns a +reservation in one operation. There is no separate "check remaining" call +that could race. + +## Running + +```bash +python cross_merchant_budget.py +``` + +No external dependencies required. The budget authority is mocked in-process. + +## Related + +- AP2 [#207](https://github.com/google-agentic-commerce/AP2/issues/207) — gap discussion +- ACP [#231](https://github.com/agentic-commerce-protocol/agentic-commerce-protocol/issues/231) — three-layer model (passport / budget authority / rail) +- Cycles ([runcycles.io](https://runcycles.io)) — independent `reserve/commit/release` implementation +- Budget Authority Protocol spec — [goodmeta/agent-payments-landscape][bap] + +[vocab]: https://github.com/aeoess/agent-governance-vocabulary +[bap]: https://github.com/goodmeta/agent-payments-landscape/blob/main/specs/budget-authority-protocol.md diff --git a/code/samples/python/scenarios/cross-merchant-budget/SPEC.md b/code/samples/python/scenarios/cross-merchant-budget/SPEC.md new file mode 100644 index 00000000..38160376 --- /dev/null +++ b/code/samples/python/scenarios/cross-merchant-budget/SPEC.md @@ -0,0 +1,335 @@ +# Aggregate Constraint: Cross-Merchant Budget Reservation + +This document defines the protocol pattern that the +[`cross_merchant_budget.py`](./cross_merchant_budget.py) sample +implements. It is normative for any AP2 deployment that wishes to +enforce a budget across multiple Checkouts under a single open +Mandate. + +Status: **Draft**. Reference implementation: this sample. +Vocabulary alignment: [`aeoess/agent-governance-vocabulary` +crosswalk/budget_reservation.yaml][vocab]. + +[vocab]: https://github.com/aeoess/agent-governance-vocabulary/blob/main/crosswalk/budget_reservation.yaml + +--- + +## 1. Scope + +AP2 defines per-Checkout authorization. An open Checkout Mandate +authorizes the Shopping Agent to assemble closed Mandates under a +set of Constraints (§166 Modes). When the same open Mandate +authorizes purchases at more than one Merchant, each Merchant +evaluates Constraints against its own transaction history only. +Cumulative-spend Constraints (`BudgetEvaluator` and equivalents) +therefore admit a budget-overflow class of error: each Merchant +returns ALLOW independently while the sum of approved Checkouts +exceeds the Mandate's stated cap. + +This document specifies an **Aggregate Constraint** evaluation +mode that closes the gap by routing the cumulative-spend check +through an external Budget Authority that holds a single ledger +across all Merchants bound by the same Mandate. + +This document does **not**: + +- modify any AP2 wire format, JWT claim, or Mandate signature + scheme +- redefine the roles, Modes, or Verification rules of AP2 §30, §166, + §292 +- mandate a specific Budget Authority implementation, deployment + topology, or settlement currency +- specify cross-Mandate aggregation; each Aggregate Constraint + scope is a single `mandate_id` + +It extends AP2's Mandate Constraints extension point (§371) with +a new Constraint `type` whose evaluation is non-local. + +--- + +## 2. Definitions + +**Aggregate Constraint** — a Mandate Constraint whose evaluation +requires state from more than one Checkout. Distinguished from a +Local Constraint, which evaluates against the closed Mandate alone. + +**Budget Authority** — a service that holds the canonical ledger +for one or more Mandates' aggregate state. Reachable by every +Verifying Party (§292) bound to those Mandates. Identified to AP2 +Verifiers by a `budget_authority` field inside the Aggregate +Constraint's evaluation parameters. + +**Reservation** — an atomic claim of budget capacity recorded by +the Budget Authority before a Merchant returns a Checkout Receipt. +Has a `reservation_id`, a `mandate_id`, an `amount`, and a status +in `{HELD, COMMITTED, RELEASED}`. Reservations in `HELD` reduce +remaining budget but do not contribute to `spent`. + +**Commitment** — the transition of a Reservation from `HELD` to +`COMMITTED`. Executed by the Verifying Party after the payment has +been confirmed by the Credential Provider (§319) or the Merchant +Payment Processor (§335). + +**Release** — the transition of a Reservation from `HELD` to +`RELEASED`. Executed by the Verifying Party if the payment fails +or the Shopping Agent withdraws the Checkout before commit. +Distinct from Refund. + +**Refund** — a reversal of a previously `COMMITTED` Reservation +that restores some or all of its amount to remaining budget. Out +of scope for this sample; defined by `crosswalk/budget_reservation.yaml` +for completeness. + +**Idempotency key** — a unique value chosen by the Verifying Party +when calling `reserve`. The Budget Authority MUST return the same +`ReserveResult` for any subsequent call carrying the same +`idempotency_key` within the lifetime of the underlying +Reservation, regardless of `amount` re-presentation. + +--- + +## 3. Protocol-Level Requirements + +### 3.1 The Budget Authority MUST + +1. Maintain a single ledger keyed by `mandate_id`. State per + Mandate consists of `budget`, `spent`, and `held` totals plus + a Reservation table. +2. Make `reserve` atomic with respect to budget evaluation: the + check that `amount ≤ remaining` and the insertion of the + Reservation into the ledger happen in one logical step. No + pair of concurrent `reserve` calls under the same `mandate_id` + MAY both succeed if their combined `amount` exceeds remaining. +3. Persist Reservations across restarts. A Reservation in `HELD` + that is lost would re-enable double-spend. +4. Deduplicate `reserve` calls by `idempotency_key`. A repeated + `idempotency_key` MUST return the same Reservation, not a new + one. This is required because Verifying Parties retry on + timeouts and partial network errors. +5. Reject `commit` and `release` calls against any Reservation + not in `HELD` status. State transitions are strictly + `HELD → COMMITTED` or `HELD → RELEASED`; no other transitions + are valid in this protocol. +6. Expose `query_budget(mandate_id)` returning current `budget`, + `spent`, `held`, and `remaining = budget − spent − held`. + `remaining` is the value an evaluator SHOULD treat as the + cap on the next `reserve`. + +### 3.2 Verifying Parties MUST + +A Verifying Party in the sense of AP2 §292 (Merchant, Credential +Provider, Network, Merchant Payment Processor) that processes a +Mandate carrying an Aggregate Constraint of `type = +"budget_reservation_v1"` MUST: + +1. Identify the Budget Authority from the Constraint's + `budget_authority` parameter and verify that the URI resolves + to a service whose key material is known to the deployment. + This document does not specify discovery; consult the + deployment's trust policy. +2. Call `reserve(mandate_id, amount_cents, idempotency_key)` + before completing its own Verification (§302, §319, §335). +3. Return a Checkout Receipt or Payment Receipt with the + appropriate error if `reserve` returns `DENY`. +4. Call `commit(reservation_id)` after the underlying payment has + been confirmed by the Credential Provider or the Merchant + Payment Processor. +5. Call `release(reservation_id)` if the underlying payment fails + or the Shopping Agent withdraws the Checkout before commit. +6. Treat a `reserve` response of `ALLOW_WITH_CAPS` as `DENY` + unless the Verifying Party's deployment is explicitly designed + to handle partial fulfillment (see §4.3). +7. Use a fresh `idempotency_key` per logical Checkout, not per + retry. Retries against the same logical Checkout MUST present + the same `idempotency_key`. + +### 3.3 Shopping Agents MUST + +When assembling a Checkout under an open Mandate carrying an +Aggregate Constraint of `type = "budget_reservation_v1"`: + +1. Treat any Verifying Party error referencing the Aggregate + Constraint as terminal for the current Checkout. The Shopping + Agent MUST NOT retry the Checkout with a different + `idempotency_key` to obtain a fresh Reservation attempt. +2. Be prepared to receive `DENY` even if a recent `query_budget` + indicated sufficient `remaining`, because another Verifying + Party may have committed in the interval. +3. Make no assumption that `release` is automatic. Withdrawn + Checkouts SHOULD be communicated to the originating Verifying + Party so that party can issue a `release` against its + Reservation. + +--- + +## 4. Verb Interface + +Canonical six verbs. The signatures below are non-binding — +this document specifies behavior, not transport. The sample +implements four; `refund` and `query_reservation` are normative +in `crosswalk/budget_reservation.yaml` but out of scope here. + +### 4.1 `reserve(mandate_id, amount_cents, idempotency_key) → ReserveResult` + +Atomically check budget and place a Reservation. See §3.1 for +authority requirements. Returns a `ReserveResult` whose +`decision` is one of `ALLOW`, `ALLOW_WITH_CAPS`, or `DENY` (§5). + +### 4.2 `commit(reservation_id) → bool` + +Transition a `HELD` Reservation to `COMMITTED`. Idempotent: +re-calling `commit` on an already-`COMMITTED` Reservation MUST +return `True` without side effect. + +### 4.3 `release(reservation_id) → bool` + +Transition a `HELD` Reservation to `RELEASED`. Returns budget +capacity to `remaining`. Idempotent under the same rule as +`commit`. `release` is pre-commit only; reversal of a committed +amount requires `refund` (out of scope). + +### 4.4 `query_budget(mandate_id) → BudgetState` + +Return current `budget`, `spent`, `held`, `remaining`. Strictly +informational. A Shopping Agent SHOULD NOT use `query_budget` to +gate its own decisions, because the value is racy with respect +to other Verifying Parties' `reserve` calls. + +--- + +## 5. Decision Shape + +The `decision` field returned by `reserve` is one of three +canonical values, aligned with +`crosswalk/budget_reservation.yaml`: + +| Decision | Meaning | +|---|---| +| `ALLOW` | Full requested amount approved. The `allowed_amount` field equals the `requested_amount`. | +| `ALLOW_WITH_CAPS` | Partial budget remains. The `allowed_amount` is strictly less than `requested_amount` and strictly greater than zero. | +| `DENY` | No budget remains, or another error blocks the reservation. `reason` carries the explanation. | + +`ALLOW_WITH_CAPS` is meaningful for divisible budgets (API +credits, streaming, stablecoin micropayments). A retail Checkout +that cannot be partially fulfilled MUST treat `ALLOW_WITH_CAPS` +as `DENY`. The safe default is `DENY` — Verifying Parties that +have not implemented partial fulfillment MUST take the safe +default (§3.2.6). + +The reference implementation in this sample emits `ALLOW` or +`DENY` only, because a retail Checkout at fixed price is +indivisible. See [Cycles][cycles] for a Budget Authority that +emits `ALLOW_WITH_CAPS` against metered API credits. + +[cycles]: https://runcycles.io + +--- + +## 6. Composition with AP2 Mandate Constraints + +AP2 specification.md §371 defines Mandate Constraints as the +extension point for new authorization rules. A new Constraint +type is registered by specifying: + +> - A uniquely defined `type`. +> - A Schema, including which fields are selectively disclosable. +> - The evaluation algorithm. + +This document registers `type = "budget_reservation_v1"` with the +following schema and evaluation: + +**Schema** (informative; format-binding deferred to deployment): + +``` +{ + "type": "budget_reservation_v1", + "budget_authority": "", + "mandate_id": "", + "max_amount": , + "currency": "" +} +``` + +`budget_authority` and `mandate_id` are NOT selectively +disclosable. Both are required for any Verifying Party to perform +the cumulative check. `max_amount` and `currency` MAY be +selectively disclosable per deployment policy. + +**Evaluation algorithm:** the Verifying Party MUST call +`reserve(mandate_id, amount_cents, idempotency_key)` on the +identified Budget Authority as part of its Verification (§3.2). +The Constraint evaluates to `pass` if `reserve` returns `ALLOW`, +or if `reserve` returns `ALLOW_WITH_CAPS` and the Verifying Party +is a deployment that handles partial fulfillment per §3.2.6. +The Constraint evaluates to `fail` otherwise, including the case +where the Authority is unreachable. + +This is a non-local Constraint: it cannot be evaluated by the +closed Mandate alone. Verifying Parties that do not have network +access to the named Budget Authority MUST treat the Constraint as +`fail` (safe default). + +--- + +## 7. Reference Implementations + +| Implementation | Verbs | Decision values | +|---|---|---| +| This sample (`cross_merchant_budget.py`) | reserve, commit, release, query_budget | ALLOW, DENY | +| [Cycles][cycles] | reserve, commit, release, refund, query_budget, query_reservation | ALLOW, ALLOW_WITH_CAPS, DENY | +| [aeoess/agent-governance-vocabulary][vocab] crosswalk/budget_reservation.yaml | Canonical six-verb interface, three-value Decision | — | + +--- + +## 8. Open Questions + +The following are deliberately unresolved in this draft. Each +admits multiple defensible answers and the right answer is likely +deployment-specific. + +1. **Discovery and trust.** This document does not specify how a + Verifying Party learns the public key of a `budget_authority` + URI. Deployments using Trusted Surfaces (§166) may distribute + keys via the same surface; deployments using DID-based identity + may resolve the URI as a `did:web` document. The mechanism is + out of scope. +2. **Dispute integration.** Where does a Reservation surface in + §262 Dispute Evidence? The `reservation_id`, the Authority's + signed log of state transitions, and the `commit` timestamp + are candidates. This document does not commit to a shape. +3. **Cross-rail composition.** A Mandate may carry both an AP2 + Aggregate Constraint and an x402/MPP equivalent against the + same conceptual budget. Whether the Authority is shared or two + Authorities reconcile out of band is a deployment choice. +4. **Multi-asset budgets.** This document defines `max_amount` and + `currency` per Constraint, implying one Constraint per asset. + A composite-budget shape (e.g., `total_usd_equivalent` across + stablecoin and fiat) requires additional rules and is not + specified here. +5. **Reservation expiry.** The sample's `HELD` state has no + timeout. Production Authorities will need expiry to recover + capacity abandoned by failed Verifying Parties. The expiry + policy (fixed, exponential, Authority-configurable) is out of + scope. +6. **Signed reservation attestations.** Whether a Reservation + itself should be a signed artifact (so that a Verifying Party + can present it as evidence) is unresolved. The reference + implementation returns plain `ReserveResult`; production may + want JWT or SD-JWT shape. + +--- + +## 9. Relationship to existing AP2 issues and external work + +- [AP2 #207][207] — original gap discussion. This document and + sample address the gap directly. +- [AP2 #252][252] — the PR carrying this sample and document. +- [ACP #231][acp231] — three-layer model (passport / budget + reservation / payment rail) accepted by `aeoess` and folded + into `crosswalk/budget_reservation.yaml`. +- [Cycles][cycles] — independent production implementation of + `reserve / commit / release` with `ALLOW_WITH_CAPS` exercised. + +[207]: https://github.com/google-agentic-commerce/AP2/issues/207 +[252]: https://github.com/google-agentic-commerce/AP2/pull/252 +[acp231]: https://github.com/agentic-commerce-protocol/agentic-commerce-protocol/issues/231 diff --git a/code/samples/python/scenarios/cross-merchant-budget/cross_merchant_budget.py b/code/samples/python/scenarios/cross-merchant-budget/cross_merchant_budget.py new file mode 100644 index 00000000..169ac09b --- /dev/null +++ b/code/samples/python/scenarios/cross-merchant-budget/cross_merchant_budget.py @@ -0,0 +1,422 @@ +"""Cross-merchant budget enforcement sample for AP2. + +Demonstrates the budget enforcement gap described in +https://github.com/google-agentic-commerce/AP2/issues/207 + +Part 1: Shows how independent merchant evaluation leads to +budget overflow. +Part 2: Shows how an external budget authority prevents it. + +No external dependencies. The budget authority is mocked +in-process. +""" + +from __future__ import annotations + +import uuid + +from dataclasses import dataclass +from enum import Enum + + +@dataclass +class Budget: + """AP2 budget constraint (simplified from SDK).""" + + max_dollars: float + currency: str = 'USD' + + +@dataclass +class MandateContext: + """Transaction history that feeds BudgetEvaluator. + + In production, populated from the merchant's own history. + The cross-merchant gap: each merchant only has ITS + history. + """ + + total_amount: int = 0 + + +@dataclass +class PaymentAmount: + """Amount for a single transaction in cents.""" + + amount: int + currency: str = 'USD' + + +def evaluate_budget( + budget: Budget, + new_amount: PaymentAmount, + context: MandateContext, +) -> list[str]: + """Evaluate whether a transaction fits within budget. + + Returns an empty list if approved, or a list of reasons. + Matches the AP2 SDK BudgetEvaluator pattern. + """ + if new_amount.currency != budget.currency: + return [ + f'Currency mismatch: expected {budget.currency},' + f' got {new_amount.currency}' + ] + + budget_max_cents = round(budget.max_dollars * 100) + total = context.total_amount + new_amount.amount + + if total > budget_max_cents: + return [ + f'Cumulative spend {total} exceeds ' + f'budget limit {budget_max_cents} ' + f'(past spend: {context.total_amount})' + ] + return [] + + +# ========================================================= +# Part 1: The problem — independent merchant evaluation +# ========================================================= + + +def demo_overspend() -> None: + """Show cross-merchant budget overflow.""" + print('=' * 60) + print('PART 1: Cross-Merchant Budget Overflow') + print('=' * 60) + print() + + budget = Budget(max_dollars=100.00) + + # Each merchant maintains its own context. + # Neither knows about the other's transactions. + ctx_a = MandateContext(total_amount=0) + ctx_b = MandateContext(total_amount=0) + + # Merchant A: agent buys $60 item + amt_a = PaymentAmount(amount=6000) + errors_a = evaluate_budget(budget, amt_a, ctx_a) + label_a = errors_a if errors_a else 'APPROVED' + print(f'Merchant A: ${amt_a.amount / 100:.2f} purchase') + print(f' Context: total_amount={ctx_a.total_amount}') + print(f' Result: {label_a}') + if not errors_a: + ctx_a.total_amount += amt_a.amount + print() + + # Merchant B: agent buys $60 item + amt_b = PaymentAmount(amount=6000) + errors_b = evaluate_budget(budget, amt_b, ctx_b) + label_b = errors_b if errors_b else 'APPROVED' + print(f'Merchant B: ${amt_b.amount / 100:.2f} purchase') + print(f' Context: total_amount={ctx_b.total_amount}') + print(f' Result: {label_b}') + if not errors_b: + ctx_b.total_amount += amt_b.amount + print() + + total = ctx_a.total_amount + ctx_b.total_amount + overspend = total - round(budget.max_dollars * 100) + print(f'Total spent: ${total / 100:.2f}') + print(f'Budget: ${budget.max_dollars:.2f}') + print(f'Overspent: ${overspend / 100:.2f}') + print() + print('Problem: each merchant evaluated independently.') + print("Neither knew about the other's transaction.") + + +# ========================================================= +# Part 2: The fix — external budget authority +# ========================================================= + + +class Decision(Enum): + """Canonical three-way decision from a budget authority. + + ALLOW — full requested amount approved. + ALLOW_WITH_CAPS — partial budget remains; approved for + less than requested. Canonical for + divisible / metered budgets (API + credits, streaming, stablecoin + cents). Not exercised in this retail + sample. Callers MUST treat as DENY + if they cannot accept partial + fulfillment. + DENY — no budget remains, or other error. + """ + + ALLOW = 'allow' + ALLOW_WITH_CAPS = 'allow_with_caps' + DENY = 'deny' + + +class ReservationStatus(Enum): + """Status of a budget reservation.""" + + HELD = 'held' + COMMITTED = 'committed' + RELEASED = 'released' + + +@dataclass +class Reservation: + """A budget reservation placed by the authority.""" + + reservation_id: str + mandate_id: str + amount: int + status: ReservationStatus = ReservationStatus.HELD + + +@dataclass +class ReserveResult: + """Result of a reserve call.""" + + decision: Decision + reservation_id: str | None = None + reason: str | None = None + remaining: int | None = None + allowed_amount: int | None = None + requested_amount: int | None = None + + +@dataclass +class BudgetState: + """Result of a query_budget call.""" + + mandate_id: str + budget: int + spent: int + held: int + remaining: int + + +class BudgetAuthority: + """External budget authority. + + Maintains a single ledger across all merchants. The + reserve call is atomic: checks the budget and places a + reservation in one operation. + + Canonical six-verb interface (see + goodmeta/agent-payments-landscape Budget Authority + Protocol). This sample demonstrates four: + + reserve — atomically check + hold + commit — confirm after successful payment + release — return unspent reservation + before commit + query_budget — snapshot of budget state + + Two additional verbs are part of the canonical interface + but out of scope for this minimal sample: + + refund(reservation_id, amount) + — reverse an already-committed amount + (post-commit, distinct from release) + query_reservation(reservation_id) + — per-reservation state lookup + """ + + def __init__(self) -> None: + """Initialize empty ledger.""" + self._budgets: dict[str, int] = {} + self._spent: dict[str, int] = {} + self._reservations: dict[str, Reservation] = {} + self._held_by_mandate: dict[str, int] = {} + self._keys: dict[str, str] = {} + + def register_mandate( + self, + mandate_id: str, + budget_cents: int, + ) -> None: + """Register a mandate with a budget limit.""" + self._budgets[mandate_id] = budget_cents + self._spent.setdefault(mandate_id, 0) + self._held_by_mandate.setdefault(mandate_id, 0) + + def reserve( + self, + mandate_id: str, + amount_cents: int, + idempotency_key: str, + ) -> ReserveResult: + """Atomically check budget and place reservation. + + This retail sample emits ALLOW or DENY only. The + canonical interface also defines ALLOW_WITH_CAPS for + divisible / metered budgets (see Cycles for an + implementation that emits it). + """ + if idempotency_key in self._keys: + rid = self._keys[idempotency_key] + res = self._reservations[rid] + return ReserveResult( + decision=Decision.ALLOW, + reservation_id=rid, + remaining=self._remaining(mandate_id), + allowed_amount=res.amount, + requested_amount=res.amount, + ) + + budget_max = self._budgets.get(mandate_id) + if budget_max is None: + return ReserveResult( + decision=Decision.DENY, + reason='Unknown mandate', + requested_amount=amount_cents, + ) + + remaining = self._remaining(mandate_id) + if amount_cents > remaining: + return ReserveResult( + decision=Decision.DENY, + reason=( + f'Budget exceeded: {amount_cents} > ' + f'{remaining} remaining' + ), + remaining=remaining, + requested_amount=amount_cents, + ) + + reservation_id = f'res_{uuid.uuid4().hex}' + self._reservations[reservation_id] = Reservation( + reservation_id=reservation_id, + mandate_id=mandate_id, + amount=amount_cents, + ) + self._held_by_mandate[mandate_id] = ( + self._held_by_mandate.get(mandate_id, 0) + + amount_cents + ) + self._keys[idempotency_key] = reservation_id + + return ReserveResult( + decision=Decision.ALLOW, + reservation_id=reservation_id, + remaining=remaining - amount_cents, + allowed_amount=amount_cents, + requested_amount=amount_cents, + ) + + def commit(self, reservation_id: str) -> bool: + """Confirm a reservation after successful payment.""" + res = self._reservations.get(reservation_id) + if not res or res.status != ReservationStatus.HELD: + return False + res.status = ReservationStatus.COMMITTED + self._spent[res.mandate_id] += res.amount + self._held_by_mandate[res.mandate_id] -= res.amount + return True + + def release(self, reservation_id: str) -> bool: + """Return an unspent reservation before commit. + + Pre-commit only. Use refund (not implemented here) + for post-commit reversal. + """ + res = self._reservations.get(reservation_id) + if not res or res.status != ReservationStatus.HELD: + return False + res.status = ReservationStatus.RELEASED + self._held_by_mandate[res.mandate_id] -= res.amount + return True + + def query_budget(self, mandate_id: str) -> BudgetState: + """Snapshot of budget state across all reservations.""" + budget_max = self._budgets.get(mandate_id, 0) + spent = self._spent.get(mandate_id, 0) + held = self._held_by_mandate.get(mandate_id, 0) + return BudgetState( + mandate_id=mandate_id, + budget=budget_max, + spent=spent, + held=held, + remaining=budget_max - spent - held, + ) + + def _remaining(self, mandate_id: str) -> int: + budget = self._budgets.get(mandate_id, 0) + spent = self._spent.get(mandate_id, 0) + held = self._held_by_mandate.get(mandate_id, 0) + return budget - spent - held + + +def _format_decision(result: ReserveResult) -> str: + if result.decision == Decision.ALLOW: + return 'ALLOW' + if result.decision == Decision.ALLOW_WITH_CAPS: + return 'ALLOW_WITH_CAPS' + return 'DENY' + + +def demo_budget_authority() -> None: + """Show budget authority preventing overspend.""" + print() + print('=' * 60) + print('PART 2: External Budget Authority') + print('=' * 60) + print() + + mandate_id = 'mandate_agent_001' + authority = BudgetAuthority() + authority.register_mandate(mandate_id, 10000) + + # Merchant A: reserve $60 → ALLOW + result_a = authority.reserve( + mandate_id, 6000, uuid.uuid4().hex, + ) + print('Merchant A: reserve($60.00)') + print(f' Decision: {_format_decision(result_a)}') + print(f' Reservation: {result_a.reservation_id}') + remaining_a = (result_a.remaining or 0) / 100 + print(f' Remaining: ${remaining_a:.2f}') + if ( + result_a.decision == Decision.ALLOW + and result_a.reservation_id + ): + authority.commit(result_a.reservation_id) + print(' Payment succeeded -> committed') + print() + + # Merchant B: reserve $60 → DENY (only $40 left, indivisible item) + result_b = authority.reserve( + mandate_id, 6000, uuid.uuid4().hex, + ) + print('Merchant B: reserve($60.00)') + print(f' Decision: {_format_decision(result_b)}') + if result_b.reason: + print(f' Reason: {result_b.reason}') + print() + + # Merchant B: retry $35 → ALLOW + result_c = authority.reserve( + mandate_id, 3500, uuid.uuid4().hex, + ) + print('Merchant B: reserve($35.00) — retry') + print(f' Decision: {_format_decision(result_c)}') + if ( + result_c.decision == Decision.ALLOW + and result_c.reservation_id + ): + remaining_c = (result_c.remaining or 0) / 100 + print(f' Reservation: {result_c.reservation_id}') + print(f' Remaining: ${remaining_c:.2f}') + authority.commit(result_c.reservation_id) + print(' Payment succeeded -> committed') + print() + + state = authority.query_budget(mandate_id) + print('Final state:') + print(f' Budget: ${state.budget / 100:.2f}') + print(f' Spent: ${state.spent / 100:.2f}') + print(f' Remaining: ${state.remaining / 100:.2f}') + print() + print('Budget enforced across both merchants.') + + +if __name__ == '__main__': + demo_overspend() + demo_budget_authority()