From 353224057ef226eda5320d9ac6eb5b1e2eaa4fca Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 01:38:22 +0000 Subject: [PATCH 1/3] docs(ip): fold the 2026-08 research into the package; assemble a provisional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #285 and #288 added verified, primary-source research to the repository and changed no file in docs/ip/. The package was therefore ranking mechanisms against an art landscape the project had already superseded. This corrects that, and assembles a filing-ordered specification. ## The prior art the package was missing `draft-farley-acta-signed-receipts` is the sharpest of it: Ed25519 over JCS-canonicalized JSON, namespaced receipt types, and from -02 a `spending_authority` type — reaching mechanisms 1, 3 and 4 at once. It was nowhere in docs/ip, including the IDS list. Also absent: protect-mcp (recorded as the closest competitor, emitting receipts verifiable without calling the issuer), jamjet (signed chained receipts plus budget enforcement), latch (idempotency plus budget caps), TraceAgent, and microsoft/agent-governance-toolkit. Separately, 02- ranked the guarded conditional UPDATE first without naming the textbook technique it instantiates. Optimistic concurrency control (Kung & Robinson, ACM TODS 6(2), June 1981, pp. 213-226) is now stated outright, along with the fact that this repository uses the same technique a second time in audit_chain.py — which an examiner may find. The distinction that survives is narrow and is stated narrowly: a domain-cap predicate rather than a version equality test, a zero-row outcome classified into an actionable reason, and no retry loop. ## The ranking changed as a result The settlement mechanisms held; the evidence mechanisms weakened, because the evidence half of this category filled in during 2026 while the package was being drafted. Offline verification drops from second to fourth: what survives is the six-state taxonomy specifically — a key the verifier does not hold resolving to UNKNOWN_KEY rather than to a tampering verdict — not "offline verification," which is now table stakes. Set A and Set C carry matching drafting notes. ## The SDK-release questions got narrower One comfortable assumption did not survive: no workflow or script in this repository downloads its own release assets, so the mechanical explanation that would have made the four download counts self-evidently benign is not present in the tree. The count is unexplained rather than explained. Recorded from the authenticated API instead: the release and both assets were produced by github-actions[bot]; updated_at equals published_at, so the release has not been edited since 2026-08-07; and both asset SHA-256 digests, which are what lets anyone later establish what was in the artifact on that date. Repository visibility could NOT be determined from this environment — an unauthenticated probe traverses a proxy, and a private repo returns 404 regardless, so neither code is evidence. That is now stated rather than assumed. ## New: 07-provisional-specification.md 03- and 05- assembled into one continuous 112(a) written description in filing order. Code citations are wrapped in [[brackets]] so counsel can find and strip them; [not implemented] markers are retained deliberately. Abstract is 146 words against the 150-word cap of 37 CFR 1.72(b). ## Validation - 40 file:line citations across docs/ip verified against the tree - 0 broken internal links - scripts/check_doc_references.py: 249 refs across 242 files resolve - ruff clean; pytest 1666 passed, 19 skipped - Kung & Robinson citation verified against dblp and the ACM DOI rather than asserted from memory Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NVvJpNRU9wp3WXowfeqtPU --- docs/ip/01-filing-risks-and-actions.md | 71 ++- docs/ip/02-prior-art-landscape.md | 203 ++++++- docs/ip/04-claim-sets.md | 25 + docs/ip/06-ids-candidates.md | 32 ++ docs/ip/07-provisional-specification.md | 675 ++++++++++++++++++++++++ docs/ip/README.md | 28 + 6 files changed, 1002 insertions(+), 32 deletions(-) create mode 100644 docs/ip/07-provisional-specification.md diff --git a/docs/ip/01-filing-risks-and-actions.md b/docs/ip/01-filing-risks-and-actions.md index 96172d87..af87799a 100644 --- a/docs/ip/01-filing-risks-and-actions.md +++ b/docs/ip/01-filing-risks-and-actions.md @@ -94,14 +94,61 @@ 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 could not be determined from the working environment.** +An unauthenticated probe returned 403 for the API and 404 for the asset, but +both requests traverse this environment's egress proxy, and an unauthenticated +request to a private repository returns 404 regardless — neither result is +evidence either way. Do not read those codes as a finding. + +### What still needs a human + +1. **Was the repository private for the entire period since 2026-08-07?** 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 +338,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/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/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..bc93ba0f --- /dev/null +++ b/docs/ip/07-provisional-specification.md @@ -0,0 +1,675 @@ +# 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 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 (§V.B). + It becomes reachable only if `expires_at` is added to the guarded predicate + and the classification — **[not implemented]**. +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 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.** + +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 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 invocation +time) [[`app/routers/me.py:102`]]. Two consequences follow: + +- 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 §VIII. + +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.** The + guarded update predicates only on `status` and the cap; expired permits keep + `status = "active"`. See §V.B. This is the only remaining gap 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. From 6bca94eeebd8ca2b7a1468a5502c7c4486f27db4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 01:40:23 +0000 Subject: [PATCH 2/3] docs(ip): record current repo visibility as authoritative, history as open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The authenticated API reports private: true as of 2026-08-17. That settles the present state and only the present state — the API exposes current visibility, not history, and the interval since 2026-08-07 is the part that bears on whether the SDK sdist was ever publicly available. Stated that way rather than as a clean answer, and the counsel question updated to say what the API can and cannot close. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NVvJpNRU9wp3WXowfeqtPU --- docs/ip/01-filing-risks-and-actions.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/ip/01-filing-risks-and-actions.md b/docs/ip/01-filing-risks-and-actions.md index af87799a..b31c1880 100644 --- a/docs/ip/01-filing-risks-and-actions.md +++ b/docs/ip/01-filing-risks-and-actions.md @@ -126,15 +126,22 @@ 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 could not be determined from the working environment.** -An unauthenticated probe returned 403 for the API and 404 for the asset, but -both requests traverse this environment's egress proxy, and an unauthenticated -request to a private repository returns 404 regardless — neither result is -evidence either way. Do not read those codes as a finding. +**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?** A repo +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 From 26e4f9d25a6635bb41b0253d7ed77a6046ae57d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 05:02:50 +0000 Subject: [PATCH 3/3] fix(permits): enforce expiry in the guarded reservation update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An expired permit keeps status="active" in storage — expiry is a dynamic check, not a stored state (app/routers/me.py:102). The guarded UPDATE predicated only on status and the budget cap, so it did not test validity. A permit could therefore pass the read-time expiry check in validation, cross expires_at, 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, which is the same reason the cap needs a database-evaluated predicate. Both reservation paths had it: the local one and the upstream MCP dispatch path. Second consequence, less obvious: the re-read that classifies a lost race looked only at stored status, so permit_expired was unreachable from it. An expired permit still read as "active" and the denial fell through to permit_budget_exceeded — telling a caller with budget remaining that it was out of money. The fix adds `expires_at > :now` to the guarded predicate on both paths and classifies expiry before budget in the re-read. One `now` is bound for the predicate and the classification so the two cannot disagree. Naive-UTC comparison matches the column type and follows the existing pattern in reconcile_expired_permits. ## The regression tests fail without the fix test_reserve_refuses_permit_that_expired_after_validation and test_remote_prepare_refuses_permit_that_expired_after_validation force the window open rather than waiting for it: validation runs for real against a live permit, the row is then expired out of band from a separate session, and only then does the guarded write run. Verified against the pre-fix code: > assert validation.allowed is False E assert True is False Both paths allowed the reservation under an expired permit. The upstream test additionally asserts the denial leaves no prepared attempt, since a prepared attempt with no reservation behind it is the inconsistency the state machine exists to prevent. ## Docs docs/ip 03-, 05- and 07- described this as the only remaining gap in the reservation mechanism and marked it [not implemented]. Updated: the SQL snippet and step-4 classification now carry the expiry term, the §7/§VIII limitation is struck through as closed, and FIG. 2 no longer tells the draftsperson to omit the permit_expired terminal — it now instructs drawing all three, ordered status → expiry → budget to match the code. ## Validation - pytest 1668 passed, 19 skipped - both new tests confirmed to fail against pre-fix code (output above) - ruff check clean; mypy clean across 172 source files - check_doc_references.py: 249 refs across 242 files resolve - 40 docs/ip file:line citations verified; 0 broken internal links This touches permits and metering, which AGENTS.md marks security-critical, so the change ships with negative-path tests on both affected paths. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NVvJpNRU9wp3WXowfeqtPU --- app/services/mcp_dispatch_attempts.py | 34 ++++- app/services/permits.py | 27 ++++ docs/ip/03-invention-disclosure.md | 81 +++++++----- docs/ip/05-abstract-and-figures.md | 19 ++- docs/ip/07-provisional-specification.md | 68 +++++----- tests/test_governed_persistence.py | 165 ++++++++++++++++++++++++ 6 files changed, 317 insertions(+), 77 deletions(-) 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/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/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/07-provisional-specification.md b/docs/ip/07-provisional-specification.md index bc93ba0f..eb2d75c5 100644 --- a/docs/ip/07-provisional-specification.md +++ b/docs/ip/07-provisional-specification.md @@ -183,15 +183,17 @@ 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 (§V.B). - 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**, 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 @@ -345,13 +347,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 @@ -360,27 +364,21 @@ 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 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 invocation -time) [[`app/routers/me.py:102`]]. Two consequences follow: - -- 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 §VIII. +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 @@ -643,10 +641,10 @@ inequitable-conduct exposure, and this repository already documents these 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.** The - guarded update predicates only on `status` and the cap; expired permits keep - `status = "active"`. See §V.B. This is the only remaining gap in the - reservation mechanism. +- ~~**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 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"