diff --git a/app/services/mcp_dispatch_attempts.py b/app/services/mcp_dispatch_attempts.py index cc726822..8ad63044 100644 --- a/app/services/mcp_dispatch_attempts.py +++ b/app/services/mcp_dispatch_attempts.py @@ -14,7 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.sql.elements import ColumnElement -from app.core.time import utc_now +from app.core.time import to_naive_utc, utc_now from app.db.database import get_session_factory from app.db.models import ( HumanApprovalModel, @@ -471,6 +471,14 @@ async def authorize_reserve_and_prepare( # which this upstream path did not inherit. The # read-validated numbers are advisory; this write is the # authority. + # + # Expiry is in the predicate for the same reason the cap is. + # An expired permit keeps status="active" in storage, so + # without this term a permit could pass the read-time expiry + # check, cross expires_at, and still be reserved against. + # One `now` is used for both the predicate and the denial + # classification below, so the two cannot disagree. + now = utc_now() reserved = await session.execute( sa_update(PermitModel) .where( @@ -482,6 +490,10 @@ async def authorize_reserve_and_prepare( ColumnElement[bool], PermitModel.status == "active", ), + cast( + ColumnElement[bool], + PermitModel.expires_at > now, + ), cast( ColumnElement[bool], PermitModel.spent_credits + credits_authorized @@ -491,7 +503,7 @@ async def authorize_reserve_and_prepare( .values( spent_credits=PermitModel.spent_credits + credits_authorized, - updated_at=utc_now(), + updated_at=now, ) .execution_options(synchronize_session=False) ) @@ -512,6 +524,24 @@ async def authorize_reserve_and_prepare( ), None, ) + # Reachable now that expiry is in the predicate above. + # Classified before budget so a permit that expired + # mid-flight is not misreported as out of money. + if to_naive_utc(permit.expires_at) <= now: + return ( + PermitValidation( + False, + "permit_expired", + permit, + { + "expired_at": to_naive_utc( + permit.expires_at + ).isoformat(), + "checked_at": now.isoformat(), + }, + ), + None, + ) return ( PermitValidation( False, diff --git a/app/services/permits.py b/app/services/permits.py index 96a653df..30332ff3 100644 --- a/app/services/permits.py +++ b/app/services/permits.py @@ -465,6 +465,15 @@ async def _once() -> PermitValidation: # both pass even on SQLite, where the FOR UPDATE above is a # silent no-op. The read-validated numbers are advisory; this # write is the authority. + # + # Expiry is in the predicate for the same reason the cap is. + # An expired permit keeps status="active" in storage (expiry + # is a dynamic check, not a stored state), so without this + # term a permit could pass the read-time expiry check, cross + # expires_at, and still be reserved against -- dispatching + # under a just-expired permit wherever the row lock above did + # not engage. Naive-UTC comparison matches the column type; + # see reconcile_expired_permits for the same pattern. now = utc_now() reserved = await session.execute( sa_update(PermitModel) @@ -477,6 +486,10 @@ async def _once() -> PermitValidation: ColumnElement[bool], PermitModel.status == "active", ), + cast( + ColumnElement[bool], + PermitModel.expires_at > now, + ), cast( ColumnElement[bool], PermitModel.spent_credits + estimated_credits @@ -506,6 +519,20 @@ async def _once() -> PermitValidation: "revoked_at": _stamp(model.revoked_at), }, ) + # Reachable now that expiry is in the predicate above. + # Classified before budget so a permit that expired + # mid-flight is not misreported as out of money. + expired_at = to_naive_utc(model.expires_at) + if expired_at <= now: + return PermitValidation( + False, + "permit_expired", + model, + { + "expired_at": _stamp(expired_at), + "checked_at": _stamp(now), + }, + ) return PermitValidation( False, "permit_budget_exceeded", diff --git a/docs/ip/01-filing-risks-and-actions.md b/docs/ip/01-filing-risks-and-actions.md index 96172d87..b31c1880 100644 --- a/docs/ip/01-filing-risks-and-actions.md +++ b/docs/ip/01-filing-risks-and-actions.md @@ -94,14 +94,68 @@ months, before this was written. Two consequences: mechanism 4 at a fully enabling level. Each asset reports `download_count: 2`, so **four downloads in total** across -the two artifacts. The repository is private, so the release and its assets -should be private too, and those downloads are most likely CI or the owner. -**That is the expected answer, not a verified one.** Counsel should confirm: +the two artifacts. -1. Was the repository private for the entire period since 2026-08-07? A repo - that was ever public, even briefly, published these assets. -2. Who performed those four downloads, and were any by a third party? -3. Was any release asset URL shared outside the repository's collaborators? +### What the API establishes, and what it does not + +Queried against the authenticated GitHub API on 2026-08-15. These are facts, not +inferences, and they narrow the questions below: + +| Field | Value | Why it matters | +| --- | --- | --- | +| Release author | `github-actions[bot]` | The release was produced by CI (`.github/workflows/python-sdk-release.yml`), not hand-cut | +| Both asset uploaders | `github-actions[bot]` | Same — the artifacts were built and attached by the workflow | +| `created_at` / `published_at` | 2026-08-07T19:18:40Z / 19:19:14Z | 34 seconds apart; publication was immediate, not staged | +| `updated_at` | 2026-08-07T19:19:14Z | **Identical to `published_at`** — the release has not been edited since it was published | +| `draft` / `prerelease` | `false` / `false` | Not a draft and not marked pre-release | +| `b2a_sdk-0.4.0.tar.gz` | 21,778 bytes, `sha256:46094226f7af6e5e60fc1068535b39db79be7d62bdbeef99753d3d5428e2efd3` | Pins exactly which bytes were published | +| `b2a_sdk-0.4.0-py3-none-any.whl` | 21,168 bytes, `sha256:8263b63aaa06ff395f6d367ca7dba2a1ada5b3f98bca5d24ae3c9b08c5df6e6c` | Same | + +Record those two digests. If the enabling-disclosure question is ever litigated +or examined, they are what lets anyone establish *what was in the artifact* on +that date, independently of the current tree. + +**One comfortable assumption did not survive.** The earlier draft reasoned that +the four downloads were "most likely CI or the owner." No workflow or script in +this repository downloads its own release assets — the only +`releases/download` reference anywhere in `.github/` or `scripts/` fetches +gitleaks from an external repository. So the mechanical explanation that would +have made the download counts self-evidently benign **is not present in the +tree**. That does not make the downloads third-party; a maintainer pulling the +file by hand, or any authenticated client, would also register. It does mean the +count is unexplained rather than explained, and it should be treated as an open +question rather than a formality. + +**Repository visibility: private *now*, history unknown.** The authenticated +GitHub API reports `"private": true` for this repository as of 2026-08-17. That +settles the present state and nothing else — the API exposes only current +visibility, so it cannot tell you whether the repository was public at any point +between 2026-08-07 and now, which is the question that actually matters. + +Note also what is *not* evidence: an unauthenticated probe from a sandboxed +environment returned 403 for the API and 404 for the asset, but both requests +traverse an egress proxy, and an unauthenticated request to a private repository +returns 404 regardless. Do not read those codes as a finding in either +direction. + +### What still needs a human + +1. **Was the repository private for the entire period since 2026-08-07?** It is + private *today* (API, 2026-08-17), but that says nothing about the interval. A repo + that was public at any point, even briefly, published these assets. This is + answerable definitively from the organization or account **audit log** + (`repo.access` events record every visibility change, with timestamps); the + API's current `visibility` field cannot answer it retrospectively. +2. **Who performed those four downloads?** GitHub does not expose per-download + identity for release assets through any public API, so this is not + machine-answerable. If the account is on an Enterprise plan, the audit log + retains more; otherwise the honest answer may be that it is unknowable, and + counsel should plan for that rather than assume a benign answer. +3. **Was any release asset URL shared outside the collaborator set?** Human + knowledge only — check design-partner correspondence, Slack/email, and any + place `python-sdk-v0.4.0` was linked. Note that a private-repo asset URL + requires authentication to fetch, so sharing the URL alone is weaker evidence + of disclosure than sharing the file. If all three come back clean this is a non-event. If any does not, an enabling disclosure of mechanism 4 dates from 2026-08-07 and the analysis above changes. @@ -291,10 +345,12 @@ by its terms license your patents to recipients. But two exposures remain: licence by conduct exist and are fact-dependent, and a defendant who received the code would very likely raise them — but this is an unverified, fact-dependent risk to have counsel evaluate, not a settled rule. -- Because the repo is private, that recipient set should be small. It is not - necessarily empty: design partners, the "stranger test" participants - (`docs/stranger-test.md`), and anyone given a handoff bundle may have received - licensed copies. +- If the repo has been private throughout, that recipient set should be small — + but note that premise is **assumed, not verified** (see "What still needs a + human" above; repository visibility history is an audit-log question). The set + is not necessarily empty either way: design partners, the "stranger test" + participants (`docs/stranger-test.md`), and anyone given a handoff bundle may + have received licensed copies. ### Actions diff --git a/docs/ip/02-prior-art-landscape.md b/docs/ip/02-prior-art-landscape.md index 4737480f..f6a93517 100644 --- a/docs/ip/02-prior-art-landscape.md +++ b/docs/ip/02-prior-art-landscape.md @@ -19,10 +19,15 @@ literature already cited in `docs/related-work.md`; and reading of this repository's implementation. **Not performed:** classification-based searching (CPC/IPC), USPTO Patent Public Search or EPO/WIPO full-text databases, any non-English art, any published-application search, and any reading of issued -claim text beyond the abstracts and summaries linked below. The rankings in §5 +claim text beyond the abstracts and summaries linked below. The rankings in §7 are engineering judgment about where the mechanisms differ from known art — they are a starting hypothesis for counsel to test, not a patentability opinion. +**§5 and §6 were added after the first draft**, and §6 in particular is +propagated from repository research (`docs/market-research-2026-08.md`, +`docs/related-work.md`) rather than from a search performed for this document. +The §7 ranking was revised as a result. + --- ## 1. Daon's agentic-AI portfolio — the closest art @@ -173,31 +178,179 @@ pattern — treat it as supporting detail in the spec, not as a point of novelty --- -## 5. Where the room actually is +## 5. Optimistic concurrency control — the closest general technique to mechanism 1 + +This section exists because an earlier draft of this document ranked atomic +guarded reservation first without naming the textbook technique it is an +instance of. Leaving that unstated does not make it go away; it just means an +examiner raises it first. + +The general technique is **optimistic concurrency control**: read a value, then +commit conditionally on that value being unchanged, and detect the lost race by +the affected-row count rather than by holding a lock. H.T. Kung and John T. Robinson set out +the model in *On Optimistic Methods for Concurrency Control* (ACM TODS 6(2), +June 1981, pp. 213–226). Every mainstream ORM ships a productized form of it — Hibernate's +`@Version`, SQLAlchemy's `version_id_col` — and conditional `UPDATE ... WHERE` +against an observed value is its ordinary SQL expression. + +**This repository contains a second, independent instance of the same +technique**, which counsel should know about before an examiner finds it. +`append_chained_audit_event()` advances a per-wallet audit chain head with +`UPDATE ... WHERE last_seq = `, checks the affected-row count, and +retries on a lost race — with the implementation explicitly noting it avoids +`SELECT ... FOR UPDATE` so behaviour is identical on SQLite and Postgres +(`app/services/audit_chain.py`, and the `AuditChainHeadModel` docstring at +`app/db/models.py:692`). Two uses of a technique in one codebase say nothing +about the prior art directly — prior art is what §102 and §103 turn on, not the +authors' habits. What it does show is that the technique is a general-purpose +tool reached for wherever a row needs contended advancement, and an examiner +who notices the second instance will read the first as an application of a +known method. + +So state the distinction narrowly, because the narrow version is the one that +holds. What OCC as such does not supply: + +- the predicate is a **domain cap** (`spent + requested <= max`), not an + equality test on an observed version or sequence value, so the same statement + both authorizes and accounts; +- the affected-row count of zero is **classified** into a reason the caller can + act on — `permit_budget_exceeded` carrying remaining/spent/max so an agent can + retry smaller, versus `permit_` for a revoked permit — rather than + being retried blindly as a lost race; +- there is **no retry loop at all** on the reservation path. A classical OCC + writer retries until it wins; a caller here is denied and told why, because + spending someone else's budget on a second attempt is not the desired + behaviour. + +Whether that combination clears §103 over Kung/Robinson plus a budgeting +reference is exactly the question for counsel. **Put OCC on the IDS** and draft +around the classification step, not around the conditional `UPDATE`. + +--- + +## 6. MCP-native governance products, and a receipt-format Internet-Draft + +**This section is propagated from research this repository performed after the +first draft of this package, and not independently re-verified here.** The +sources and per-claim verification levels live in +[`../market-research-2026-08.md`](../market-research-2026-08.md) and +[`../related-work.md`](../related-work.md), whose rows below are marked +*Verified* by that work — meaning the primary source was read on 2026-08-15. +Counsel must read the primaries before relying on any of it. The reason it +matters here is blunt: **PRs #285 and #288 added this material to the repository +and changed no file in `docs/ip/`**, so the package was ranking mechanisms +against an art landscape that the project had already superseded. + +### 6.1 The receipt-format draft + +**`draft-farley-acta-signed-receipts`** +([IETF Datatracker](https://datatracker.ietf.org/doc/draft-farley-acta-signed-receipts/)) +specifies Ed25519 signatures over JCS-canonicalized JSON with namespaced receipt +types, and from `-02` adds a **`spending_authority` receipt type** and a Merkle +commitment mode. + +Read that against this package. Ed25519 over a canonical JSON contract is the +shared substrate of mechanisms 3 and 4; mechanism 3 is additive fields signed +only when present, which is exactly what a namespaced, versioned receipt type is +designed to accommodate; and `spending_authority` is a budget-bearing artifact, +which is mechanism 1's territory. This is the single most material reference +found in the new research and it is **not currently on the IDS**. + +Two qualifications, both from `related-work.md` and both important: + +1. It is an **individual submission with no IETF standing** — cite it as one + vendor's draft, not as a standards-track document. It does not carry the + weight of RFC 8785. +2. Its priority relationship to this work is unestablished. A draft's + publication date matters under §102(a)(1); which version first recited + `spending_authority` matters more than the draft's existence. Counsel should + pull the dated revision history from the Datatracker rather than the current + text. + +### 6.2 The MCP-native competitive set + +| Project | What the research records | Bears on | +| --- | --- | --- | +| [protect-mcp / ScopeBlind gateway](https://github.com/tomjwxf/scopeblind-gateway) | Recorded as **the closest competitor**. A proxy intercepting `tools/call`, per-tool Cedar policy, and optional **Ed25519-signed decision receipts verifiable without calling the issuer**. No wallet, budget, or charge-once semantics. Author of the draft in §6.1. | Mechanisms 3 and 4 — a network boundary paired with offline-verifiable receipts is occupied | +| [jamjet-labs/jamjet](https://github.com/jamjet-labs/jamjet) | One `policy.yaml` across hooks, guardrails, MCP gateways and SDKs; **enforces budgets**; signed receipts with a hash-chained `previousReceiptHash` and **pre/post-execution signatures**. | Mechanism 1 (budget enforcement in an agent gateway is not novel) and mechanisms 3–4 | +| [sangaraju1988/latch](https://github.com/sangaraju1988/latch) | Python library, not a proxy: **idempotency, budget guardrail**, circuit breaker, saga/compensation, Redis backend for cross-process idempotency. No signed receipts. | Mechanisms 1 and 2 — idempotency plus budget caps in one library | +| [TraceAgent](https://www.traceagent.dev/) | Append-only receipts with SHA-256 hash chains. **Verified only that receipts are hash-chained**; whether they carry an issuer signature or verify offline is *not verified*. Do not classify as offline-verifiable without a primary source. | Mechanism 3, pending verification | +| [microsoft/agent-governance-toolkit](https://github.com/microsoft/agent-governance-toolkit) | An active proposal for independently verifiable compliance receipts. | Strategic risk; a large vendor entering the same space | + +### 6.3 Problem evidence, and why it belongs on the IDS + +The research also collected third-party reports of the retry/double-execution +problem: [stripe/ai#402](https://github.com/stripe/ai/issues/402), +[langchain-ai/langgraph#7417](https://github.com/langchain-ai/langgraph/issues/7417) +(a confirmed production incident of a managed platform re-dispatching a tool call +the caller believes is still running), +[crewAIInc/crewAI#5802](https://github.com/crewAIInc/crewAI/issues/5802), and +[OpenBB-finance/OpenBB#7455](https://github.com/OpenBB-finance/OpenBB/issues/7455) +(an MCP operator asking for signed per-call evidence). + +These cut both ways and counsel should hear both. They are **evidence of a +long-felt need** — a *Graham v. John Deere* secondary consideration, useful +against an obviousness rejection. They are also **publicly available printed +publications describing the problem**, which is exactly the material an examiner +uses to establish that a person of ordinary skill was motivated to solve it. +Disclose them; do not lean on them as though they only helped. + +--- + +## 7. Where the room actually is -Ranked by how defensible each looks against the art above: +Ranked by how defensible each looks against the art above. **This ranking was +revised after §5 and §6 were added** — the earlier version placed offline +verification third on the strength of a landscape that did not yet include the +competitive set, and it read as more secure than the evidence supports. -1. **Atomic guarded reservation under weak isolation** (`app/services/permits.py:426`). - The single conditional `UPDATE ... WHERE spent + amount <= cap` with a - `rowcount != 1` denial is a real solution to a real bug — the repo history - records the overspend being found under concurrency and then fixed. Crucially, - it stays correct on engines where `SELECT ... FOR UPDATE` is silently a no-op, - which is a claim limitation grounded in database behavior, not bookkeeping. - Strongest §101 posture of the four. -2. **Crash-recovery classification of a charged-but-unfinalized operation** +1. **Mechanism 2 — crash-recovery classification of a charged-but-unfinalized operation** (`app/services/idempotency.py:408`). Specific problem, specific asymmetric - solution, no clean analogue found in the searched art. -3. **Offline verification with a status taxonomy that separates a signature - failure from a key the verifier does not hold** - (`b2a_sdk/.../receipt_verifier.py`). The "a missing key is never reported as - tampering" property is a genuine and articulable technical contribution. - Verifying a signature offline is not. State it in those terms rather than as - outage detection — the verifier fetches nothing and cannot observe an - outage (§4.6 of the disclosure). -4. **Signature-stable schema evolution** (`app/services/receipts.py:312`). + solution, and still no clean analogue in the searched art — `latch` pairs + idempotency with saga/compensation, but compensation is a different answer + from *classifying* a crashed record into never-charged, charged-and- + reconstructable, or needs-review. This is now the strongest of the four + because it is the one the new art in §6 does not touch at all. +2. **Mechanism 1 — atomic guarded reservation under weak isolation** + (`app/services/permits.py:426`, and the upstream dispatch path at + `app/services/mcp_dispatch_attempts.py:463`). Still a real solution to a real + bug — the repo history records the overspend being found under concurrency + and then fixed, twice, on two paths. Correctness on engines where + `SELECT ... FOR UPDATE` is silently a no-op remains a claim limitation + grounded in database behaviour rather than bookkeeping, and the §101 posture + is the best of the four. It moves to second because §5 names the general + technique it instantiates, and `jamjet` and `latch` both enforce budgets in + agent tooling. Draft it around the **classification of the zero-row + outcome**, which is where the distinction actually lives. +3. **Mechanism 3 — signature-stable schema evolution** (`app/services/receipts.py:312`). Signing additive fields only when present, so old signatures keep verifying as the schema grows, with a fail-closed constrained fallback for the one - backfilled migration. Narrow, but clean and concrete. + backfilled migration. Narrow, clean and concrete — but check it against the + namespaced, versioned receipt types in the §6.1 draft before drafting, since + that is a published approach to the same problem. +4. **Mechanism 4 — offline verification with a status taxonomy that separates + a signature failure from a key the verifier does not hold** + (`b2a_sdk/.../receipt_verifier.py`). **Demoted from second on the §6 + evidence.** Offline-verifiable Ed25519 receipts at a network boundary are + occupied — protect-mcp emits receipts verifiable without calling the issuer, + and its author has an Internet-Draft for the format. So the claimable + surface is not "offline verification," which is now table stakes, and not + "signed receipts at a gateway." What survives is the **six-state taxonomy + itself**: that a key the verifier does not hold resolves to `UNKNOWN_KEY` + rather than to a tampering verdict, so a key-distribution failure is never + reported as fraud. That property is articulable and, on the reviewed + materials, unaddressed by the competitors — none of the records describes + what its verifier returns when the key is missing. It is also a much thinner + claim than the earlier ranking implied. State it as the taxonomy, never as + outage detection: the verifier fetches nothing and cannot observe an outage + (§4.6 of the disclosure). + +**The honest summary for counsel:** the two mechanisms on the *settlement* side +— crash-recovery classification and cap enforcement — held up against the new +art. The two on the *evidence* side weakened, because the evidence half of this +category filled in during 2026 while this package was being drafted. That is an +argument for filing promptly on the settlement mechanisms and for treating the +receipt mechanisms as dependent claims rather than independent ones. ### The combination-claim trap @@ -225,3 +378,11 @@ problem the art does not address** — not on the assembly. That is what - [Avoiding double payments in a distributed payments system — Airbnb Engineering](https://medium.com/airbnb-engineering/avoiding-double-payments-in-a-distributed-payments-system-2981f6b070bb) - [Google Patents — US 12,563,045 B1](https://patents.google.com/patent/US12563045B1/en) - [Macaroons: Cookies with Contextual Caveats — Google Research](https://research.google/pubs/macaroons-cookies-with-contextual-caveats-for-decentralized-authorization-in-the-cloud/) +- Kung, H.T. & Robinson, J.T., *On Optimistic Methods for Concurrency Control*, ACM Transactions on Database Systems 6(2), June 1981, pp. 213–226 — [ACM DL](https://dl.acm.org/doi/10.1145/319566.319567), [author PDF](https://www.eecs.harvard.edu/~htk/publication/1981-tods-kung-robinson.pdf), [dblp](https://dblp.uni-trier.de/rec/journals/tods/KungR81.html) +- [`draft-farley-acta-signed-receipts` — IETF Datatracker](https://datatracker.ietf.org/doc/draft-farley-acta-signed-receipts/) +- [protect-mcp / ScopeBlind gateway](https://github.com/tomjwxf/scopeblind-gateway) +- [jamjet-labs/jamjet](https://github.com/jamjet-labs/jamjet) +- [sangaraju1988/latch](https://github.com/sangaraju1988/latch) +- [microsoft/agent-governance-toolkit](https://github.com/microsoft/agent-governance-toolkit) +- [`docs/market-research-2026-08.md`](../market-research-2026-08.md) — competitive set and per-claim verification levels (added by #285) +- [`docs/related-work.md`](../related-work.md) — external literature with per-source verification levels diff --git a/docs/ip/03-invention-disclosure.md b/docs/ip/03-invention-disclosure.md index 49c304fb..7b0a0f6b 100644 --- a/docs/ip/03-invention-disclosure.md +++ b/docs/ip/03-invention-disclosure.md @@ -127,13 +127,15 @@ UPDATE permits updated_at = :now WHERE permit_id = :permit_id AND status = 'active' + AND expires_at > :now AND spent_credits + :estimated <= max_credits ``` 4. If the affected row count is not exactly 1, the reservation lost a race — - refresh the row and deny with an accurate reason (`permit_` if the - status changed, otherwise `permit_budget_exceeded` with remaining, spent, - and maximum credits attached). **No budget moved.** + refresh the row and deny with an accurate reason: `permit_` if the + status changed, `permit_expired` if it crossed `expires_at`, otherwise + `permit_budget_exceeded` with remaining, spent, and maximum credits + attached. **No budget moved.** The essential property: *the numbers read during validation are advisory; the guarded write is the sole authority.* The cap predicate is evaluated by the @@ -141,31 +143,42 @@ database as part of the same statement that performs the increment, so two concurrent reservations cannot both satisfy it, **regardless of whether the row lock in step 1 actually engaged**. -**Scope this claim precisely.** The guarded predicate covers exactly two -things — `status = 'active'` and the budget cap. Those two are enforced -independently of the lock. Everything else validated in step 2 — signature, -key match, expiry, scopes, per-tool counts, aggregate value cap, forbidden -fields — is protected by the row lock alone, and therefore *does* depend on the -engine's locking semantics. - -Expiry is the sharpest instance, because an expired permit deliberately keeps -`status = "active"` in storage (expiry is enforced dynamically at invoke time; -see the comment at `app/routers/me.py:102`). Two consequences follow, and the -disclosure must not paper over either: - -- On an engine where the lock is a no-op, a permit can pass the step-2 expiry - check, cross `expires_at`, and still satisfy the guarded update — dispatching - an invocation under a just-expired permit. The window is narrow and requires - expiry to fall inside it, but it is real. -- The step-4 re-read classifies only by stored `status`, so it cannot return - `permit_expired`; an expired permit still reads as `active` and the denial - falls through to `permit_budget_exceeded`. - -Adding `expires_at > :now` to the guarded update predicate, and consulting -`expires_at` in the re-read classification, would extend the lock-independent -guarantee to expiry. **[not implemented]** — see §7. Counsel should keep the -independent claim anchored on the budget cap, which is what the code actually -enforces without the lock. +**Scope this claim precisely.** The guarded predicate covers exactly three +things — `status = 'active'`, `expires_at > :now`, and the budget cap. Those +three are enforced independently of the lock. Everything else validated in +step 2 — signature, key match, scopes, per-tool counts, aggregate value cap, +forbidden fields — is protected by the row lock alone, and therefore *does* +depend on the engine's locking semantics. + +> **Expiry moved into the predicate; this section used to describe a gap.** +> An expired permit keeps `status = "active"` in storage — expiry is a dynamic +> check, not a stored state (see the comment at `app/routers/me.py:102`). While +> the guarded predicate covered only `status` and the cap, two things followed: +> a permit could pass the step-2 expiry check, cross `expires_at`, and still +> satisfy the guarded update — dispatching under a just-expired permit wherever +> the row lock was a no-op; and the step-4 re-read, classifying only by stored +> `status`, could never return `permit_expired`, so such a denial fell through +> to `permit_budget_exceeded`. +> +> Both are now closed. `expires_at > :now` is a term of the guarded update on +> **both** reservation paths, and the re-read classifies expiry before budget so +> a permit that expired mid-flight is not misreported as out of money. +> +> **Regression coverage:** +> `tests/test_governed_persistence.py::test_reserve_refuses_permit_that_expired_after_validation` +> and `::test_remote_prepare_refuses_permit_that_expired_after_validation` +> force the window open — validation runs for real against a live permit, the +> row is then expired out of band, and only then does the guarded write run. +> Both were confirmed to **fail against the pre-fix code** with +> `assert True is False`: the reservation was allowed under an expired permit, +> on both paths. The upstream test additionally asserts that the denial leaves +> **no prepared attempt**. +> +> Consequence for the claims: the lock-independent guarantee now covers expiry +> as well as the cap, and `permit_expired` is a reachable denial reason from the +> re-read. Counsel may now recite expiry in the guarded-update limitation — +> though the budget cap remains the cleaner anchor, since it is the one that +> carries an accounting consequence. The same guarded-update discipline is used for standalone reservation (`reserve_budget()`, `:709`, which also emits threshold notifications at 80%, @@ -445,11 +458,13 @@ and inequitable-conduct exposure, and this repository already documents them "the payload was modified" without qualification. Closing this is a small code change and would strengthen claim 15 — do it **before** filing if the stronger claim is wanted. -- **The lock-independent budget guarantee does not extend to expiry.** The - guarded update predicates only on `status` and the cap; expired permits keep - `status = "active"`. See §4.2 for the resulting race and the missing - `permit_expired` denial reason. This is now the *only* remaining gap in the - reservation mechanism — the upstream-path gap was closed (§4.3). +- ~~**The lock-independent budget guarantee does not extend to expiry.**~~ + **Closed.** `expires_at > :now` is now a term of the guarded update on both + reservation paths, and the re-read classifies expiry before budget, so + `permit_expired` is reachable. See §4.2 for the mechanism and the regression + tests that fail against the pre-fix code. **No known gap remains in the + reservation mechanism** — the upstream-path gap closed earlier (§4.3), this + one after it. - **Private-key rotation is out of band.** `rotate_active_key_metadata()` rotates metadata only. - Settlement, compliance-grade ledger storage, and production readiness for diff --git a/docs/ip/04-claim-sets.md b/docs/ip/04-claim-sets.md index d21c8303..56c0809a 100644 --- a/docs/ip/04-claim-sets.md +++ b/docs/ip/04-claim-sets.md @@ -26,6 +26,18 @@ US 12,688,261 sits (see [`02-prior-art-landscape.md`](02-prior-art-landscape.md) > — is the limitation that distinguishes this from ordinary row locking, and it > is supported by the SQLite embodiment in §5 of the disclosure. Do not let it > be amended away. +> +> **Added after the 2026-08 research pass:** the general technique here is +> **optimistic concurrency control** (Kung & Robinson, ACM TODS 6(2), June 1981, pp. 213–226), and +> `jamjet` and `latch` both enforce budgets in agent tooling — see +> [`02-prior-art-landscape.md`](02-prior-art-landscape.md) §5 and §6.2. A +> conditional `UPDATE` predicated on an observed value is textbook, so lean the +> claim on what OCC does not supply: the predicate is a **domain cap** rather +> than a version equality test, the zero-row outcome is **classified into an +> actionable reason** (budget versus status) rather than retried, and there is +> **no retry loop** — a loser is denied, not re-run. Note also that +> `app/services/audit_chain.py` uses the same technique for a different purpose +> in this repository, which an examiner may find. **1.** A system for enforcing a delegated spending limit on tool invocations by an autonomous software agent, the system comprising: @@ -190,6 +202,19 @@ records of invocations still in progress are not subjected to the repair action. > before filing, tell counsel: the claim should then be amended to recite > selection from the signed payload, and limitation (f) can be tightened to > "only status (i) indicates modification of the payload." +> +> **Added after the 2026-08 research pass — this set got weaker, not stronger.** +> Offline-verifiable Ed25519 receipts at a network boundary are **occupied**: +> protect-mcp emits decision receipts verifiable without calling the issuer, and +> its author has an Internet-Draft (`draft-farley-acta-signed-receipts`) whose +> `-02` adds a `spending_authority` receipt type. See +> [`02-prior-art-landscape.md`](02-prior-art-landscape.md) §6. The narrow +> framing in the note above is therefore not merely good practice — it is now +> the **only** framing available. Do not let this set drift back toward "signed +> receipts at a gateway" or "offline verification," both of which are table +> stakes. The taxonomy is the claim. Counsel should also check what each +> competitor's verifier returns when the key is missing; on the reviewed +> materials none of them says, which is where the distinction lives. **15.** A non-transitory computer-readable medium storing instructions that, when executed by one or more processors, cause the processors to verify a diff --git a/docs/ip/05-abstract-and-figures.md b/docs/ip/05-abstract-and-figures.md index e4abaf37..f21e5b1b 100644 --- a/docs/ip/05-abstract-and-figures.md +++ b/docs/ip/05-abstract-and-figures.md @@ -74,15 +74,20 @@ Flowchart: scopes, per-tool count, aggregate cap, forbidden fields 4. **240** Decision: valid? → No → **245** Deny with reason 5. **250** Execute guarded conditional `UPDATE`, showing the predicate inline: - `SET spent = spent + est WHERE status='active' AND spent + est <= max` + `SET spent = spent + est WHERE status='active' AND expires_at > now + AND spent + est <= max` 6. **260** Decision: affected row count == 1? 7. **265** No → refresh row → **270** status changed? → deny `permit_revoked`; - otherwise deny `permit_budget_exceeded` with remaining, spent, max — - *annotate: "no budget moved"*. - Do **not** draw a `permit_expired` terminal here: expired permits keep - `status = "active"`, so this re-read cannot produce that reason (§4.2). - It becomes reachable only if `expires_at` is added to the guarded predicate - and the classification — **[not implemented]**. + → **272** expired (`expires_at <= now`)? → deny `permit_expired` with + expired-at and checked-at; otherwise deny `permit_budget_exceeded` with + remaining, spent, max — *annotate: "no budget moved"*. + **Draw all three terminals.** An earlier draft told the draftsperson to omit + `permit_expired` on the ground that expired permits keep `status = "active"` + and the re-read could never produce it. That was true of the code at the + time and is no longer: `expires_at` is now a term of the guarded predicate, + and the classification consults it *before* budget, so a permit that expired + mid-flight reports as expired rather than as out of money. + Order the branches status → expiry → budget, matching the code. 8. **280** Yes → commit → **290** dispatch invocation **Both reservation paths use this flow.** The figure depicts diff --git a/docs/ip/06-ids-candidates.md b/docs/ip/06-ids-candidates.md index 855f66e7..3f7433ae 100644 --- a/docs/ip/06-ids-candidates.md +++ b/docs/ip/06-ids-candidates.md @@ -49,6 +49,7 @@ are precisely where competing 2025–2026 agent-governance filings will be sitti | 7 | RFC 8785 — JSON Canonicalization Scheme (JCS) | Closest standardized analogue to the `awi-canonical-json/1` contract. Counsel should note where the implementation **differs** from JCS — decimal normalization and datetime coercion in particular — since those differences are what the canonicalization-version check protects. | | 8 | RFC 6962 — Certificate Transparency | Hash-linked tamper-evident log structure; relevant to the audit chain (§4.7), which is described as supporting detail, not novelty. | | 9 | RFC 8693 — OAuth 2.0 Token Exchange; RFC 9396 — Rich Authorization Requests | Scoped, delegated, fine-grained authorization credentials. | +| 9a | **`draft-farley-acta-signed-receipts`** ([IETF Datatracker](https://datatracker.ietf.org/doc/draft-farley-acta-signed-receipts/)) | **The most material reference added since the first draft of this package.** Ed25519 over JCS-canonicalized JSON, namespaced receipt types, and from `-02` a **`spending_authority`** receipt type plus a Merkle commitment mode — reaching mechanisms 1, 3 and 4 simultaneously. Two qualifications: it is an **individual submission with no IETF standing**, so cite it as one vendor's draft rather than a standards-track document; and pull the **dated revision history**, since which revision first recited `spending_authority` matters more under §102(a)(1) than the draft's existence. Authored by the party behind protect-mcp (item 20a). | --- @@ -62,6 +63,37 @@ are precisely where competing 2025–2026 agent-governance filings will be sitti | 13 | *Constant-Size Cryptographic Evidence Structures for Regulated AI Workflows*, arXiv:2511.17118 | Hash-and-sign evidence structures composing with hash chains. Cited in `docs/related-work.md`. | | 14 | *Creating Characteristically Auditable Agentic AI Systems*, ACM DOI 10.1145/3759355.3759356 | Agent auditability as a system property. Cited in `docs/related-work.md`. | | 15 | *From Prompt Injections to Protocol Exploits*, arXiv:2506.23260 | Threat taxonomy for LLM-agent ecosystems including protocol-level attacks. Background for the confused-deputy and permit-misuse framing. | +| 15a | **Kung, H.T. & Robinson, J.T., *On Optimistic Methods for Concurrency Control*, ACM TODS 6(2), June 1981, pp. 213–226** (DOI [10.1145/319566.319567](https://dl.acm.org/doi/10.1145/319566.319567)) | The general technique mechanism 1 instantiates: commit conditionally on an observed value and detect the lost race by affected-row count instead of holding a lock. Productized in Hibernate `@Version` and SQLAlchemy `version_id_col`. Disclose it and draft around the **classification of the zero-row outcome** — see §5 of `02-prior-art-landscape.md`. Note this repository contains a second instance of the technique in `app/services/audit_chain.py`, which an examiner may find. | + +### C.1 MCP-native governance products + +Propagated from [`../market-research-2026-08.md`](../market-research-2026-08.md) +(added by #285), whose rows carry per-claim verification levels. **Not +independently re-verified for this package** — counsel must read the primaries. +All are publicly available and describe systems overlapping the claimed +mechanisms, so all are IDS candidates. + +| # | Reference | Relevance | +| --- | --- | --- | +| 20a | [protect-mcp / ScopeBlind gateway](https://github.com/tomjwxf/scopeblind-gateway) | Recorded as **the closest competitor**: a proxy intercepting `tools/call` with per-tool Cedar policy and optional **Ed25519-signed decision receipts verifiable without calling the issuer**. Occupies network-boundary-plus-offline-receipts. No wallet, budget, or charge-once semantics. Author of item 9a. **Establish its public-availability date** — it bears on §102(a)(1) for mechanisms 3 and 4. | +| 20b | [jamjet-labs/jamjet](https://github.com/jamjet-labs/jamjet) | **Enforces budgets** and emits signed receipts with a hash-chained `previousReceiptHash` plus pre/post-execution signatures. Reaches mechanism 1 (budget enforcement in an agent gateway) and mechanisms 3–4. | +| 20c | [sangaraju1988/latch](https://github.com/sangaraju1988/latch) | Python library pairing **idempotency with a budget guardrail**, plus circuit breaker and saga/compensation, with a Redis backend for cross-process idempotency. Closest to Set B; note compensation is a different answer from crash-state classification. No signed receipts. | +| 20d | [TraceAgent](https://www.traceagent.dev/) | Append-only SHA-256 hash-chained receipts. **Verified only that receipts are hash-chained**; issuer signature and offline verifiability are *unverified*. Do not characterize it as offline-verifiable without a primary source. | +| 20e | [microsoft/agent-governance-toolkit](https://github.com/microsoft/agent-governance-toolkit) | Active proposal for independently verifiable compliance receipts. Disclose as a large-vendor entrant in the same space. | + +### C.2 Third-party problem reports + +These are double-edged and counsel should treat them as such: they evidence a +**long-felt need** (a *Graham v. John Deere* secondary consideration against +obviousness) while also being **printed publications establishing that a person +of ordinary skill was motivated to solve the problem**. Disclose them. + +| # | Reference | Relevance | +| --- | --- | --- | +| 21a | [stripe/ai#402](https://github.com/stripe/ai/issues/402) | Agent-level retry creates duplicate charges; SDK-level idempotency keys do not cover it because the framework retries as a new invocation with a fresh key. Concludes the guard belongs above the tool layer. A reproduction, **not** a confirmed production charge. | +| 21b | [langchain-ai/langgraph#7417](https://github.com/langchain-ai/langgraph/issues/7417) | **Confirmed production incident**: managed infrastructure re-dispatches a tool call the caller believes is still running. Duplicated execution and cost — the platform-level instance of the ambiguous-outcome case `delivery_uncertain` models. | +| 21c | [crewAIInc/crewAI#5802](https://github.com/crewAIInc/crewAI/issues/5802) | A second framework documenting the same gap, with a `stripe.charge()`-fires-twice worked example. | +| 21d | [OpenBB-finance/OpenBB#7455](https://github.com/OpenBB-finance/OpenBB/issues/7455) | A production MCP operator requesting signed per-call evidence for regulatory compliance. | --- diff --git a/docs/ip/07-provisional-specification.md b/docs/ip/07-provisional-specification.md new file mode 100644 index 00000000..eb2d75c5 --- /dev/null +++ b/docs/ip/07-provisional-specification.md @@ -0,0 +1,673 @@ +# Provisional application — assembled specification (DRAFT) + +> **What this file is.** [`03-invention-disclosure.md`](03-invention-disclosure.md) +> and [`05-abstract-and-figures.md`](05-abstract-and-figures.md) are drafting +> *inputs*, organized for analysis. A provisional application needs one +> continuous written description that satisfies 35 U.S.C. §112(a) — enablement +> and written description — in the order a patent specification is read. This +> file is that assembly, so counsel edits a draft rather than building one from +> parts. +> +> **What this file is not.** It is not a filing, and it is not legal advice. +> Nothing here has been reviewed by a registered practitioner. A provisional +> requires no claims (37 CFR 1.51(c)), so none are included; the claim sets in +> [`04-claim-sets.md`](04-claim-sets.md) are for the non-provisional and are +> reproduced nowhere below. +> +> **Before filing, counsel must:** +> +> 1. **Strip every `file.py:NNN` citation.** They appear throughout in +> `[[brackets]]` precisely so they can be found and deleted. They exist to let +> a reviewer verify each statement against the implementation; they are +> meaningless in a filed specification and they gratuitously identify +> proprietary internals. +> 2. **Decide what to do with each `[not implemented]` marker.** These mark +> design intentions that are described but *not reduced to practice*. They are +> retained deliberately — a provisional may describe unimplemented subject +> matter to support later claims, but it must not represent it as built. +> Removing the markers without removing the text would misrepresent the state +> of the invention. +> 3. **Replace the figure descriptions with actual drawings.** §IV below +> describes five figures in draftsperson terms. Provisionals may be filed +> with informal drawings, but they must be filed *with* drawings if the +> drawings are necessary to understand the subject matter (35 U.S.C. §113). +> 4. **Settle inventorship.** Not addressed anywhere in this package. See +> [`01-filing-risks-and-actions.md`](01-filing-risks-and-actions.md). +> +> Assembled 2026-08-15 from `03-` and `05-` at their then-current state. If +> either source file changes, this assembly is stale — it is a snapshot, not a +> generated view. + +--- + +## Title of the Invention + +**Atomic Budget Reservation and Crash-Consistent Metering for Governed +Tool Invocation by Autonomous Software Agents, with Offline-Verifiable +Transaction Receipts** + +*Drafting note: deliberately mechanism-first. A title opening with "authorizing +AI agents" invites the §101 framing discussed in `01-filing-risks-and-actions.md`. +Counsel may prefer something shorter; keep the settlement half in it.* + +--- + +## I. Field + +Distributed computing; access control and metered resource consumption for +autonomous software agents invoking external tools over a protocol such as the +Model Context Protocol (MCP); tamper-evident transaction evidence. + +--- + +## II. Background + +An autonomous agent invoking a third-party tool creates three failure modes that +existing systems handle separately, and badly in combination. + +**(a) Concurrent overspend.** A delegated authorization that carries a spending +cap must enforce that cap across concurrent invocations. The natural +implementation — read the permit, check remaining budget, write the new total — +is a read-modify-write, and two concurrent invocations can both read the same +stale total and both pass the check. Each then writes its own increment, and one +clobbers the other. The cap is exceeded and nothing in the record shows it. + +Row locking is the textbook answer, and it is not sufficient. On some engines +(notably SQLite) `SELECT ... FOR UPDATE` parses and executes but takes no lock — +it is a silent no-op. Code that relies on it is correct in staging on PostgreSQL +and wrong in a single-file deployment, with no error to indicate the difference. + +**(b) Double debit across a crash.** Invoking a remote tool is not transactional +with the local ledger. The gateway must debit the caller's wallet and dispatch a +network call to a server it does not control. If the process dies between the +debit and the recording of what happened, a retry of the same idempotency key +faces an ambiguous record: an in-progress row with no stored response. Treating +it as "never happened" double-charges. Treating it as "already done" may report +success for a call that was refused, or that never reached the tool. + +**(c) Evidence nobody has to trust the issuer for.** A gateway that authorizes, +meters, and bills its own tenants is not a disinterested party. A record it +alone can verify is an assertion, not evidence. The party who needs to check +what happened — an auditor, a counterparty, the human who delegated the +authority — may have no account, no credential, and no network path to the +gateway, and should not need one. + +Prior systems address (a) with distributed locks or serializable isolation, (b) +with idempotency keys sharing a transaction with the business operation, and (c) +with signed audit logs. None composes: the remote tool call cannot join a local +transaction, and a signed log the issuer alone can interpret does not solve the +trust problem. + +--- + +## III. Summary + +A gateway mediates agent-to-tool invocations and, for each invocation: + +1. **Atomically authorizes and reserves** budget against a signed delegated + permit, using a single guarded conditional update that enforces the cap at + the row level and therefore remains correct where advisory row locks do not + fire. Both reservation paths share this mechanism — the local path and the + upstream dispatch path. +2. **Checkpoints the debit** on the idempotency record before finalization + begins, so a later crash leaves a record that classifies itself. +3. **Reconciles** crashed records asymmetrically: never-charged records are + released for safe retry; charged-but-unfinalized records are repaired from + the signed receipt's actual outcome; charged records with no recoverable + evidence are flagged rather than guessed at. +4. **Issues a signed receipt** whose signature covers a canonical serialization + that embeds a digest of the payload's other fields, and which signs additive + fields only when present so that schema growth does not invalidate + historical signatures. +5. **Publishes verification keys** at a well-known location so any third party + can verify a receipt **offline**, with a status taxonomy that never reports + a key the verifier does not hold as evidence of tampering. + +--- + +## IV. Brief Description of the Drawings + +Five drawings. Descriptions are written for a patent draftsperson; each notes +the section of the detailed description it supports. + +**FIG. 1** is a block diagram of the system architecture. *Supports §§V.A–V.F.* + +**FIG. 2** is a flowchart of atomic authorization and reservation. *Supports §V.B.* + +**FIG. 3** is a two-panel diagram of the debit checkpoint and asymmetric +reconciliation. *Supports §V.D.* + +**FIG. 4** is a two-column diagram of receipt signing and signature-stable +evolution. *Supports §V.E.* + +**FIG. 5** is a flowchart of the offline verification status taxonomy. +*Supports §V.F.* + +### FIG. 1 — System architecture + +Block diagram, left to right: + +- **110** Autonomous agent (with framework/SDK) +- **120** Governance gateway, containing: + - **122** Permit service — authorize and reserve + - **124** Idempotency service — checkpoint and reconcile + - **126** Dispatch attempt state machine + - **128** Receipt service — sign + - **130** Signing key service (Ed25519) +- **140** Database, containing: **142** permit records, **144** idempotency + records, **146** dispatch-attempt records, **148** receipts, **150** ledger + entries, **152** audit chain +- **160** Remote tool server (MCP) +- **170** Well-known key endpoints (`/.well-known/trust-keys.json`, + `/.well-known/jwks.json`) +- **180** Third-party verifier — **shown with no live connection to 120, 140, + or 170** — holding a receipt bundle **182** and a **previously retrieved key + set 184** (a local cache or file obtained at some earlier time) + +The absent connections are the point of the figure: the verifier needs no +account, no credential, and no network path to anything at verification time. +Draw the key-set arrow from **170** to **184** as a **dashed line labelled +"earlier, out of band"** — key retrieval happens before verification and is not +part of it. A solid live link from **180** to **170** would contradict the word +"offline" and misdescribe the verifier, which fetches nothing and accepts only +a caller-supplied key set. + +### FIG. 2 — Atomic authorization and reservation + +Flowchart: + +1. **210** Receive invocation request (permit ID, tool, estimated credits) +2. **220** Begin transaction; request row lock on permit — *annotate: "lock + request may be a no-op on some engines"* +3. **230** Validate — signature, wallet, key, status, expiry, tool allowlist, + scopes, per-tool count, aggregate cap, forbidden fields +4. **240** Decision: valid? → No → **245** Deny with reason +5. **250** Execute guarded conditional `UPDATE`, showing the predicate inline: + `SET spent = spent + est WHERE status='active' AND expires_at > now + AND spent + est <= max` +6. **260** Decision: affected row count == 1? +7. **265** No → refresh row → **270** status changed? → deny `permit_revoked`; + → **272** expired (`expires_at <= now`)? → deny `permit_expired` with + expired-at and checked-at; otherwise deny `permit_budget_exceeded` with + remaining, spent, max — *annotate: "no budget moved"*. + **Draw all three terminals**, ordered status → expiry → budget, matching the + code. `expires_at` is a term of the guarded predicate, and the classification + consults it *before* budget, so a permit that expired mid-flight reports as + expired rather than as out of money (§V.B). +8. **280** Yes → commit → **290** dispatch invocation + +**Both reservation paths use this flow.** The upstream dispatch path performs +the same guarded update at **250** with the same affected-row check at **260**. + +It differs on the success branch, and the **ordering matters** — draw it exactly +as the code runs: + +> **250** guarded `UPDATE` → **260** affected-row check → **275** insert the +> dispatch-attempt record in `prepared` state and flush → **280** commit → +> **290** dispatch invocation + +The insert and flush sit **inside** the transaction, before the commit. Do not +place them after **280**: the reservation and the prepared-attempt creation +occur *within the same database transaction*, and a figure showing the insert +after commit would depict the opposite. + +### FIG. 3 — Debit checkpoint and asymmetric reconciliation + +*Draw in two panels.* + +**Panel A — normal path (vertical):** +**310** create idempotency record → **320** authorize and reserve → **330** +debit wallet, write ledger entry → **340** ⭐ *write `ledger_entry_id` +checkpoint* → **350** dispatch to remote tool → **360** sign receipt → **370** +store response on idempotency record. + +Draw a lightning bolt **345** between **340** and **370** labelled "process +failure window." + +**Panel B — reconciliation decision tree**, for a record with no stored +response, idle > threshold: + +- **380** Dispatch attempt exists? → Yes → **382** skip (remote reconciler owns + it, preserves delivery-uncertain state) +- **384** Checkpoint present? → No → **386** attempt or receipt exists? → No → + **387** endpoint effect-free (canonical MCP identity, + `operation_kind == "upstream_mcp"`)? → Yes → **388** delete record; key safe + to retry. *The effect-free test is a precondition, not a formality:* deletion + is confined to the narrow upstream-MCP identity that crashed before any + budget, debit, attempt, or receipt existed. +- Checkpoint present → **390** receipt exists? → + - Yes → **392** reconstruct response from `receipt.outcome`; select status + code (success→200, insufficient_funds→402, denied→403, + delivery_uncertain→504, response_rejected→502) + - No → **394** leave unmodified; increment manual-review count + +### FIG. 4 — Receipt signing and signature-stable evolution + +Two parallel columns showing **two different receipts** — one issued at time T1 +and one at time T2, after an optional field was added to the schema. They are +distinct instances, **not the same receipt signed twice**: the T1 receipt keeps +its original signed bytes untouched forever, and only the T2 receipt carries the +new field. Nothing is ever re-signed. + +- **410** Receipt fields (T1) → **420** add `alg`, `kid` → **430** compute + `payload_hash = SHA-256(canonical_json(payload))` **over the payload as it + stands at this point, before the digest field exists** → **440** insert + `payload_hash` into the payload → **450** `canonical_json` of the payload + **now including** `payload_hash` → **460** Ed25519 sign. + *Annotate 430/440 explicitly: the digest covers the other fields, not itself.* +- Right column repeats with **415** an additional optional field + (`dispatch_attempt_id`) present. +- **470** Verification block below both, branching: reconstruct payload + including optional fields **only when present** → **480** T1 receipt verifies + under original bytes; T2 receipt verifies under extended bytes — *annotate: + "no re-signing migration"*. +- **490** Constrained legacy fallback, shown as a dashed side branch with its + gate conditions — exactly one referencing idempotency record; wallet and + request hash agree; **and the receipt carries no dispatch link** — and a + **fail-closed** terminal. All three gates are required. + +### FIG. 5 — Offline verification status taxonomy + +Flowchart with **six distinct terminal states**, drawn so the terminals are +visually distinguishable — this figure exists to show they are not one boolean: + +1. **510** Receive bundle + key set +2. **520** Parse → fail → **525** `MALFORMED` +3. **530** Declared canonicalization matches implemented contract? → No → + **535** `UNSUPPORTED` +4. **540** Read `kid` **from the bundle envelope**; present in key set + (excluding disabled)? → No → **545** `UNKNOWN_KEY` +5. **550** Verify Ed25519 signature over the signed bytes **as received** → + fail → **555** `INVALID` — *annotate: "signature did not verify under the + selected key"* +6. **560** `kid` in signed payload == `kid` used in step 4? → No → **565** + `MISMATCH` +7. **570** Recompute `payload_hash` over the signed payload **excluding that + field**; disagrees? → **565** `MISMATCH` +8. **575** Envelope values agree with signed payload values? → No → **565** + `MISMATCH` +9. **580** `VERIFIED` — return claims **read from signed payload** + +**Group the terminals into two families, not one-against-the-rest.** Shade +**555** (`INVALID`) and **565** (`MISMATCH`) together as **integrity failures** +— both are reached only after the verifier has enough input to make a judgment +about the evidence itself. Shade **525** (`MALFORMED`), **535** (`UNSUPPORTED`) +and **545** (`UNKNOWN_KEY`) together as **missing input or capability** — the +verifier is saying "I cannot judge this," not "this is bad." + +*Draftsperson note:* steps 4 and 6 are deliberately ordered that way — key +selection uses the envelope's `kid`, and the signed payload's `kid` is checked +only after the signature verifies. Do not "correct" the figure to read the +`kid` from the signed payload; that would depict something the system does not +do. See §V.F. + +--- + +## V. Detailed Description + +### V.A The permit + +A permit [[`app/schemas/trust.py:12`, `app/services/permits.py:247`]] is an +Ed25519-signed record binding: + +| Field | Constraint expressed | +| --- | --- | +| `issuer_wallet_id` / `subject_wallet_id` | who delegated to whom | +| `subject_key_id` | which credential may exercise it | +| `scopes`, `allowed_tools` | which actions and tools | +| `max_credits`, `spent_credits` | total spend cap and consumption to date | +| `expires_at`, `nonce`, `status`, `revoked_at` | validity window, replay identity, revocation | +| `max_calls_per_tool` | per-tool invocation ceilings | +| `aggregate_value_cap` | cap on cumulative charged value | +| `forbidden_fields` | argument keys the subject may not supply | +| `recipient_domain` | destination restriction | +| `requires_human_approval` | gate before budget moves | + +Permit issuance is refused when `max_credits` exceeds the issuing wallet's +balance (`permit_budget_exceeds_wallet_balance`) — authority cannot be delegated +that the issuer does not hold. + +> *Drafting note, not specification text:* this structure is close to Daon +> US 12,688,261 and is described here as context, **not** as a point of novelty. +> See [`02-prior-art-landscape.md`](02-prior-art-landscape.md) §1. + +### V.B Atomic authorization and reservation + +In one embodiment, authorization and reservation +[[`app/services/permits.py:426`]] run inside a single transaction: + +1. Load the permit row requesting a row lock. +2. Validate: wallet match, key match, signature, status, expiry, tool allowlist, + scopes, per-tool call count, aggregate value cap, forbidden argument fields. +3. **Reserve with a single guarded conditional update:** + +```sql +UPDATE permits + SET spent_credits = spent_credits + :estimated, + updated_at = :now + WHERE permit_id = :permit_id + AND status = 'active' + AND expires_at > :now + AND spent_credits + :estimated <= max_credits +``` + +4. If the affected row count is not exactly 1, the reservation lost a race — + refresh the row and deny with an accurate reason: `permit_` if the + status changed, `permit_expired` if it crossed `expires_at`, otherwise + `permit_budget_exceeded` with remaining, spent, and maximum credits + attached. **No budget moved.** + +The essential property: *the numbers read during validation are advisory; the +guarded write is the sole authority.* The cap predicate is evaluated by the +database as part of the same statement that performs the increment, so two +concurrent reservations cannot both satisfy it, **regardless of whether the row +lock in step 1 actually engaged**. + +**The scope of that guarantee is precise, and the specification should say so.** +The guarded predicate covers exactly three things — `status = 'active'`, +`expires_at > :now`, and the budget cap. Those three are enforced independently +of the lock. Everything else validated in step 2 — signature, key match, scopes, +per-tool counts, aggregate value cap, forbidden fields — is protected by the row +lock alone, and therefore *does* depend on the engine's locking semantics. + +Expiry is in the predicate for a specific reason worth reciting: an expired +permit deliberately keeps `status = "active"` in storage, because expiry is a +dynamic check rather than a stored state [[`app/routers/me.py:102`]]. A +predicate testing only `status` therefore does not test validity. With +`expires_at` in the predicate, a permit that crosses its expiry between the +read-time check and the guarded write fails the write instead of being reserved +against, and the re-read classifies expiry before budget so the denial reports +`permit_expired` rather than misreporting a permit with budget left as out of +money. + +The same guarded-update discipline is used for standalone reservation +[[`reserve_budget()`, `permits.py:709`]], which also emits threshold +notifications at 80%, 90%, and 100% consumption; for clamped release +[[`release_budget()`, `:784`]]; and for exactly-once release keyed to a dispatch +attempt [[`release_dispatch_budget_once()`, `:820`]], which closes a crash +window that a plain release leaves open. A retry wrapper re-runs the operation +on retryable write conflicts. + +> *Drafting note:* the general technique here is optimistic concurrency control +> (Kung & Robinson, ACM TODS 6(2), June 1981). What distinguishes this embodiment is that the +> predicate is a **domain cap** rather than an equality test on an observed +> version, that the zero-row outcome is **classified into an actionable reason** +> rather than retried blindly, and that there is **no retry loop** on the +> reservation path. Draft around the classification step. See +> [`02-prior-art-landscape.md`](02-prior-art-landscape.md) §5. + +### V.C Dispatch attempt state machine + +Because the tool call is remote and cannot join the local transaction, each +governed invocation carries a durable attempt record +[[`app/services/mcp_dispatch_attempts.py`]] advancing monotonically through +`prepared → dispatched → {succeeded | failed | delivery_uncertain}`. + +Authorization, reservation, and preparation [[`:344`]] establish the budget +reservation and the `prepared` row in one atomic step, so a reservation can +never exist without the attempt record that governs its release. Re-entry adopts +only an invariant-equivalent prepared row, and the attempt is bound to the +approval identity that authorized it. + +Both reservation paths perform the identical guarded update of §V.B with the +same affected-row check. A lost race denies with `permit_budget_exceeded` (or +`permit_`) and creates **no prepared attempt**, so it leaves nothing to +compensate. + +`delivery_uncertain` is a first-class terminal state, surfaced as HTTP 504. The +system declines to assert that a remote side effect did or did not occur when it +cannot know. Remote side effects are exactly-once only when the upstream honors +the forwarded idempotency key — a boundary the system states rather than hides. + +### V.D Debit checkpoint and asymmetric reconciliation + +An idempotency service [[`app/services/idempotency.py`]] keys a record on +`(wallet_id, endpoint, idempotency_key)` under a unique constraint, storing a +SHA-256 request fingerprint. Reuse of a key with a different fingerprint is +refused (`idempotency_key_reused`). A concurrent identical request may wait a +bounded interval for the first to finalize rather than failing immediately. + +Two mechanisms go past the standard pattern. + +**The checkpoint.** The charge-marking step [[`:379`]] writes `ledger_entry_id` +onto the idempotency record *immediately after the wallet debit lands and +before* the receipt/audit/complete finalization sequence. Its sole purpose is to +make a crashed record self-classifying. + +**Asymmetric repair.** Reconciliation [[`:408`]] processes only records idle +beyond a threshold (default 900 seconds), so in-flight requests are never +touched, and takes four different actions: + +| Observed state | Interpretation | Action | +| --- | --- | --- | +| No checkpoint, no attempt, no receipt, effect-free endpoint | Crashed before anything happened | Delete the record; the key is safe to retry | +| Checkpoint present, receipt exists | Debit landed, finalization crashed | Rebuild the replay response **from the receipt's outcome** | +| Checkpoint present, no receipt | Money moved, nothing reconstructable | Leave untouched; count for manual review | +| Checkpoint present, dispatch attempt exists | Remote reconciler owns this | Skip — a generic repair would erase delivery uncertainty | + +The second row carries a detail that matters: the rebuilt response is derived +from `receipt.outcome`, mapping `success → 200`, `insufficient_funds → 402`, +`denied → 403`, `delivery_uncertain → 504`, `response_rejected → 502`. A crash +can just as easily interrupt a *denial* as a success. Reconciling every crashed +record as a bare 200 would tell a replaying client its call succeeded when it +was refused. + +A complementary path [[`abandon()`, `:347`]] releases an in-progress record when +a governed invocation stops on a retryable, side-effect-free condition (a human +approval still pending, an approval backend unreachable). It refuses to delete +any record carrying a stored response or a `ledger_entry_id` — if money moved, +the record survives for replay and repair. + +### V.E The receipt and its signature + +A receipt [[`app/services/receipts.py:208`]] records one completed governed +invocation: identity, the permit and wallet, the tool, SHA-256 hashes of request +and response, the ledger entry, credits authorized versus charged, outcome and +reason code, the audit event, the approval, the constraints evaluated, and the +linkage to both the idempotency record and the dispatch attempt. + +Signing proceeds [[`app/services/signing_keys.py:324`]]: + +1. Set `alg = "Ed25519"` and `kid = `. +2. Compute `payload_hash = SHA-256(canonical_json(payload))` over the payload + **as it stands before the digest field is added**, then insert the digest + into the payload [[`:338`]]. +3. Sign `canonical_json(payload)` — now including `payload_hash` — with Ed25519. + +The digest therefore covers **the other signed fields, not itself**; a +self-referential digest is not computable. The verifier mirrors this by +stripping `payload_hash` before recomputing +[[`b2a_sdk/src/b2a_sdk/receipt_verifier.py:406`]] and reporting `MISMATCH` on +disagreement. Its function is a redundant integrity check *inside* the +signature: it detects a payload whose fields were altered in some way that +preserved the signature bytes, and gives a verifier a cheap consistency check it +can perform before doing curve arithmetic. + +Canonicalization [[`canonical_json`, `signing_keys.py:66`]] is a named, +versioned contract — `awi-canonical-json/1` — so an independent verifier can +state which rules it implements: keys sorted, no whitespace, `Decimal` rendered +via `normalize()` and fixed-point formatting, `datetime` coerced to UTC +ISO-8601, dicts recursively sorted. A bundle declaring a different +canonicalization version is refused rather than verified under rules it was not +signed with. + +**Signature-stable schema evolution.** Optional fields — `reason_code`, +`idempotency_record_id`, `dispatch_attempt_id`, `approval_id`, +`constraints_evaluated` — are included in the signed payload **only when +present**. Verification [[`verify_model`, `receipts.py:558`]] mirrors that +construction exactly. The result is that receipts signed before a field existed +continue to verify unchanged after it is added: the evidence schema can grow +without a re-signing migration and without silently invalidating history. + +One historical migration backfilled an idempotency link onto receipts signed +before linkage existed. Rather than loosening verification generally, a +constrained fallback [[`_has_unambiguous_historical_idempotency_link`, +`receipts.py:115`]] retries the legacy payload shape **only** when exactly one +idempotency record points at the receipt and its wallet and request hash both +agree. Ambiguous, absent, mismatched, or dispatch-linked cases fail closed. + +A signing-input accessor [[`:520`]] returns the exact canonical bytes a given +signature covers, branch for branch — and returns `None` when neither branch +verifies, so a receipt that cannot be proven is never exported as evidence. + +### V.F Key distribution and offline verification + +Public keys are served at `/.well-known/trust-keys.json` and, for standard +tooling, as a JWK Set at `/.well-known/jwks.json` +[[`app/routers/well_known.py:619`, `:670`]]. Neither endpoint will serve an +empty key set with a 200 — an unusable key set is refused rather than published, +so a verifier is never handed a document that would make every receipt look +unknown. + +Keys carry lifecycle status (`active`, `retired`, `disabled`). Rebinding an +existing key ID to different public material is rejected +(`signing_key_id_public_key_mismatch`). Verification against a disabled key +fails closed [[`signing_keys.py:345`]], and any decode, key construction, or +signature failure is caught and reported as "not verified" rather than raised — +one corrupt row must not mask tampering behind a 500. + +The verifier [[`b2a_sdk/src/b2a_sdk/receipt_verifier.py`]] requires no network, +no database, and no credential; its only dependency beyond the standard library +is a cryptography library. Two properties define it. + +**Every reported claim value is read from the signed bytes.** The enclosing +bundle is unauthenticated envelope data and is never a source of truth for +reported values. A bundle claiming `receipt_id: X` around a payload signed for +`Y` is rejected as `MISMATCH`. + +One exception, stated precisely because it bounds the property above: the `kid` +used to **select** the verification key is read from the envelope [[`:289`]]; +the signed payload's `kid` is compared against it only *after* the signature +verifies [[`:397`]]. The consequence is that an envelope relabelled with a +different *published* `kid` resolves to `INVALID` rather than `MISMATCH`, +because the wrong key is selected and the signature fails before the cross-check +runs — even though the signed payload was never touched. Selecting the key from +the parsed signed payload instead would close that gap and make the taxonomy +exact. **[not implemented]** — see §VIII. + +**Failure is never silently "false".** The status taxonomy: + +| Status | Meaning | +| --- | --- | +| `VERIFIED` | Signature holds — cryptographic claim | +| `INVALID` | The signature did not verify under the selected key — a cryptographic claim. Payload modification is one possible cause; a relabelled envelope is another | +| `MISMATCH` | Envelope, payload, or caller expectation disagree, no demonstrated signature failure | +| `UNKNOWN_KEY` | Signing key not in the supplied key set | +| `MALFORMED` | Structurally unparseable input | +| `UNSUPPORTED` | Declared algorithm or canonicalization the verifier does not implement | + +The distinction is the point. A verifier that collapses these into a boolean +reports "I do not hold that key" identically to a forged receipt, and a caller +acting on that boolean will eventually escalate a key-distribution problem as +fraud. `INVALID` is the only status that asserts a signature failure; the rest +describe missing input or capability. + +Two precisions the specification should not let drift: + +- **The verifier never observes an outage itself.** It accepts a + caller-supplied key set and performs no retrieval, so a key-server fetch + failure happens *before* verification is invoked and is reported by whatever + fetched the keys. What the verifier contributes is that a key it does not hold + resolves to `UNKNOWN_KEY` rather than to a tampering verdict. That is the + claimable property; "detects outages" is not. +- **`INVALID` is not exclusively a payload-modification signal**, because of the + envelope-`kid` selection path described above. + +### V.G Supporting: audit chain + +Control-plane events are hash-linked and signed +[[`app/services/audit_chain.py`]]: each event carries `previous_hash`, +`payload_hash`, and `chain_hash = SHA-256(previous_hash, payload_hash)`. +Concurrent appends detect a moved chain head — via a conditional update +predicated on the observed head, with retry — and do not rely on row locking. + +> *Drafting note:* a known pattern (Certificate Transparency, Trillian). +> Include as supporting detail, **not** as a point of novelty. Note also that +> this is a second instance of the optimistic-concurrency technique used in +> §V.B; see [`02-prior-art-landscape.md`](02-prior-art-landscape.md) §5. + +--- + +## VI. Embodiments + +**Primary — hosted governance gateway.** The gateway sits between agent +frameworks and MCP tool servers, holds the signing key, serves the well-known +key endpoints, and returns a receipt with every invocation. + +**Self-hosted, single-file deployment.** The same code on SQLite rather than +PostgreSQL. This embodiment is what makes the guarded conditional update +necessary rather than merely elegant: it is the only thing enforcing the cap +when `FOR UPDATE` does not engage. **Keep this embodiment in the specification — +it is direct support for the lock-independence limitation.** + +**Federated issuers with a shared verifier. [not implemented]** Multiple +independent gateways issue receipts under distinct key IDs; a verifier holding +several key sets checks any of them offline. The key-set and `kid` machinery +supports this; no federated deployment exists. + +--- + +## VII. Advantages + +- Spend caps hold under concurrency without depending on engine-specific lock + semantics. +- A crash between debit and finalization resolves to a known state, or is + flagged rather than guessed at. +- Evidence is verifiable by parties who trust neither the agent nor the gateway, + with no account and no network path. +- Evidence schemas can evolve without invalidating historical signatures. +- Verification failures are diagnosable: a missing key is never reported as + fraud. + +--- + +## VIII. Known limitations + +Retained deliberately. Overclaiming in a specification creates enablement and +inequitable-conduct exposure, and this repository already documents these +(`SECURITY_LIMITATIONS.md`). + +- **Exactly-once is guaranteed at the gateway debit, not at the remote tool.** + Remote side effects are exactly-once only if the upstream honors the forwarded + idempotency key. `delivery_uncertain` exists because that cannot be assumed. +- **The charged-with-no-receipt case is unrecoverable by design.** It is counted + for manual review, not repaired. +- **Key distribution and issuer identity remain the verifier's trust decision.** + Offline verification proves a signature, not that the issuer is honest. The + verifier retrieves nothing and therefore cannot distinguish a key-server + outage from any other reason its key set lacks a `kid`. +- **Key selection reads the envelope, not the signed payload.** A bundle + relabelled with a different published `kid` yields `INVALID`, not `MISMATCH`, + so `INVALID` cannot be read as "the payload was modified" without + qualification. Closing this is a small code change — **do it before filing** + if the stronger claim is wanted. +- ~~**The lock-independent budget guarantee does not extend to expiry.**~~ + **Closed.** `expires_at > :now` is a term of the guarded update on both + reservation paths, and the re-read classifies expiry before budget. See §V.B. + No known gap remains in the reservation mechanism. +- **Private-key rotation is out of band.** The rotation routine rotates metadata + only. +- Settlement, compliance-grade ledger storage, and production readiness for + arbitrary agent fleets are **not** claimed. + +--- + +## Abstract + +> 37 CFR 1.72(b): a single paragraph, 150 words maximum. Not required for a +> provisional; included because the non-provisional will need it and it is +> easier to refine early. **146 words — recount with `wc -w` after any edit.** + +A gateway mediates tool invocations by autonomous software agents while +enforcing a delegated spending limit, deduplicating retries that carry an +idempotency key, and producing verifiable evidence. Budget is reserved by a +single conditional update whose limit predicate the database evaluates as part +of the same statement that performs the increment, so concurrent invocations +cannot jointly exceed the limit irrespective of whether a requested row lock was +granted; an affected-row count other than one yields a denial identifying +whether budget or permit status caused it. An identifier of the ledger entry is +written to the idempotency record after the debit and before finalization, so a +record left unfinished by a process failure is classified as never charged, as +charged and reconstructable from a signed receipt, or as requiring review. The +receipt verifies offline against a published key, returning a status +distinguishing signature failure from an unavailable key. diff --git a/docs/ip/README.md b/docs/ip/README.md index 0179edce..56fc636d 100644 --- a/docs/ip/README.md +++ b/docs/ip/README.md @@ -18,6 +18,7 @@ should be filed as-is. | [`04-claim-sets.md`](04-claim-sets.md) | Three independent claims plus dependents | Counsel | | [`05-abstract-and-figures.md`](05-abstract-and-figures.md) | Abstract and figure descriptions | Counsel / draftsperson | | [`06-ids-candidates.md`](06-ids-candidates.md) | References to disclose under 37 CFR 1.97 | Counsel | +| [`07-provisional-specification.md`](07-provisional-specification.md) | `03-` and `05-` assembled into one continuous §112(a) written description, in filing order. A draft to edit, **not** a filing — see its header for the four things counsel must do before it can be filed. | Counsel | ## The short version @@ -48,3 +49,30 @@ The authorization half of the system — scoped, expiring, signed permits for agent tool calls — is where the closest prior art lives, and is the weakest place to anchor a claim. See [`02-prior-art-landscape.md`](02-prior-art-landscape.md). + +## What changed after the 2026-08 research pass + +The list above is ordered as originally drafted. **It is no longer the order of +strength.** PRs #285 and #288 added verified, primary-source research to the +repository — `docs/market-research-2026-08.md` and new rows in +`docs/related-work.md` — and changed no file in `docs/ip/`. Folding it in +(§§5–7 of `02-`) moved things: + +- **The settlement mechanisms held.** Exactly-once debit across a crash (2) is + now the strongest: nothing in the new art classifies a crashed record the way + the reconciler does. +- **The evidence mechanisms weakened.** Offline-verifiable Ed25519 receipts at a + network boundary are occupied — protect-mcp emits receipts verifiable without + calling the issuer, and its author has an Internet-Draft, + `draft-farley-acta-signed-receipts`, whose `-02` adds a `spending_authority` + receipt type. What survives of (4) is the **status taxonomy** specifically: + that a key the verifier does not hold resolves to `UNKNOWN_KEY` rather than to + a tampering verdict. +- **Mechanism 1 has a named general technique**: optimistic concurrency control + (Kung & Robinson, 1981). Draft it around the classification of the zero-row + outcome, not around the conditional `UPDATE`. + +Read [`02-prior-art-landscape.md`](02-prior-art-landscape.md) §§5–7 before +relying on the ordering above, and +[`06-ids-candidates.md`](06-ids-candidates.md) §§C.1–C.2 for what that research +added to the disclosure list. diff --git a/tests/test_governed_persistence.py b/tests/test_governed_persistence.py index a2193b84..369caf4a 100644 --- a/tests/test_governed_persistence.py +++ b/tests/test_governed_persistence.py @@ -1204,3 +1204,168 @@ async def _run(index: int) -> None: ) ).scalar_one() assert attempts == 1, "a denied reservation must not leave a prepared attempt" + + +async def _expire_permit_row(permit_id: str) -> None: + """Push a permit's ``expires_at`` into the past, in its own transaction. + + Used to simulate the permit crossing its expiry *after* validation read it + as live. Doing it through a separate session is what makes the race + deterministic instead of timing-dependent. + """ + from app.db.models import PermitModel + + factory = get_session_factory() + async with factory() as session: + async with session.begin(): + model = await session.get(PermitModel, permit_id) + assert model is not None + model.expires_at = utc_now() - timedelta(seconds=1) + session.add(model) + + +@pytest.mark.anyio +async def test_reserve_refuses_permit_that_expired_after_validation( + client: AsyncClient, + clean_database, + monkeypatch, +) -> None: + """A permit crossing expires_at mid-reservation is denied, not reserved. + + Expiry is a dynamic check: an expired permit keeps ``status="active"`` in + storage. So before ``expires_at`` was added to the guarded UPDATE predicate, + a permit could pass the read-time expiry check in validation, cross its + expiry, and still satisfy the guarded write -- reserving budget, and + dispatching, under a permit that was no longer valid. The row lock does not + close that window on engines where it is a silent no-op. + + The window is forced open here rather than waited for: validation runs for + real, then the row is expired out-of-band before the guarded UPDATE runs. + """ + from app.services.permits import PermitService + + provisioned = await provision_agent_wallet(client) + tool_name = "partner-tool-expiry-race" + permit = await create_tool_permit( + client, + wallet_id=provisioned["agent_wallet_id"], + key_id=provisioned["key_id"], + tool_name=tool_name, + max_credits=10, + idem_key="permit-expiry-race", + ) + + original_validate = PermitService._validate_model_for_action + + async def _expire_after_validate(self, **kwargs): + # Validate exactly as production does -- the permit is still live here, + # so this returns allowed... + result = await original_validate(self, **kwargs) + # ...and only then does the permit expire, before the guarded write. + await _expire_permit_row(permit["permit_id"]) + return result + + monkeypatch.setattr( + PermitService, + "_validate_model_for_action", + _expire_after_validate, + ) + + validation = await get_permit_service().authorize_and_reserve( + permit_id=permit["permit_id"], + wallet_id=provisioned["agent_wallet_id"], + tool_name=tool_name, + estimated_credits=Decimal("4"), + key_id=provisioned["key_id"], + ) + + assert validation.allowed is False + # Reported as expired, not as out of money -- the permit had budget left. + assert validation.reason == "permit_expired" + assert validation.details is not None + assert validation.details["expired_at"] is not None + assert validation.details["checked_at"] is not None + + # The invariant that matters: no budget moved. + stored = await get_permit_service().get_permit(permit["permit_id"]) + assert stored is not None + assert stored.spent_credits == Decimal("0") + + +@pytest.mark.anyio +async def test_remote_prepare_refuses_permit_that_expired_after_validation( + client: AsyncClient, + clean_database, + monkeypatch, +) -> None: + """The upstream dispatch path enforces expiry in the same guarded write. + + Same race as the local path, on the path reached from + ``app/routers/mcp.py``. A denial here must also leave no prepared attempt, + because a prepared attempt with no reservation behind it is exactly the + inconsistency the state machine exists to prevent. + """ + from app.services.permits import PermitService + + provisioned = await provision_agent_wallet(client) + tool_name = "partner-tool-expiry-race-remote" + permit = await create_tool_permit( + client, + wallet_id=provisioned["agent_wallet_id"], + key_id=provisioned["key_id"], + tool_name=tool_name, + max_credits=10, + idem_key="permit-expiry-race-remote", + ) + + original_validate = PermitService._validate_model_for_action + + async def _expire_after_validate(self, **kwargs): + result = await original_validate(self, **kwargs) + await _expire_permit_row(permit["permit_id"]) + return result + + monkeypatch.setattr( + PermitService, + "_validate_model_for_action", + _expire_after_validate, + ) + + payload = {"tool": tool_name, "arguments": {"value": 1}} + begun = await get_idempotency_service().begin_with_record( + wallet_id=provisioned["agent_wallet_id"], + endpoint="/mcp/messages", + idempotency_key="invoke-expiry-race", + request_payload=payload, + ) + ( + validation, + attempt, + ) = await get_mcp_dispatch_attempt_service().authorize_reserve_and_prepare( + idempotency_record_id=begun.record_id, + wallet_id=provisioned["agent_wallet_id"], + permit_id=permit["permit_id"], + key_id=provisioned["key_id"], + public_tool_id=tool_name, + upstream_tool_name="remote_tool", + upstream_origin="https://partner.example", + request_hash=begun.request_hash, + credits_authorized=Decimal("4"), + ) + + assert validation.allowed is False + assert validation.reason == "permit_expired" + assert attempt is None + + stored = await get_permit_service().get_permit(permit["permit_id"]) + assert stored is not None + assert stored.spent_credits == Decimal("0") + + factory = get_session_factory() + async with factory() as session: + attempts = ( + await session.execute( + select(func.count()).select_from(McpDispatchAttemptModel) + ) + ).scalar_one() + assert attempts == 0, "an expiry denial must not leave a prepared attempt"