-
Notifications
You must be signed in to change notification settings - Fork 2
feat(governance): enforce pre-action spend caps #314
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
gnanirahulnutakki
wants to merge
3
commits into
dev
Choose a base branch
from
gnanirahulnutakki/issue-310-spend-gates
base: dev
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| # ADR-025: Pre-action spend reservation and conservative settlement | ||
|
|
||
| **Status:** Accepted | ||
|
|
||
| **Date:** 2026-07-14 | ||
|
|
||
| ## Context | ||
|
|
||
| Ardur already enforces governed tool-call counts and reserves descendant call | ||
| authority. A provider or metered tool call has a different budget lifecycle: | ||
| the final output quantity is unknown before execution, price depends on an | ||
| operator-approved quote, and actual usage arrives only after the action. A | ||
| post-call cost field cannot prevent an over-budget call. | ||
|
|
||
| Mutable provider prices, provider-returned model labels, and caller-supplied | ||
| rates are not authorization authority. Fetching current prices during policy | ||
| evaluation would also add network availability and latency to a fail-closed hot | ||
| path. Binary floating-point arithmetic is unsuitable for exact budget gates. | ||
|
|
||
| The existing local lineage ledger establishes Ardur's reference durability | ||
| model: process-local coordination plus an exclusive file lock, a temporary | ||
| state file, and atomic replacement. Python documents `flock(LOCK_EX)` as the | ||
| exclusive advisory-lock primitive and `os.replace()` as atomic on POSIX when | ||
| successful. RFC 8785 defines the invariant JSON representation Ardur already | ||
| uses for signed and hashed data. | ||
|
|
||
| Primary references: | ||
|
|
||
| - [RFC 8785 JSON Canonicalization Scheme](https://www.rfc-editor.org/rfc/rfc8785.html) | ||
| - [Python `fcntl.flock` documentation](https://docs.python.org/3/library/fcntl.html#fcntl.flock) | ||
| - [Python `os.replace` documentation](https://docs.python.org/3/library/os.html#os.replace) | ||
|
|
||
| ## Decision | ||
|
|
||
| 1. Spend authority is an optional signed Mission Passport claim distinct from | ||
| call counts. It declares exact metered tools, a currency, and integer token | ||
| and currency-micro ceilings for session, agent, and delegation-lineage | ||
| scopes. | ||
| 2. Root issuance binds spend authority to the root passport JTI. Derived child | ||
| passports inherit the complete spend policy and lineage identifier; they | ||
| cannot change currency, metered tools, or ceilings. | ||
| 3. Operators load immutable, validity-bounded quote snapshots into the proxy. | ||
| A caller supplies only quote ID, model ID, request ID, known maximum input | ||
| tokens, and configured maximum output tokens. The quote supplies rates, | ||
| currency, tool binding, model binding, and validity. No authorization-path | ||
| network fetch occurs. | ||
| 4. Authorization uses non-negative integers no larger than the interoperable | ||
| JSON safe-integer bound. Monetary rates are currency micro-units per million | ||
| tokens; multiplication and ceiling division use integers only. | ||
| 5. One file-locked transaction reserves both token and monetary upper bounds | ||
| across all three scopes before `evaluate_tool_call()` can return `PERMIT`. | ||
| A duplicate active request is denied rather than permitting a potentially | ||
| duplicated provider execution. Close operations must present the session | ||
| identity bound into the reservation; one sibling cannot settle, release, or | ||
| quarantine another sibling's authority. | ||
| 6. Trusted settlement recomputes actual money from the originally bound quote. | ||
| Usage within the reservation consumes actual amounts and refunds only the | ||
| proven unused portion. Missing, invalid, or oversized usage is | ||
| quarantined with the conservative reservation retained. | ||
| 7. Stale reservations can be moved to quarantine by their owning session and | ||
| later reconciled with trusted usage. There is no automatic timeout refund. | ||
| A session cannot finalize while it has an active reservation, and spend | ||
| lifecycle APIs cannot append evidence after session finalization. | ||
| 8. Reservation decisions and settlement lifecycle operations emit separate, | ||
| signed, hash-linked receipts. Existing receipts are immutable. Public | ||
| evidence contains bounded amounts, remaining scopes, currency, quote and | ||
| request hashes, and reason codes; it excludes prompts, responses, raw quote | ||
| documents, account identifiers, credentials, request IDs, and host paths. | ||
| 9. Metrics use only bounded operation/outcome/unit labels. The reference ledger | ||
| is local to one shared state directory; distributed deployments require an | ||
| external transactional implementation of the same ledger interface. | ||
| 10. An exception after durable reservation but before authorization returns | ||
| triggers an idempotent release. If that compensation cannot be persisted, | ||
| evaluation fails with `spend_compensation_failed` and retains the full | ||
| reservation. A failure in receipt construction can prevent signed evidence | ||
| for that internal compensation, so the durable ledger remains the | ||
| fail-closed source of truth for recovery. | ||
|
|
||
| ## Consequences | ||
|
|
||
| - A metered action cannot start after any declared scope is exhausted. | ||
| - Conservative output reservation can temporarily reject work that would have | ||
| fit after actual settlement. This is an intentional availability-for-safety | ||
| trade-off. | ||
| - Crashes and missing usage retain authority, so operators need stale | ||
| quarantine monitoring and a trusted reconciliation path. | ||
| - Adapters are in the trusted computing base for usage evidence. The ledger | ||
| prevents replay and oversubscription but cannot prove a provider's usage | ||
| report independently. | ||
| - Credentials without `spend_budget` retain existing call-count behavior. | ||
| - The reference implementation does not perform provider billing | ||
| reconciliation, chargeback, or cross-host consensus. | ||
|
|
||
| ## Alternatives considered | ||
|
|
||
| - **Record provider cost after the call.** Rejected because it observes an | ||
| overspend after the external side effect and cannot enforce a pre-action cap. | ||
| - **Fetch live prices during evaluation.** Rejected because mutable remote data | ||
| would become authorization authority and introduce a network fail point. | ||
| - **Let the caller submit a rate or provider-selected model.** Rejected because | ||
| an untrusted caller could select a cheaper quote and widen effective spend. | ||
| - **Reuse the call-count lineage ledger.** Rejected because calls, tokens, and | ||
| currency have different units and settlement semantics. | ||
| - **Automatically release stale reservations.** Rejected because a timeout | ||
| does not prove that the provider call did not execute or incur cost. | ||
| - **Permit an active duplicate request idempotently.** Rejected because the | ||
| proxy cannot guarantee the downstream provider will deduplicate execution. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,204 @@ | ||
| # Pre-action spend budgets | ||
|
|
||
| Ardur's Python library can reserve token and monetary authority before a | ||
| metered tool or provider call. This surface is separate from | ||
| `max_tool_calls`: call counts, token quantities, and money are never converted | ||
| implicitly. | ||
|
|
||
| ## Policy claim | ||
|
|
||
| Pass `spend_budget` when constructing a `MissionPassport`: | ||
|
|
||
| ```python | ||
| from vibap import MissionPassport | ||
|
|
||
| mission = MissionPassport( | ||
| agent_id="report-agent", | ||
| mission="Generate the approved report", | ||
| allowed_tools=["llm_generate"], | ||
| resource_scope=["**"], | ||
| spend_budget={ | ||
| "version": 1, | ||
| "currency": "USD", | ||
| "metered_tools": ["llm_generate"], | ||
| "ceilings": { | ||
| "tokens": { | ||
| "session": 100_000, | ||
| "agent": 200_000, | ||
| "lineage": 500_000, | ||
| }, | ||
| "currency_micros": { | ||
| "session": 2_000_000, | ||
| "agent": 4_000_000, | ||
| "lineage": 10_000_000, | ||
| }, | ||
| }, | ||
| }, | ||
| ) | ||
| ``` | ||
|
|
||
| All amounts must be non-negative integers no greater than | ||
| `9007199254740991`. `currency_micros` means one millionth of the declared | ||
| three-letter uppercase currency. Issuance adds `lineage_id`; mission authors | ||
| must not invent a different lineage identity for a child. Derived children | ||
| inherit the exact policy and share the root lineage ceiling. | ||
|
|
||
| Only tools named in `metered_tools` require a spend request. Supplying a spend | ||
| request for an unmetered tool fails closed so an adapter cannot mistakenly | ||
| believe an unenforced request was reserved. | ||
|
|
||
| ## Operator quote snapshots | ||
|
|
||
| Quotes are immutable local configuration. They bind a quote ID to one exact | ||
| tool, model, currency, validity interval, and integer input/output rates: | ||
|
|
||
| ```python | ||
| import time | ||
|
|
||
| from vibap import GovernanceProxy, SpendQuote, StaticSpendQuoteStore | ||
|
|
||
| now = int(time.time()) | ||
| quote_store = StaticSpendQuoteStore([ | ||
| SpendQuote( | ||
| quote_id="approved-model-2026-07", | ||
| tool_name="llm_generate", | ||
| model="approved-model-v1", | ||
| currency="USD", | ||
| input_micros_per_million_tokens=1_000_000, | ||
| output_micros_per_million_tokens=2_000_000, | ||
| valid_from=now, | ||
| valid_until=now + 86_400, | ||
| ) | ||
| ]) | ||
|
|
||
| proxy = GovernanceProxy(spend_quote_store=quote_store) | ||
| ``` | ||
|
|
||
| The values above demonstrate the contract; they are not provider prices. | ||
| Operators own price sourcing, review, rotation, and validity intervals. Ardur | ||
| does not fetch pricing over the network and does not accept a rate or currency | ||
| from the caller or provider response. | ||
|
|
||
| ## Reserve before execution | ||
|
|
||
| The adapter provides bounded usage intent, not pricing authority: | ||
|
|
||
| ```python | ||
| from vibap import Decision, SpendReservationRequest | ||
|
|
||
| request = SpendReservationRequest( | ||
| request_id="adapter-idempotency-key", | ||
| quote_id="approved-model-2026-07", | ||
| model="approved-model-v1", | ||
| max_input_tokens=4_000, | ||
| max_output_tokens=2_000, | ||
| ) | ||
|
|
||
| decision, reason = proxy.evaluate_tool_call( | ||
| session, | ||
| "llm_generate", | ||
| {"input_digest": "adapter-owned-bounded-reference"}, | ||
| spend_request=request, | ||
| ) | ||
|
|
||
| if decision != Decision.PERMIT: | ||
| # Do not invoke the provider. | ||
| raise PermissionError(reason) | ||
|
|
||
| response = provider.generate(...) | ||
| ``` | ||
|
|
||
| The ledger atomically checks token and money at session, agent, and lineage | ||
| scopes. A request exceeding any scope is denied before `PERMIT`. Reusing an | ||
| active request ID with the same fingerprint is also denied to prevent a second | ||
| provider execution; reusing it with different semantics is a conflict. Every | ||
| close operation is bound to the session that created the reservation, so a | ||
| sibling session cannot settle or quarantine it. | ||
|
|
||
| If evaluation raises after the ledger accepted a reservation but before | ||
| returning, the proxy attempts an idempotent internal release. A failed release | ||
| raises `spend_compensation_failed` and deliberately retains the reservation; | ||
| operators must treat that state as unresolved rather than assuming authority | ||
| was refunded. | ||
|
|
||
| ## Settle or quarantine | ||
|
|
||
| After a trusted adapter verifies provider usage, settle against the originally | ||
| bound quote: | ||
|
|
||
| ```python | ||
| result = proxy.settle_spend( | ||
| session, | ||
| request_id=request.request_id, | ||
| actual_input_tokens=3_700, | ||
| actual_output_tokens=1_250, | ||
| usage_proof_digest="a" * 64, | ||
| ) | ||
| ``` | ||
|
|
||
| The digest is a bounded reference to trusted usage evidence; raw provider | ||
| responses and usage documents do not enter the signed receipt. Settlement | ||
| recomputes cost using the quote selected at reservation. It consumes actual | ||
| usage and refunds only the verified remainder. | ||
|
|
||
| If trusted usage is missing, quarantine explicitly: | ||
|
|
||
| ```python | ||
| proxy.quarantine_spend( | ||
| session, | ||
| request_id=request.request_id, | ||
| reason_code="spend_settlement_evidence_missing", | ||
| ) | ||
| ``` | ||
|
|
||
| `quarantine_stale_spend()` moves old active reservations to quarantine without | ||
| refunding them. It scans only reservations owned by the supplied governance | ||
| session. A later trusted `settle_spend()` reconciles a quarantined reservation. | ||
| Usage beyond the reserved input/output bounds remains quarantined; a | ||
| post-action observation cannot retroactively authorize an overspend. | ||
|
|
||
| Every active reservation must be settled, released, or quarantined before | ||
| `end_session()` or attestation finalization. Once finalized, the proxy rejects | ||
| all spend lifecycle changes so the signed receipt chain cannot be extended | ||
| after its terminal summary. Reconcile quarantined usage before finalization if | ||
| the resulting settlement must appear in that session's signed evidence. | ||
|
|
||
| ## Evidence and metrics | ||
|
|
||
| Metered action receipts carry a spend-shaped `budget_delta` with operation, | ||
| requested/reserved/actual/refunded amounts, scope-level remaining authority, | ||
| currency, quote digest, reservation hash, and stable reason code. Settlement | ||
| and quarantine append separate signed receipt-chain links. | ||
|
|
||
| The runtime emits: | ||
|
|
||
| - `ardur_spend_events_total{operation,outcome}` | ||
| - `ardur_spend_amount_total{operation,unit}` | ||
|
|
||
| These labels never include agent, session, lineage, quote, model, request, or | ||
| account identifiers. | ||
|
|
||
| ## Failure modes and deployment boundary | ||
|
|
||
| - Missing, unknown, not-yet-valid, expired, mismatched, or unavailable quote | ||
| data fails closed. | ||
| - Missing trusted settlement retains the reservation. Conservative retention | ||
| can reduce availability and must be monitored. | ||
| - Exceptional evaluation compensates accepted reservations when possible. If | ||
| durable compensation cannot be proven, the reservation remains active and | ||
| session finalization fails closed. | ||
| - Closed records are retained through the passport lifetime for replay | ||
| protection. Expired terminal records may be pruned; settled amounts remain | ||
| archived in aggregate scope accounting. Active and quarantined reservations | ||
| are never age-refunded. | ||
| - The reference ledger coordinates processes that share one local state | ||
| directory. It is not a distributed consensus mechanism. Multi-host proxies | ||
| need a transactional `SpendBudgetLedger` implementation with equivalent | ||
| atomic and idempotent semantics. | ||
| - Quote configuration and trusted usage adapters are part of the operator's | ||
| trusted computing base. | ||
| - This surface does not reconcile cloud bills, implement chargeback, or prove | ||
| provider metering independently. | ||
|
|
||
| The design rationale is recorded in | ||
| [ADR-025](../decisions/ADR-025-pre-action-spend-reservation.md). | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Regenerate and commit the generated documentation.
The Hugo validation job currently fails because
site/scripts/sync_source_docs.py --checkreports a missing generated file. Run the repository’s source-doc synchronization step, commit the generated counterpart for this page, and rerun the check before merging.🧰 Tools
🪛 GitHub Actions: hugo-site / 2_Validate and build Hugo site.txt
[error] 1-1: site/scripts/sync_source_docs.py --check: missing generated file.
🪛 LanguageTool
[grammar] ~200-~200: Ensure spelling is correct
Context: ...ator's trusted computing base. - This surface does not reconcile cloud bills, impleme...
(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)
[grammar] ~204-~204: Ensure spelling is correct
Context: ...design rationale is recorded in ADR-025.
(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)
🤖 Prompt for AI Agents
Source: Pipeline failures