diff --git a/README.md b/README.md index d5a87c72..66b114fe 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,7 @@ Concretely — these are the design principles the repo is being built to meet, - **Composable with what already exists.** Designed around SPIFFE for workload identity, Biscuit for first-party-attenuation credentials, Cedar for policy, the individual AAT Internet-Draft for delegation-token semantics, and EAT (RFC 9711) for attestation-token semantics. We didn't reinvent the substrate. - **Cryptographically bound by design.** Mission credentials are designed to be signed by an issuer key and produce signed receipts chain-hashed to the previous one. The Python Biscuit path reports SPIFFE holder binding only when the proxy has a server-owned Biscuit issuer key, JWT-SVID trust bundle, and audience and the presented credentials verify against them; request payloads cannot choose those verifier inputs. JWT-SVID itself remains a replayable bearer credential, so this is bounded holder evidence rather than universal replay prevention. The design is documented in the [ADRs](docs/decisions/README.md); the public code that implements it is being curated in phases. - **Delegation that narrows, never widens.** Child sessions get strictly narrower authority than their parent — fewer tools, smaller resource scope, smaller budget. The narrowing discipline is formalised in [ADR-017](docs/decisions/ADR-017-biscuit-attenuation-narrowing-semantics.md). +- **Pre-action spend authority.** Library adapters can reserve signed token and currency-micro ceilings across session, agent, and delegation-lineage scopes before a metered call, then settle trusted usage or quarantine uncertainty without silently refunding it. [Reference](docs/reference/spend-budgets.md). - **No authority by omission.** An absent or empty `resource_scope` grants no resource authority. Operators who intentionally permit every resource must sign the sole explicit wildcard `resource_scope: ["**"]`; issuance and governed-run surfaces warn when they do. The decision and format-specific attenuation rules are documented in [ADR-023](docs/decisions/ADR-023-explicit-resource-scope-authority.md). - **Explicit about what it doesn't do.** Scope-level governance can't catch semantic misuse — if an allowed tool is used on an allowed resource for the wrong reason, that's a different layer's job. - **MIT licensed.** The research foundation (the Silence Theorem, the protocol formalism, the benchmark methodology) will be linked from this repo when the paper's public identifier is assigned. Articles in this repo paraphrase the research in original prose; they do not reproduce paper content. diff --git a/docs/decisions/ADR-025-pre-action-spend-reservation.md b/docs/decisions/ADR-025-pre-action-spend-reservation.md new file mode 100644 index 00000000..1098751c --- /dev/null +++ b/docs/decisions/ADR-025-pre-action-spend-reservation.md @@ -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. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 48a1bb7f..b1af7682 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -20,6 +20,7 @@ ADRs are migrated from the private research repo with the two-pass cleanup appli | 022 | [SPIFFE mTLS identity for operator telemetry](./ADR-022-operator-telemetry-spiffe-mtls.md) | Accepted | 2026-07-11 | | 023 | [Explicit resource-scope authority](./ADR-023-explicit-resource-scope-authority.md) | Accepted | 2026-07-12 | | 024 | [Self-asserted owner identity assurance](./ADR-024-self-asserted-owner-identity-assurance.md) | Accepted | 2026-07-12 | +| 025 | [Pre-action spend reservation and conservative settlement](./ADR-025-pre-action-spend-reservation.md) | Accepted | 2026-07-14 | ## Conventions diff --git a/docs/reference/README.md b/docs/reference/README.md index c5777e0a..80c06394 100644 --- a/docs/reference/README.md +++ b/docs/reference/README.md @@ -22,6 +22,9 @@ walkthroughs see [`../guides/`](../guides/); for protocol semantics see - [Advisory AI Controls](advisory-ai-controls.md) — semantic-judge and behavioral-fingerprint defaults, non-authoritative status, failure policy, cost, and integration requirements +- [Pre-action Spend Budgets](spend-budgets.md) — signed token/currency policy, + operator quote snapshots, atomic reserve/settle/quarantine semantics, + evidence, metrics, and deployment limits - [Agent Recognition Evaluation](agent-recognition-evaluation.md) — versioned maintained corpus, deterministic metrics, Wilson intervals, CI thresholds, and claim boundaries diff --git a/docs/reference/spend-budgets.md b/docs/reference/spend-budgets.md new file mode 100644 index 00000000..5d5ae6b2 --- /dev/null +++ b/docs/reference/spend-budgets.md @@ -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). diff --git a/docs/specs/ardur-drp-mapping-v0.1.json b/docs/specs/ardur-drp-mapping-v0.1.json index c4e12e06..af0d663b 100644 --- a/docs/specs/ardur-drp-mapping-v0.1.json +++ b/docs/specs/ardur-drp-mapping-v0.1.json @@ -465,6 +465,13 @@ "drp_path": "metadata.x-ardur.budget.maxToolCalls", "rationale": "DRP has no action-count budget." }, + { + "source_surface": "legacy_python_passport", + "source_path": "spend_budget", + "classification": "extension", + "drp_path": "metadata.x-ardur.budget.spendBudget", + "rationale": "Token and monetary authority is security-critical. This mapping ledger reserves the extension path; the current DRP v0.1 emitter/profile does not project it and must fail closed rather than omit or downgrade the claim." + }, { "source_surface": "legacy_python_passport", "source_path": "max_duration_s", diff --git a/docs/specs/ardur-drp-mapping-v0.1.md b/docs/specs/ardur-drp-mapping-v0.1.md index 1410fe50..b4bc8464 100644 --- a/docs/specs/ardur-drp-mapping-v0.1.md +++ b/docs/specs/ardur-drp-mapping-v0.1.md @@ -75,7 +75,7 @@ transformations are: | `par_hash`, `parent_token_hash`, `parent_jti` | `parentReceiptId` plus Ardur audit fields | Resolve the actual profiled parent receipt. Token hashes and token IDs are retained but are not DRP receipt IDs. | | `cnf.jwk` | `metadata.x-ardur.capabilityTokenRef.holderConfirmation.jwk` | The holder key is not the DRP receipt-signing key. | | depth and delegation policy | `metadata.x-ardur.redelegation` | DRP describes depth behavior but has no Authorization Object fields for mode, depth, or maximum depth. | -| budgets and policy references | `metadata.x-ardur.budget`, `metadata.x-ardur.policy` | Security-critical extensions that participate in attenuation checks. | +| call-count/spend budgets and policy references | `metadata.x-ardur.budget`, `metadata.x-ardur.policy` | Security-critical extensions that participate in attenuation checks. The v0.1 mapping ledger reserves `spendBudget`, but the current DRP emitter/profile does not project it and must fail closed rather than omit or downgrade that authority. | | `mission_ref` | `metadata.x-ardur.missionRef` | DRP instruction commitment does not replace the governing Mission Declaration reference. | ### 3.1. Critical Extension Rule diff --git a/docs/specs/execution-receipt-v0.2.schema.json b/docs/specs/execution-receipt-v0.2.schema.json index e653de7d..2b8b6d49 100644 --- a/docs/specs/execution-receipt-v0.2.schema.json +++ b/docs/specs/execution-receipt-v0.2.schema.json @@ -385,6 +385,9 @@ }, { "$ref": "#/$defs/lineageBudgetDelta" + }, + { + "$ref": "#/$defs/spendBudgetDelta" } ] }, @@ -486,6 +489,73 @@ } } }, + "spendBudgetDelta": { + "type": "object", + "additionalProperties": false, + "required": [ + "operation", + "resource", + "requested", + "reserved", + "actual", + "refunded", + "remaining", + "currency", + "quote_digest", + "reservation_hash", + "reason_code" + ], + "properties": { + "operation": { + "type": "string", + "enum": ["reserve", "reject", "release", "settle", "quarantine"] + }, + "resource": {"const": "spend"}, + "requested": {"$ref": "#/$defs/spendAmounts"}, + "reserved": {"$ref": "#/$defs/spendAmounts"}, + "actual": {"$ref": "#/$defs/spendAmounts"}, + "refunded": {"$ref": "#/$defs/spendAmounts"}, + "remaining": { + "type": "object", + "additionalProperties": false, + "required": ["session", "agent", "lineage"], + "properties": { + "session": {"$ref": "#/$defs/spendAmounts"}, + "agent": {"$ref": "#/$defs/spendAmounts"}, + "lineage": {"$ref": "#/$defs/spendAmounts"} + } + }, + "currency": { + "type": "string", + "pattern": "^[A-Z]{3}$" + }, + "quote_digest": {"$ref": "#/$defs/sha256HexString"}, + "reservation_hash": {"$ref": "#/$defs/sha256HexString"}, + "reason_code": { + "type": "string", + "pattern": "^[A-Za-z0-9._:-]{1,128}$" + }, + "idempotent": {"type": "boolean"}, + "reconciled": {"type": "boolean"} + } + }, + "spendAmounts": { + "type": "object", + "additionalProperties": false, + "required": ["tokens", "currency_micros"], + "properties": { + "tokens": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "currency_micros": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + } + }, "policyDecision": { "type": "object", "additionalProperties": false, diff --git a/python/README.md b/python/README.md index 84f87b8c..7002b76a 100644 --- a/python/README.md +++ b/python/README.md @@ -209,6 +209,13 @@ domain, or audience. Without server trust configuration, Biscuit sessions remain explicitly `svid_bound=false`. JWT-SVID is still a bearer credential with a bounded replay window. +Library adapters for metered tools can also configure operator-owned quote +snapshots and signed session/agent/lineage spend ceilings. The proxy reserves +integer token and currency-micro upper bounds before returning `PERMIT`, then +settles trusted usage or conservatively quarantines missing evidence. See the +[pre-action spend budget reference](../docs/reference/spend-budgets.md); no +provider prices are hard-coded or fetched on the authorization hot path. + ## Protocol identifier rename This implementation is a **clean break** on protocol identifiers — v0.1 receipts, passports, and attestations only emit and accept the new Ardur type strings. There is no dual-type backward-compat shim. If you have artifacts produced before the rename, they won't validate against this code, and that's intentional. diff --git a/python/tests/test_spend_budget.py b/python/tests/test_spend_budget.py new file mode 100644 index 00000000..896ec3df --- /dev/null +++ b/python/tests/test_spend_budget.py @@ -0,0 +1,1037 @@ +from __future__ import annotations + +import json +import time +from concurrent.futures import ThreadPoolExecutor + +import pytest +from vibap.passport import ( + MissionPassport, + derive_child_passport, + issue_passport, + verify_passport, +) +from vibap.metrics import ArdurMetrics +from vibap.proxy import Decision, GovernanceProxy +from vibap.receipt import verify_receipt +from vibap.spend_budget import ( + FileSpendBudgetLedger, + SpendBudgetConflictError, + SpendBudgetError, + SpendQuote, + SpendReservationRequest, + StaticSpendQuoteStore, + normalize_spend_budget, +) + + +def _policy( + *, + lineage_id: str = "lineage-1", + token_session: int = 100, + token_agent: int = 100, + token_lineage: int = 100, + money_session: int = 1_000, + money_agent: int = 1_000, + money_lineage: int = 1_000, +) -> dict: + return { + "version": 1, + "currency": "USD", + "metered_tools": ["llm_generate"], + "ceilings": { + "tokens": { + "session": token_session, + "agent": token_agent, + "lineage": token_lineage, + }, + "currency_micros": { + "session": money_session, + "agent": money_agent, + "lineage": money_lineage, + }, + }, + "lineage_id": lineage_id, + } + + +def _mission_policy(**kwargs) -> dict: + policy = _policy(**kwargs) + policy.pop("lineage_id") + return policy + + +def _quote( + *, + quote_id: str = "quote-1", + valid_from: int | None = None, + valid_until: int | None = None, +) -> SpendQuote: + now = int(time.time()) + return SpendQuote( + quote_id=quote_id, + tool_name="llm_generate", + model="operator-model-v1", + currency="USD", + input_micros_per_million_tokens=1_000_000, + output_micros_per_million_tokens=2_000_000, + valid_from=now - 60 if valid_from is None else valid_from, + valid_until=now + 3600 if valid_until is None else valid_until, + ) + + +def _request( + request_id: str, + *, + input_tokens: int = 10, + output_tokens: int = 10, + quote_id: str = "quote-1", +) -> SpendReservationRequest: + return SpendReservationRequest( + request_id=request_id, + quote_id=quote_id, + model="operator-model-v1", + max_input_tokens=input_tokens, + max_output_tokens=output_tokens, + ) + + +def test_policy_rejects_float_duplicate_tools_and_incomplete_scopes() -> None: + float_policy = _policy() + float_policy["ceilings"]["tokens"]["session"] = 1.5 + with pytest.raises(SpendBudgetError, match="non-negative integer"): + normalize_spend_budget(float_policy, require_lineage_id=True) + + duplicate = _policy() + duplicate["metered_tools"] = ["llm_generate", "llm_generate"] + with pytest.raises(SpendBudgetError, match="duplicates"): + normalize_spend_budget(duplicate, require_lineage_id=True) + + incomplete = _policy() + del incomplete["ceilings"]["currency_micros"]["lineage"] + with pytest.raises(SpendBudgetError, match="session, agent, and lineage"): + normalize_spend_budget(incomplete, require_lineage_id=True) + + +def test_quote_uses_integer_ceiling_arithmetic_and_validity() -> None: + quote = _quote() + assert quote.reserve_amounts(1, 1) == { + "tokens": 2, + "currency_micros": 3, + } + + store = StaticSpendQuoteStore([quote]) + assert ( + store.resolve( + quote.quote_id, + tool_name=quote.tool_name, + model=quote.model, + currency=quote.currency, + ).digest + == quote.digest + ) + + with pytest.raises(SpendBudgetError) as exc: + store.resolve( + quote.quote_id, + tool_name=quote.tool_name, + model="provider-selected-model", + currency=quote.currency, + ) + assert exc.value.reason_code == "spend_quote_scope_mismatch" + + +def test_reservation_breach_denies_without_mutating_scope_totals(tmp_path) -> None: + ledger = FileSpendBudgetLedger(tmp_path) + result = ledger.reserve( + policy=_policy(token_lineage=19), + session_id="session-1", + agent_id="agent-1", + request=_request("too-large"), + quote=_quote(), + ) + + assert result.accepted is False + assert result.reason_code == "spend_lineage_tokens_exhausted" + snapshot = ledger.snapshot("lineage-1") + assert snapshot["reservations"] == {} + assert all( + totals["reserved"] == {"tokens": 0, "currency_micros": 0} + for totals in snapshot["scopes"].values() + ) + + +def test_concurrent_siblings_cannot_oversubscribe_lineage(tmp_path) -> None: + policy = _policy(token_session=1_000, token_agent=1_000, token_lineage=50) + quote = _quote() + + def attempt(index: int) -> bool: + ledger = FileSpendBudgetLedger(tmp_path) + result = ledger.reserve( + policy=policy, + session_id=f"session-{index}", + agent_id=f"agent-{index}", + request=_request(f"sibling-{index}", input_tokens=5, output_tokens=5), + quote=quote, + ) + return result.accepted + + with ThreadPoolExecutor(max_workers=16) as pool: + accepted = list(pool.map(attempt, range(32))) + + assert sum(accepted) == 5 + snapshot = FileSpendBudgetLedger(tmp_path).snapshot("lineage-1") + lineage_scope = next( + totals + for scope_ref, totals in snapshot["scopes"].items() + if any( + record["scope_refs"]["lineage"] == scope_ref + for record in snapshot["reservations"].values() + ) + ) + assert lineage_scope["reserved"]["tokens"] == 50 + + +def test_duplicate_active_request_is_fail_closed_and_conflict_is_rejected( + tmp_path, +) -> None: + ledger = FileSpendBudgetLedger(tmp_path) + first = ledger.reserve( + policy=_policy(), + session_id="session-1", + agent_id="agent-1", + request=_request("retry-1"), + quote=_quote(), + ) + replay = ledger.reserve( + policy=_policy(), + session_id="session-1", + agent_id="agent-1", + request=_request("retry-1"), + quote=_quote(), + ) + + assert first.accepted is True + assert replay.accepted is False + assert replay.idempotent is True + assert replay.reason_code == "spend_request_already_reserved" + + with pytest.raises(SpendBudgetConflictError) as exc: + ledger.reserve( + policy=_policy(), + session_id="session-1", + agent_id="agent-1", + request=_request("retry-1", output_tokens=11), + quote=_quote(), + ) + assert exc.value.reason_code == "spend_request_conflict" + + +def test_settlement_refunds_only_verified_unused_and_replay_is_idempotent( + tmp_path, +) -> None: + ledger = FileSpendBudgetLedger(tmp_path) + ledger.reserve( + policy=_policy(), + session_id="session-1", + agent_id="agent-1", + request=_request("settle-1"), + quote=_quote(), + ) + proof = "a" * 64 + settled = ledger.settle( + lineage_id="lineage-1", + session_id="session-1", + request_id="settle-1", + actual_input_tokens=4, + actual_output_tokens=3, + usage_proof_digest=proof, + ) + replay = ledger.settle( + lineage_id="lineage-1", + session_id="session-1", + request_id="settle-1", + actual_input_tokens=4, + actual_output_tokens=3, + usage_proof_digest=proof, + ) + + assert settled.operation == "settle" + assert settled.actual == {"tokens": 7, "currency_micros": 10} + assert settled.refunded == {"tokens": 13, "currency_micros": 20} + assert replay.idempotent is True + + with pytest.raises(SpendBudgetConflictError) as exc: + ledger.settle( + lineage_id="lineage-1", + session_id="session-1", + request_id="settle-1", + actual_input_tokens=4, + actual_output_tokens=4, + usage_proof_digest=proof, + ) + assert exc.value.reason_code == "spend_settlement_conflict" + + +def test_release_refund_replay_is_idempotent(tmp_path) -> None: + ledger = FileSpendBudgetLedger(tmp_path) + ledger.reserve( + policy=_policy(), + session_id="session-1", + agent_id="agent-1", + request=_request("release-replay"), + quote=_quote(), + ) + + first = ledger.cancel( + lineage_id="lineage-1", + session_id="session-1", + request_id="release-replay", + reason_code="spend_action_not_permitted", + ) + replay = ledger.cancel( + lineage_id="lineage-1", + session_id="session-1", + request_id="release-replay", + reason_code="spend_action_not_permitted", + ) + + assert first.operation == "release" + assert first.refunded == first.reserved + assert replay.idempotent is True + assert replay.refunded == first.refunded + + +def test_missing_usage_quarantines_then_trusted_reconciliation_settles( + tmp_path, +) -> None: + first = FileSpendBudgetLedger(tmp_path) + first.reserve( + policy=_policy(), + session_id="session-1", + agent_id="agent-1", + request=_request("crash-1"), + quote=_quote(), + ) + quarantined = first.settle( + lineage_id="lineage-1", + session_id="session-1", + request_id="crash-1", + actual_input_tokens=0, + actual_output_tokens=0, + usage_proof_digest=None, + ) + + assert quarantined.operation == "quarantine" + assert quarantined.reserved["tokens"] == 20 + + reloaded = FileSpendBudgetLedger(tmp_path) + reconciled = reloaded.settle( + lineage_id="lineage-1", + session_id="session-1", + request_id="crash-1", + actual_input_tokens=2, + actual_output_tokens=2, + usage_proof_digest="b" * 64, + ) + assert reconciled.operation == "settle" + assert reconciled.reconciled is True + assert reloaded.snapshot("lineage-1")["quarantined_reservations"] == {} + + +def test_failed_reconciliation_does_not_claim_reconciled(tmp_path) -> None: + ledger = FileSpendBudgetLedger(tmp_path) + ledger.reserve( + policy=_policy(), + session_id="session-1", + agent_id="agent-1", + request=_request("failed-reconcile"), + quote=_quote(), + ) + first = ledger.quarantine( + lineage_id="lineage-1", + session_id="session-1", + request_id="failed-reconcile", + reason_code="spend_settlement_evidence_missing", + ) + second = ledger.settle( + lineage_id="lineage-1", + session_id="session-1", + request_id="failed-reconcile", + actual_input_tokens=0, + actual_output_tokens=0, + usage_proof_digest=None, + ) + + assert first.reconciled is False + assert second.operation == "quarantine" + assert second.reconciled is False + + +def test_expired_terminal_records_are_pruned_before_capacity_check( + tmp_path, + monkeypatch, +) -> None: + monkeypatch.setattr("vibap.spend_budget.MAX_LEDGER_RECORDS", 3) + ledger = FileSpendBudgetLedger(tmp_path) + denied_policy = _policy(token_lineage=0) + old_quote = _quote(valid_from=0, valid_until=10) + for index in range(3): + result = ledger.reserve( + policy=denied_policy, + session_id="session-1", + agent_id="agent-1", + request=_request(f"old-reject-{index}"), + quote=old_quote, + now=5, + ) + assert result.accepted is False + + fresh = ledger.reserve( + policy=denied_policy, + session_id="session-1", + agent_id="agent-1", + request=_request("new-reject"), + quote=_quote(valid_from=10, valid_until=20), + now=11, + ) + + assert fresh.accepted is False + snapshot = ledger.snapshot("lineage-1") + assert len(snapshot["closed_reservations"]) == 1 + + +@pytest.mark.parametrize("tamper", ["negative", "accounting_mismatch"]) +def test_malformed_or_inconsistent_ledger_state_fails_closed(tmp_path, tamper) -> None: + ledger = FileSpendBudgetLedger(tmp_path) + ledger.reserve( + policy=_policy(), + session_id="session-1", + agent_id="agent-1", + request=_request("tamper-state"), + quote=_quote(), + ) + path = ledger._path("lineage-1") + payload = json.loads(path.read_text(encoding="utf-8")) + scope_ref = next(iter(payload["scopes"])) + if tamper == "negative": + payload["scopes"][scope_ref]["reserved"]["tokens"] = -1 + else: + payload["scopes"][scope_ref]["reserved"]["tokens"] += 1 + path.write_text(json.dumps(payload), encoding="utf-8") + + with pytest.raises(SpendBudgetError) as exc: + FileSpendBudgetLedger(tmp_path).snapshot("lineage-1") + assert exc.value.reason_code in { + "spend_ledger_invalid", + "spend_ledger_accounting_mismatch", + } + + +def test_stale_reservation_is_quarantined_without_refund_after_restart( + tmp_path, +) -> None: + ledger = FileSpendBudgetLedger(tmp_path) + ledger.reserve( + policy=_policy(), + session_id="session-1", + agent_id="agent-1", + request=_request("stale-1"), + quote=_quote(valid_from=0, valid_until=1_000), + now=100, + ) + + results = FileSpendBudgetLedger(tmp_path).quarantine_stale( + lineage_id="lineage-1", + session_id="session-1", + older_than_s=10, + now=111, + ) + snapshot = FileSpendBudgetLedger(tmp_path).snapshot("lineage-1") + + assert len(results) == 1 + assert results[0].reason_code == "spend_reservation_stale" + assert snapshot["reservations"] == {} + assert len(snapshot["quarantined_reservations"]) == 1 + assert any( + totals["reserved"]["tokens"] == 20 for totals in snapshot["scopes"].values() + ) + + +def test_sibling_session_cannot_close_or_stale_another_reservation(tmp_path) -> None: + ledger = FileSpendBudgetLedger(tmp_path) + ledger.reserve( + policy=_policy(), + session_id="session-a", + agent_id="agent-a", + request=_request("owned-by-a"), + quote=_quote(valid_from=0, valid_until=1_000), + now=100, + ) + + with pytest.raises(SpendBudgetConflictError) as exc: + ledger.settle( + lineage_id="lineage-1", + session_id="session-b", + request_id="owned-by-a", + actual_input_tokens=1, + actual_output_tokens=1, + usage_proof_digest="e" * 64, + ) + assert exc.value.reason_code == "spend_reservation_session_mismatch" + + assert ( + ledger.quarantine_stale( + lineage_id="lineage-1", + session_id="session-b", + older_than_s=0, + now=101, + ) + == [] + ) + quarantined = ledger.quarantine_stale( + lineage_id="lineage-1", + session_id="session-a", + older_than_s=0, + now=101, + ) + assert len(quarantined) == 1 + + +def test_root_issuance_binds_lineage_and_child_inherits_authority( + private_key, + public_key, +) -> None: + parent = MissionPassport( + agent_id="parent", + mission="coordinate", + allowed_tools=["llm_generate"], + resource_scope=["**"], + max_tool_calls=10, + delegation_allowed=True, + max_delegation_depth=1, + spend_budget=_mission_policy(), + ) + parent_token = issue_passport(parent, private_key, ttl_s=300) + parent_claims = verify_passport(parent_token, public_key) + child_token = derive_child_passport( + parent_token, + public_key, + private_key, + "child", + ["llm_generate"], + "run bounded generation", + child_max_tool_calls=2, + parent_calls_remaining=10, + ) + child_claims = verify_passport( + child_token, + public_key, + parent_token=parent_token, + ) + + assert parent_claims["spend_budget"]["lineage_id"] == parent_claims["jti"] + assert child_claims["spend_budget"] == parent_claims["spend_budget"] + + +def test_root_cannot_preselect_lineage_or_override_spend_with_extra_claims( + private_key, +) -> None: + preselected = MissionPassport( + agent_id="root", + mission="bounded root", + allowed_tools=["llm_generate"], + resource_scope=["**"], + spend_budget=_policy(lineage_id="attacker-selected-lineage"), + ) + with pytest.raises(ValueError, match="root spend_budget must omit lineage_id"): + issue_passport(preselected, private_key, ttl_s=60) + + normal = MissionPassport( + agent_id="root", + mission="bounded root", + allowed_tools=["llm_generate"], + resource_scope=["**"], + spend_budget=_mission_policy(), + ) + with pytest.raises(ValueError, match="must not override"): + issue_passport( + normal, + private_key, + ttl_s=60, + extra_claims={"spend_budget": _policy(lineage_id="override")}, + ) + + +def test_direct_child_spend_policy_must_carry_inherited_lineage(private_key) -> None: + child = MissionPassport( + agent_id="child", + mission="bounded child", + allowed_tools=["llm_generate"], + resource_scope=["**"], + parent_jti="parent-jti", + spend_budget=_mission_policy(), + ) + with pytest.raises(ValueError, match="must inherit the parent lineage_id"): + issue_passport(child, private_key, ttl_s=60) + + +def _spend_proxy(tmp_path, public_key, private_key, session_keys_dir, policy) -> tuple: + quote = _quote() + proxy = GovernanceProxy( + log_path=tmp_path / "governance.jsonl", + receipts_log_path=tmp_path / "receipts.jsonl", + state_dir=tmp_path / "state", + keys_dir=session_keys_dir, + public_key=public_key, + private_key=private_key, + spend_quote_store=StaticSpendQuoteStore([quote]), + ) + mission = MissionPassport( + agent_id="metered-agent", + mission="bounded model call", + allowed_tools=["llm_generate"], + resource_scope=["**"], + max_tool_calls=10, + spend_budget=policy, + ) + token = issue_passport(mission, private_key, ttl_s=300) + return proxy, proxy.start_session(token) + + +def test_proxy_denies_cap_breach_before_executor_and_signs_denial( + tmp_path, + public_key, + private_key, + session_keys_dir, +) -> None: + proxy, session = _spend_proxy( + tmp_path, + public_key, + private_key, + session_keys_dir, + _mission_policy(token_session=19), + ) + executor_calls: list[dict] = [] + + def executor(arguments: dict) -> None: + executor_calls.append(arguments) + + decision, reason = proxy.evaluate_tool_call( + session, + "llm_generate", + {"input_hash": "bounded"}, + spend_request=_request("deny-before-provider"), + ) + if decision == Decision.PERMIT: + executor({"input_hash": "bounded"}) + + assert decision == Decision.DENY + assert reason == "spend_session_tokens_exhausted" + assert executor_calls == [] + entry = json.loads(proxy.receipts_log_path.read_text().splitlines()[-1]) + claims = verify_receipt(entry["jwt"], proxy.receipt_public_key) + assert claims["budget_delta"]["operation"] == "reject" + assert claims["budget_delta"]["requested"]["tokens"] == 20 + assert claims["budget_delta"]["reserved"]["tokens"] == 0 + assert "deny-before-provider" not in entry["jwt"] + + +def test_proxy_permit_and_settlement_emit_verifiable_chained_receipts( + tmp_path, + public_key, + private_key, + session_keys_dir, +) -> None: + proxy, session = _spend_proxy( + tmp_path, + public_key, + private_key, + session_keys_dir, + _mission_policy(), + ) + request = _request("provider-call-1") + decision, _ = proxy.evaluate_tool_call( + session, + "llm_generate", + {"input_hash": "not-a-prompt"}, + spend_request=request, + ) + assert decision == Decision.PERMIT + + result = proxy.settle_spend( + session, + request_id=request.request_id, + actual_input_tokens=4, + actual_output_tokens=3, + usage_proof_digest="c" * 64, + ) + + assert result.operation == "settle" + entries = [ + json.loads(line) + for line in proxy.receipts_log_path.read_text().splitlines() + if line.strip() + ] + assert len(entries) == 2 + first = verify_receipt(entries[0]["jwt"], proxy.receipt_public_key) + second = verify_receipt(entries[1]["jwt"], proxy.receipt_public_key) + assert first["budget_delta"]["operation"] == "reserve" + assert second["budget_delta"]["operation"] == "settle" + assert second["parent_receipt_hash"] is not None + assert second["budget_delta"]["actual"] == { + "tokens": 7, + "currency_micros": 10, + } + assert request.request_id not in entries[1]["jwt"] + assert proxy._session_no_out_of_scope_permits(proxy.get_session(session.jti)) + + +@pytest.mark.parametrize( + "failure_point", + [ + "_apply_mic_conformance_checks", + "_persist_session", + "_build_receipt_log_entry", + ], +) +def test_proxy_compensates_accepted_reservation_on_exception( + tmp_path, + public_key, + private_key, + session_keys_dir, + monkeypatch, + failure_point, +) -> None: + proxy, session = _spend_proxy( + tmp_path, + public_key, + private_key, + session_keys_dir, + _mission_policy(), + ) + monkeypatch.setattr( + proxy, + failure_point, + lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("injected")), + ) + + with pytest.raises(RuntimeError, match="injected"): + proxy.evaluate_tool_call( + session, + "llm_generate", + {"input_hash": "bounded"}, + spend_request=_request(f"exception-{failure_point}"), + ) + + lineage_id = session.passport_claims["spend_budget"]["lineage_id"] + snapshot = proxy.spend_budget_ledger.snapshot(lineage_id) + assert snapshot["reservations"] == {} + assert len(snapshot["closed_reservations"]) == 1 + record = next(iter(snapshot["closed_reservations"].values())) + assert record["operation"] == "release" + assert record["reason_code"] == "spend_evaluation_failed" + + +def test_proxy_compensation_failure_preserves_conservative_reservation( + tmp_path, + public_key, + private_key, + session_keys_dir, + monkeypatch, +) -> None: + proxy, session = _spend_proxy( + tmp_path, + public_key, + private_key, + session_keys_dir, + _mission_policy(), + ) + monkeypatch.setattr( + proxy, + "_apply_mic_conformance_checks", + lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("injected")), + ) + monkeypatch.setattr( + proxy.spend_budget_ledger, + "cancel", + lambda **kwargs: (_ for _ in ()).throw(OSError("disk unavailable")), + ) + + with pytest.raises(SpendBudgetError) as exc: + proxy.evaluate_tool_call( + session, + "llm_generate", + {"input_hash": "bounded"}, + spend_request=_request("compensation-failure"), + ) + + assert exc.value.reason_code == "spend_compensation_failed" + lineage_id = session.passport_claims["spend_budget"]["lineage_id"] + snapshot = proxy.spend_budget_ledger.snapshot(lineage_id) + assert len(snapshot["reservations"]) == 1 + + +def test_proxy_compensation_metrics_cannot_mask_original_failure( + tmp_path, + public_key, + private_key, + session_keys_dir, + monkeypatch, +) -> None: + proxy, session = _spend_proxy( + tmp_path, + public_key, + private_key, + session_keys_dir, + _mission_policy(), + ) + monkeypatch.setattr( + proxy, + "_apply_mic_conformance_checks", + lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("original")), + ) + monkeypatch.setattr( + proxy, + "_record_spend_close_metrics", + lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("metrics")), + ) + + with pytest.raises(RuntimeError, match="original"): + proxy.evaluate_tool_call( + session, + "llm_generate", + {"input_hash": "bounded"}, + spend_request=_request("metrics-failure"), + ) + + lineage_id = session.passport_claims["spend_budget"]["lineage_id"] + assert proxy.spend_budget_ledger.snapshot(lineage_id)["reservations"] == {} + + +def test_proxy_requires_spend_closure_before_finalization_and_freezes_chain( + tmp_path, + public_key, + private_key, + session_keys_dir, +) -> None: + proxy, session = _spend_proxy( + tmp_path, + public_key, + private_key, + session_keys_dir, + _mission_policy(), + ) + request = _request("finalization-gate") + decision, _ = proxy.evaluate_tool_call( + session, + "llm_generate", + {"input_hash": "bounded"}, + spend_request=request, + ) + assert decision == Decision.PERMIT + + with pytest.raises(PermissionError, match="spend_reservations_unresolved"): + proxy.end_session(session) + + proxy.quarantine_spend( + session, + request_id=request.request_id, + reason_code="spend_settlement_evidence_missing", + ) + proxy.end_session(session) + + with pytest.raises(PermissionError, match="session already finalized"): + proxy.quarantine_stale_spend(session, older_than_s=0) + with pytest.raises(PermissionError, match="session already finalized"): + proxy.quarantine_spend(session, request_id=request.request_id) + with pytest.raises(PermissionError, match="session already finalized"): + proxy.settle_spend( + session, + request_id=request.request_id, + actual_input_tokens=1, + actual_output_tokens=1, + usage_proof_digest="d" * 64, + ) + + +@pytest.mark.parametrize( + ("spend_request", "expected_reason"), + [ + (_request("missing-quote", quote_id="unknown"), "spend_quote_unknown"), + (None, "spend_reservation_missing"), + ], +) +def test_proxy_missing_quote_or_request_fails_closed_with_signed_reason( + tmp_path, + public_key, + private_key, + session_keys_dir, + spend_request, + expected_reason, +) -> None: + proxy, session = _spend_proxy( + tmp_path, + public_key, + private_key, + session_keys_dir, + _mission_policy(), + ) + + decision, reason = proxy.evaluate_tool_call( + session, + "llm_generate", + {"input_hash": "bounded"}, + spend_request=spend_request, + ) + + assert decision == Decision.INSUFFICIENT_EVIDENCE + assert reason == expected_reason + entry = json.loads(proxy.receipts_log_path.read_text().splitlines()[-1]) + claims = verify_receipt(entry["jwt"], proxy.receipt_public_key) + assert claims["reason"] == expected_reason + assert claims["public_denial_reason"] == "insufficient_evidence" + + +def test_proxy_expired_quote_fails_closed( + tmp_path, + public_key, + private_key, + session_keys_dir, +) -> None: + now = int(time.time()) + proxy = GovernanceProxy( + log_path=tmp_path / "governance.jsonl", + receipts_log_path=tmp_path / "receipts.jsonl", + state_dir=tmp_path / "state", + keys_dir=session_keys_dir, + public_key=public_key, + private_key=private_key, + spend_quote_store=StaticSpendQuoteStore( + [_quote(valid_from=now - 100, valid_until=now - 1)] + ), + ) + mission = MissionPassport( + agent_id="metered-agent", + mission="bounded model call", + allowed_tools=["llm_generate"], + resource_scope=["**"], + spend_budget=_mission_policy(), + ) + session = proxy.start_session(issue_passport(mission, private_key, ttl_s=300)) + + decision, reason = proxy.evaluate_tool_call( + session, + "llm_generate", + {"input_hash": "bounded"}, + spend_request=_request("expired-quote"), + ) + assert decision == Decision.INSUFFICIENT_EVIDENCE + assert reason == "spend_quote_expired" + + +def test_proxy_missing_settlement_evidence_quarantines_and_signs_receipt( + tmp_path, + public_key, + private_key, + session_keys_dir, +) -> None: + proxy, session = _spend_proxy( + tmp_path, + public_key, + private_key, + session_keys_dir, + _mission_policy(), + ) + request = _request("missing-usage-proof") + decision, _ = proxy.evaluate_tool_call( + session, + "llm_generate", + {"input_hash": "bounded"}, + spend_request=request, + ) + assert decision == Decision.PERMIT + + result = proxy.settle_spend( + session, + request_id=request.request_id, + actual_input_tokens=0, + actual_output_tokens=0, + usage_proof_digest=None, + ) + + assert result.operation == "quarantine" + assert result.reason_code == "spend_settlement_evidence_missing" + entry = json.loads(proxy.receipts_log_path.read_text().splitlines()[-1]) + claims = verify_receipt(entry["jwt"], proxy.receipt_public_key) + assert claims["verdict"] == "insufficient_evidence" + assert claims["budget_delta"]["operation"] == "quarantine" + assert claims["budget_delta"]["reserved"]["tokens"] == 20 + assert request.request_id not in entry["jwt"] + + +def test_spend_metrics_are_bounded_and_exclude_identifiers( + tmp_path, + public_key, + private_key, + session_keys_dir, + monkeypatch, +) -> None: + isolated_metrics = ArdurMetrics() + monkeypatch.setattr("vibap.proxy.ardur_metrics", isolated_metrics) + proxy, session = _spend_proxy( + tmp_path, + public_key, + private_key, + session_keys_dir, + _mission_policy(), + ) + request = _request("private-metric-request") + + decision, _ = proxy.evaluate_tool_call( + session, + "llm_generate", + {"input_hash": "bounded"}, + spend_request=request, + ) + assert decision == Decision.PERMIT + proxy.settle_spend( + session, + request_id=request.request_id, + actual_input_tokens=4, + actual_output_tokens=3, + usage_proof_digest="f" * 64, + ) + + rendered = isolated_metrics.render() + assert ( + 'ardur_spend_events_total{operation="reserve",outcome="spend_reserved"} 1' + in rendered + ) + assert ( + 'ardur_spend_events_total{operation="settle",outcome="spend_settled"} 1' + in rendered + ) + assert 'ardur_spend_amount_total{operation="reserve",unit="tokens"} 20' in rendered + assert 'ardur_spend_amount_total{operation="settle",unit="tokens"} 7' in rendered + assert "private-metric-request" not in rendered + assert session.jti not in rendered + assert session.passport_claims["sub"] not in rendered + + +def test_non_metered_passport_remains_backward_compatible( + proxy, + example_mission, + private_key, +) -> None: + session = proxy.start_session( + issue_passport(example_mission, private_key, ttl_s=60) + ) + decision, reason = proxy.evaluate_tool_call( + session, + "read_file", + {"path": "README.md"}, + ) + assert decision == Decision.PERMIT + assert reason == "within scope" + assert session.events[-1].budget_delta["resource"] == "tool_call" diff --git a/python/vibap/__init__.py b/python/vibap/__init__.py index 38ef04df..13b0a5de 100644 --- a/python/vibap/__init__.py +++ b/python/vibap/__init__.py @@ -29,6 +29,16 @@ ) from .proxy import Decision, GovernanceProxy, GovernanceSession, PolicyEvent from .receipt import ExecutionReceipt, build_receipt, sign_receipt, verify_receipt +from .spend_budget import ( + FileSpendBudgetLedger, + SpendBudgetConflictError, + SpendBudgetError, + SpendCloseResult, + SpendQuote, + SpendReservationRequest, + SpendReservationResult, + StaticSpendQuoteStore, +) __all__ = [ "ALGORITHM", @@ -44,12 +54,20 @@ "DRPVerifiedReceiptChainEvidence", "DRPVerifiedRevocationEvidence", "ExecutionReceipt", + "FileSpendBudgetLedger", "GovernanceProxy", "GovernanceSession", "MissionPassport", "MissionCache", "MissionDeclaration", "PolicyEvent", + "SpendBudgetConflictError", + "SpendBudgetError", + "SpendCloseResult", + "SpendQuote", + "SpendReservationRequest", + "SpendReservationResult", + "StaticSpendQuoteStore", "build_receipt", "compute_log_digest", "derive_child_passport", diff --git a/python/vibap/_specs/execution_receipt_v02.schema.json b/python/vibap/_specs/execution_receipt_v02.schema.json index e653de7d..2b8b6d49 100644 --- a/python/vibap/_specs/execution_receipt_v02.schema.json +++ b/python/vibap/_specs/execution_receipt_v02.schema.json @@ -385,6 +385,9 @@ }, { "$ref": "#/$defs/lineageBudgetDelta" + }, + { + "$ref": "#/$defs/spendBudgetDelta" } ] }, @@ -486,6 +489,73 @@ } } }, + "spendBudgetDelta": { + "type": "object", + "additionalProperties": false, + "required": [ + "operation", + "resource", + "requested", + "reserved", + "actual", + "refunded", + "remaining", + "currency", + "quote_digest", + "reservation_hash", + "reason_code" + ], + "properties": { + "operation": { + "type": "string", + "enum": ["reserve", "reject", "release", "settle", "quarantine"] + }, + "resource": {"const": "spend"}, + "requested": {"$ref": "#/$defs/spendAmounts"}, + "reserved": {"$ref": "#/$defs/spendAmounts"}, + "actual": {"$ref": "#/$defs/spendAmounts"}, + "refunded": {"$ref": "#/$defs/spendAmounts"}, + "remaining": { + "type": "object", + "additionalProperties": false, + "required": ["session", "agent", "lineage"], + "properties": { + "session": {"$ref": "#/$defs/spendAmounts"}, + "agent": {"$ref": "#/$defs/spendAmounts"}, + "lineage": {"$ref": "#/$defs/spendAmounts"} + } + }, + "currency": { + "type": "string", + "pattern": "^[A-Z]{3}$" + }, + "quote_digest": {"$ref": "#/$defs/sha256HexString"}, + "reservation_hash": {"$ref": "#/$defs/sha256HexString"}, + "reason_code": { + "type": "string", + "pattern": "^[A-Za-z0-9._:-]{1,128}$" + }, + "idempotent": {"type": "boolean"}, + "reconciled": {"type": "boolean"} + } + }, + "spendAmounts": { + "type": "object", + "additionalProperties": false, + "required": ["tokens", "currency_micros"], + "properties": { + "tokens": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "currency_micros": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + } + }, "policyDecision": { "type": "object", "additionalProperties": false, diff --git a/python/vibap/metrics.py b/python/vibap/metrics.py index dbb97c8a..8c22f0df 100644 --- a/python/vibap/metrics.py +++ b/python/vibap/metrics.py @@ -20,9 +20,14 @@ def __init__(self, name: str, help_text: str, labels: tuple[str, ...] = ()): self._lock = threading.Lock() def inc(self, **label_values: str) -> None: + self.add(1, **label_values) + + def add(self, amount: int, **label_values: str) -> None: + if isinstance(amount, bool) or not isinstance(amount, int) or amount < 0: + raise ValueError("counter amount must be a non-negative integer") key = tuple(label_values.get(label, "") for label in self.labels) with self._lock: - self._data[key] += 1 + self._data[key] += amount def render(self) -> str: lines = [f"# HELP {self.name} {self.help}", f"# TYPE {self.name} counter"] @@ -99,6 +104,16 @@ def __init__(self): self.kill_switch_active = _Gauge("ardur_kill_switch_active", "1 if kill switch is active") self.request_duration_seconds = _Histogram("ardur_request_duration_seconds", "Request duration in seconds") self.evaluation_duration_seconds = _Histogram("ardur_evaluation_duration_seconds", "Evaluation duration in seconds") + self.spend_events_total = _Counter( + "ardur_spend_events_total", + "Spend budget lifecycle events", + ("operation", "outcome"), + ) + self.spend_amount_total = _Counter( + "ardur_spend_amount_total", + "Spend budget amounts by lifecycle operation and unit", + ("operation", "unit"), + ) self._startup_time = time.time() def render(self) -> str: @@ -110,6 +125,8 @@ def render(self) -> str: self.kill_switch_active.render(), self.request_duration_seconds.render(), self.evaluation_duration_seconds.render(), + self.spend_events_total.render(), + self.spend_amount_total.render(), ] uptime = time.time() - self._startup_time parts.append(f"# HELP ardur_uptime_seconds Proxy uptime in seconds\n# TYPE ardur_uptime_seconds gauge\nardur_uptime_seconds {uptime:.3f}\n") diff --git a/python/vibap/passport.py b/python/vibap/passport.py index 9ec20c08..7ab20735 100644 --- a/python/vibap/passport.py +++ b/python/vibap/passport.py @@ -18,6 +18,8 @@ from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import ec +from .spend_budget import normalize_spend_budget + ALGORITHM = "ES256" DEFAULT_ISSUER = "vibap-governance-proxy" DEFAULT_AUDIENCE = "vibap-proxy" @@ -302,11 +304,17 @@ class MissionPassport: # DENY-wins across native + additional; formally verified in # verification/composition_smt.py (properties P1-P4). additional_policies: list[dict[str, Any]] = field(default_factory=list) + # Optional pre-action monetary/token authority. The mission form omits a + # lineage_id; issue_passport binds it to the fresh root JTI. Derived + # children inherit the signed policy and lineage identifier unchanged. + spend_budget: dict[str, Any] | None = None def __post_init__(self) -> None: # Validate/normalize cwd at construction time so an invalid passport # can never be issued. Empty string → None; relative → ValueError. self.cwd = _normalize_cwd(self.cwd) + if self.spend_budget is not None: + self.spend_budget = normalize_spend_budget(self.spend_budget) if ( UNRESTRICTED_RESOURCE_SCOPE_PATTERN in self.resource_scope and not resource_scope_is_explicitly_unrestricted(self.resource_scope) @@ -338,6 +346,7 @@ def __post_init__(self) -> None: "holder_spiffe_id", "additional_policies", # pluggable policy backends "mission_id", # H1: stable mission identifier for PolicyStore lookup + "spend_budget", # pre-action token and monetary authority # Mission-file metadata handled by load_mission_file / issue_passport "budget", "ttl_s", "issued_at", "expires_at", }) @@ -386,6 +395,7 @@ def from_dict(cls, data: dict[str, Any]) -> "MissionPassport": holder_spiffe_id=data.get("holder_spiffe_id"), additional_policies=list(data.get("additional_policies", [])), mission_id=data.get("mission_id"), + spend_budget=data.get("spend_budget"), ) def to_dict(self) -> dict[str, Any]: @@ -394,6 +404,8 @@ def to_dict(self) -> dict[str, Any]: data = asdict(self) if data.get("cwd") is None: data.pop("cwd", None) + if data.get("spend_budget") is None: + data.pop("spend_budget", None) return data @@ -621,6 +633,25 @@ def issue_passport( claims["max_tool_calls_per_class"] = mission.max_tool_calls_per_class if mission.additional_policies: claims["additional_policies"] = mission.additional_policies + if mission.spend_budget is not None: + inherited_lineage_id = mission.spend_budget.get("lineage_id") + if mission.parent_jti is None and inherited_lineage_id is not None: + raise ValueError( + "root spend_budget must omit lineage_id; issuance binds it to the fresh jti" + ) + if mission.parent_jti is not None and inherited_lineage_id is None: + raise ValueError( + "child spend_budget must inherit the parent lineage_id" + ) + claims["spend_budget"] = normalize_spend_budget( + mission.spend_budget, + lineage_id=( + str(inherited_lineage_id) + if inherited_lineage_id is not None + else jti + ), + require_lineage_id=True, + ) # K2 (I6): Proof of Possession via cnf claim. When the mission declares # a holder_key_thumbprint, the passport is bound to that key. Presenters # must prove possession by signing a KB-JWT with the matching private key. @@ -629,6 +660,10 @@ def issue_passport( if mission.holder_key_thumbprint: claims["cnf"] = {"jkt": mission.holder_key_thumbprint} if extra_claims: + if "spend_budget" in extra_claims: + raise ValueError( + "extra_claims must not override the normalized spend_budget authority" + ) claims.update(extra_claims) return jwt.encode(claims, private_key, algorithm=ALGORITHM) @@ -1160,6 +1195,14 @@ def derive_child_passport( max_delegation_depth=child_depth, parent_jti=parent["jti"], cwd=final_cwd, + spend_budget=( + normalize_spend_budget( + parent["spend_budget"], + require_lineage_id=True, + ) + if parent.get("spend_budget") is not None + else None + ), ) child_chain: list[dict[str, str]] = [{"jti": str(parent["jti"])}] # Embed parent's own token hash in the chain link. This is ONE of two diff --git a/python/vibap/proxy.py b/python/vibap/proxy.py index c90ec429..242f9eae 100644 --- a/python/vibap/proxy.py +++ b/python/vibap/proxy.py @@ -35,6 +35,16 @@ from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import ec +from .spend_budget import ( + FileSpendBudgetLedger, + SpendBudgetError, + SpendBudgetLedger, + SpendCloseResult, + SpendReservationRequest, + SpendReservationResult, + StaticSpendQuoteStore, + normalize_spend_budget, +) from .aat_adapter import ( AAT_CREDENTIAL_FORMAT, decode_aat_claims, @@ -2072,6 +2082,8 @@ def __init__( receipts_log_path: str | Path | None = None, policy_store: Any | None = None, lineage_budget_ledger: LineageBudgetLedger | None = None, + spend_quote_store: StaticSpendQuoteStore | None = None, + spend_budget_ledger: SpendBudgetLedger | None = None, biscuit_issuer_public_key: Any | None = None, biscuit_peer_trust_bundle: Any | None = None, biscuit_svid_audience: str = "ardur-proxy", @@ -2113,6 +2125,10 @@ def __init__( self.lineage_budget_ledger = lineage_budget_ledger or FileLineageBudgetLedger( self.state_dir ) + self.spend_quote_store = spend_quote_store + self.spend_budget_ledger = spend_budget_ledger or FileSpendBudgetLedger( + self.state_dir + ) self._biscuit_issuer_public_key = biscuit_issuer_public_key if ( not isinstance(biscuit_svid_audience, str) @@ -2454,6 +2470,153 @@ def _receipt_budget_delta( "side_effect_class": event.side_effect_class, } + @staticmethod + def _zero_spend_amounts() -> dict[str, int]: + return {"tokens": 0, "currency_micros": 0} + + @classmethod + def _spend_reservation_delta( + cls, + result: SpendReservationResult, + ) -> dict[str, Any]: + zero = cls._zero_spend_amounts() + return { + "operation": "reserve" if result.accepted else "reject", + "resource": "spend", + "requested": dict(result.reserved), + "reserved": dict(result.reserved) if result.accepted else zero, + "actual": cls._zero_spend_amounts(), + "refunded": cls._zero_spend_amounts(), + "remaining": copy.deepcopy(result.remaining), + "currency": result.currency, + "quote_digest": result.quote_digest, + "reservation_hash": result.reservation_hash, + "reason_code": result.reason_code, + "idempotent": result.idempotent, + } + + @classmethod + def _spend_close_delta(cls, result: SpendCloseResult) -> dict[str, Any]: + retained = ( + dict(result.reserved) + if result.operation == "quarantine" + else cls._zero_spend_amounts() + ) + return { + "operation": result.operation, + "resource": "spend", + "requested": dict(result.reserved), + "reserved": retained, + "actual": dict(result.actual), + "refunded": dict(result.refunded), + "remaining": copy.deepcopy(result.remaining), + "currency": result.currency, + "quote_digest": result.quote_digest, + "reservation_hash": result.reservation_hash, + "reason_code": result.reason_code, + "idempotent": result.idempotent, + "reconciled": result.reconciled, + } + + @staticmethod + def _spend_budget_remaining(delta: Mapping[str, Any]) -> dict[str, int]: + remaining: dict[str, int] = {} + raw_remaining = delta.get("remaining") + currency = str(delta.get("currency", "")).lower() + if not isinstance(raw_remaining, Mapping) or not currency: + return remaining + for scope in ("session", "agent", "lineage"): + amounts = raw_remaining.get(scope) + if not isinstance(amounts, Mapping): + continue + for dimension, bucket in ( + ("tokens", "tokens"), + ("currency_micros", f"{currency}_micros"), + ): + raw_amount = amounts.get(dimension) + if isinstance(raw_amount, int) and not isinstance(raw_amount, bool): + remaining[f"spend.{scope}.{bucket}"] = max(0, raw_amount) + return remaining + + def _reserve_spend_if_required( + self, + session: GovernanceSession, + tool_name: str, + policy_claims: Mapping[str, Any], + spend_request: SpendReservationRequest | None, + ) -> SpendReservationResult | None: + raw_policy = policy_claims.get("spend_budget") + if raw_policy is None: + if spend_request is not None: + raise SpendBudgetError("spend_request_unexpected") + return None + policy = normalize_spend_budget(raw_policy, require_lineage_id=True) + if tool_name not in policy["metered_tools"]: + if spend_request is not None: + raise SpendBudgetError("spend_request_unexpected") + return None + if spend_request is None: + raise SpendBudgetError("spend_reservation_missing") + if not isinstance(spend_request, SpendReservationRequest): + raise SpendBudgetError("spend_request_invalid") + if self.spend_quote_store is None: + raise SpendBudgetError("spend_quote_store_unavailable") + quote = self.spend_quote_store.resolve( + spend_request.quote_id, + tool_name=tool_name, + model=spend_request.model, + currency=str(policy["currency"]), + ) + return self.spend_budget_ledger.reserve( + policy=policy, + session_id=session.jti, + agent_id=str(session.passport_claims.get("sub", "unknown")), + request=spend_request, + quote=quote, + retention_until=int(session.passport_claims["exp"]), + ) + + @staticmethod + def _record_spend_amount_metrics( + operation: str, amounts: Mapping[str, int] + ) -> None: + for unit in ("tokens", "currency_micros"): + amount = amounts.get(unit) + if isinstance(amount, int) and not isinstance(amount, bool): + ardur_metrics.spend_amount_total.add( + max(0, amount), + operation=operation, + unit=unit, + ) + + @classmethod + def _record_spend_reservation_metrics( + cls, + result: SpendReservationResult, + ) -> None: + operation = "reserve" if result.accepted else "reject" + ardur_metrics.spend_events_total.inc( + operation=operation, + outcome=result.reason_code, + ) + cls._record_spend_amount_metrics(operation, result.reserved) + + @classmethod + def _record_spend_close_metrics(cls, result: SpendCloseResult) -> None: + ardur_metrics.spend_events_total.inc( + operation=result.operation, + outcome=result.reason_code, + ) + if result.operation == "settle": + cls._record_spend_amount_metrics("settle", result.actual) + cls._record_spend_amount_metrics("refund", result.refunded) + if result.reconciled: + cls._record_spend_amount_metrics("reconcile", result.actual) + elif result.operation == "release": + cls._record_spend_amount_metrics("release", result.refunded) + elif result.operation == "quarantine": + cls._record_spend_amount_metrics("quarantine", result.reserved) + def _build_receipt_log_entry( self, session: GovernanceSession, @@ -2489,13 +2652,19 @@ def _build_receipt_log_entry( "mission_ref": copy.deepcopy(policy_claims.get("mission_ref")), "mission_digest": policy_claims.get("mission_digest"), } + budget_remaining = self._receipt_budget_remaining(session, policy_claims) + if ( + isinstance(event.budget_delta, Mapping) + and event.budget_delta.get("resource") == "spend" + ): + budget_remaining.update(self._spend_budget_remaining(event.budget_delta)) receipt = build_receipt( decision, event, parent_receipt_hash=session.last_receipt_full_hash, policy_decisions=signed_policy_decisions, reason=audit_reason, - budget_remaining=self._receipt_budget_remaining(session, policy_claims), + budget_remaining=budget_remaining, ) signed_jwt = sign_receipt(receipt, self.receipt_private_key) session.last_receipt_id = receipt.receipt_id @@ -3389,9 +3558,57 @@ def evaluate_tool_call( arguments: dict[str, Any], *, receipt_callback: Callable[[str], None] | None = None, + spend_request: SpendReservationRequest | None = None, + ) -> tuple[Decision, str]: + compensation: dict[str, Any] = {} + try: + return self._evaluate_tool_call( + session, + tool_name, + arguments, + receipt_callback=receipt_callback, + spend_request=spend_request, + _spend_compensation=compensation, + ) + except BaseException as exc: + if compensation.get("accepted") and not compensation.get("closed"): + try: + result = self.spend_budget_ledger.cancel( + lineage_id=str(compensation["lineage_id"]), + session_id=str(compensation["session_id"]), + request_id=str(compensation["request_id"]), + reason_code=str( + compensation.get( + "cancel_reason", + "spend_evaluation_failed", + ) + ), + ) + compensation["closed"] = True + except BaseException as cancel_exc: + raise SpendBudgetError( + "spend_compensation_failed", + f"reservation compensation failed after {type(exc).__name__}", + ) from cancel_exc + try: + self._record_spend_close_metrics(result) + except Exception: # pragma: no cover - defensive observability boundary + logger.exception("failed to record spend compensation metrics") + raise + + def _evaluate_tool_call( + self, + session: GovernanceSession | str, + tool_name: str, + arguments: dict[str, Any], + *, + receipt_callback: Callable[[str], None] | None = None, + spend_request: SpendReservationRequest | None = None, + _spend_compensation: dict[str, Any], ) -> tuple[Decision, str]: arguments_snapshot = copy.deepcopy(arguments) receipt_entry: dict[str, Any] | None = None + spend_reservation: SpendReservationResult | None = None # Refresh persisted state under a per-session coordination lock before # mutating. Without this, separate proxies that share a state_dir can # both approve from stale in-memory snapshots and last-writer-wins the @@ -3475,9 +3692,53 @@ def evaluate_tool_call( self._persist_session(target) else: receipt_policy_claims = dict(policy_claims) - mic_result = self._apply_mic_conformance_checks( - target, tool_name, arguments_snapshot, receipt_policy_claims - ) + try: + spend_reservation = self._reserve_spend_if_required( + target, + tool_name, + receipt_policy_claims, + spend_request, + ) + except SpendBudgetError as exc: + mic_result = ( + Decision.INSUFFICIENT_EVIDENCE, + exc.reason_code, + DenialReason.TELEMETRY_MISSING, + ) + else: + if ( + spend_reservation is not None + and spend_reservation.accepted + ): + spend_policy = normalize_spend_budget( + receipt_policy_claims["spend_budget"], + require_lineage_id=True, + ) + _spend_compensation.update( + { + "accepted": True, + "closed": False, + "lineage_id": spend_policy["lineage_id"], + "session_id": target.jti, + "request_id": spend_request.request_id, + } + ) + if ( + spend_reservation is not None + and not spend_reservation.accepted + ): + mic_result = ( + Decision.DENY, + spend_reservation.reason_code, + DenialReason.BUDGET_EXHAUSTED, + ) + else: + mic_result = self._apply_mic_conformance_checks( + target, + tool_name, + arguments_snapshot, + receipt_policy_claims, + ) if mic_result is not None: decision, reason, denial_reason = mic_result self._record_tool_policy_event( @@ -3592,6 +3853,30 @@ def evaluate_tool_call( ) event = target.events[-1] self._persist_session(target) + if spend_reservation is not None: + if spend_reservation.accepted and decision != Decision.PERMIT: + _spend_compensation["cancel_reason"] = ( + "spend_action_not_permitted" + ) + spend_policy = normalize_spend_budget( + receipt_policy_claims["spend_budget"], + require_lineage_id=True, + ) + spend_close = self.spend_budget_ledger.cancel( + lineage_id=str(spend_policy["lineage_id"]), + session_id=target.jti, + request_id=str(spend_request.request_id), + reason_code="spend_action_not_permitted", + ) + _spend_compensation["closed"] = True + event.budget_delta = self._spend_close_delta(spend_close) + else: + event.budget_delta = self._spend_reservation_delta( + spend_reservation + ) + self._record_spend_reservation_metrics(spend_reservation) + if spend_reservation.accepted and decision != Decision.PERMIT: + self._record_spend_close_metrics(spend_close) receipt_entry = self._build_receipt_log_entry( target, event, @@ -3635,6 +3920,199 @@ def record_tool_result( target.events[-1].duration_ms = duration_ms self._persist_session(target) + def settle_spend( + self, + session: GovernanceSession | str, + *, + request_id: str, + actual_input_tokens: int, + actual_output_tokens: int, + usage_proof_digest: str | None, + ) -> SpendCloseResult: + """Settle a pre-action reservation and append signed chain evidence. + + ``usage_proof_digest`` identifies usage evidence produced by the + trusted provider adapter. Missing or invalid evidence quarantines the + full reservation instead of returning authority. + """ + + receipt_entry: dict[str, Any] | None = None + with self._locked_persisted_session(session) as target: + with target._lock: + self._assert_spend_lifecycle_open(target) + policy_claims = self._resolve_authoritative_policy_claims( + target.passport_claims + ) + raw_policy = policy_claims.get("spend_budget") + if raw_policy is None: + raise SpendBudgetError("spend_policy_missing") + policy = normalize_spend_budget(raw_policy, require_lineage_id=True) + result = self.spend_budget_ledger.settle( + lineage_id=str(policy["lineage_id"]), + session_id=target.jti, + request_id=request_id, + actual_input_tokens=actual_input_tokens, + actual_output_tokens=actual_output_tokens, + usage_proof_digest=usage_proof_digest, + ) + decision = ( + Decision.PERMIT + if result.operation == "settle" + else Decision.INSUFFICIENT_EVIDENCE + ) + event = self._spend_lifecycle_event( + target, + result, + decision=decision, + ) + target.events.append(event) + receipt_entry = self._build_receipt_log_entry( + target, + event, + decision, + result.reason_code, + dict(policy_claims), + ) + self._persist_session(target) + self._record_spend_close_metrics(result) + if receipt_entry is not None: + self._log_receipt(receipt_entry) + return result + + def quarantine_spend( + self, + session: GovernanceSession | str, + *, + request_id: str, + reason_code: str = "spend_settlement_evidence_missing", + ) -> SpendCloseResult: + """Retain a reservation when trusted settlement cannot be produced.""" + + receipt_entry: dict[str, Any] | None = None + with self._locked_persisted_session(session) as target: + with target._lock: + self._assert_spend_lifecycle_open(target) + policy_claims = self._resolve_authoritative_policy_claims( + target.passport_claims + ) + policy = normalize_spend_budget( + policy_claims.get("spend_budget"), + require_lineage_id=True, + ) + result = self.spend_budget_ledger.quarantine( + lineage_id=str(policy["lineage_id"]), + session_id=target.jti, + request_id=request_id, + reason_code=reason_code, + ) + event = self._spend_lifecycle_event( + target, + result, + decision=Decision.INSUFFICIENT_EVIDENCE, + ) + target.events.append(event) + receipt_entry = self._build_receipt_log_entry( + target, + event, + Decision.INSUFFICIENT_EVIDENCE, + result.reason_code, + dict(policy_claims), + ) + self._persist_session(target) + self._record_spend_close_metrics(result) + if receipt_entry is not None: + self._log_receipt(receipt_entry) + return result + + def quarantine_stale_spend( + self, + session: GovernanceSession | str, + *, + older_than_s: int, + now: int | None = None, + ) -> list[SpendCloseResult]: + """Quarantine stale active reservations without refunding authority.""" + + receipt_entries: list[dict[str, Any]] = [] + with self._locked_persisted_session(session) as target: + with target._lock: + self._assert_spend_lifecycle_open(target) + policy_claims = self._resolve_authoritative_policy_claims( + target.passport_claims + ) + policy = normalize_spend_budget( + policy_claims.get("spend_budget"), + require_lineage_id=True, + ) + results = self.spend_budget_ledger.quarantine_stale( + lineage_id=str(policy["lineage_id"]), + session_id=target.jti, + older_than_s=older_than_s, + now=now, + ) + for result in results: + event = self._spend_lifecycle_event( + target, + result, + decision=Decision.INSUFFICIENT_EVIDENCE, + ) + target.events.append(event) + receipt_entries.append( + self._build_receipt_log_entry( + target, + event, + Decision.INSUFFICIENT_EVIDENCE, + result.reason_code, + dict(policy_claims), + ) + ) + if results: + self._persist_session(target) + for result in results: + self._record_spend_close_metrics(result) + for entry in receipt_entries: + self._log_receipt(entry) + return results + + def _spend_lifecycle_event( + self, + session: GovernanceSession, + result: SpendCloseResult, + *, + decision: Decision, + ) -> PolicyEvent: + timestamp = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + arguments = { + "reservation_hash": result.reservation_hash, + "quote_digest": result.quote_digest, + } + return PolicyEvent( + timestamp=timestamp, + step_id=_receipt_step_id( + session.jti, + timestamp, + f"spend_{result.operation}", + arguments, + ), + actor=str(session.passport_claims.get("sub", "unknown")), + verifier_id=self.verifier_id, + tool_name=f"spend_{result.operation}", + arguments=arguments, + action_class="observe", + target="spend_budget", + resource_family="budget", + side_effect_class="none", + decision=decision, + reason=result.reason_code, + passport_jti=session.jti, + trace_id=session.jti, + run_nonce=session.run_nonce, + denial_reason=( + None if decision == Decision.PERMIT else DenialReason.TELEMETRY_MISSING + ), + budget_delta=self._spend_close_delta(result), + ) + def summarize_session(self, session: GovernanceSession | str) -> dict[str, Any]: with self._locked_persisted_session(session) as target: with target._lock: @@ -3713,6 +4191,20 @@ def _finalize_session_locked( ) -> tuple[dict[str, Any], bool]: if session.summary is not None: return dict(session.summary), False + policy_claims = self._resolve_authoritative_policy_claims( + session.passport_claims + ) + raw_spend_policy = policy_claims.get("spend_budget") + if raw_spend_policy is not None: + spend_policy = normalize_spend_budget( + raw_spend_policy, + require_lineage_id=True, + ) + if self.spend_budget_ledger.has_active_reservations( + lineage_id=str(spend_policy["lineage_id"]), + session_id=session.jti, + ): + raise PermissionError("spend_reservations_unresolved") session.end_time = time.time() summary = self._build_summary(session) session.summary = summary @@ -3869,12 +4361,24 @@ def _session_no_out_of_scope_permits(session: GovernanceSession) -> bool: for event in session.events: if event.decision != Decision.PERMIT: continue + if ( + event.resource_family == "budget" + and event.target == "spend_budget" + and event.tool_name + in {"spend_release", "spend_settle", "spend_quarantine"} + ): + continue if event.tool_name in forbidden: return False if tool_scope_mode != "unrestricted" and event.tool_name not in allowed: return False return True + @staticmethod + def _assert_spend_lifecycle_open(session: GovernanceSession) -> None: + if session.summary is not None or session.attestation_token is not None: + raise PermissionError("session already finalized") + def _session_path(self, session_id: str) -> Path: if not _SESSION_ID_RE.match(session_id): raise ValueError("invalid session ID format: must be UUID") diff --git a/python/vibap/receipt.py b/python/vibap/receipt.py index 206aae36..fe434614 100644 --- a/python/vibap/receipt.py +++ b/python/vibap/receipt.py @@ -278,9 +278,79 @@ def _validate_budget_delta(value: Any) -> None: if "idempotent" in value and not isinstance(value["idempotent"], bool): _schema_violation("budget_delta.idempotent must be boolean") return + spend_required = { + "operation", + "resource", + "requested", + "reserved", + "actual", + "refunded", + "remaining", + "currency", + "quote_digest", + "reservation_hash", + "reason_code", + } + if spend_required <= set(value): + allowed = spend_required | {"idempotent", "reconciled"} + if set(value) - allowed: + _schema_violation("budget_delta contains unknown spend fields") + if value.get("operation") not in { + "reserve", "reject", "release", "settle", "quarantine" + }: + _schema_violation("budget_delta.operation has invalid spend value") + if value.get("resource") != "spend": + _schema_violation("budget_delta.resource must be 'spend'") + for field_name in ("requested", "reserved", "actual", "refunded"): + _validate_spend_amounts(value.get(field_name), f"budget_delta.{field_name}") + remaining = value.get("remaining") + if not isinstance(remaining, dict) or set(remaining) != { + "session", "agent", "lineage" + }: + _schema_violation( + "budget_delta.remaining must contain session, agent, and lineage" + ) + for scope in ("session", "agent", "lineage"): + _validate_spend_amounts( + remaining[scope], + f"budget_delta.remaining.{scope}", + ) + currency = value.get("currency") + if not isinstance(currency, str) or re.fullmatch(r"[A-Z]{3}", currency) is None: + _schema_violation("budget_delta.currency must be an uppercase code") + for key in ("quote_digest", "reservation_hash"): + digest = value.get(key) + if not isinstance(digest, str) or not _SHA256_HEX_RE.fullmatch(digest): + _schema_violation(f"budget_delta.{key} must be a SHA-256 hex digest") + reason_code = value.get("reason_code") + if ( + not isinstance(reason_code, str) + or not reason_code + or len(reason_code) > 128 + or _TOKEN_FIELD_RE.fullmatch(reason_code) is None + ): + _schema_violation("budget_delta.reason_code has invalid value") + for key in ("idempotent", "reconciled"): + if key in value and not isinstance(value[key], bool): + _schema_violation(f"budget_delta.{key} must be boolean") + return _schema_violation("budget_delta must match a supported shape") +def _validate_spend_amounts(value: Any, field_name: str) -> None: + if not isinstance(value, dict) or set(value) != {"tokens", "currency_micros"}: + _schema_violation(f"{field_name} must contain tokens and currency_micros") + for dimension in ("tokens", "currency_micros"): + amount = value[dimension] + if ( + isinstance(amount, bool) + or not isinstance(amount, int) + or amount < 0 + or amount > 9_007_199_254_740_991 + ): + _schema_violation(f"{field_name}.{dimension} must be a safe integer") + + def _validate_receipt_claim_schema(claims: dict[str, Any]) -> None: schema_version = claims.get("schema_version") if schema_version is None: diff --git a/python/vibap/spend_budget.py b/python/vibap/spend_budget.py new file mode 100644 index 00000000..1a7ec4df --- /dev/null +++ b/python/vibap/spend_budget.py @@ -0,0 +1,1370 @@ +"""Pre-action token and monetary spend reservation. + +The existing :mod:`vibap.lineage_budget` ledger conserves delegated tool-call +counts. Spend authority is deliberately separate: it has two units, a +reserve/settle lifecycle, and an operator-owned quote trust boundary. + +All authorization arithmetic is integer-only. Quote and request fingerprints +use the package's RFC 8785 canonical JSON implementation. The local reference +ledger serializes writers with ``flock`` and durably replaces state files; it +does not fetch mutable provider pricing on the authorization hot path. +""" + +from __future__ import annotations + +import contextlib +import fcntl +import hashlib +import json +import os +import re +import threading +import time +import uuid +import weakref +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterable, Mapping + +from .canonical_json import canonical_json_bytes + + +SPEND_BUDGET_VERSION = 1 +SPEND_QUOTE_VERSION = 1 +SPEND_LEDGER_VERSION = 1 +MAX_SAFE_INTEGER = 9_007_199_254_740_991 +MAX_METERED_TOOLS = 256 +MAX_LEDGER_RECORDS = 100_000 +_CURRENCY_RE = re.compile(r"^[A-Z]{3}$") +_DIGEST_RE = re.compile(r"^[0-9a-f]{64}$") +_SCOPES = ("session", "agent", "lineage") +_DIMENSIONS = ("tokens", "currency_micros") + + +class SpendBudgetError(ValueError): + """Fail-closed spend policy, quote, or settlement error.""" + + def __init__(self, reason_code: str, detail: str | None = None) -> None: + self.reason_code = reason_code + super().__init__(detail or reason_code) + + +class SpendBudgetConflictError(SpendBudgetError): + """A stable request identifier was reused with different semantics.""" + + +def _non_negative_int(value: Any, field_name: str) -> int: + if isinstance(value, bool) or not isinstance(value, int): + raise SpendBudgetError( + "spend_policy_invalid", + f"{field_name} must be a non-negative integer", + ) + if value < 0 or value > MAX_SAFE_INTEGER: + raise SpendBudgetError( + "spend_policy_invalid", + f"{field_name} must be between 0 and {MAX_SAFE_INTEGER}", + ) + return value + + +def _bounded_string(value: Any, field_name: str, *, max_bytes: int = 256) -> str: + if not isinstance(value, str) or not value.strip(): + raise SpendBudgetError( + "spend_policy_invalid", + f"{field_name} must be a non-empty string", + ) + normalized = value.strip() + if len(normalized.encode("utf-8")) > max_bytes: + raise SpendBudgetError( + "spend_policy_invalid", + f"{field_name} exceeds {max_bytes} bytes", + ) + return normalized + + +def _currency(value: Any, field_name: str = "currency") -> str: + normalized = _bounded_string(value, field_name, max_bytes=3) + if not _CURRENCY_RE.fullmatch(normalized): + raise SpendBudgetError( + "spend_policy_invalid", + f"{field_name} must be a three-letter uppercase currency code", + ) + return normalized + + +def _reject_unknown_fields( + value: Mapping[str, Any], + allowed: set[str], + field_name: str, +) -> None: + unknown = sorted(set(value) - allowed) + if unknown: + raise SpendBudgetError( + "spend_policy_invalid", + f"{field_name} contains unknown fields: {unknown}", + ) + + +def normalize_spend_budget( + raw: Mapping[str, Any], + *, + lineage_id: str | None = None, + require_lineage_id: bool = False, +) -> dict[str, Any]: + """Validate and canonicalize a signed ``spend_budget`` claim. + + Mission input omits ``lineage_id``. Root issuance supplies the fresh + passport JTI; child derivation preserves the already-signed identifier. + """ + + if not isinstance(raw, Mapping): + raise SpendBudgetError("spend_policy_invalid", "spend_budget must be an object") + _reject_unknown_fields( + raw, + {"version", "currency", "metered_tools", "ceilings", "lineage_id"}, + "spend_budget", + ) + version = _non_negative_int(raw.get("version"), "spend_budget.version") + if version != SPEND_BUDGET_VERSION: + raise SpendBudgetError( + "spend_policy_invalid", + f"unsupported spend_budget.version {version}", + ) + currency = _currency(raw.get("currency"), "spend_budget.currency") + + tools_raw = raw.get("metered_tools") + if not isinstance(tools_raw, (list, tuple)) or not tools_raw: + raise SpendBudgetError( + "spend_policy_invalid", + "spend_budget.metered_tools must be a non-empty array", + ) + if len(tools_raw) > MAX_METERED_TOOLS: + raise SpendBudgetError( + "spend_policy_invalid", + f"spend_budget.metered_tools exceeds {MAX_METERED_TOOLS} entries", + ) + tools = sorted( + {_bounded_string(item, "spend_budget.metered_tools[]") for item in tools_raw} + ) + if len(tools) != len(tools_raw): + raise SpendBudgetError( + "spend_policy_invalid", + "spend_budget.metered_tools must not contain duplicates", + ) + + ceilings_raw = raw.get("ceilings") + if not isinstance(ceilings_raw, Mapping): + raise SpendBudgetError( + "spend_policy_invalid", + "spend_budget.ceilings must be an object", + ) + _reject_unknown_fields(ceilings_raw, set(_DIMENSIONS), "spend_budget.ceilings") + if set(ceilings_raw) != set(_DIMENSIONS): + raise SpendBudgetError( + "spend_policy_invalid", + "spend_budget.ceilings must declare tokens and currency_micros", + ) + ceilings: dict[str, dict[str, int]] = {} + for dimension in _DIMENSIONS: + scope_raw = ceilings_raw.get(dimension) + if not isinstance(scope_raw, Mapping) or set(scope_raw) != set(_SCOPES): + raise SpendBudgetError( + "spend_policy_invalid", + f"spend_budget.ceilings.{dimension} must declare session, agent, and lineage", + ) + ceilings[dimension] = { + scope: _non_negative_int( + scope_raw.get(scope), + f"spend_budget.ceilings.{dimension}.{scope}", + ) + for scope in _SCOPES + } + + claimed_lineage = raw.get("lineage_id") + if lineage_id is not None and claimed_lineage is not None: + normalized_claimed = _bounded_string( + claimed_lineage, + "spend_budget.lineage_id", + ) + if normalized_claimed != lineage_id: + raise SpendBudgetError( + "spend_policy_invalid", + "spend_budget.lineage_id conflicts with issuance lineage", + ) + effective_lineage = lineage_id if lineage_id is not None else claimed_lineage + if effective_lineage is not None: + effective_lineage = _bounded_string( + effective_lineage, + "spend_budget.lineage_id", + ) + elif require_lineage_id: + raise SpendBudgetError( + "spend_policy_invalid", + "spend_budget.lineage_id is required at runtime", + ) + + normalized: dict[str, Any] = { + "version": SPEND_BUDGET_VERSION, + "currency": currency, + "metered_tools": tools, + "ceilings": ceilings, + } + if effective_lineage is not None: + normalized["lineage_id"] = effective_lineage + return normalized + + +@dataclass(frozen=True, slots=True) +class SpendQuote: + """Immutable operator-controlled token price snapshot.""" + + quote_id: str + tool_name: str + model: str + currency: str + input_micros_per_million_tokens: int + output_micros_per_million_tokens: int + valid_from: int + valid_until: int + + def __post_init__(self) -> None: + object.__setattr__(self, "quote_id", _bounded_string(self.quote_id, "quote_id")) + object.__setattr__( + self, "tool_name", _bounded_string(self.tool_name, "tool_name") + ) + object.__setattr__(self, "model", _bounded_string(self.model, "model")) + object.__setattr__(self, "currency", _currency(self.currency)) + for field_name in ( + "input_micros_per_million_tokens", + "output_micros_per_million_tokens", + "valid_from", + "valid_until", + ): + object.__setattr__( + self, + field_name, + _non_negative_int(getattr(self, field_name), field_name), + ) + if self.valid_until <= self.valid_from: + raise SpendBudgetError( + "spend_quote_invalid", + "valid_until must be greater than valid_from", + ) + + def canonical_payload(self) -> dict[str, Any]: + return { + "version": SPEND_QUOTE_VERSION, + "quote_id": self.quote_id, + "tool_name": self.tool_name, + "model": self.model, + "currency": self.currency, + "input_micros_per_million_tokens": self.input_micros_per_million_tokens, + "output_micros_per_million_tokens": self.output_micros_per_million_tokens, + "valid_from": self.valid_from, + "valid_until": self.valid_until, + } + + @property + def digest(self) -> str: + return hashlib.sha256( + canonical_json_bytes(self.canonical_payload()) + ).hexdigest() + + def reserve_amounts(self, input_tokens: int, output_tokens: int) -> dict[str, int]: + input_count = _non_negative_int(input_tokens, "input_tokens") + output_count = _non_negative_int(output_tokens, "output_tokens") + total_tokens = input_count + output_count + if total_tokens > MAX_SAFE_INTEGER: + raise SpendBudgetError("spend_amount_overflow") + input_micros = _ceil_div( + input_count * self.input_micros_per_million_tokens, + 1_000_000, + ) + output_micros = _ceil_div( + output_count * self.output_micros_per_million_tokens, + 1_000_000, + ) + money = input_micros + output_micros + if money > MAX_SAFE_INTEGER: + raise SpendBudgetError("spend_amount_overflow") + return {"tokens": total_tokens, "currency_micros": money} + + +def _ceil_div(numerator: int, denominator: int) -> int: + return (numerator + denominator - 1) // denominator + + +class StaticSpendQuoteStore: + """In-memory immutable quote set loaded by the operator at startup.""" + + def __init__(self, quotes: Iterable[SpendQuote]) -> None: + by_id: dict[str, SpendQuote] = {} + for quote in quotes: + if not isinstance(quote, SpendQuote): + raise TypeError("quotes must contain SpendQuote values") + if quote.quote_id in by_id: + raise SpendBudgetError( + "spend_quote_invalid", + f"duplicate quote_id {quote.quote_id!r}", + ) + by_id[quote.quote_id] = quote + self._quotes = by_id + + def resolve( + self, + quote_id: str, + *, + tool_name: str, + model: str, + currency: str, + now: int | None = None, + ) -> SpendQuote: + quote = self._quotes.get(quote_id) + if quote is None: + raise SpendBudgetError("spend_quote_unknown") + if quote.tool_name != tool_name or quote.model != model: + raise SpendBudgetError("spend_quote_scope_mismatch") + if quote.currency != currency: + raise SpendBudgetError("spend_quote_currency_mismatch") + observed = int(time.time() if now is None else now) + if observed < quote.valid_from: + raise SpendBudgetError("spend_quote_not_yet_valid") + if observed >= quote.valid_until: + raise SpendBudgetError("spend_quote_expired") + return quote + + +@dataclass(frozen=True, slots=True) +class SpendReservationRequest: + request_id: str + quote_id: str + model: str + max_input_tokens: int + max_output_tokens: int + + def __post_init__(self) -> None: + object.__setattr__( + self, + "request_id", + _bounded_string(self.request_id, "request_id", max_bytes=512), + ) + object.__setattr__(self, "quote_id", _bounded_string(self.quote_id, "quote_id")) + object.__setattr__(self, "model", _bounded_string(self.model, "model")) + object.__setattr__( + self, + "max_input_tokens", + _non_negative_int(self.max_input_tokens, "max_input_tokens"), + ) + object.__setattr__( + self, + "max_output_tokens", + _non_negative_int(self.max_output_tokens, "max_output_tokens"), + ) + if self.max_input_tokens + self.max_output_tokens <= 0: + raise SpendBudgetError( + "spend_request_invalid", + "at least one reserved token is required", + ) + + +@dataclass(frozen=True, slots=True) +class SpendReservationResult: + accepted: bool + reason_code: str + reservation_hash: str + quote_digest: str + currency: str + reserved: dict[str, int] + remaining: dict[str, dict[str, int]] + idempotent: bool = False + + +@dataclass(frozen=True, slots=True) +class SpendCloseResult: + operation: str + reason_code: str + reservation_hash: str + quote_digest: str + currency: str + reserved: dict[str, int] + actual: dict[str, int] + refunded: dict[str, int] + remaining: dict[str, dict[str, int]] + idempotent: bool = False + reconciled: bool = False + + +class SpendBudgetLedger: + def reserve( + self, + *, + policy: Mapping[str, Any], + session_id: str, + agent_id: str, + request: SpendReservationRequest, + quote: SpendQuote, + now: int | None = None, + retention_until: int | None = None, + ) -> SpendReservationResult: + raise NotImplementedError + + def cancel( + self, + *, + lineage_id: str, + session_id: str, + request_id: str, + reason_code: str, + ) -> SpendCloseResult: + raise NotImplementedError + + def settle( + self, + *, + lineage_id: str, + session_id: str, + request_id: str, + actual_input_tokens: int, + actual_output_tokens: int, + usage_proof_digest: str | None, + ) -> SpendCloseResult: + raise NotImplementedError + + def quarantine( + self, + *, + lineage_id: str, + session_id: str, + request_id: str, + reason_code: str, + ) -> SpendCloseResult: + raise NotImplementedError + + def quarantine_stale( + self, + *, + lineage_id: str, + session_id: str, + older_than_s: int, + now: int | None = None, + ) -> list[SpendCloseResult]: + raise NotImplementedError + + def snapshot(self, lineage_id: str) -> dict[str, Any]: + raise NotImplementedError + + def has_active_reservations( + self, + *, + lineage_id: str, + session_id: str, + ) -> bool: + raise NotImplementedError + + +class _ProcessLock: + __slots__ = ("lock", "__weakref__") + + def __init__(self) -> None: + self.lock = threading.RLock() + + +_LOCKS: weakref.WeakValueDictionary[str, _ProcessLock] = weakref.WeakValueDictionary() +_LOCKS_GUARD = threading.Lock() + + +class FileSpendBudgetLedger(SpendBudgetLedger): + """File-backed, cross-process spend ledger rooted under ``state_dir``.""" + + def __init__(self, state_dir: str | Path) -> None: + self.state_dir = Path(state_dir).expanduser() + self.ledger_dir = self.state_dir / "spend_budgets" + self.ledger_dir.mkdir(parents=True, mode=0o700, exist_ok=True) + self.ledger_dir.chmod(0o700) + + def reserve( + self, + *, + policy: Mapping[str, Any], + session_id: str, + agent_id: str, + request: SpendReservationRequest, + quote: SpendQuote, + now: int | None = None, + retention_until: int | None = None, + ) -> SpendReservationResult: + normalized = normalize_spend_budget(policy, require_lineage_id=True) + lineage_id = str(normalized["lineage_id"]) + if quote.currency != normalized["currency"]: + raise SpendBudgetError("spend_quote_currency_mismatch") + if quote.quote_id != request.quote_id or quote.model != request.model: + raise SpendBudgetError("spend_quote_scope_mismatch") + observed = int(time.time() if now is None else now) + if observed < quote.valid_from: + raise SpendBudgetError("spend_quote_not_yet_valid") + if observed >= quote.valid_until: + raise SpendBudgetError("spend_quote_expired") + terminal_retention = _non_negative_int( + quote.valid_until if retention_until is None else retention_until, + "retention_until", + ) + if terminal_retention <= observed: + raise SpendBudgetError("spend_retention_expired") + + reserved = quote.reserve_amounts( + request.max_input_tokens, + request.max_output_tokens, + ) + scope_refs = { + "session": _scope_hash("session", session_id), + "agent": _scope_hash("agent", agent_id), + "lineage": _scope_hash("lineage", lineage_id), + } + reservation_hash = _reservation_hash(request.request_id) + fingerprint_payload = { + "version": SPEND_LEDGER_VERSION, + "lineage_id_hash": _scope_hash("lineage", lineage_id), + "scope_refs": scope_refs, + "policy": normalized, + "quote_digest": quote.digest, + "request": { + "request_id_hash": reservation_hash, + "quote_id_hash": _opaque_hash("quote", request.quote_id), + "model_hash": _opaque_hash("model", request.model), + "max_input_tokens": request.max_input_tokens, + "max_output_tokens": request.max_output_tokens, + }, + } + fingerprint = hashlib.sha256( + canonical_json_bytes(fingerprint_payload) + ).hexdigest() + + with self._locked(lineage_id): + payload = self._load(lineage_id) + self._prune_terminal_records(payload, now=observed) + active = payload["reservations"] + closed = payload["closed_reservations"] + quarantined = payload["quarantined_reservations"] + + prior_active = active.get(reservation_hash) + if isinstance(prior_active, dict): + self._require_fingerprint(prior_active, fingerprint) + return self._result_from_record( + prior_active, + accepted=False, + reason_code="spend_request_already_reserved", + payload=payload, + idempotent=True, + ) + prior_quarantined = quarantined.get(reservation_hash) + if isinstance(prior_quarantined, dict): + self._require_fingerprint(prior_quarantined, fingerprint) + return self._result_from_record( + prior_quarantined, + accepted=False, + reason_code="spend_reservation_quarantined", + payload=payload, + idempotent=True, + ) + prior_closed = closed.get(reservation_hash) + if isinstance(prior_closed, dict): + self._require_fingerprint(prior_closed, fingerprint) + reason = str( + prior_closed.get("reason_code") or "spend_request_already_closed" + ) + return self._result_from_record( + prior_closed, + accepted=False, + reason_code=reason, + payload=payload, + idempotent=True, + ) + if ( + sum(len(container) for container in (active, closed, quarantined)) + >= MAX_LEDGER_RECORDS + ): + raise SpendBudgetError("spend_ledger_capacity_exhausted") + + remaining = self._remaining(payload, normalized, scope_refs) + breach = _first_breach(reserved, remaining) + record = { + "fingerprint": fingerprint, + "reservation_hash": reservation_hash, + "quote_digest": quote.digest, + "quote_id_hash": _opaque_hash("quote", request.quote_id), + "currency": quote.currency, + "reserved": dict(reserved), + "scope_refs": scope_refs, + "ceilings": normalized["ceilings"], + "max_input_tokens": request.max_input_tokens, + "max_output_tokens": request.max_output_tokens, + "pricing": { + "input_micros_per_million_tokens": quote.input_micros_per_million_tokens, + "output_micros_per_million_tokens": quote.output_micros_per_million_tokens, + }, + "created_at": observed, + "retention_until": terminal_retention, + } + if breach is not None: + scope, dimension = breach + record.update( + { + "operation": "reject", + "reason_code": f"spend_{scope}_{dimension}_exhausted", + "remaining_at_decision": remaining, + "closed_at": observed, + } + ) + closed[reservation_hash] = record + self._persist(lineage_id, payload) + return self._result_from_record( + record, + accepted=False, + reason_code=str(record["reason_code"]), + payload=payload, + ) + + for scope in _SCOPES: + totals = self._scope_totals(payload, scope_refs[scope]) + for dimension in _DIMENSIONS: + totals["reserved"][dimension] += reserved[dimension] + active[reservation_hash] = record + self._persist(lineage_id, payload) + return self._result_from_record( + record, + accepted=True, + reason_code="spend_reserved", + payload=payload, + ) + + def cancel( + self, + *, + lineage_id: str, + session_id: str, + request_id: str, + reason_code: str, + ) -> SpendCloseResult: + return self._close_without_spend( + lineage_id=lineage_id, + session_id=session_id, + request_id=request_id, + operation="release", + reason_code=_bounded_string(reason_code, "reason_code"), + ) + + def settle( + self, + *, + lineage_id: str, + session_id: str, + request_id: str, + actual_input_tokens: int, + actual_output_tokens: int, + usage_proof_digest: str | None, + ) -> SpendCloseResult: + input_tokens = _non_negative_int(actual_input_tokens, "actual_input_tokens") + output_tokens = _non_negative_int(actual_output_tokens, "actual_output_tokens") + reservation_hash = _reservation_hash(request_id) + settlement_payload = { + "reservation_hash": reservation_hash, + "actual_input_tokens": input_tokens, + "actual_output_tokens": output_tokens, + "usage_proof_digest": usage_proof_digest, + } + settlement_fingerprint = hashlib.sha256( + canonical_json_bytes(settlement_payload) + ).hexdigest() + + with self._locked(lineage_id): + payload = self._load(lineage_id) + closed = payload["closed_reservations"] + prior = closed.get(reservation_hash) + if isinstance(prior, dict): + self._require_session_scope(prior, session_id) + if prior.get("operation") != "settle": + raise SpendBudgetConflictError( + "spend_settlement_conflict", + "reservation is already closed without settlement", + ) + if prior.get("settlement_fingerprint") != settlement_fingerprint: + raise SpendBudgetConflictError( + "spend_settlement_conflict", + "settlement replay carries different usage evidence", + ) + return self._close_result(prior, payload, idempotent=True) + + active = payload["reservations"] + quarantined = payload["quarantined_reservations"] + record = active.get(reservation_hash) + reconciled = False + if not isinstance(record, dict): + record = quarantined.get(reservation_hash) + reconciled = isinstance(record, dict) + if not isinstance(record, dict): + raise SpendBudgetError("spend_reservation_unknown") + self._require_session_scope(record, session_id) + + proof_valid = ( + isinstance(usage_proof_digest, str) + and _DIGEST_RE.fullmatch(usage_proof_digest) is not None + ) + within_reservation = input_tokens <= int( + record["max_input_tokens"] + ) and output_tokens <= int(record["max_output_tokens"]) + if not proof_valid or not within_reservation: + reason = ( + "spend_settlement_evidence_missing" + if not proof_valid + else "spend_usage_exceeds_reservation" + ) + if not reconciled: + del active[reservation_hash] + record = dict(record) + record.update( + { + "operation": "quarantine", + "reason_code": reason, + "quarantined_at": int(time.time()), + } + ) + quarantined[reservation_hash] = record + self._persist(lineage_id, payload) + return self._close_result(record, payload, reconciled=False) + + pricing = record["pricing"] + actual = { + "tokens": input_tokens + output_tokens, + "currency_micros": _ceil_div( + input_tokens * int(pricing["input_micros_per_million_tokens"]), + 1_000_000, + ) + + _ceil_div( + output_tokens * int(pricing["output_micros_per_million_tokens"]), + 1_000_000, + ), + } + reserved = {key: int(record["reserved"][key]) for key in _DIMENSIONS} + if any(actual[key] > reserved[key] for key in _DIMENSIONS): + if not reconciled: + del active[reservation_hash] + record = dict(record) + record.update( + { + "operation": "quarantine", + "reason_code": "spend_usage_exceeds_reservation", + "quarantined_at": int(time.time()), + } + ) + quarantined[reservation_hash] = record + self._persist(lineage_id, payload) + return self._close_result(record, payload, reconciled=False) + + for scope in _SCOPES: + totals = self._scope_totals(payload, record["scope_refs"][scope]) + for dimension in _DIMENSIONS: + totals["reserved"][dimension] -= reserved[dimension] + totals["spent"][dimension] += actual[dimension] + if reconciled: + del quarantined[reservation_hash] + else: + del active[reservation_hash] + record = dict(record) + record.update( + { + "operation": "settle", + "reason_code": "spend_settled", + "settlement_fingerprint": settlement_fingerprint, + "usage_proof_digest": usage_proof_digest, + "actual": actual, + "refunded": { + key: reserved[key] - actual[key] for key in _DIMENSIONS + }, + "settled_at": int(time.time()), + "reconciled": reconciled, + } + ) + closed[reservation_hash] = record + self._persist(lineage_id, payload) + return self._close_result(record, payload, reconciled=reconciled) + + def quarantine( + self, + *, + lineage_id: str, + session_id: str, + request_id: str, + reason_code: str, + ) -> SpendCloseResult: + reservation_hash = _reservation_hash(request_id) + reason = _bounded_string(reason_code, "reason_code") + with self._locked(lineage_id): + payload = self._load(lineage_id) + active = payload["reservations"] + quarantined = payload["quarantined_reservations"] + prior = quarantined.get(reservation_hash) + if isinstance(prior, dict): + self._require_session_scope(prior, session_id) + if prior.get("reason_code") != reason: + raise SpendBudgetConflictError("spend_quarantine_conflict") + return self._close_result(prior, payload, idempotent=True) + prior_closed = payload["closed_reservations"].get(reservation_hash) + if isinstance(prior_closed, dict): + self._require_session_scope(prior_closed, session_id) + raise SpendBudgetConflictError("spend_reservation_already_closed") + record = active.get(reservation_hash) + if not isinstance(record, dict): + raise SpendBudgetError("spend_reservation_unknown") + self._require_session_scope(record, session_id) + del active[reservation_hash] + record = dict(record) + record.update( + { + "operation": "quarantine", + "reason_code": reason, + "quarantined_at": int(time.time()), + } + ) + quarantined[reservation_hash] = record + self._persist(lineage_id, payload) + return self._close_result(record, payload) + + def quarantine_stale( + self, + *, + lineage_id: str, + session_id: str, + older_than_s: int, + now: int | None = None, + ) -> list[SpendCloseResult]: + age = _non_negative_int(older_than_s, "older_than_s") + observed = int(time.time() if now is None else now) + cutoff = observed - age + session_ref = _scope_hash("session", session_id) + results: list[SpendCloseResult] = [] + with self._locked(lineage_id): + payload = self._load(lineage_id) + active = payload["reservations"] + quarantined = payload["quarantined_reservations"] + for reservation_hash, original in list(active.items()): + if original.get("scope_refs", {}).get("session") != session_ref: + continue + if int(original.get("created_at", observed)) > cutoff: + continue + record = dict(original) + record.update( + { + "operation": "quarantine", + "reason_code": "spend_reservation_stale", + "quarantined_at": observed, + } + ) + del active[reservation_hash] + quarantined[reservation_hash] = record + results.append(self._close_result(record, payload)) + if results: + self._persist(lineage_id, payload) + return results + + def snapshot(self, lineage_id: str) -> dict[str, Any]: + with self._locked(lineage_id): + return self._load(lineage_id) + + def has_active_reservations( + self, + *, + lineage_id: str, + session_id: str, + ) -> bool: + session_ref = _scope_hash("session", session_id) + with self._locked(lineage_id): + payload = self._load(lineage_id) + return any( + record.get("scope_refs", {}).get("session") == session_ref + for record in payload["reservations"].values() + ) + + def _close_without_spend( + self, + *, + lineage_id: str, + session_id: str, + request_id: str, + operation: str, + reason_code: str, + ) -> SpendCloseResult: + reservation_hash = _reservation_hash(request_id) + with self._locked(lineage_id): + payload = self._load(lineage_id) + closed = payload["closed_reservations"] + prior = closed.get(reservation_hash) + if isinstance(prior, dict): + self._require_session_scope(prior, session_id) + if ( + prior.get("operation") != operation + or prior.get("reason_code") != reason_code + ): + raise SpendBudgetConflictError("spend_close_conflict") + return self._close_result(prior, payload, idempotent=True) + record = payload["reservations"].get(reservation_hash) + if not isinstance(record, dict): + raise SpendBudgetError("spend_reservation_unknown") + self._require_session_scope(record, session_id) + del payload["reservations"][reservation_hash] + reserved = {key: int(record["reserved"][key]) for key in _DIMENSIONS} + for scope in _SCOPES: + totals = self._scope_totals(payload, record["scope_refs"][scope]) + for dimension in _DIMENSIONS: + totals["reserved"][dimension] -= reserved[dimension] + record = dict(record) + record.update( + { + "operation": operation, + "reason_code": reason_code, + "actual": {key: 0 for key in _DIMENSIONS}, + "refunded": reserved, + "closed_at": int(time.time()), + } + ) + closed[reservation_hash] = record + self._persist(lineage_id, payload) + return self._close_result(record, payload) + + @staticmethod + def _require_fingerprint(record: Mapping[str, Any], fingerprint: str) -> None: + if record.get("fingerprint") != fingerprint: + raise SpendBudgetConflictError( + "spend_request_conflict", + "request_id was reused with different reservation semantics", + ) + + @staticmethod + def _require_session_scope(record: Mapping[str, Any], session_id: str) -> None: + expected = _scope_hash("session", session_id) + if record.get("scope_refs", {}).get("session") != expected: + raise SpendBudgetConflictError("spend_reservation_session_mismatch") + + def _result_from_record( + self, + record: Mapping[str, Any], + *, + accepted: bool, + reason_code: str, + payload: dict[str, Any], + idempotent: bool = False, + ) -> SpendReservationResult: + return SpendReservationResult( + accepted=accepted, + reason_code=reason_code, + reservation_hash=str(record["reservation_hash"]), + quote_digest=str(record["quote_digest"]), + currency=str(record["currency"]), + reserved={key: int(record["reserved"][key]) for key in _DIMENSIONS}, + remaining=self._remaining_from_record(payload, record), + idempotent=idempotent, + ) + + def _close_result( + self, + record: Mapping[str, Any], + payload: dict[str, Any], + *, + idempotent: bool = False, + reconciled: bool | None = None, + ) -> SpendCloseResult: + reserved = {key: int(record["reserved"][key]) for key in _DIMENSIONS} + operation = str(record.get("operation", "quarantine")) + actual_raw = record.get("actual") if operation == "settle" else None + actual = ( + {key: int(actual_raw[key]) for key in _DIMENSIONS} + if isinstance(actual_raw, Mapping) + else {key: 0 for key in _DIMENSIONS} + ) + refunded_raw = record.get("refunded") + refunded = ( + {key: int(refunded_raw[key]) for key in _DIMENSIONS} + if isinstance(refunded_raw, Mapping) + else {key: 0 for key in _DIMENSIONS} + ) + return SpendCloseResult( + operation=operation, + reason_code=str(record.get("reason_code", "spend_reservation_quarantined")), + reservation_hash=str(record["reservation_hash"]), + quote_digest=str(record["quote_digest"]), + currency=str(record["currency"]), + reserved=reserved, + actual=actual, + refunded=refunded, + remaining=self._remaining_from_record(payload, record), + idempotent=idempotent, + reconciled=bool( + record.get("reconciled", False) if reconciled is None else reconciled + ), + ) + + def _remaining_from_record( + self, + payload: dict[str, Any], + record: Mapping[str, Any], + ) -> dict[str, dict[str, int]]: + policy = { + "version": SPEND_BUDGET_VERSION, + "currency": record["currency"], + "metered_tools": ["ledger_projection"], + "ceilings": record["ceilings"], + "lineage_id": "ledger_projection", + } + return self._remaining(payload, policy, record["scope_refs"]) + + def _remaining( + self, + payload: dict[str, Any], + policy: Mapping[str, Any], + scope_refs: Mapping[str, str], + ) -> dict[str, dict[str, int]]: + ceilings = policy["ceilings"] + remaining: dict[str, dict[str, int]] = {} + for scope in _SCOPES: + totals = self._scope_totals(payload, scope_refs[scope]) + remaining[scope] = { + dimension: max( + 0, + int(ceilings[dimension][scope]) + - int(totals["spent"][dimension]) + - int(totals["reserved"][dimension]), + ) + for dimension in _DIMENSIONS + } + return remaining + + @staticmethod + def _scope_totals( + payload: dict[str, Any], scope_ref: str + ) -> dict[str, dict[str, int]]: + scopes = payload["scopes"] + totals = scopes.get(scope_ref) + if totals is None: + totals = { + "spent": {key: 0 for key in _DIMENSIONS}, + "reserved": {key: 0 for key in _DIMENSIONS}, + } + scopes[scope_ref] = totals + else: + FileSpendBudgetLedger._validate_scope_totals(totals) + return totals + + @staticmethod + def _validate_ledger_int(value: Any) -> int: + if ( + isinstance(value, bool) + or not isinstance(value, int) + or value < 0 + or value > MAX_SAFE_INTEGER + ): + raise SpendBudgetError("spend_ledger_invalid") + return value + + @classmethod + def _validate_amounts(cls, value: Any) -> dict[str, int]: + if not isinstance(value, dict) or set(value) != set(_DIMENSIONS): + raise SpendBudgetError("spend_ledger_invalid") + return { + dimension: cls._validate_ledger_int(value[dimension]) + for dimension in _DIMENSIONS + } + + @classmethod + def _validate_scope_totals(cls, value: Any) -> None: + if not isinstance(value, dict) or set(value) != {"spent", "reserved"}: + raise SpendBudgetError("spend_ledger_invalid") + cls._validate_amounts(value["spent"]) + cls._validate_amounts(value["reserved"]) + + @classmethod + def _validate_record( + cls, + reservation_hash: str, + record: Any, + *, + container: str, + ) -> None: + if not isinstance(record, dict): + raise SpendBudgetError("spend_ledger_invalid") + if not _DIGEST_RE.fullmatch(reservation_hash): + raise SpendBudgetError("spend_ledger_invalid") + for digest_field in ( + "fingerprint", + "reservation_hash", + "quote_digest", + "quote_id_hash", + ): + digest = record.get(digest_field) + if not isinstance(digest, str) or _DIGEST_RE.fullmatch(digest) is None: + raise SpendBudgetError("spend_ledger_invalid") + if record["reservation_hash"] != reservation_hash: + raise SpendBudgetError("spend_ledger_invalid") + if ( + not isinstance(record.get("currency"), str) + or _CURRENCY_RE.fullmatch(record["currency"]) is None + ): + raise SpendBudgetError("spend_ledger_invalid") + cls._validate_amounts(record.get("reserved")) + for int_field in ( + "max_input_tokens", + "max_output_tokens", + "created_at", + "retention_until", + ): + cls._validate_ledger_int(record.get(int_field)) + if record["retention_until"] <= record["created_at"]: + raise SpendBudgetError("spend_ledger_invalid") + + scope_refs = record.get("scope_refs") + if not isinstance(scope_refs, dict) or set(scope_refs) != set(_SCOPES): + raise SpendBudgetError("spend_ledger_invalid") + if any( + not isinstance(scope_refs[scope], str) + or _DIGEST_RE.fullmatch(scope_refs[scope]) is None + for scope in _SCOPES + ): + raise SpendBudgetError("spend_ledger_invalid") + + ceilings = record.get("ceilings") + if not isinstance(ceilings, dict) or set(ceilings) != set(_DIMENSIONS): + raise SpendBudgetError("spend_ledger_invalid") + for dimension in _DIMENSIONS: + values = ceilings.get(dimension) + if not isinstance(values, dict) or set(values) != set(_SCOPES): + raise SpendBudgetError("spend_ledger_invalid") + for scope in _SCOPES: + cls._validate_ledger_int(values[scope]) + + pricing = record.get("pricing") + if not isinstance(pricing, dict) or set(pricing) != { + "input_micros_per_million_tokens", + "output_micros_per_million_tokens", + }: + raise SpendBudgetError("spend_ledger_invalid") + for rate in pricing.values(): + cls._validate_ledger_int(rate) + + operation = record.get("operation") + if container == "reservations": + if operation is not None: + raise SpendBudgetError("spend_ledger_invalid") + return + if container == "quarantined_reservations": + if operation != "quarantine": + raise SpendBudgetError("spend_ledger_invalid") + return + if operation not in {"reject", "release", "settle"}: + raise SpendBudgetError("spend_ledger_invalid") + if operation == "settle": + actual = cls._validate_amounts(record.get("actual")) + refunded = cls._validate_amounts(record.get("refunded")) + reserved = cls._validate_amounts(record.get("reserved")) + if any( + actual[dimension] + refunded[dimension] != reserved[dimension] + for dimension in _DIMENSIONS + ): + raise SpendBudgetError("spend_ledger_accounting_mismatch") + for digest_field in ("settlement_fingerprint", "usage_proof_digest"): + digest = record.get(digest_field) + if not isinstance(digest, str) or _DIGEST_RE.fullmatch(digest) is None: + raise SpendBudgetError("spend_ledger_invalid") + elif operation == "release": + actual = cls._validate_amounts(record.get("actual")) + refunded = cls._validate_amounts(record.get("refunded")) + reserved = cls._validate_amounts(record.get("reserved")) + if actual != {dimension: 0 for dimension in _DIMENSIONS}: + raise SpendBudgetError("spend_ledger_accounting_mismatch") + if refunded != reserved: + raise SpendBudgetError("spend_ledger_accounting_mismatch") + + @classmethod + def _validate_payload(cls, payload: dict[str, Any], lineage_id: str) -> None: + if payload.get("version") != SPEND_LEDGER_VERSION: + raise SpendBudgetError("spend_ledger_invalid") + if payload.get("lineage_id_hash") != _scope_hash("lineage", lineage_id): + raise SpendBudgetError("spend_ledger_lineage_mismatch") + for field_name in ( + "scopes", + "archived_spent", + "reservations", + "closed_reservations", + "quarantined_reservations", + ): + if not isinstance(payload.get(field_name), dict): + raise SpendBudgetError("spend_ledger_invalid") + + scopes = payload["scopes"] + for scope_ref, totals in scopes.items(): + if ( + not isinstance(scope_ref, str) + or _DIGEST_RE.fullmatch(scope_ref) is None + ): + raise SpendBudgetError("spend_ledger_invalid") + cls._validate_scope_totals(totals) + archived_spent = payload["archived_spent"] + for scope_ref, amounts in archived_spent.items(): + if scope_ref not in scopes: + raise SpendBudgetError("spend_ledger_invalid") + cls._validate_amounts(amounts) + + for container_name in ( + "reservations", + "closed_reservations", + "quarantined_reservations", + ): + for reservation_hash, record in payload[container_name].items(): + cls._validate_record( + reservation_hash, + record, + container=container_name, + ) + if any(ref not in scopes for ref in record["scope_refs"].values()): + raise SpendBudgetError("spend_ledger_invalid") + + expected_reserved = { + scope_ref: {dimension: 0 for dimension in _DIMENSIONS} + for scope_ref in scopes + } + expected_spent = { + scope_ref: { + dimension: int(archived_spent.get(scope_ref, {}).get(dimension, 0)) + for dimension in _DIMENSIONS + } + for scope_ref in scopes + } + for container_name in ("reservations", "quarantined_reservations"): + for record in payload[container_name].values(): + for scope_ref in record["scope_refs"].values(): + for dimension in _DIMENSIONS: + expected_reserved[scope_ref][dimension] += int( + record["reserved"][dimension] + ) + for record in payload["closed_reservations"].values(): + if record.get("operation") != "settle": + continue + for scope_ref in record["scope_refs"].values(): + for dimension in _DIMENSIONS: + expected_spent[scope_ref][dimension] += int( + record["actual"][dimension] + ) + for scope_ref, totals in scopes.items(): + if totals["reserved"] != expected_reserved[scope_ref]: + raise SpendBudgetError("spend_ledger_accounting_mismatch") + if totals["spent"] != expected_spent[scope_ref]: + raise SpendBudgetError("spend_ledger_accounting_mismatch") + + @classmethod + def _prune_terminal_records(cls, payload: dict[str, Any], *, now: int) -> None: + closed = payload["closed_reservations"] + archived_spent = payload["archived_spent"] + for reservation_hash, record in list(closed.items()): + if int(record["retention_until"]) > now: + continue + if record.get("operation") == "settle": + for scope_ref in record["scope_refs"].values(): + amounts = archived_spent.setdefault( + scope_ref, + {dimension: 0 for dimension in _DIMENSIONS}, + ) + for dimension in _DIMENSIONS: + amounts[dimension] += int(record["actual"][dimension]) + del closed[reservation_hash] + + def _path(self, lineage_id: str) -> Path: + return self.ledger_dir / f"{_scope_hash('lineage', lineage_id)}.json" + + def _lock_path(self, lineage_id: str) -> Path: + return self._path(lineage_id).with_suffix(".lock") + + @contextlib.contextmanager + def _locked(self, lineage_id: str): + lock_path = self._lock_path(lineage_id) + fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600) + os.close(fd) + with contextlib.suppress(OSError): + os.chmod(lock_path, 0o600) + key = str(lock_path.resolve()) + with _LOCKS_GUARD: + process_lock = _LOCKS.get(key) + if process_lock is None: + process_lock = _ProcessLock() + _LOCKS[key] = process_lock + with process_lock.lock: + with lock_path.open("a+b") as lock_handle: + fcntl.flock(lock_handle.fileno(), fcntl.LOCK_EX) + try: + yield + finally: + fcntl.flock(lock_handle.fileno(), fcntl.LOCK_UN) + + def _load(self, lineage_id: str) -> dict[str, Any]: + path = self._path(lineage_id) + if not path.exists(): + return { + "version": SPEND_LEDGER_VERSION, + "lineage_id_hash": _scope_hash("lineage", lineage_id), + "scopes": {}, + "archived_spent": {}, + "reservations": {}, + "closed_reservations": {}, + "quarantined_reservations": {}, + } + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise SpendBudgetError("spend_ledger_invalid") + self._validate_payload(payload, lineage_id) + return payload + + def _persist(self, lineage_id: str, payload: dict[str, Any]) -> None: + self._validate_payload(payload, lineage_id) + path = self._path(lineage_id) + tmp = path.with_name(f"{path.stem}.{uuid.uuid4().hex}.tmp") + fd = -1 + try: + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + fd = -1 + json.dump(payload, handle, indent=2, sort_keys=True) + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp, path) + directory_fd = os.open(self.ledger_dir, os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + except Exception: + if fd >= 0: + os.close(fd) + with contextlib.suppress(OSError): + tmp.unlink() + raise + + +def _scope_hash(scope: str, value: str) -> str: + return _opaque_hash(f"scope:{scope}", value) + + +def _reservation_hash(request_id: str) -> str: + return _opaque_hash( + "spend-reservation", _bounded_string(request_id, "request_id", max_bytes=512) + ) + + +def _opaque_hash(domain: str, value: str) -> str: + return hashlib.sha256(f"ardur:{domain}:v1\0{value}".encode("utf-8")).hexdigest() + + +def _first_breach( + requested: Mapping[str, int], + remaining: Mapping[str, Mapping[str, int]], +) -> tuple[str, str] | None: + for scope in _SCOPES: + for dimension in _DIMENSIONS: + if int(requested[dimension]) > int(remaining[scope][dimension]): + return scope, dimension + return None diff --git a/site/content/source/README.md b/site/content/source/README.md index 23ff1758..97bb3361 100644 --- a/site/content/source/README.md +++ b/site/content/source/README.md @@ -2,7 +2,7 @@ title: "Ardur" description: "Ardur governs AI-agent tool calls that pass through a configured adapter or" source_path: "README.md" -source_sha256: "521195f3c5e80fc19944148a0963d378e403e8b7620c15ebbd4844fb37b23b84" +source_sha256: "dd845efc0eaa2b9eb9e1daf37b383af712e206e2e43c92f1bd760127b05553b7" weight: 100 maturity: ["public-now"] claim_types: ["orientation", "runtime-boundary"] @@ -233,6 +233,7 @@ Concretely — these are the design principles the repo is being built to meet, - **Composable with what already exists.** Designed around SPIFFE for workload identity, Biscuit for first-party-attenuation credentials, Cedar for policy, the individual AAT Internet-Draft for delegation-token semantics, and EAT (RFC 9711) for attestation-token semantics. We didn't reinvent the substrate. - **Cryptographically bound by design.** Mission credentials are designed to be signed by an issuer key and produce signed receipts chain-hashed to the previous one. The Python Biscuit path reports SPIFFE holder binding only when the proxy has a server-owned Biscuit issuer key, JWT-SVID trust bundle, and audience and the presented credentials verify against them; request payloads cannot choose those verifier inputs. JWT-SVID itself remains a replayable bearer credential, so this is bounded holder evidence rather than universal replay prevention. The design is documented in the [ADRs](/__ardur_internal__/source/docs/decisions/readme/); the public code that implements it is being curated in phases. - **Delegation that narrows, never widens.** Child sessions get strictly narrower authority than their parent — fewer tools, smaller resource scope, smaller budget. The narrowing discipline is formalised in [ADR-017](/__ardur_internal__/source/docs/decisions/adr-017-biscuit-attenuation-narrowing-semantics/). +- **Pre-action spend authority.** Library adapters can reserve signed token and currency-micro ceilings across session, agent, and delegation-lineage scopes before a metered call, then settle trusted usage or quarantine uncertainty without silently refunding it. [Reference](/__ardur_internal__/source/docs/reference/spend-budgets/). - **No authority by omission.** An absent or empty `resource_scope` grants no resource authority. Operators who intentionally permit every resource must sign the sole explicit wildcard `resource_scope: ["**"]`; issuance and governed-run surfaces warn when they do. The decision and format-specific attenuation rules are documented in [ADR-023](/__ardur_internal__/source/docs/decisions/adr-023-explicit-resource-scope-authority/). - **Explicit about what it doesn't do.** Scope-level governance can't catch semantic misuse — if an allowed tool is used on an allowed resource for the wrong reason, that's a different layer's job. - **MIT licensed.** The research foundation (the Silence Theorem, the protocol formalism, the benchmark methodology) will be linked from this repo when the paper's public identifier is assigned. Articles in this repo paraphrase the research in original prose; they do not reproduce paper content. diff --git a/site/content/source/_index.md b/site/content/source/_index.md index 22b3e07f..691acec8 100644 --- a/site/content/source/_index.md +++ b/site/content/source/_index.md @@ -11,4 +11,4 @@ evidence_levels: ["code-and-doc", "spec", "archival-media", "doc-and-manifest", -The pages in this section are generated from 116 public Markdown files in the repo. The site also mirrors 113 documentation artifacts such as schemas, mission examples, helper source files, casts, and deployment manifests. Generated site content, local review context, and dependency/vendor directories are excluded from publication. The CI check fails when generated documentation drifts from its source hash. +The pages in this section are generated from 118 public Markdown files in the repo. The site also mirrors 113 documentation artifacts such as schemas, mission examples, helper source files, casts, and deployment manifests. Generated site content, local review context, and dependency/vendor directories are excluded from publication. The CI check fails when generated documentation drifts from its source hash. diff --git a/site/content/source/docs/decisions/ADR-025-pre-action-spend-reservation.md b/site/content/source/docs/decisions/ADR-025-pre-action-spend-reservation.md new file mode 100644 index 00000000..94dfd76e --- /dev/null +++ b/site/content/source/docs/decisions/ADR-025-pre-action-spend-reservation.md @@ -0,0 +1,124 @@ +--- +title: "ADR-025: Pre-action spend reservation and conservative settlement" +description: "**Status:** Accepted" +source_path: "docs/decisions/ADR-025-pre-action-spend-reservation.md" +source_sha256: "d538d0c7c9fc3931c8f0a22077c9a7b18917f5d583a56340bb4762316fb7cae4" +weight: 100 +maturity: ["public-now"] +claim_types: ["decision-record"] +surfaces: ["docs"] +frameworks: ["framework-agnostic"] +evidence_levels: ["code-and-doc"] +--- + + + +{{< proof-status state="public" label="Source-backed mirror" source="docs/decisions/ADR-025-pre-action-spend-reservation.md" >}} +This page is generated from the public repository source file. Edit the source file, then run `python3 site/scripts/sync_source_docs.py` to refresh the Hugo mirror. +{{< /proof-status >}} + +**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. diff --git a/site/content/source/docs/decisions/README.md b/site/content/source/docs/decisions/README.md index 54bc5c7a..2be888eb 100644 --- a/site/content/source/docs/decisions/README.md +++ b/site/content/source/docs/decisions/README.md @@ -2,7 +2,7 @@ title: "Architecture Decision Records" description: "ADRs document load-bearing design decisions behind Ardur's runtime, protocol, and deployment shape. Each record captures the context, the decision, and the trade-offs known at the " source_path: "docs/decisions/README.md" -source_sha256: "84309234880b2334351b79faf2ed8d2cb17c4a838141561f8803f12eb0cf1d7d" +source_sha256: "0c1a1c73b593a43c0fa1a34c35a10c4715c89fd596e9bfb100801d92cbe7f20c" weight: 100 maturity: ["public-now"] claim_types: ["decision-record"] @@ -37,6 +37,7 @@ ADRs are migrated from the private research repo with the two-pass cleanup appli | 022 | [SPIFFE mTLS identity for operator telemetry](/__ardur_internal__/source/docs/decisions/adr-022-operator-telemetry-spiffe-mtls/) | Accepted | 2026-07-11 | | 023 | [Explicit resource-scope authority](/__ardur_internal__/source/docs/decisions/adr-023-explicit-resource-scope-authority/) | Accepted | 2026-07-12 | | 024 | [Self-asserted owner identity assurance](/__ardur_internal__/source/docs/decisions/adr-024-self-asserted-owner-identity-assurance/) | Accepted | 2026-07-12 | +| 025 | [Pre-action spend reservation and conservative settlement](/__ardur_internal__/source/docs/decisions/adr-025-pre-action-spend-reservation/) | Accepted | 2026-07-14 | ## Conventions diff --git a/site/content/source/docs/decisions/_index.md b/site/content/source/docs/decisions/_index.md index 9802d314..ed1f608f 100644 --- a/site/content/source/docs/decisions/_index.md +++ b/site/content/source/docs/decisions/_index.md @@ -25,4 +25,5 @@ This section lists hosted documentation and mirrored artifacts generated from `d - [`ADR-022-operator-telemetry-spiffe-mtls.md`](/__ardur_internal__/source/docs/decisions/adr-022-operator-telemetry-spiffe-mtls/) - [`ADR-023-explicit-resource-scope-authority.md`](/__ardur_internal__/source/docs/decisions/adr-023-explicit-resource-scope-authority/) - [`ADR-024-self-asserted-owner-identity-assurance.md`](/__ardur_internal__/source/docs/decisions/adr-024-self-asserted-owner-identity-assurance/) +- [`ADR-025-pre-action-spend-reservation.md`](/__ardur_internal__/source/docs/decisions/adr-025-pre-action-spend-reservation/) - [`README.md`](/__ardur_internal__/source/docs/decisions/readme/) diff --git a/site/content/source/docs/reference/README.md b/site/content/source/docs/reference/README.md index 22d40097..dabb10a1 100644 --- a/site/content/source/docs/reference/README.md +++ b/site/content/source/docs/reference/README.md @@ -2,7 +2,7 @@ title: "Technical Reference" description: "Flat technical reference pages for the public Ardur surface. These describe" source_path: "docs/reference/README.md" -source_sha256: "f1354da872c6c097e8b84fa717780d4358bd8398f20ce39d1a993ee18bd68728" +source_sha256: "11b569d541bc6d43359ae82453131852a0fc7e701f671ff576ff60dd9d611470" weight: 100 maturity: ["public-now"] claim_types: ["documentation"] @@ -39,6 +39,9 @@ walkthroughs see [`../guides/`](/__ardur_internal__/source/docs/guides/); for pr - [Advisory AI Controls](/__ardur_internal__/source/docs/reference/advisory-ai-controls/) — semantic-judge and behavioral-fingerprint defaults, non-authoritative status, failure policy, cost, and integration requirements +- [Pre-action Spend Budgets](/__ardur_internal__/source/docs/reference/spend-budgets/) — signed token/currency policy, + operator quote snapshots, atomic reserve/settle/quarantine semantics, + evidence, metrics, and deployment limits - [Agent Recognition Evaluation](/__ardur_internal__/source/docs/reference/agent-recognition-evaluation/) — versioned maintained corpus, deterministic metrics, Wilson intervals, CI thresholds, and claim boundaries diff --git a/site/content/source/docs/reference/_index.md b/site/content/source/docs/reference/_index.md index 779bccdd..aac9cfd2 100644 --- a/site/content/source/docs/reference/_index.md +++ b/site/content/source/docs/reference/_index.md @@ -23,3 +23,4 @@ This section lists hosted documentation and mirrored artifacts generated from `d - [`kernel-capture-daemon.md`](/__ardur_internal__/source/docs/reference/kernel-capture-daemon/) - [`personal-hub-api.md`](/__ardur_internal__/source/docs/reference/personal-hub-api/) - [`proxy-oci-image.md`](/__ardur_internal__/source/docs/reference/proxy-oci-image/) +- [`spend-budgets.md`](/__ardur_internal__/source/docs/reference/spend-budgets/) diff --git a/site/content/source/docs/reference/spend-budgets.md b/site/content/source/docs/reference/spend-budgets.md new file mode 100644 index 00000000..10a7a95f --- /dev/null +++ b/site/content/source/docs/reference/spend-budgets.md @@ -0,0 +1,221 @@ +--- +title: "Pre-action spend budgets" +description: "Ardur's Python library can reserve token and monetary authority before a" +source_path: "docs/reference/spend-budgets.md" +source_sha256: "5ab9c03ba3047289d31ad092beb22e1c78bc2cb98051a5b6b9381bfbf9bffd95" +weight: 100 +maturity: ["public-now"] +claim_types: ["documentation"] +surfaces: ["docs"] +frameworks: ["framework-agnostic"] +evidence_levels: ["code-and-doc"] +--- + + + +{{< proof-status state="public" label="Source-backed mirror" source="docs/reference/spend-budgets.md" >}} +This page is generated from the public repository source file. Edit the source file, then run `python3 site/scripts/sync_source_docs.py` to refresh the Hugo mirror. +{{< /proof-status >}} + +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](/__ardur_internal__/source/docs/decisions/adr-025-pre-action-spend-reservation/). diff --git a/site/content/source/docs/specs/ardur-drp-mapping-v0.1.md b/site/content/source/docs/specs/ardur-drp-mapping-v0.1.md index 5de25cf6..2677acd2 100644 --- a/site/content/source/docs/specs/ardur-drp-mapping-v0.1.md +++ b/site/content/source/docs/specs/ardur-drp-mapping-v0.1.md @@ -2,7 +2,7 @@ title: "Ardur DRP Mapping Profile v0.1" description: "This document maps the current Ardur delegation and action-receipt surfaces to" source_path: "docs/specs/ardur-drp-mapping-v0.1.md" -source_sha256: "c675c0dfef6f10af6f2b0ed2a435fe5f92ae03b8e1bbfdf3b0b091ff0cecae26" +source_sha256: "36014979a9e810ffa6e338f713ad0b0206e4817949cf940706c49fc4795c80ad" weight: 100 maturity: ["public-now"] claim_types: ["protocol-spec"] @@ -92,7 +92,7 @@ transformations are: | `par_hash`, `parent_token_hash`, `parent_jti` | `parentReceiptId` plus Ardur audit fields | Resolve the actual profiled parent receipt. Token hashes and token IDs are retained but are not DRP receipt IDs. | | `cnf.jwk` | `metadata.x-ardur.capabilityTokenRef.holderConfirmation.jwk` | The holder key is not the DRP receipt-signing key. | | depth and delegation policy | `metadata.x-ardur.redelegation` | DRP describes depth behavior but has no Authorization Object fields for mode, depth, or maximum depth. | -| budgets and policy references | `metadata.x-ardur.budget`, `metadata.x-ardur.policy` | Security-critical extensions that participate in attenuation checks. | +| call-count/spend budgets and policy references | `metadata.x-ardur.budget`, `metadata.x-ardur.policy` | Security-critical extensions that participate in attenuation checks. The v0.1 mapping ledger reserves `spendBudget`, but the current DRP emitter/profile does not project it and must fail closed rather than omit or downgrade that authority. | | `mission_ref` | `metadata.x-ardur.missionRef` | DRP instruction commitment does not replace the governing Mission Declaration reference. | ### 3.1. Critical Extension Rule diff --git a/site/content/source/python/README.md b/site/content/source/python/README.md index 5b165392..e8f114b3 100644 --- a/site/content/source/python/README.md +++ b/site/content/source/python/README.md @@ -2,7 +2,7 @@ title: "Ardur — Python Reference Implementation" description: "The public Python runtime for Ardur lives here: a runtime governance and evidence layer for AI agents that issues signed mission passports, enforces them at execution time, and rec" source_path: "python/README.md" -source_sha256: "0a48e07a21ab40fc4ca976c4f4fb2d003efb77730ee0d7238777256454843e65" +source_sha256: "8b920c7735a475f75eab3fe9d1ad628bff34e201f853d92fe36bfcba924af30d" weight: 100 maturity: ["public-now"] claim_types: ["runtime-boundary"] @@ -226,6 +226,13 @@ domain, or audience. Without server trust configuration, Biscuit sessions remain explicitly `svid_bound=false`. JWT-SVID is still a bearer credential with a bounded replay window. +Library adapters for metered tools can also configure operator-owned quote +snapshots and signed session/agent/lineage spend ceilings. The proxy reserves +integer token and currency-micro upper bounds before returning `PERMIT`, then +settles trusted usage or conservatively quarantines missing evidence. See the +[pre-action spend budget reference](/__ardur_internal__/source/docs/reference/spend-budgets/); no +provider prices are hard-coded or fetched on the authorization hot path. + ## Protocol identifier rename This implementation is a **clean break** on protocol identifiers — v0.1 receipts, passports, and attestations only emit and accept the new Ardur type strings. There is no dual-type backward-compat shim. If you have artifacts produced before the rename, they won't validate against this code, and that's intentional. diff --git a/site/data/source_routes.json b/site/data/source_routes.json index f3ce5501..ec9e885c 100644 --- a/site/data/source_routes.json +++ b/site/data/source_routes.json @@ -222,6 +222,7 @@ "docs/decisions/ADR-022-operator-telemetry-spiffe-mtls.md": "source/docs/decisions/adr-022-operator-telemetry-spiffe-mtls/", "docs/decisions/ADR-023-explicit-resource-scope-authority.md": "source/docs/decisions/adr-023-explicit-resource-scope-authority/", "docs/decisions/ADR-024-self-asserted-owner-identity-assurance.md": "source/docs/decisions/adr-024-self-asserted-owner-identity-assurance/", + "docs/decisions/ADR-025-pre-action-spend-reservation.md": "source/docs/decisions/adr-025-pre-action-spend-reservation/", "docs/decisions/README.md": "source/docs/decisions/readme/", "docs/demo/enforce-e2e.md": "source/docs/demo/enforce-e2e/", "docs/engineering-standards.md": "source/docs/engineering-standards/", @@ -243,6 +244,7 @@ "docs/reference/kernel-capture-daemon.md": "source/docs/reference/kernel-capture-daemon/", "docs/reference/personal-hub-api.md": "source/docs/reference/personal-hub-api/", "docs/reference/proxy-oci-image.md": "source/docs/reference/proxy-oci-image/", + "docs/reference/spend-budgets.md": "source/docs/reference/spend-budgets/", "docs/research/epic-b-performance-fp-budget.md": "source/docs/research/epic-b-performance-fp-budget/", "docs/research/epic-b-policy-selection.md": "source/docs/research/epic-b-policy-selection/", "docs/roadmap/epic-b-auto-detection-plan.md": "source/docs/roadmap/epic-b-auto-detection-plan/", diff --git a/site/static/repo/docs/specs/ardur-drp-mapping-v0.1.json b/site/static/repo/docs/specs/ardur-drp-mapping-v0.1.json index c4e12e06..af0d663b 100644 --- a/site/static/repo/docs/specs/ardur-drp-mapping-v0.1.json +++ b/site/static/repo/docs/specs/ardur-drp-mapping-v0.1.json @@ -465,6 +465,13 @@ "drp_path": "metadata.x-ardur.budget.maxToolCalls", "rationale": "DRP has no action-count budget." }, + { + "source_surface": "legacy_python_passport", + "source_path": "spend_budget", + "classification": "extension", + "drp_path": "metadata.x-ardur.budget.spendBudget", + "rationale": "Token and monetary authority is security-critical. This mapping ledger reserves the extension path; the current DRP v0.1 emitter/profile does not project it and must fail closed rather than omit or downgrade the claim." + }, { "source_surface": "legacy_python_passport", "source_path": "max_duration_s", diff --git a/site/static/repo/docs/specs/execution-receipt-v0.2.schema.json b/site/static/repo/docs/specs/execution-receipt-v0.2.schema.json index e653de7d..2b8b6d49 100644 --- a/site/static/repo/docs/specs/execution-receipt-v0.2.schema.json +++ b/site/static/repo/docs/specs/execution-receipt-v0.2.schema.json @@ -385,6 +385,9 @@ }, { "$ref": "#/$defs/lineageBudgetDelta" + }, + { + "$ref": "#/$defs/spendBudgetDelta" } ] }, @@ -486,6 +489,73 @@ } } }, + "spendBudgetDelta": { + "type": "object", + "additionalProperties": false, + "required": [ + "operation", + "resource", + "requested", + "reserved", + "actual", + "refunded", + "remaining", + "currency", + "quote_digest", + "reservation_hash", + "reason_code" + ], + "properties": { + "operation": { + "type": "string", + "enum": ["reserve", "reject", "release", "settle", "quarantine"] + }, + "resource": {"const": "spend"}, + "requested": {"$ref": "#/$defs/spendAmounts"}, + "reserved": {"$ref": "#/$defs/spendAmounts"}, + "actual": {"$ref": "#/$defs/spendAmounts"}, + "refunded": {"$ref": "#/$defs/spendAmounts"}, + "remaining": { + "type": "object", + "additionalProperties": false, + "required": ["session", "agent", "lineage"], + "properties": { + "session": {"$ref": "#/$defs/spendAmounts"}, + "agent": {"$ref": "#/$defs/spendAmounts"}, + "lineage": {"$ref": "#/$defs/spendAmounts"} + } + }, + "currency": { + "type": "string", + "pattern": "^[A-Z]{3}$" + }, + "quote_digest": {"$ref": "#/$defs/sha256HexString"}, + "reservation_hash": {"$ref": "#/$defs/sha256HexString"}, + "reason_code": { + "type": "string", + "pattern": "^[A-Za-z0-9._:-]{1,128}$" + }, + "idempotent": {"type": "boolean"}, + "reconciled": {"type": "boolean"} + } + }, + "spendAmounts": { + "type": "object", + "additionalProperties": false, + "required": ["tokens", "currency_micros"], + "properties": { + "tokens": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "currency_micros": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + } + }, "policyDecision": { "type": "object", "additionalProperties": false, diff --git a/site/static/repo/python/vibap/_specs/execution_receipt_v02.schema.json b/site/static/repo/python/vibap/_specs/execution_receipt_v02.schema.json index e653de7d..2b8b6d49 100644 --- a/site/static/repo/python/vibap/_specs/execution_receipt_v02.schema.json +++ b/site/static/repo/python/vibap/_specs/execution_receipt_v02.schema.json @@ -385,6 +385,9 @@ }, { "$ref": "#/$defs/lineageBudgetDelta" + }, + { + "$ref": "#/$defs/spendBudgetDelta" } ] }, @@ -486,6 +489,73 @@ } } }, + "spendBudgetDelta": { + "type": "object", + "additionalProperties": false, + "required": [ + "operation", + "resource", + "requested", + "reserved", + "actual", + "refunded", + "remaining", + "currency", + "quote_digest", + "reservation_hash", + "reason_code" + ], + "properties": { + "operation": { + "type": "string", + "enum": ["reserve", "reject", "release", "settle", "quarantine"] + }, + "resource": {"const": "spend"}, + "requested": {"$ref": "#/$defs/spendAmounts"}, + "reserved": {"$ref": "#/$defs/spendAmounts"}, + "actual": {"$ref": "#/$defs/spendAmounts"}, + "refunded": {"$ref": "#/$defs/spendAmounts"}, + "remaining": { + "type": "object", + "additionalProperties": false, + "required": ["session", "agent", "lineage"], + "properties": { + "session": {"$ref": "#/$defs/spendAmounts"}, + "agent": {"$ref": "#/$defs/spendAmounts"}, + "lineage": {"$ref": "#/$defs/spendAmounts"} + } + }, + "currency": { + "type": "string", + "pattern": "^[A-Z]{3}$" + }, + "quote_digest": {"$ref": "#/$defs/sha256HexString"}, + "reservation_hash": {"$ref": "#/$defs/sha256HexString"}, + "reason_code": { + "type": "string", + "pattern": "^[A-Za-z0-9._:-]{1,128}$" + }, + "idempotent": {"type": "boolean"}, + "reconciled": {"type": "boolean"} + } + }, + "spendAmounts": { + "type": "object", + "additionalProperties": false, + "required": ["tokens", "currency_micros"], + "properties": { + "tokens": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "currency_micros": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + } + }, "policyDecision": { "type": "object", "additionalProperties": false,