From 13e8b353e8564f168e2ffe8bc378131c0d8ab097 Mon Sep 17 00:00:00 2001 From: Eric Tsang Date: Fri, 1 May 2026 12:39:01 +0800 Subject: [PATCH 1/7] samples: cross-merchant budget enforcement scenario --- .../scenarios/cross-merchant-budget/README.md | 56 +++ .../cross_merchant_budget.py | 351 ++++++++++++++++++ 2 files changed, 407 insertions(+) create mode 100644 code/samples/python/scenarios/cross-merchant-budget/README.md create mode 100644 code/samples/python/scenarios/cross-merchant-budget/cross_merchant_budget.py 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..24b6619e --- /dev/null +++ b/code/samples/python/scenarios/cross-merchant-budget/README.md @@ -0,0 +1,56 @@ +# 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: + +``` +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 four verbs: + +| Verb | Purpose | +|------|---------| +| `authorize(mandate_id, amount, idempotency_key)` | Atomically check + hold | +| `commit(hold_id)` | Confirm after successful payment | +| `refund(hold_id)` | Release if payment fails | +| `query(mandate_id)` | Check remaining budget | + +The `authorize` call is atomic: it decrements the budget and returns a hold in +one operation. There is no separate "check remaining" call that could race. + +``` +Agent mandate: $100 budget + +Merchant A: authority.authorize(mandate, 60) → ✅ hold_1 (remaining: 40) +Merchant B: authority.authorize(mandate, 60) → ❌ rejected (40 < 60) + +Total spent: $60. Budget enforced. +``` + +## Running + +```bash +python cross_merchant_budget.py +``` + +No external dependencies required. The budget authority is mocked in-process. 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..ef70d98a --- /dev/null +++ b/code/samples/python/scenarios/cross-merchant-budget/cross_merchant_budget.py @@ -0,0 +1,351 @@ +"""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 = int(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 - int(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 HoldStatus(Enum): + """Status of a budget hold.""" + + HELD = 'held' + COMMITTED = 'committed' + REFUNDED = 'refunded' + + +@dataclass +class Hold: + """A budget hold placed by the authority.""" + + hold_id: str + mandate_id: str + amount: int + status: HoldStatus = HoldStatus.HELD + + +@dataclass +class AuthorizeResult: + """Result of an authorize call.""" + + approved: bool + hold_id: str | None = None + reason: str | None = None + remaining: int | None = None + + +class BudgetAuthority: + """External budget authority with four verbs. + + Maintains a single ledger across all merchants. + The authorize call is atomic: it checks the budget and + places a hold in one operation. + + Verbs: + authorize — atomically check + hold + commit — confirm after successful payment + refund — release if payment fails + query — check remaining budget + """ + + def __init__(self) -> None: + """Initialize empty ledger.""" + self._budgets: dict[str, int] = {} + self._spent: dict[str, int] = {} + self._holds: dict[str, Hold] = {} + 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) + + def authorize( + self, + mandate_id: str, + amount_cents: int, + idempotency_key: str, + ) -> AuthorizeResult: + """Atomically check budget and place hold.""" + if idempotency_key in self._keys: + hid = self._keys[idempotency_key] + return AuthorizeResult( + approved=True, + hold_id=hid, + remaining=self._remaining(mandate_id), + ) + + budget_max = self._budgets.get(mandate_id) + if budget_max is None: + return AuthorizeResult( + approved=False, + reason='Unknown mandate', + ) + + remaining = self._remaining(mandate_id) + if amount_cents > remaining: + return AuthorizeResult( + approved=False, + reason=( + f'Budget exceeded: {amount_cents} > ' + f'{remaining} remaining' + ), + remaining=remaining, + ) + + hold_id = f'hold_{uuid.uuid4().hex[:16]}' + self._holds[hold_id] = Hold( + hold_id=hold_id, + mandate_id=mandate_id, + amount=amount_cents, + ) + self._keys[idempotency_key] = hold_id + + return AuthorizeResult( + approved=True, + hold_id=hold_id, + remaining=remaining - amount_cents, + ) + + def commit(self, hold_id: str) -> bool: + """Confirm a hold after successful payment.""" + hold = self._holds.get(hold_id) + if not hold or hold.status != HoldStatus.HELD: + return False + hold.status = HoldStatus.COMMITTED + self._spent[hold.mandate_id] += hold.amount + return True + + def refund(self, hold_id: str) -> bool: + """Release a hold when payment fails.""" + hold = self._holds.get(hold_id) + if not hold or hold.status != HoldStatus.HELD: + return False + hold.status = HoldStatus.REFUNDED + return True + + def query( + self, mandate_id: str, + ) -> dict[str, int | str]: + """Query budget state.""" + budget_max = self._budgets.get(mandate_id, 0) + spent = self._spent.get(mandate_id, 0) + held = self._active_holds(mandate_id) + return { + '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._active_holds(mandate_id) + return budget - spent - held + + def _active_holds(self, mandate_id: str) -> int: + return sum( + h.amount + for h in self._holds.values() + if h.mandate_id == mandate_id + and h.status == HoldStatus.HELD + ) + + +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: authorize $60 + result_a = authority.authorize( + mandate_id, 6000, uuid.uuid4().hex, + ) + label = 'APPROVED' if result_a.approved else 'DENIED' + print('Merchant A: authorize($60.00)') + print(f' Result: {label}') + print(f' Hold: {result_a.hold_id}') + remaining_a = (result_a.remaining or 0) / 100 + print(f' Remaining: ${remaining_a:.2f}') + if result_a.approved and result_a.hold_id: + authority.commit(result_a.hold_id) + print(' Payment succeeded -> committed') + print() + + # Merchant B: authorize $60 — should be denied + result_b = authority.authorize( + mandate_id, 6000, uuid.uuid4().hex, + ) + label = 'APPROVED' if result_b.approved else 'DENIED' + print('Merchant B: authorize($60.00)') + print(f' Result: {label}') + if not result_b.approved: + print(f' Reason: {result_b.reason}') + print() + + # Merchant B: retry with smaller amount + result_c = authority.authorize( + mandate_id, 3500, uuid.uuid4().hex, + ) + label = 'APPROVED' if result_c.approved else 'DENIED' + print('Merchant B: authorize($35.00) — retry') + print(f' Result: {label}') + if result_c.approved and result_c.hold_id: + remaining_c = (result_c.remaining or 0) / 100 + print(f' Hold: {result_c.hold_id}') + print(f' Remaining: ${remaining_c:.2f}') + authority.commit(result_c.hold_id) + print(' Payment succeeded -> committed') + print() + + state = authority.query(mandate_id) + print('Final state:') + print(f' Budget: ${int(state["budget"]) / 100:.2f}') + print(f' Spent: ${int(state["spent"]) / 100:.2f}') + remaining = int(state['remaining']) / 100 + print(f' Remaining: ${remaining:.2f}') + print() + print('Budget enforced across both merchants.') + + +if __name__ == '__main__': + demo_overspend() + demo_budget_authority() From 750bbcac9cad2fa06d14cee189f63c6d4707035f Mon Sep 17 00:00:00 2001 From: Ectsang <1057844+Ectsang@users.noreply.github.com> Date: Fri, 1 May 2026 12:57:10 +0800 Subject: [PATCH 2/7] fix: use round() instead of int() for float-to-cents conversion --- .../scenarios/cross-merchant-budget/cross_merchant_budget.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index ef70d98a..44e036b6 100644 --- a/code/samples/python/scenarios/cross-merchant-budget/cross_merchant_budget.py +++ b/code/samples/python/scenarios/cross-merchant-budget/cross_merchant_budget.py @@ -63,7 +63,7 @@ def evaluate_budget( f' got {new_amount.currency}' ] - budget_max_cents = int(budget.max_dollars * 100) + budget_max_cents = round(budget.max_dollars * 100) total = context.total_amount + new_amount.amount if total > budget_max_cents: @@ -117,7 +117,7 @@ def demo_overspend() -> None: print() total = ctx_a.total_amount + ctx_b.total_amount - overspend = total - int(budget.max_dollars * 100) + 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}') From 31141266ef7f308910cf4e4b0c88f9eb24bc571b Mon Sep 17 00:00:00 2001 From: Ectsang <1057844+Ectsang@users.noreply.github.com> Date: Fri, 1 May 2026 13:04:24 +0800 Subject: [PATCH 3/7] Address review: BudgetState dataclass, O(1) hold tracking, full UUID --- .../cross_merchant_budget.py | 58 +++++++++++-------- 1 file changed, 33 insertions(+), 25 deletions(-) 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 index 44e036b6..bf261267 100644 --- a/code/samples/python/scenarios/cross-merchant-budget/cross_merchant_budget.py +++ b/code/samples/python/scenarios/cross-merchant-budget/cross_merchant_budget.py @@ -159,6 +159,17 @@ class AuthorizeResult: remaining: int | None = None +@dataclass +class BudgetState: + """Result of a query call.""" + + mandate_id: str + budget: int + spent: int + held: int + remaining: int + + class BudgetAuthority: """External budget authority with four verbs. @@ -178,6 +189,7 @@ def __init__(self) -> None: self._budgets: dict[str, int] = {} self._spent: dict[str, int] = {} self._holds: dict[str, Hold] = {} + self._held_by_mandate: dict[str, int] = {} self._keys: dict[str, str] = {} def register_mandate( @@ -188,6 +200,7 @@ def register_mandate( """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 authorize( self, @@ -222,12 +235,16 @@ def authorize( remaining=remaining, ) - hold_id = f'hold_{uuid.uuid4().hex[:16]}' + hold_id = f'hold_{uuid.uuid4().hex}' self._holds[hold_id] = Hold( hold_id=hold_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] = hold_id return AuthorizeResult( @@ -243,6 +260,7 @@ def commit(self, hold_id: str) -> bool: return False hold.status = HoldStatus.COMMITTED self._spent[hold.mandate_id] += hold.amount + self._held_by_mandate[hold.mandate_id] -= hold.amount return True def refund(self, hold_id: str) -> bool: @@ -251,37 +269,28 @@ def refund(self, hold_id: str) -> bool: if not hold or hold.status != HoldStatus.HELD: return False hold.status = HoldStatus.REFUNDED + self._held_by_mandate[hold.mandate_id] -= hold.amount return True - def query( - self, mandate_id: str, - ) -> dict[str, int | str]: + def query(self, mandate_id: str) -> BudgetState: """Query budget state.""" budget_max = self._budgets.get(mandate_id, 0) spent = self._spent.get(mandate_id, 0) - held = self._active_holds(mandate_id) - return { - 'mandate_id': mandate_id, - 'budget': budget_max, - 'spent': spent, - 'held': held, - 'remaining': budget_max - spent - held, - } + 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._active_holds(mandate_id) + held = self._held_by_mandate.get(mandate_id, 0) return budget - spent - held - def _active_holds(self, mandate_id: str) -> int: - return sum( - h.amount - for h in self._holds.values() - if h.mandate_id == mandate_id - and h.status == HoldStatus.HELD - ) - def demo_budget_authority() -> None: """Show budget authority preventing overspend.""" @@ -338,10 +347,9 @@ def demo_budget_authority() -> None: state = authority.query(mandate_id) print('Final state:') - print(f' Budget: ${int(state["budget"]) / 100:.2f}') - print(f' Spent: ${int(state["spent"]) / 100:.2f}') - remaining = int(state['remaining']) / 100 - print(f' Remaining: ${remaining:.2f}') + 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.') From b547f68b1d7563d6516d74f344026c1e666bedc1 Mon Sep 17 00:00:00 2001 From: Ectsang <1057844+Ectsang@users.noreply.github.com> Date: Fri, 1 May 2026 13:27:51 +0800 Subject: [PATCH 4/7] fix: README markdown lint (code block language, table spacing) --- .../scenarios/cross-merchant-budget/README.md | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/code/samples/python/scenarios/cross-merchant-budget/README.md b/code/samples/python/scenarios/cross-merchant-budget/README.md index 24b6619e..e9266101 100644 --- a/code/samples/python/scenarios/cross-merchant-budget/README.md +++ b/code/samples/python/scenarios/cross-merchant-budget/README.md @@ -11,11 +11,11 @@ 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 +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. ``` @@ -28,21 +28,21 @@ that feeds `total_amount` is local to each merchant. An external budget authority that both merchants call before accepting payment. The authority maintains a single ledger and exposes four verbs: -| Verb | Purpose | -|------|---------| -| `authorize(mandate_id, amount, idempotency_key)` | Atomically check + hold | -| `commit(hold_id)` | Confirm after successful payment | -| `refund(hold_id)` | Release if payment fails | -| `query(mandate_id)` | Check remaining budget | +| Verb | Purpose | +| -------------------------------------------------- | -------------------------------- | +| `authorize(mandate_id, amount, idempotency_key)` | Atomically check + hold | +| `commit(hold_id)` | Confirm after successful payment | +| `refund(hold_id)` | Release if payment fails | +| `query(mandate_id)` | Check remaining budget | The `authorize` call is atomic: it decrements the budget and returns a hold in one operation. There is no separate "check remaining" call that could race. -``` +```text Agent mandate: $100 budget -Merchant A: authority.authorize(mandate, 60) → ✅ hold_1 (remaining: 40) -Merchant B: authority.authorize(mandate, 60) → ❌ rejected (40 < 60) +Merchant A: authority.authorize(mandate, 60) → hold_1 (remaining: 40) +Merchant B: authority.authorize(mandate, 60) → rejected (40 < 60) Total spent: $60. Budget enforced. ``` From 403fc9b70c50e618aafa7df8f227ece3c49c0eea Mon Sep 17 00:00:00 2001 From: Ectsang <1057844+Ectsang@users.noreply.github.com> Date: Tue, 12 May 2026 07:12:57 +0800 Subject: [PATCH 5/7] feat(samples): rename verbs to canonical reserve/release + three-way decision Aligns the cross-merchant budget enforcement sample with the canonical budget_authority verb set being crosswalked across AP2, Cycles, and APS in aeoess/agent-governance-vocabulary. Verbs: - authorize() -> reserve() - refund() -> release() (pre-commit return of unspent reservation) - query() -> query_budget() (budget snapshot) Decision shape: - approved: bool -> decision: Decision (ALLOW / ALLOW_WITH_CAPS / DENY) - This retail sample emits ALLOW or DENY only. ALLOW_WITH_CAPS is canonical for divisible / metered budgets (Cycles, runcycles.io). Callers that cannot accept partial fulfillment MUST treat ALLOW_WITH_CAPS as DENY (safe default). Types renamed for consistency: Hold -> Reservation, AuthorizeResult -> ReserveResult, HoldStatus -> ReservationStatus (REFUNDED -> RELEASED). Demo behavior preserved: Merchant A reserves \$60 (ALLOW), Merchant B denied at \$60 (budget exceeded), Merchant B retries at \$35 (ALLOW). Final state: \$95 spent of \$100 budget. References: - ACP #231 three-layer model (passport / budget authority / rail) - Cycles (runcycles.io) - independent reserve/commit/release impl - aeoess/agent-governance-vocabulary - crosswalk in progress (AAIF donation trajectory documented) --- .../scenarios/cross-merchant-budget/README.md | 66 +++++- .../cross_merchant_budget.py | 223 +++++++++++------- 2 files changed, 197 insertions(+), 92 deletions(-) diff --git a/code/samples/python/scenarios/cross-merchant-budget/README.md b/code/samples/python/scenarios/cross-merchant-budget/README.md index e9266101..89807d08 100644 --- a/code/samples/python/scenarios/cross-merchant-budget/README.md +++ b/code/samples/python/scenarios/cross-merchant-budget/README.md @@ -26,27 +26,59 @@ 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 four verbs: +The authority maintains a single ledger and exposes a verb interface. -| Verb | Purpose | -| -------------------------------------------------- | -------------------------------- | -| `authorize(mandate_id, amount, idempotency_key)` | Atomically check + hold | -| `commit(hold_id)` | Confirm after successful payment | -| `refund(hold_id)` | Release if payment fails | -| `query(mandate_id)` | Check remaining budget | +### Canonical verb set -The `authorize` call is atomic: it decrements the budget and returns a hold in -one operation. There is no separate "check remaining" call that could race. +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.authorize(mandate, 60) → hold_1 (remaining: 40) -Merchant B: authority.authorize(mandate, 60) → rejected (40 < 60) +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: $60. Budget enforced. +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 @@ -54,3 +86,13 @@ 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/cross_merchant_budget.py b/code/samples/python/scenarios/cross-merchant-budget/cross_merchant_budget.py index bf261267..169ac09b 100644 --- a/code/samples/python/scenarios/cross-merchant-budget/cross_merchant_budget.py +++ b/code/samples/python/scenarios/cross-merchant-budget/cross_merchant_budget.py @@ -131,37 +131,59 @@ def demo_overspend() -> None: # ========================================================= -class HoldStatus(Enum): - """Status of a budget hold.""" +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' - REFUNDED = 'refunded' + RELEASED = 'released' @dataclass -class Hold: - """A budget hold placed by the authority.""" +class Reservation: + """A budget reservation placed by the authority.""" - hold_id: str + reservation_id: str mandate_id: str amount: int - status: HoldStatus = HoldStatus.HELD + status: ReservationStatus = ReservationStatus.HELD @dataclass -class AuthorizeResult: - """Result of an authorize call.""" +class ReserveResult: + """Result of a reserve call.""" - approved: bool - hold_id: str | None = None + 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 call.""" + """Result of a query_budget call.""" mandate_id: str budget: int @@ -171,24 +193,37 @@ class BudgetState: class BudgetAuthority: - """External budget authority with four verbs. - - Maintains a single ledger across all merchants. - The authorize call is atomic: it checks the budget and - places a hold in one operation. - - Verbs: - authorize — atomically check + hold - commit — confirm after successful payment - refund — release if payment fails - query — check remaining budget + """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._holds: dict[str, Hold] = {} + self._reservations: dict[str, Reservation] = {} self._held_by_mandate: dict[str, int] = {} self._keys: dict[str, str] = {} @@ -202,42 +237,53 @@ def register_mandate( self._spent.setdefault(mandate_id, 0) self._held_by_mandate.setdefault(mandate_id, 0) - def authorize( + def reserve( self, mandate_id: str, amount_cents: int, idempotency_key: str, - ) -> AuthorizeResult: - """Atomically check budget and place hold.""" + ) -> 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: - hid = self._keys[idempotency_key] - return AuthorizeResult( - approved=True, - hold_id=hid, + 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 AuthorizeResult( - approved=False, + return ReserveResult( + decision=Decision.DENY, reason='Unknown mandate', + requested_amount=amount_cents, ) remaining = self._remaining(mandate_id) if amount_cents > remaining: - return AuthorizeResult( - approved=False, + return ReserveResult( + decision=Decision.DENY, reason=( f'Budget exceeded: {amount_cents} > ' f'{remaining} remaining' ), remaining=remaining, + requested_amount=amount_cents, ) - hold_id = f'hold_{uuid.uuid4().hex}' - self._holds[hold_id] = Hold( - hold_id=hold_id, + reservation_id = f'res_{uuid.uuid4().hex}' + self._reservations[reservation_id] = Reservation( + reservation_id=reservation_id, mandate_id=mandate_id, amount=amount_cents, ) @@ -245,35 +291,41 @@ def authorize( self._held_by_mandate.get(mandate_id, 0) + amount_cents ) - self._keys[idempotency_key] = hold_id + self._keys[idempotency_key] = reservation_id - return AuthorizeResult( - approved=True, - hold_id=hold_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, hold_id: str) -> bool: - """Confirm a hold after successful payment.""" - hold = self._holds.get(hold_id) - if not hold or hold.status != HoldStatus.HELD: + 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 - hold.status = HoldStatus.COMMITTED - self._spent[hold.mandate_id] += hold.amount - self._held_by_mandate[hold.mandate_id] -= hold.amount + res.status = ReservationStatus.COMMITTED + self._spent[res.mandate_id] += res.amount + self._held_by_mandate[res.mandate_id] -= res.amount return True - def refund(self, hold_id: str) -> bool: - """Release a hold when payment fails.""" - hold = self._holds.get(hold_id) - if not hold or hold.status != HoldStatus.HELD: + 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 - hold.status = HoldStatus.REFUNDED - self._held_by_mandate[hold.mandate_id] -= hold.amount + res.status = ReservationStatus.RELEASED + self._held_by_mandate[res.mandate_id] -= res.amount return True - def query(self, mandate_id: str) -> BudgetState: - """Query budget state.""" + 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) @@ -292,6 +344,14 @@ def _remaining(self, mandate_id: str) -> int: 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() @@ -304,48 +364,51 @@ def demo_budget_authority() -> None: authority = BudgetAuthority() authority.register_mandate(mandate_id, 10000) - # Merchant A: authorize $60 - result_a = authority.authorize( + # Merchant A: reserve $60 → ALLOW + result_a = authority.reserve( mandate_id, 6000, uuid.uuid4().hex, ) - label = 'APPROVED' if result_a.approved else 'DENIED' - print('Merchant A: authorize($60.00)') - print(f' Result: {label}') - print(f' Hold: {result_a.hold_id}') + 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.approved and result_a.hold_id: - authority.commit(result_a.hold_id) + if ( + result_a.decision == Decision.ALLOW + and result_a.reservation_id + ): + authority.commit(result_a.reservation_id) print(' Payment succeeded -> committed') print() - # Merchant B: authorize $60 — should be denied - result_b = authority.authorize( + # Merchant B: reserve $60 → DENY (only $40 left, indivisible item) + result_b = authority.reserve( mandate_id, 6000, uuid.uuid4().hex, ) - label = 'APPROVED' if result_b.approved else 'DENIED' - print('Merchant B: authorize($60.00)') - print(f' Result: {label}') - if not result_b.approved: + 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 with smaller amount - result_c = authority.authorize( + # Merchant B: retry $35 → ALLOW + result_c = authority.reserve( mandate_id, 3500, uuid.uuid4().hex, ) - label = 'APPROVED' if result_c.approved else 'DENIED' - print('Merchant B: authorize($35.00) — retry') - print(f' Result: {label}') - if result_c.approved and result_c.hold_id: + 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' Hold: {result_c.hold_id}') + print(f' Reservation: {result_c.reservation_id}') print(f' Remaining: ${remaining_c:.2f}') - authority.commit(result_c.hold_id) + authority.commit(result_c.reservation_id) print(' Payment succeeded -> committed') print() - state = authority.query(mandate_id) + state = authority.query_budget(mandate_id) print('Final state:') print(f' Budget: ${state.budget / 100:.2f}') print(f' Spent: ${state.spent / 100:.2f}') From a7a59471b04dddf49c4ea31ec3b6b3d9f0976a0d Mon Sep 17 00:00:00 2001 From: Ectsang <1057844+Ectsang@users.noreply.github.com> Date: Tue, 12 May 2026 07:27:00 +0800 Subject: [PATCH 6/7] chore(cspell): add proper-noun terms for cross-merchant-budget sample Adds aeoess, crosswalked, goodmeta, runcycles to custom-words for cspell to accept the rename PR's references to upstream verb-set crosswalk and reference implementations. --- .cspell/custom-words.txt | 4 ++++ 1 file changed, 4 insertions(+) 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 From 5975f1e58277fbca6a884753e33499865a8c3de5 Mon Sep 17 00:00:00 2001 From: Ectsang <1057844+Ectsang@users.noreply.github.com> Date: Tue, 19 May 2026 14:26:24 +0800 Subject: [PATCH 7/7] =?UTF-8?q?docs(samples):=20add=20SPEC.md=20=E2=80=94?= =?UTF-8?q?=20normative=20protocol=20for=20cross-merchant=20budget=20reser?= =?UTF-8?q?vation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a colocated specification document defining the protocol pattern that cross_merchant_budget.py implements: - 9 sections covering scope, definitions, MUST/SHOULD requirements for Budget Authority, Verifying Parties, and Shopping Agents - Six canonical verbs (reserve/commit/release/refund/query_budget/ query_reservation) with the four-verb subset implemented here - Three-value Decision shape (ALLOW/ALLOW_WITH_CAPS/DENY) with safe default rule (treat ALLOW_WITH_CAPS as DENY unless partial- fulfillment-capable) - Registration as Mandate Constraint type "budget_reservation_v1" via AP2 specification.md §371 extension point - Six open questions listed honestly (discovery/trust, dispute integration, cross-rail composition, multi-asset budgets, reservation expiry, signed reservation attestations) Sits alongside the Python sample. Does not modify AP2 wire format, JWT claims, or Mandate signature scheme. MUSTs apply to deployments that opt into the budget_reservation_v1 Constraint type. Vocabulary aligns with crosswalk/budget_reservation.yaml in aeoess/agent-governance-vocabulary. Cycles cited as independent production implementation with ALLOW_WITH_CAPS exercised. Refs #207, #252, ACP #231. --- .../scenarios/cross-merchant-budget/SPEC.md | 335 ++++++++++++++++++ 1 file changed, 335 insertions(+) create mode 100644 code/samples/python/scenarios/cross-merchant-budget/SPEC.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