From 9f23ea285f512e5c9db7449e3e95cc576ba95fbb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 00:18:01 +0000 Subject: [PATCH 01/20] chore(deps): update greenlet requirement from >=3.5.4 to >=3.5.5 Updates the requirements on [greenlet](https://github.com/python-greenlet/greenlet) to permit the latest version. - [Changelog](https://github.com/python-greenlet/greenlet/blob/master/CHANGES.rst) - [Commits](https://github.com/python-greenlet/greenlet/compare/3.5.4...3.5.5) --- updated-dependencies: - dependency-name: greenlet dependency-version: 3.5.5 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index c19de3ba..53ef08e8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,7 +25,7 @@ python-dotenv>=1.2.2 # .env file loading cryptography>=50.0.0 # Ed25519 trust-plane signatures (>=50.0.0 fixes GHSA-g6cj-pr64-35w5) redis>=8.1.0 # Redis-backed rate limiting and runtime persistence asyncpg>=0.31.0 # PostgreSQL-backed runtime persistence -greenlet>=3.5.4 # SQLAlchemy async engine support +greenlet>=3.5.5 # SQLAlchemy async engine support stripe>=15.4.0 # Stripe payment processing mcp>=1.29.0,<2 # MCP SDK; >=1.29.0 provides the pre-parse request-body guard used by public/partner transports; <2 guards v1 internals From 5255d2654f518637af2637a3dae6099c34acc9ee Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 00:21:31 +0000 Subject: [PATCH 02/20] feat(examples)+docs: run the interop experiment, write the demand instrument MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continues the two open threads from docs/market-research-2026-08.md §8.3, turning both from recommendations into artifacts. The interop experiment has been run. examples/acta_receipt_interop.py transcodes a signed portable receipt into the ScopeBlind ACTA envelope (draft-farley-acta-signed-receipts) as a custom agentmiddleware:governed_invoke receipt type: inner production signature verified over the exact signing_input bytes exactly as b2a-verify-receipt does, outer Ed25519-over-JCS envelope with issuer_id bound to the signing kid, and the ledger/idempotency/permit linkage — the row no surveyed format defines — carried as first-class fields. The demo runs against the checked-in public proof artifact and both signatures verify. Fail-closed by design: unverified inner signatures, tampered signing_input, kid mismatches, missing economic linkage, and floats in the JCS subset are all refused, each with a negative test (8 tests total). Deliberately an experiment in examples/, not an SDK export or route — WEDGE.md's freeze discipline stands, and shipping it is a product call. The measured cost (~200 lines, no trust-plane changes) replaces the estimate in §8.3. The demand question has an instrument. docs/partner-interview-script.md is a past-tense-only discovery script: incident excavation first, the product unmentioned for 25 minutes, current mitigations treated as the real competitor, money questions about past spend rather than hypothetical willingness, a commitment test priced in a named engineer's afternoon, a per-call scoring table, and a decision rule committed before the interviews (3/5 incidents + 2/5 commitments → paid-pilot conversation; incidents without commitment → reposition around evidence; no incidents → the market isn't here yet). The script explicitly forbids using the LangGraph citation to manufacture pain. Also updates §7/§8.3 of the research doc to point at both artifacts, and the changelog. Tests: 51 passed across the new suite, wedge-honesty, and site contracts. Ruff clean. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FsXYfTHDLiFNQ24R5JPN7H --- CHANGELOG.md | 14 ++ docs/market-research-2026-08.md | 16 +- docs/partner-interview-script.md | 180 ++++++++++++++++++++ examples/acta_receipt_interop.py | 253 +++++++++++++++++++++++++++++ tests/test_acta_receipt_interop.py | 168 +++++++++++++++++++ 5 files changed, 627 insertions(+), 4 deletions(-) create mode 100644 docs/partner-interview-script.md create mode 100644 examples/acta_receipt_interop.py create mode 100644 tests/test_acta_receipt_interop.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 95558d7e..be06e27d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,20 @@ full release gate; do not backfill a final `v1.2.0` tag. ### 🔬 Competitive research, and the positioning correction it forced +- **The interop question now has running code.** + `examples/acta_receipt_interop.py` (plus `tests/test_acta_receipt_interop.py`, + 8 tests) transcodes a signed portable receipt into the ScopeBlind ACTA + receipt envelope as a custom `agentmiddleware:governed_invoke` type — inner + production signature verified over the exact `signing_input` bytes, outer + Ed25519-over-JCS envelope, economic linkage as first-class fields, floats + and unverified or linkage-less bundles refused. Deliberately an experiment + in `examples/`, not a product surface; shipping it remains a product call. +- **The demand question now has an instrument.** + `docs/partner-interview-script.md`: a past-tense-only discovery script with + an incident-first structure, a commitment test priced in engineer-afternoons + rather than opinions, a per-call scoring table, and a decision rule + committed before the interviews so five calls produce a verdict. + - **Second research pass (2026-08-15) answered two of the open questions.** The protect-mcp receipt draft (`draft-farley-acta-signed-receipts`) was read directly: it is an individual diff --git a/docs/market-research-2026-08.md b/docs/market-research-2026-08.md index f618e52a..09b41226 100644 --- a/docs/market-research-2026-08.md +++ b/docs/market-research-2026-08.md @@ -237,6 +237,10 @@ built from §3 and §4. conversation settles that. 5. Would a buyer with an EU AI Act mandate reject a vendor with no compliance mapping outright, or accept receipts as one input among several? +6. Does the demand exist? The instrument for answering is now written: + [`partner-interview-script.md`](partner-interview-script.md), with a + pre-committed decision rule so five interviews produce a verdict rather + than an argument. --- @@ -308,10 +312,14 @@ thread's author fixed it with idempotency keys and moved on. documents. - **The practitioner quote is the receipt argument verbatim**: "nothing in the transcript says so." Candidate for `/compare/` or the pitch, cited. -- **Interop vs. compete is now a live, cheap experiment**: one serializer - emitting `agentmiddleware:*` receipts in the draft's envelope, offline - verification included. Whether to run it is a product call; the cost side is - no longer unknown. +- **Interop vs. compete is now a live, cheap experiment — and the experiment + has been run.** `examples/acta_receipt_interop.py` transcodes the published + proof receipt into an `agentmiddleware:governed_invoke` receipt in the + draft's envelope (JCS canonicalization, Ed25519, `issuer_id`=`kid`), with + the original signed bundle embedded and independently verifiable, and the + ledger/idempotency linkage carried as first-class fields. Cost measured: + ~200 lines plus tests, no trust-plane changes. Whether to *ship* it stays a + product call; the cost side is now a fact, not an estimate. - **§6 risk 1 escalates**: the receipt-format neighbour added spend evidence within two draft revisions. If a registry lands, economic receipt types become table stakes the way signatures did. diff --git a/docs/partner-interview-script.md b/docs/partner-interview-script.md new file mode 100644 index 00000000..e7982958 --- /dev/null +++ b/docs/partner-interview-script.md @@ -0,0 +1,180 @@ +# Design-Partner Interview Script + +**Purpose:** answer the one question research cannot — +[`market-research-2026-08.md`](market-research-2026-08.md) §7 question 4's +remainder: does anyone *pay* for charge-once semantics, or does everyone patch +their own tool layer and move on? + +**Posture:** this is a discovery interview, not a pitch. The rules below exist +because a founder-led interview drifts toward selling within minutes, and a +sold prospect tells you what you want to hear. `ELEVATOR_PITCH.md` already +commits us: *"Existing IAM, gateway, or logging controls may already be +sufficient; the first conversation must establish that they are not before +this product is proposed."* This script is that commitment made operational. + +## Rules (read before every call) + +1. **Past tense only.** Ask what happened, never "would you use…". A yes to a + hypothetical is worth nothing; a story about last March is evidence. +2. **The product does not exist for the first 25 minutes.** If asked, say + "we're researching how teams handle agent tool-call failures — I'll show + you what we're building at the end if you want." +3. **Chase money and named people, not opinions.** "Who noticed?" and "what + did it cost?" beat "was it bad?" +4. **Disqualify eagerly.** A clean "this isn't a problem for us" is a + successful interview. Record it with the same care as an enthusiastic one. +5. **Never supply the vocabulary.** Do not say idempotency, receipt, + exactly-once, or audit until they do. Note verbatim which words *they* + reach for — that's the landing-page copy. + +## Who to interview + +The qualifying shape from `ELEVATOR_PITCH.md`: platform/AI-infrastructure/ +security teams already running agents against internal tools where **one tool +call costs real money or causes a real side effect** (payments, ticketing, +provisioning, trades, outbound email). Framework users are the richest pool — +the documented gaps are in LangGraph and CrewAI specifically +(`langchain-ai/langgraph#7417`, `crewAIInc/crewAI#5802`). + +Screen out (politely, early): teams whose agent tools are read-only or +idempotent by construction; teams that want an all-tools governance platform +(wrong product); anyone shopping for compliance certifications (send them to +the audit-layer vendors named on `/compare/`). + +--- + +## The script + +### 0. Framing (2 min) + +> "Thanks for the time. I'm researching how teams run AI agents against tools +> that have real side effects — payments, tickets, emails, infrastructure. +> I'm not going to pitch you anything for the next half hour; I want to hear +> what's actually happened in your stack. Some of it may be embarrassing — +> that's the useful part." + +### 1. The incident question (10 min — this is the interview) + +Open with it. Everything else is follow-up. + +> **"Has an agent ever retried something expensive — and either double-did it, +> or left you unable to prove afterward what actually ran?"** + +If yes, excavate the story completely before moving on: + +- When? Which framework/orchestrator? Which tool fired twice? +- How did you *find out*? (Alerting? A customer? Reconciliation? Luck?) +- What did it cost — money, hours, trust? Who outside engineering heard + about it? +- What was the ambiguous moment — did you know the call was in flight, or did + the platform re-dispatch behind your back? + *(Listening for the `delivery_uncertain` shape: langgraph#7417 is exactly + this — the platform silently re-ran calls users believed were still + running. If their story rhymes, note it, don't lead them to it.)* +- What did you change afterward? Show me, roughly — decorator? Redis dedup? + Manual runbook? + +If no, probe once, gently, then believe them: + +> "How would you know if it had happened? Walk me through what your logs +> would show for a call that timed out and got retried." + +*(A team that cannot answer this may have the problem invisibly — the +practitioner quote from the CrewAI thread was "two tickets, two emails, two +rows, and nothing in the transcript says so." Note whether their answer is +confident or uncomfortable. Do not quote the thread at them.)* + +### 2. The current-mitigation question (7 min) + +Whatever they built, take it seriously — it is the real competitor. + +- "You added idempotency keys at the tool layer — how long did that take? + Who maintains it? Does it survive a worker restart / a second process?" +- "Have you had to prove to anyone *outside* the team what an agent did and + what it cost? Who asked? What did you show them? Did they accept it?" + *(This is the build-vs-buy fork from `/compare/`: an in-process cache is a + reliability fix; the moment a finance owner, auditor, or customer needs to + check the record, evidence becomes the product. If nobody outside eng ever + asks, our wedge may not apply to them — record that honestly.)* +- "What does a tool-call record look like in your system today? Could someone + alter it after the fact? Does that matter to anyone you answer to?" + +### 3. The money questions (5 min — only if §1 surfaced a real incident) + +Still past tense. Budgets reveal more than intentions. + +- "After the incident, did anyone propose buying something instead of + building? What happened to that conversation?" +- "What have you already paid for in this general area — observability, + gateways, audit tooling? Roughly what does that cost you a year?" +- "If the fix you built broke tomorrow, what would you do first: rebuild it, + or look for a vendor?" + +**Do not ask "would you pay for X?"** The affirmative answer is worthless and +contaminates the rest of the interview. + +### 4. Reveal and test (5 min — optional, only if §1–3 qualified them) + +Now, and only now, one paragraph — the debit-first pitch, no superlatives +(the never-claim list in `WEDGE.md` applies verbatim in conversation): + +> "What we're building is a gateway you put in front of *one* tool like that. +> One accepted request key permits at most one dispatch and at most one +> debit from an internal budget, no matter how many times the agent retries — +> and every terminal outcome, including denials and failures, gets a signed +> receipt someone can verify without trusting us or you." + +Then stop talking. The reactions worth recording: + +- Do they map it onto the incident from §1 unprompted? +- Do they object with the *right* objections ("what about the upstream + tool's own side effect?" — the honest answer is in `ELEVATOR_PITCH.md`)? +- The commitment test — ask for something that costs them: + > "We take one design partner at a time: one real tool, one engineer, a + > staging environment. If retrying deliberately and verifying the receipt + > in *your* environment doesn't make you want it in prod, we part friends. + > Would you put an engineer on that for an afternoon?" + + An afternoon of a named engineer is the smallest real currency. "Sounds + cool, keep me posted" is a no; write it down as a no. + +### 5. Close (1 min) + +> "Last one: who else do you know who's been bitten by this?" *(Referral = +> the problem is talked about; silence = it may be too niche to spread.)* + +--- + +## Scoring — fill in within one hour of the call + +| Field | Answer | +|---|---| +| Incident in past tense? (what, when, cost) | | +| Discovery mechanism (how they found out) | | +| `delivery_uncertain` shape present? | | +| Anyone outside eng needed the record? Who? | | +| Current mitigation, and its owner | | +| Prior spend in the category | | +| Their words for the problem (verbatim) | | +| Commitment test result (afternoon: yes/no) | | +| Disqualified? Why? | | + +## Decision rule + +Set **before** the interviews, so the results can't be argued with after: + +- **3+ of 5 interviews** surface a past-tense incident **and** at least + **2 of 5** pass the commitment test → proceed to a paid-pilot conversation + with the strongest one. +- Incidents everywhere but **zero** commitment → the problem is real and the + build-it-yourself answer is winning; revisit whether the wedge is evidence + (sell to whoever outside eng asks for the record) rather than reliability. +- **No past-tense incidents in 5 interviews** → stripe/ai#402 and the + framework issues are ahead of the market; pause the sales motion, keep the + gateway as the proof asset, and re-interview in a quarter. + +What this script must never do: rescue a failing interview with the LangGraph +citation. The evidence in §2 of the research doc is for *us* — to know the +problem is real — and for a qualified prospect who asks "has this happened to +anyone?" It is not a crowbar for manufacturing pain in someone who doesn't +have it. diff --git a/examples/acta_receipt_interop.py b/examples/acta_receipt_interop.py new file mode 100644 index 00000000..168c40b8 --- /dev/null +++ b/examples/acta_receipt_interop.py @@ -0,0 +1,253 @@ +"""Experiment: emit a portable receipt in the ACTA signed-receipt envelope. + +Answers `docs/market-research-2026-08.md` §7 question 2 with running code +instead of an estimate. The ACTA format is +draft-farley-acta-signed-receipts (ScopeBlind / protect-mcp): an individual +Internet-Draft with **no IETF standing** — namespaced receipt types over +RFC 8785 (JCS) canonical JSON, signed with Ed25519. + +What this demonstrates +---------------------- +A signed Agent Middleware portable receipt bundle (the artifact served by +``/v1/receipts/{id}/portable`` and published at ``site/proof/receipt.json``) +can be transcoded into an ACTA envelope as a custom +``agentmiddleware:governed_invoke`` receipt type without changing the trust +plane. The economic claims — ledger entry, idempotency record, permit, +credits — ride as first-class fields the draft's namespacing explicitly +permits, which is precisely the room the research pass found. + +What this deliberately does NOT do +---------------------------------- +- It does not re-sign with the production service key. The original bundle + travels inside the envelope untouched, and its Ed25519 signature is + verified over the exact ``signing_input`` bytes against the bundle's own + key material, the same check ``b2a-verify-receipt`` performs. The outer + ACTA signature is applied by whoever transcodes (here: a caller-supplied + key; in any real adoption: the service signing key, server-side). +- It is not a product surface, an API route, or an SDK export. It is an + executable answer to "would interop cost a serializer or a redesign?" + Promoting it is a product decision recorded in the research doc, not here. + +Run the demo against the checked-in public proof artifact: + + python3 examples/acta_receipt_interop.py site/proof/receipt.json +""" + +from __future__ import annotations + +import base64 +import json +import sys +from dataclasses import dataclass +from typing import Any + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PrivateKey, + Ed25519PublicKey, +) + +ACTA_TYPE = "agentmiddleware:governed_invoke" + +# Fields the transcoder requires inside the portable bundle's signing_input. +# Missing any of these is a hard error: an economic receipt without its +# ledger linkage is exactly the artifact this experiment exists to avoid. +REQUIRED_CLAIMS = ( + "receipt_id", + "outcome", + "tool", + "created_at", + "credits_charged", + "ledger_entry_id", + "idempotency_record_id", + "permit_id", +) + + +class TranscodeError(ValueError): + """A portable bundle that cannot be honestly represented in ACTA form.""" + + +def jcs_canonicalize(value: Any) -> bytes: + """Minimal RFC 8785 (JCS) canonicalization for the value shapes we emit. + + The full JCS spec includes IEEE-754 number serialization rules. Every + field this transcoder produces is a string, bool, None, int, list, or + dict, so floats are rejected outright rather than risking a + canonicalization mismatch between implementations. Fail closed: a + signature over ambiguous bytes is worse than no signature. + """ + + def check(node: Any) -> None: + if isinstance(node, float): + raise TranscodeError( + "float values are not supported by this JCS subset; " + "represent amounts as strings, as the trust plane already does" + ) + if isinstance(node, dict): + for key, item in node.items(): + if not isinstance(key, str): + raise TranscodeError("non-string object keys are not valid JSON") + check(item) + elif isinstance(node, list): + for item in node: + check(item) + + check(value) + return json.dumps( + value, ensure_ascii=False, separators=(",", ":"), sort_keys=True + ).encode("utf-8") + + +@dataclass(frozen=True) +class VerifiedBundle: + """A portable bundle whose own Ed25519 signature has been checked.""" + + claims: dict[str, Any] + signing_input: str + signature_b64: str + kid: str + issuer: str + + +def verify_portable_bundle( + bundle: dict[str, Any], public_key_b64: str +) -> VerifiedBundle: + """Verify the inner receipt exactly as the offline SDK verifier does. + + The signature is checked over the raw ``signing_input`` bytes — never + over a re-serialization — so this function cannot be tricked by a bundle + whose pretty ``claims`` disagree with what was actually signed. + """ + for field in ("signing_input", "signature", "kid", "issuer"): + if not isinstance(bundle.get(field), str) or not bundle[field]: + raise TranscodeError(f"portable bundle is missing {field!r}") + + public_key = Ed25519PublicKey.from_public_bytes( + base64.b64decode(public_key_b64, validate=True) + ) + try: + public_key.verify( + base64.b64decode(bundle["signature"], validate=True), + bundle["signing_input"].encode("utf-8"), + ) + except InvalidSignature as exc: + raise TranscodeError( + "portable bundle signature does not verify; refusing to transcode " + "an unverified receipt" + ) from exc + + claims = json.loads(bundle["signing_input"]) + if not isinstance(claims, dict): + raise TranscodeError("signing_input is not a JSON object") + if claims.get("kid") != bundle["kid"]: + raise TranscodeError("bundle kid does not match the signed kid claim") + + missing = [field for field in REQUIRED_CLAIMS if not claims.get(field)] + if missing: + raise TranscodeError(f"signed claims missing required fields: {missing}") + + return VerifiedBundle( + claims=claims, + signing_input=bundle["signing_input"], + signature_b64=bundle["signature"], + kid=bundle["kid"], + issuer=bundle["issuer"], + ) + + +def to_acta_receipt(verified: VerifiedBundle, issuer_id: str) -> dict[str, Any]: + """Map a verified portable receipt into an unsigned ACTA receipt body. + + Field choices follow the draft's conventions where one exists + (``type``, ``issued_at``, ``issuer_id``, ``tool_name``) and carry the + economic linkage — the part no surveyed receipt format defines — as + plainly named custom fields. The untouched original bundle is embedded + so cryptographic trust never depends on the transcoder. + """ + claims = verified.claims + return { + "type": ACTA_TYPE, + "issued_at": claims["created_at"], + "issuer_id": issuer_id, + "tool_name": claims["tool"], + "outcome": claims["outcome"], + "credits_charged": claims["credits_charged"], + "receipt_id": claims["receipt_id"], + "permit_id": claims["permit_id"], + "ledger_entry_id": claims["ledger_entry_id"], + "idempotency_record_id": claims["idempotency_record_id"], + "request_hash": claims.get("request_hash"), + "response_hash": claims.get("response_hash"), + "original_bundle": { + "alg": "Ed25519", + "issuer": verified.issuer, + "kid": verified.kid, + "signature": verified.signature_b64, + "signing_input": verified.signing_input, + }, + } + + +def sign_acta_receipt( + receipt: dict[str, Any], private_key: Ed25519PrivateKey, kid: str +) -> dict[str, Any]: + """Attach the draft's signature block: Ed25519 over JCS bytes, hex.""" + if receipt.get("issuer_id") != kid: + raise TranscodeError( + "the draft requires issuer_id to match the signing kid; " + f"got issuer_id={receipt.get('issuer_id')!r} kid={kid!r}" + ) + signature = private_key.sign(jcs_canonicalize(receipt)) + return {**receipt, "signature": {"alg": "Ed25519", "kid": kid, "sig": signature.hex()}} + + +def verify_acta_receipt( + signed: dict[str, Any], public_key: Ed25519PublicKey +) -> dict[str, Any]: + """Verify the outer envelope and return the receipt body.""" + signature_block = signed.get("signature") + if not isinstance(signature_block, dict): + raise TranscodeError("ACTA receipt has no signature block") + body = {key: value for key, value in signed.items() if key != "signature"} + if body.get("issuer_id") != signature_block.get("kid"): + raise TranscodeError("issuer_id does not match the signature kid") + try: + public_key.verify( + bytes.fromhex(signature_block["sig"]), jcs_canonicalize(body) + ) + except (InvalidSignature, ValueError) as exc: + raise TranscodeError("ACTA envelope signature does not verify") from exc + return body + + +def _demo(bundle_path: str) -> int: + with open(bundle_path, "r", encoding="utf-8") as handle: + bundle = json.load(handle) + + # The public proof bundle's key snapshot ships beside it. + with open("site/proof/trust-keys.json", "r", encoding="utf-8") as handle: + keys = json.load(handle) + key_b64 = next( + entry["public_key_b64"] + for entry in keys["keys"] + if entry["kid"] == bundle["kid"] + ) + + verified = verify_portable_bundle(bundle, key_b64) + print(f"inner signature: VERIFIED (kid={verified.kid})") + + transcoder_key = Ed25519PrivateKey.generate() + signed = sign_acta_receipt( + to_acta_receipt(verified, issuer_id="demo-transcoder-key"), + transcoder_key, + kid="demo-transcoder-key", + ) + body = verify_acta_receipt(signed, transcoder_key.public_key()) + print(f"outer envelope: VERIFIED (type={body['type']})") + print(json.dumps(signed, indent=2)[:800]) + return 0 + + +if __name__ == "__main__": + raise SystemExit(_demo(sys.argv[1] if len(sys.argv) > 1 else "site/proof/receipt.json")) diff --git a/tests/test_acta_receipt_interop.py b/tests/test_acta_receipt_interop.py new file mode 100644 index 00000000..e31b69c7 --- /dev/null +++ b/tests/test_acta_receipt_interop.py @@ -0,0 +1,168 @@ +"""Tests for the ACTA receipt-envelope interop experiment. + +The experiment (`examples/acta_receipt_interop.py`) answers +`docs/market-research-2026-08.md` §7 question 2 with running code. These +tests pin the two properties that make the answer trustworthy: the +transcoder refuses anything it cannot verify, and both signatures — the +embedded original and the outer envelope — actually bind their bytes. +""" + +from __future__ import annotations + +import base64 +import importlib.util +import json +import sys +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +ROOT = Path(__file__).resolve().parents[1] + +_spec = importlib.util.spec_from_file_location( + "acta_receipt_interop", ROOT / "examples" / "acta_receipt_interop.py" +) +assert _spec is not None and _spec.loader is not None +interop = importlib.util.module_from_spec(_spec) +# Dataclass creation resolves annotations through sys.modules, so the module +# must be registered before exec, not just held locally. +sys.modules[_spec.name] = interop +_spec.loader.exec_module(interop) + + +def _make_bundle(private_key: Ed25519PrivateKey, kid: str, **overrides): + claims = { + "alg": "Ed25519", + "created_at": "2026-08-15T00:00:00+00:00", + "credits_charged": "1", + "idempotency_record_id": "idm-test", + "kid": kid, + "ledger_entry_id": "ledger-test", + "outcome": "success", + "permit_id": "permit-test", + "receipt_id": "rcpt-test", + "request_hash": "aa" * 32, + "response_hash": "bb" * 32, + "tool": "partner.echo", + } + claims.update(overrides.pop("claims", {})) + signing_input = json.dumps( + claims, ensure_ascii=False, separators=(",", ":"), sort_keys=True + ) + bundle = { + "issuer": "https://example.invalid", + "kid": kid, + "signature": base64.b64encode( + private_key.sign(signing_input.encode("utf-8")) + ).decode("ascii"), + "signing_input": signing_input, + } + bundle.update(overrides) + return bundle + + +def _public_b64(private_key: Ed25519PrivateKey) -> str: + from cryptography.hazmat.primitives.serialization import ( + Encoding, + PublicFormat, + ) + + return base64.b64encode( + private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) + ).decode("ascii") + + +def test_checked_in_proof_bundle_transcodes_and_roundtrips() -> None: + """The public proof artifact — real signature, real key snapshot.""" + bundle = json.loads((ROOT / "site" / "proof" / "receipt.json").read_text()) + keys = json.loads((ROOT / "site" / "proof" / "trust-keys.json").read_text()) + key_b64 = next( + entry["public_key_b64"] + for entry in keys["keys"] + if entry["kid"] == bundle["kid"] + ) + + verified = interop.verify_portable_bundle(bundle, key_b64) + transcoder = Ed25519PrivateKey.generate() + signed = interop.sign_acta_receipt( + interop.to_acta_receipt(verified, issuer_id="test-kid"), + transcoder, + kid="test-kid", + ) + body = interop.verify_acta_receipt(signed, transcoder.public_key()) + + assert body["type"] == interop.ACTA_TYPE + # The economic linkage — the row no surveyed format defines — must survive. + assert body["ledger_entry_id"] == verified.claims["ledger_entry_id"] + assert body["idempotency_record_id"] == verified.claims["idempotency_record_id"] + # The untouched original stays independently verifiable. + inner = body["original_bundle"] + assert inner["signing_input"] == bundle["signing_input"] + assert inner["signature"] == bundle["signature"] + + +def test_unverified_inner_signature_is_refused() -> None: + key = Ed25519PrivateKey.generate() + bundle = _make_bundle(key, "kid-a") + wrong_key = Ed25519PrivateKey.generate() + with pytest.raises(interop.TranscodeError, match="does not verify"): + interop.verify_portable_bundle(bundle, _public_b64(wrong_key)) + + +def test_tampered_signing_input_is_refused() -> None: + key = Ed25519PrivateKey.generate() + bundle = _make_bundle(key, "kid-a") + bundle["signing_input"] = bundle["signing_input"].replace('"1"', '"2"', 1) + with pytest.raises(interop.TranscodeError, match="does not verify"): + interop.verify_portable_bundle(bundle, _public_b64(key)) + + +def test_kid_mismatch_between_bundle_and_signed_claim_is_refused() -> None: + key = Ed25519PrivateKey.generate() + bundle = _make_bundle(key, "kid-a") + bundle["kid"] = "kid-b" + with pytest.raises(interop.TranscodeError, match="kid"): + interop.verify_portable_bundle(bundle, _public_b64(key)) + + +def test_missing_economic_linkage_is_refused() -> None: + """An economic receipt without its ledger link must not transcode.""" + key = Ed25519PrivateKey.generate() + bundle = _make_bundle(key, "kid-a", claims={"ledger_entry_id": ""}) + with pytest.raises(interop.TranscodeError, match="ledger_entry_id"): + interop.verify_portable_bundle(bundle, _public_b64(key)) + + +def test_outer_envelope_tamper_is_detected() -> None: + key = Ed25519PrivateKey.generate() + bundle = _make_bundle(key, "kid-a") + verified = interop.verify_portable_bundle(bundle, _public_b64(key)) + transcoder = Ed25519PrivateKey.generate() + signed = interop.sign_acta_receipt( + interop.to_acta_receipt(verified, issuer_id="test-kid"), + transcoder, + kid="test-kid", + ) + signed["credits_charged"] = "999" + with pytest.raises(interop.TranscodeError, match="does not verify"): + interop.verify_acta_receipt(signed, transcoder.public_key()) + + +def test_issuer_id_must_match_signing_kid() -> None: + """The draft binds issuer_id to the signature kid; enforce both ways.""" + key = Ed25519PrivateKey.generate() + bundle = _make_bundle(key, "kid-a") + verified = interop.verify_portable_bundle(bundle, _public_b64(key)) + transcoder = Ed25519PrivateKey.generate() + with pytest.raises(interop.TranscodeError, match="issuer_id"): + interop.sign_acta_receipt( + interop.to_acta_receipt(verified, issuer_id="someone-else"), + transcoder, + kid="test-kid", + ) + + +def test_floats_fail_closed_in_canonicalization() -> None: + with pytest.raises(interop.TranscodeError, match="float"): + interop.jcs_canonicalize({"amount": 1.5}) From 3da966dbd852119905e5a31919bcaf0ccb1c6529 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 00:26:34 +0000 Subject: [PATCH 03/20] chore: retrigger CI after runner-startup failure on 5255d26 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 14 jobs on the previous run were refused in under two seconds with no runner assigned and no step output — an Actions capacity/limit refusal, not a test failure. No code changes. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FsXYfTHDLiFNQ24R5JPN7H From 3e34331548576e1f777c26e167139bd8f9c67a16 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 00:34:55 +0000 Subject: [PATCH 04/20] =?UTF-8?q?chore:=20retrigger=20CI=20=E2=80=94=20pro?= =?UTF-8?q?be=20whether=20Actions=20is=20assigning=20runners=20again?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FsXYfTHDLiFNQ24R5JPN7H From 9fb5d974be5cd944461ecb514fe9f5fd6c20b0ff Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 00:52:17 +0000 Subject: [PATCH 05/20] chore: retrigger CI now that the PR is marked ready for review Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FsXYfTHDLiFNQ24R5JPN7H From 0773da9cb6a3db0a065a4c16d80219caa33eaa9d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 01:00:34 +0000 Subject: [PATCH 06/20] =?UTF-8?q?fix:=20address=20CodeRabbit=20review=20?= =?UTF-8?q?=E2=80=94=20issuer=20binding,=20alg=20validation,=20interview-s?= =?UTF-8?q?cript=20rigor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interop example: - verify_portable_bundle now takes expected_issuer: the wrapper's issuer field is outside the signed bytes, so it is checked against the issuer bound to the key material in the trust-keys snapshot instead of being trusted on its own. Demo and roundtrip test pass the snapshot's issuer; a new negative test mutates issuer post-signing and is refused. - verify_acta_receipt now rejects a signature block whose alg is not Ed25519 or whose sig is not a hex string; new test tampers only alg. - Research doc no longer implies the issuer label itself is signed. Interview script: - One timing boundary: reveal at ~minute 25, framing says 25 minutes. - §1 opens with a broad narrative prompt before the pointed probe, so incidents that don't fit our shape still get told. - §2 no longer supplies 'idempotency keys'; it reuses the interviewee's own words (rule 5 marked as applying everywhere except §4). - §3 adds the buyer question: last approver, budget owner, procurement. - §3's vendor question is past-tense ('has the fix ever broken') instead of hypothetical. - Commitment test requires observables on the call: named engineer, named staging environment, scheduled date — scoring table records each. - Decision rule defines qualified_n, adds an unknown outcome, and defaults every unlisted pattern to more interviews, not a decision. Tests: 53 passed (interop, wedge-honesty, site contracts). Ruff clean. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FsXYfTHDLiFNQ24R5JPN7H --- docs/market-research-2026-08.md | 5 +- docs/partner-interview-script.md | 93 ++++++++++++++++++++---------- examples/acta_receipt_interop.py | 30 +++++++++- tests/test_acta_receipt_interop.py | 31 +++++++++- 4 files changed, 125 insertions(+), 34 deletions(-) diff --git a/docs/market-research-2026-08.md b/docs/market-research-2026-08.md index 09b41226..7c853050 100644 --- a/docs/market-research-2026-08.md +++ b/docs/market-research-2026-08.md @@ -316,7 +316,10 @@ thread's author fixed it with idempotency keys and moved on. has been run.** `examples/acta_receipt_interop.py` transcodes the published proof receipt into an `agentmiddleware:governed_invoke` receipt in the draft's envelope (JCS canonicalization, Ed25519, `issuer_id`=`kid`), with - the original signed bundle embedded and independently verifiable, and the + the original signed bundle embedded and independently verifiable — the + signature covers the exact `signing_input` bytes, and the wrapper's + `issuer` label is checked against the issuer bound to the key material in + the trust-keys snapshot rather than trusted on its own — and the ledger/idempotency linkage carried as first-class fields. Cost measured: ~200 lines plus tests, no trust-plane changes. Whether to *ship* it stays a product call; the cost side is now a fact, not an estimate. diff --git a/docs/partner-interview-script.md b/docs/partner-interview-script.md index e7982958..c2f96469 100644 --- a/docs/partner-interview-script.md +++ b/docs/partner-interview-script.md @@ -16,16 +16,18 @@ this product is proposed."* This script is that commitment made operational. 1. **Past tense only.** Ask what happened, never "would you use…". A yes to a hypothetical is worth nothing; a story about last March is evidence. -2. **The product does not exist for the first 25 minutes.** If asked, say - "we're researching how teams handle agent tool-call failures — I'll show - you what we're building at the end if you want." +2. **The product does not exist until the reveal (§4, ~minute 25).** If + asked earlier, say "we're researching how teams handle agent tool-call + failures — I'll show you what we're building at the end if you want." 3. **Chase money and named people, not opinions.** "Who noticed?" and "what did it cost?" beat "was it bad?" 4. **Disqualify eagerly.** A clean "this isn't a problem for us" is a successful interview. Record it with the same care as an enthusiastic one. 5. **Never supply the vocabulary.** Do not say idempotency, receipt, exactly-once, or audit until they do. Note verbatim which words *they* - reach for — that's the landing-page copy. + reach for — that's the landing-page copy. §4 (the reveal) is the **only** + section where product vocabulary is permitted, and only after §1–3 are + done. ## Who to interview @@ -49,18 +51,24 @@ the audit-layer vendors named on `/compare/`). > "Thanks for the time. I'm researching how teams run AI agents against tools > that have real side effects — payments, tickets, emails, infrastructure. -> I'm not going to pitch you anything for the next half hour; I want to hear +> I'm not going to pitch you anything for the next 25 minutes; I want to hear > what's actually happened in your stack. Some of it may be embarrassing — > that's the useful part." ### 1. The incident question (10 min — this is the interview) -Open with it. Everything else is follow-up. +Open broad, so a story that doesn't fit our shape still gets told: + +> **"Tell me about the last time an agent tool call failed, timed out, or +> got retried."** + +Let them finish. Then, if the story didn't already cover it, the pointed +probe: > **"Has an agent ever retried something expensive — and either double-did it, > or left you unable to prove afterward what actually ran?"** -If yes, excavate the story completely before moving on: +If yes to either, excavate the story completely before moving on: - When? Which framework/orchestrator? Which tool fired twice? - How did you *find out*? (Alerting? A customer? Reconciliation? Luck?) @@ -86,9 +94,11 @@ confident or uncomfortable. Do not quote the thread at them.)* ### 2. The current-mitigation question (7 min) -Whatever they built, take it seriously — it is the real competitor. +Whatever they built, take it seriously — it is the real competitor. Refer to +it in *their* words from §1 (rule 5 still applies here — if they called it +"the dedup thing," so do you): -- "You added idempotency keys at the tool layer — how long did that take? +- "That safeguard you built at the tool layer — how long did that take? Who maintains it? Does it survive a worker restart / a second process?" - "Have you had to prove to anyone *outside* the team what an agent did and what it cost? Who asked? What did you show them? Did they accept it?" @@ -107,8 +117,12 @@ Still past tense. Budgets reveal more than intentions. building? What happened to that conversation?" - "What have you already paid for in this general area — observability, gateways, audit tooling? Roughly what does that cost you a year?" -- "If the fix you built broke tomorrow, what would you do first: rebuild it, - or look for a vendor?" +- "The last tool you bought in this space — who approved it, whose budget + did it come out of, and what did procurement put you through?" + *(This is the buyer question. Adjacent-category spend without a named + approver and budget is not evidence anyone can buy; record all three.)* +- "Has the fix you built ever broken, or needed a rewrite? What did you + actually do — rebuild it, or go looking for a vendor?" **Do not ask "would you pay for X?"** The affirmative answer is worthless and contaminates the rest of the interview. @@ -129,14 +143,18 @@ Then stop talking. The reactions worth recording: - Do they map it onto the incident from §1 unprompted? - Do they object with the *right* objections ("what about the upstream tool's own side effect?" — the honest answer is in `ELEVATOR_PITCH.md`)? -- The commitment test — ask for something that costs them: +- The commitment test — ask for something that costs them, and make it + observable *on the call*, not aspirational: > "We take one design partner at a time: one real tool, one engineer, a > staging environment. If retrying deliberately and verifying the receipt > in *your* environment doesn't make you want it in prod, we part friends. - > Would you put an engineer on that for an afternoon?" + > Who on your team would own that — and can we put their afternoon on the + > calendar before we hang up?" - An afternoon of a named engineer is the smallest real currency. "Sounds - cool, keep me posted" is a no; write it down as a no. + A pass requires all three: a **named engineer**, a **named staging + environment**, and a **scheduled date** (or a concrete scheduling step + taken on the call). "Sounds cool, keep me posted" — or a yes with no name + and no date — is a no; write it down as a no. ### 5. Close (1 min) @@ -149,29 +167,46 @@ Then stop talking. The reactions worth recording: | Field | Answer | |---|---| -| Incident in past tense? (what, when, cost) | | +| Incident in past tense? (yes / no / **unknown** — couldn't tell from their logs story) | | | Discovery mechanism (how they found out) | | | `delivery_uncertain` shape present? | | | Anyone outside eng needed the record? Who? | | | Current mitigation, and its owner | | | Prior spend in the category | | +| Buyer evidence (last approver, budget owner, procurement path) | | | Their words for the problem (verbatim) | | -| Commitment test result (afternoon: yes/no) | | -| Disqualified? Why? | | +| Commitment test: engineer named | | +| Commitment test: staging env named | | +| Commitment test: date scheduled | | +| Disqualified at screening? Why? | | + +An interview counts toward the denominator only if it passed screening and +ran through §2 — call that number **qualified_n**. Screen-outs are recorded +but never counted. "Unknown" incident answers count as neither yes nor no. ## Decision rule -Set **before** the interviews, so the results can't be argued with after: - -- **3+ of 5 interviews** surface a past-tense incident **and** at least - **2 of 5** pass the commitment test → proceed to a paid-pilot conversation - with the strongest one. -- Incidents everywhere but **zero** commitment → the problem is real and the - build-it-yourself answer is winning; revisit whether the wedge is evidence - (sell to whoever outside eng asks for the record) rather than reliability. -- **No past-tense incidents in 5 interviews** → stripe/ai#402 and the - framework issues are ahead of the market; pause the sales motion, keep the - gateway as the proof asset, and re-interview in a quarter. +Set **before** the interviews, so the results can't be argued with after. +All counts are over **qualified_n = 5** completed, qualified interviews — +keep interviewing until there are five; do not decide on fewer. + +- **3+ of 5** surface a past-tense incident **and** **2+ of 5** pass the + full commitment test (engineer + staging + date) → proceed to a paid-pilot + conversation with the strongest one. A paid-pilot *conversation* — the + commitment test proves an engineer's afternoon, not a budget; the pilot + conversation is where the §3 buyer evidence (approver, budget, procurement) + gets tested for real. +- Incidents at 3+ but **zero or one** commitments → the problem is real and + the build-it-yourself answer is winning; revisit whether the wedge is + evidence (sell to whoever outside eng asks for the record) rather than + reliability. +- **No past-tense incidents in 5** → stripe/ai#402 and the framework issues + are ahead of the market; pause the sales motion, keep the gateway as the + proof asset, and re-interview in a quarter. +- **Any other pattern** — 1–2 incidents, a pile of unknowns, commitments + without incidents — is not a decision, it's noise. Default action for + every case not listed above: run five more qualified interviews before + deciding anything. What this script must never do: rescue a failing interview with the LangGraph citation. The evidence in §2 of the research doc is for *us* — to know the diff --git a/examples/acta_receipt_interop.py b/examples/acta_receipt_interop.py index 168c40b8..745d097e 100644 --- a/examples/acta_receipt_interop.py +++ b/examples/acta_receipt_interop.py @@ -111,17 +111,29 @@ class VerifiedBundle: def verify_portable_bundle( - bundle: dict[str, Any], public_key_b64: str + bundle: dict[str, Any], public_key_b64: str, expected_issuer: str | None = None ) -> VerifiedBundle: """Verify the inner receipt exactly as the offline SDK verifier does. The signature is checked over the raw ``signing_input`` bytes — never over a re-serialization — so this function cannot be tricked by a bundle whose pretty ``claims`` disagree with what was actually signed. + + The bundle's ``issuer`` field is wrapper metadata *outside* the signed + bytes. Pass ``expected_issuer`` — the issuer bound to the key material in + the trust-keys snapshot the public key came from — to refuse a bundle + whose wrapper claims a different issuer. Without it, the issuer is + carried through as unverified routing metadata; trust is anchored in the + key, not the label. """ for field in ("signing_input", "signature", "kid", "issuer"): if not isinstance(bundle.get(field), str) or not bundle[field]: raise TranscodeError(f"portable bundle is missing {field!r}") + if expected_issuer is not None and bundle["issuer"] != expected_issuer: + raise TranscodeError( + "bundle issuer does not match the issuer bound to the trusted " + f"key record: {bundle['issuer']!r} != {expected_issuer!r}" + ) public_key = Ed25519PublicKey.from_public_bytes( base64.b64decode(public_key_b64, validate=True) @@ -163,7 +175,10 @@ def to_acta_receipt(verified: VerifiedBundle, issuer_id: str) -> dict[str, Any]: (``type``, ``issued_at``, ``issuer_id``, ``tool_name``) and carry the economic linkage — the part no surveyed receipt format defines — as plainly named custom fields. The untouched original bundle is embedded - so cryptographic trust never depends on the transcoder. + so cryptographic trust never depends on the transcoder. Its ``issuer`` + field is wrapper metadata, trustworthy only when the caller verified it + against the key record (``expected_issuer``); the signature covers + ``signing_input`` alone. """ claims = verified.claims return { @@ -209,6 +224,13 @@ def verify_acta_receipt( signature_block = signed.get("signature") if not isinstance(signature_block, dict): raise TranscodeError("ACTA receipt has no signature block") + if signature_block.get("alg") != "Ed25519": + raise TranscodeError( + "ACTA signature alg must be Ed25519; refusing " + f"{signature_block.get('alg')!r}" + ) + if not isinstance(signature_block.get("sig"), str): + raise TranscodeError("ACTA signature sig must be a hex string") body = {key: value for key, value in signed.items() if key != "signature"} if body.get("issuer_id") != signature_block.get("kid"): raise TranscodeError("issuer_id does not match the signature kid") @@ -234,7 +256,9 @@ def _demo(bundle_path: str) -> int: if entry["kid"] == bundle["kid"] ) - verified = verify_portable_bundle(bundle, key_b64) + verified = verify_portable_bundle( + bundle, key_b64, expected_issuer=keys["issuer"] + ) print(f"inner signature: VERIFIED (kid={verified.kid})") transcoder_key = Ed25519PrivateKey.generate() diff --git a/tests/test_acta_receipt_interop.py b/tests/test_acta_receipt_interop.py index e31b69c7..f9509011 100644 --- a/tests/test_acta_receipt_interop.py +++ b/tests/test_acta_receipt_interop.py @@ -83,7 +83,9 @@ def test_checked_in_proof_bundle_transcodes_and_roundtrips() -> None: if entry["kid"] == bundle["kid"] ) - verified = interop.verify_portable_bundle(bundle, key_b64) + verified = interop.verify_portable_bundle( + bundle, key_b64, expected_issuer=keys["issuer"] + ) transcoder = Ed25519PrivateKey.generate() signed = interop.sign_acta_receipt( interop.to_acta_receipt(verified, issuer_id="test-kid"), @@ -134,6 +136,33 @@ def test_missing_economic_linkage_is_refused() -> None: interop.verify_portable_bundle(bundle, _public_b64(key)) +def test_issuer_mutated_after_signing_is_refused() -> None: + """The wrapper issuer is unsigned; the key-record binding must catch it.""" + key = Ed25519PrivateKey.generate() + bundle = _make_bundle(key, "kid-a") + bundle["issuer"] = "https://attacker.invalid" + with pytest.raises(interop.TranscodeError, match="issuer"): + interop.verify_portable_bundle( + bundle, _public_b64(key), expected_issuer="https://example.invalid" + ) + + +def test_signature_alg_tamper_is_refused() -> None: + """Changing only signature.alg must fail even though the sig bytes hold.""" + key = Ed25519PrivateKey.generate() + bundle = _make_bundle(key, "kid-a") + verified = interop.verify_portable_bundle(bundle, _public_b64(key)) + transcoder = Ed25519PrivateKey.generate() + signed = interop.sign_acta_receipt( + interop.to_acta_receipt(verified, issuer_id="test-kid"), + transcoder, + kid="test-kid", + ) + signed["signature"] = {**signed["signature"], "alg": "ES256"} + with pytest.raises(interop.TranscodeError, match="alg"): + interop.verify_acta_receipt(signed, transcoder.public_key()) + + def test_outer_envelope_tamper_is_detected() -> None: key = Ed25519PrivateKey.generate() bundle = _make_bundle(key, "kid-a") From e2b9f5239b1b1830c16c644e43f609f7ca4b048d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 01:06:25 +0000 Subject: [PATCH 07/20] =?UTF-8?q?fix:=20address=20CodeRabbit=20round=202?= =?UTF-8?q?=20=E2=80=94=20signer-identity=20validation,=20gate=20consisten?= =?UTF-8?q?cy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - verify_acta_receipt refuses an envelope whose issuer_id or signature kid is missing or empty, closing the None == None bypass where a valid signature could verify with no declared signer; regression test signs a body with neither field and asserts refusal. - Commitment test uses one scheduling condition everywhere: date scheduled or a concrete scheduling step taken on the call, in the prompt, the scoring table, and the decision rule. - Commitment gate explicitly marked pre-qualification, pointing at the stricter Pilot Acceptance Test in 30-day-customer-validation.md (partner-owned agent, partner-owned staging MCP tool, partner engineer, independent partner-side receipt verification). - Paid-pilot selection is deterministic: earliest scheduled staging date, tie-break on buyer-evidence completeness, then interview order; 'the strongest one' removed. - §2 opens with a neutral 'what, if anything, did you change' so a no-mitigation answer records as none instead of being presupposed away. Tests: 11 interop tests passed. Ruff clean. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FsXYfTHDLiFNQ24R5JPN7H --- docs/partner-interview-script.md | 30 +++++++++++++++++++++++------- examples/acta_receipt_interop.py | 8 +++++++- tests/test_acta_receipt_interop.py | 10 ++++++++++ 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/docs/partner-interview-script.md b/docs/partner-interview-script.md index c2f96469..8ff58881 100644 --- a/docs/partner-interview-script.md +++ b/docs/partner-interview-script.md @@ -98,7 +98,9 @@ Whatever they built, take it seriously — it is the real competitor. Refer to it in *their* words from §1 (rule 5 still applies here — if they called it "the dedup thing," so do you): -- "That safeguard you built at the tool layer — how long did that take? +- "What, if anything, did you change after the incident?" + *(A real answer may be "nothing" — record `none` for the mitigation field + and move on.)* If they describe a safeguard: "How long did that take? Who maintains it? Does it survive a worker restart / a second process?" - "Have you had to prove to anyone *outside* the team what an agent did and what it cost? Who asked? What did you show them? Did they accept it?" @@ -152,9 +154,18 @@ Then stop talking. The reactions worth recording: > calendar before we hang up?" A pass requires all three: a **named engineer**, a **named staging - environment**, and a **scheduled date** (or a concrete scheduling step - taken on the call). "Sounds cool, keep me posted" — or a yes with no name - and no date — is a no; write it down as a no. + environment**, and a **scheduled date or a concrete scheduling step taken + on the call** (an invite sent, a named owner asked to propose a slot). + "Sounds cool, keep me posted" — or a yes with no name and no date — is a + no; write it down as a no. + + This gate is **pre-qualification only**. Pilot readiness is a separate, + stricter contract — the Pilot Acceptance Test in + [`30-day-customer-validation.md`](30-day-customer-validation.md) requires + a partner-owned agent, a partner-owned staging MCP tool, a partner + engineer operating the workflow, and independent partner-side receipt + verification. Passing this interview gate advances someone to that + conversation, not past it. ### 5. Close (1 min) @@ -177,7 +188,7 @@ Then stop talking. The reactions worth recording: | Their words for the problem (verbatim) | | | Commitment test: engineer named | | | Commitment test: staging env named | | -| Commitment test: date scheduled | | +| Commitment test: date scheduled, or concrete scheduling step taken | | | Disqualified at screening? Why? | | An interview counts toward the denominator only if it passed screening and @@ -191,8 +202,13 @@ All counts are over **qualified_n = 5** completed, qualified interviews — keep interviewing until there are five; do not decide on fewer. - **3+ of 5** surface a past-tense incident **and** **2+ of 5** pass the - full commitment test (engineer + staging + date) → proceed to a paid-pilot - conversation with the strongest one. A paid-pilot *conversation* — the + full commitment test (engineer + staging + date or concrete scheduling + step) → proceed to a paid-pilot conversation. Selection is deterministic: + take the passer with the **earliest scheduled staging date** first (a + concrete-step-only pass sorts after any dated pass); tie-break on the most + complete buyer-evidence row (approver, budget, procurement — three fields + beat two), then on the earlier interview. The rest queue in the same + order — one design partner at a time. A paid-pilot *conversation* — the commitment test proves an engineer's afternoon, not a budget; the pilot conversation is where the §3 buyer evidence (approver, budget, procurement) gets tested for real. diff --git a/examples/acta_receipt_interop.py b/examples/acta_receipt_interop.py index 745d097e..f81c94f7 100644 --- a/examples/acta_receipt_interop.py +++ b/examples/acta_receipt_interop.py @@ -232,7 +232,13 @@ def verify_acta_receipt( if not isinstance(signature_block.get("sig"), str): raise TranscodeError("ACTA signature sig must be a hex string") body = {key: value for key, value in signed.items() if key != "signature"} - if body.get("issuer_id") != signature_block.get("kid"): + issuer_id = body.get("issuer_id") + kid = signature_block.get("kid") + if not isinstance(issuer_id, str) or not issuer_id: + raise TranscodeError("ACTA receipt issuer_id must be a non-empty string") + if not isinstance(kid, str) or not kid: + raise TranscodeError("ACTA signature kid must be a non-empty string") + if issuer_id != kid: raise TranscodeError("issuer_id does not match the signature kid") try: public_key.verify( diff --git a/tests/test_acta_receipt_interop.py b/tests/test_acta_receipt_interop.py index f9509011..125e6780 100644 --- a/tests/test_acta_receipt_interop.py +++ b/tests/test_acta_receipt_interop.py @@ -192,6 +192,16 @@ def test_issuer_id_must_match_signing_kid() -> None: ) +def test_missing_signer_identity_is_refused() -> None: + """Absent issuer_id and absent kid must not slip past as None == None.""" + key = Ed25519PrivateKey.generate() + body = {"type": interop.ACTA_TYPE, "receipt_id": "rcpt-test"} + signature = key.sign(interop.jcs_canonicalize(body)) + signed = {**body, "signature": {"alg": "Ed25519", "sig": signature.hex()}} + with pytest.raises(interop.TranscodeError, match="issuer_id|kid"): + interop.verify_acta_receipt(signed, key.public_key()) + + def test_floats_fail_closed_in_canonicalization() -> None: with pytest.raises(interop.TranscodeError, match="float"): interop.jcs_canonicalize({"amount": 1.5}) From 5ab6828954fcf4e02862b767dcddafdb212f8c65 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 01:10:05 +0000 Subject: [PATCH 08/20] fix: reject mismatched inner-bundle alg; parametrize signer-identity test - verify_portable_bundle refuses a bundle declaring a non-Ed25519 alg instead of silently verifying with Ed25519 and reporting a result the bundle's own metadata contradicts; negative test included. - The signer-identity regression test is parametrized across absent and empty issuer_id and kid independently, so both validation branches stay covered. Tests: 16 interop tests passed. Ruff clean. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FsXYfTHDLiFNQ24R5JPN7H --- examples/acta_receipt_interop.py | 6 ++++++ tests/test_acta_receipt_interop.py | 34 ++++++++++++++++++++++++++---- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/examples/acta_receipt_interop.py b/examples/acta_receipt_interop.py index f81c94f7..1be2fb2c 100644 --- a/examples/acta_receipt_interop.py +++ b/examples/acta_receipt_interop.py @@ -129,6 +129,12 @@ def verify_portable_bundle( for field in ("signing_input", "signature", "kid", "issuer"): if not isinstance(bundle.get(field), str) or not bundle[field]: raise TranscodeError(f"portable bundle is missing {field!r}") + declared_alg = bundle.get("alg") + if declared_alg is not None and declared_alg != "Ed25519": + raise TranscodeError( + f"bundle declares alg {declared_alg!r}; this verifier only " + "performs Ed25519 and will not report a misleading result" + ) if expected_issuer is not None and bundle["issuer"] != expected_issuer: raise TranscodeError( "bundle issuer does not match the issuer bound to the trusted " diff --git a/tests/test_acta_receipt_interop.py b/tests/test_acta_receipt_interop.py index 125e6780..9505cff4 100644 --- a/tests/test_acta_receipt_interop.py +++ b/tests/test_acta_receipt_interop.py @@ -192,13 +192,39 @@ def test_issuer_id_must_match_signing_kid() -> None: ) -def test_missing_signer_identity_is_refused() -> None: - """Absent issuer_id and absent kid must not slip past as None == None.""" +def test_inner_bundle_declared_alg_mismatch_is_refused() -> None: + """A bundle declaring a non-Ed25519 alg must not verify as Ed25519.""" + key = Ed25519PrivateKey.generate() + bundle = _make_bundle(key, "kid-a") + bundle["alg"] = "ES256" + with pytest.raises(interop.TranscodeError, match="alg"): + interop.verify_portable_bundle(bundle, _public_b64(key)) + + +@pytest.mark.parametrize( + ("issuer_id", "kid", "match"), + [ + (None, None, "issuer_id"), # both absent: None == None must not pass + (None, "test-kid", "issuer_id"), + ("", "test-kid", "issuer_id"), + ("test-kid", None, "kid"), + ("test-kid", "", "kid"), + ], +) +def test_missing_signer_identity_is_refused( + issuer_id: str | None, kid: str | None, match: str +) -> None: + """Each signer-identity field must independently refuse absent/empty.""" key = Ed25519PrivateKey.generate() body = {"type": interop.ACTA_TYPE, "receipt_id": "rcpt-test"} + if issuer_id is not None: + body["issuer_id"] = issuer_id signature = key.sign(interop.jcs_canonicalize(body)) - signed = {**body, "signature": {"alg": "Ed25519", "sig": signature.hex()}} - with pytest.raises(interop.TranscodeError, match="issuer_id|kid"): + signature_block = {"alg": "Ed25519", "sig": signature.hex()} + if kid is not None: + signature_block["kid"] = kid + signed = {**body, "signature": signature_block} + with pytest.raises(interop.TranscodeError, match=match): interop.verify_acta_receipt(signed, key.public_key()) From c1bf1dd0b67dadb1a4964ebbccea38f6ebdb5e9b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 01:14:37 +0000 Subject: [PATCH 09/20] =?UTF-8?q?chore:=20retrigger=20CI=20=E2=80=94=20per?= =?UTF-8?q?iodic=20probe=20for=20Actions=20runner=20availability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FsXYfTHDLiFNQ24R5JPN7H From 23be3dd55056c712066794bf0a89b873193bfd16 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 01:26:04 +0000 Subject: [PATCH 10/20] =?UTF-8?q?chore:=20retrigger=20CI=20=E2=80=94=20clo?= =?UTF-8?q?sure=20probe=20for=20Actions=20runner=20availability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FsXYfTHDLiFNQ24R5JPN7H From dc062ca5a2fb6a395333428742735f4d75b33a96 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 01:26:49 +0000 Subject: [PATCH 11/20] docs: complete function docstring coverage in the interop example and tests Clears the one remaining non-blocking review warning (docstring coverage below threshold). No behavior changes. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FsXYfTHDLiFNQ24R5JPN7H --- examples/acta_receipt_interop.py | 2 ++ tests/test_acta_receipt_interop.py | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/examples/acta_receipt_interop.py b/examples/acta_receipt_interop.py index 1be2fb2c..cbf90c87 100644 --- a/examples/acta_receipt_interop.py +++ b/examples/acta_receipt_interop.py @@ -79,6 +79,7 @@ def jcs_canonicalize(value: Any) -> bytes: """ def check(node: Any) -> None: + """Recursively reject value shapes outside the supported JCS subset.""" if isinstance(node, float): raise TranscodeError( "float values are not supported by this JCS subset; " @@ -256,6 +257,7 @@ def verify_acta_receipt( def _demo(bundle_path: str) -> int: + """Verify a portable bundle end-to-end and print the ACTA envelope.""" with open(bundle_path, "r", encoding="utf-8") as handle: bundle = json.load(handle) diff --git a/tests/test_acta_receipt_interop.py b/tests/test_acta_receipt_interop.py index 9505cff4..1939e96f 100644 --- a/tests/test_acta_receipt_interop.py +++ b/tests/test_acta_receipt_interop.py @@ -32,6 +32,7 @@ def _make_bundle(private_key: Ed25519PrivateKey, kid: str, **overrides): + """Build a synthetic signed portable bundle, with override hooks.""" claims = { "alg": "Ed25519", "created_at": "2026-08-15T00:00:00+00:00", @@ -63,6 +64,7 @@ def _make_bundle(private_key: Ed25519PrivateKey, kid: str, **overrides): def _public_b64(private_key: Ed25519PrivateKey) -> str: + """Return the raw public key of *private_key*, base64-encoded.""" from cryptography.hazmat.primitives.serialization import ( Encoding, PublicFormat, @@ -105,6 +107,7 @@ def test_checked_in_proof_bundle_transcodes_and_roundtrips() -> None: def test_unverified_inner_signature_is_refused() -> None: + """A bundle signed by a different key must not transcode.""" key = Ed25519PrivateKey.generate() bundle = _make_bundle(key, "kid-a") wrong_key = Ed25519PrivateKey.generate() @@ -113,6 +116,7 @@ def test_unverified_inner_signature_is_refused() -> None: def test_tampered_signing_input_is_refused() -> None: + """Altering signing_input after signing must break verification.""" key = Ed25519PrivateKey.generate() bundle = _make_bundle(key, "kid-a") bundle["signing_input"] = bundle["signing_input"].replace('"1"', '"2"', 1) @@ -121,6 +125,7 @@ def test_tampered_signing_input_is_refused() -> None: def test_kid_mismatch_between_bundle_and_signed_claim_is_refused() -> None: + """The wrapper kid must match the kid inside the signed claims.""" key = Ed25519PrivateKey.generate() bundle = _make_bundle(key, "kid-a") bundle["kid"] = "kid-b" @@ -164,6 +169,7 @@ def test_signature_alg_tamper_is_refused() -> None: def test_outer_envelope_tamper_is_detected() -> None: + """Changing any envelope field after signing must fail verification.""" key = Ed25519PrivateKey.generate() bundle = _make_bundle(key, "kid-a") verified = interop.verify_portable_bundle(bundle, _public_b64(key)) @@ -229,5 +235,6 @@ def test_missing_signer_identity_is_refused( def test_floats_fail_closed_in_canonicalization() -> None: + """Floats are outside the JCS subset and must be refused outright.""" with pytest.raises(interop.TranscodeError, match="float"): interop.jcs_canonicalize({"amount": 1.5}) From 45da2df09bddcbbe9258c8ad91f7e1591457c703 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:28:39 +0000 Subject: [PATCH 12/20] chore(deps): update alembic requirement from >=1.19.0 to >=1.19.1 Updates the requirements on [alembic](https://github.com/sqlalchemy/alembic) to permit the latest version. - [Release notes](https://github.com/sqlalchemy/alembic/releases) - [Changelog](https://github.com/sqlalchemy/alembic/blob/main/CHANGES) - [Commits](https://github.com/sqlalchemy/alembic/commits) --- updated-dependencies: - dependency-name: alembic dependency-version: 1.19.1 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index f13504b0..e0c24a4c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -31,7 +31,7 @@ mcp>=1.29.0,<2 # MCP SDK; >=1.29.0 provides the pre-parse request-body g # --- Database --- sqlmodel>=0.0.39 # SQLAlchemy + Pydantic hybrid ORM -alembic>=1.19.0 # Database migrations +alembic>=1.19.1 # Database migrations aiosqlite>=0.22.1 # Async SQLite driver for testing # --- Development --- From e194a03f3836c04c2a9b307bfb5eff69b794ebf5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:28:43 +0000 Subject: [PATCH 13/20] chore(deps): update stripe requirement from >=15.4.0 to >=15.5.0 Updates the requirements on [stripe](https://github.com/stripe/stripe-python) to permit the latest version. - [Release notes](https://github.com/stripe/stripe-python/releases) - [Changelog](https://github.com/stripe/stripe-python/blob/master/CHANGELOG.md) - [Commits](https://github.com/stripe/stripe-python/compare/v15.4.0...v15.5.0) --- updated-dependencies: - dependency-name: stripe dependency-version: 15.5.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index f13504b0..1c7e6f42 100644 --- a/requirements.txt +++ b/requirements.txt @@ -26,7 +26,7 @@ cryptography>=50.0.0 # Ed25519 trust-plane signatures (>=50.0.0 fixes GHSA redis>=8.1.0 # Redis-backed rate limiting and runtime persistence asyncpg>=0.31.0 # PostgreSQL-backed runtime persistence greenlet>=3.5.4 # SQLAlchemy async engine support -stripe>=15.4.0 # Stripe payment processing +stripe>=15.5.0 # Stripe payment processing mcp>=1.29.0,<2 # MCP SDK; >=1.29.0 provides the pre-parse request-body guard used by public/partner transports; <2 guards v1 internals # --- Database --- From 89634c3e86f47eae772c5e4bde40b9e004d1149f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 01:34:50 +0000 Subject: [PATCH 14/20] fix(discovery): manifest generation must not crash on an oversized price MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit float() raises OverflowError — not ValueError — for an int too large to represent, so a registry row carrying one made tools.json generation raise instead of falling back. Catch it alongside the existing guards and cover every invalid-price branch: NaN, ±inf, unparseable text, non-numeric types, and oversized ints, in both the exact and non-exact fields. A legitimately zero-priced tool is still advertised as non-economic. Closes the follow-up left open on #277. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Y1seEp4Pa4DtY1HMuJS3AX --- app/services/mcp_generator.py | 4 ++- tests/test_tool_interface_authority.py | 50 ++++++++++++++++++++++---- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/app/services/mcp_generator.py b/app/services/mcp_generator.py index 23a40767..d785abea 100644 --- a/app/services/mcp_generator.py +++ b/app/services/mcp_generator.py @@ -162,7 +162,9 @@ def _service_to_mcp_tool(self, service: dict) -> dict[str, Any]: raw_cost = service.get("credits_per_unit", 1.0) try: per_call_cost = float(raw_cost) - except (TypeError, ValueError): + except (TypeError, ValueError, OverflowError): + # OverflowError: an int too large for a float (a registry write + # could carry one even though prices are normally float/Decimal). per_call_cost = 1.0 if not math.isfinite(per_call_cost): # A malformed price (unparseable, NaN, infinite) must not diff --git a/tests/test_tool_interface_authority.py b/tests/test_tool_interface_authority.py index 89ffc35c..46ec7a58 100644 --- a/tests/test_tool_interface_authority.py +++ b/tests/test_tool_interface_authority.py @@ -683,19 +683,55 @@ async def test_annotations_are_honest_in_permissive_trust_mode( registry.unregister_local("authority-permit-forced") -def test_non_finite_cost_is_not_advertised_as_free(): +def _priced_tool(**price_fields): from app.services.mcp_generator import McpGenerator - tool = McpGenerator()._service_to_mcp_tool( + return McpGenerator()._service_to_mcp_tool( { - "service_id": "authority-nan-priced", - "description": "Tool with a malformed exact price", + "service_id": "authority-priced", + "description": "Tool under price normalization", "category": ServiceCategory.AGENT_COMMS.value, - "credits_per_unit": 2.0, - "credits_per_unit_exact": "nan", + **price_fields, } ) - assert tool["annotations"]["economicAction"] is True + + +@pytest.mark.parametrize( + "price_fields", + [ + # Exact field malformed in every way float() can fail or lie. + {"credits_per_unit": 2.0, "credits_per_unit_exact": "nan"}, + {"credits_per_unit": 2.0, "credits_per_unit_exact": "inf"}, + {"credits_per_unit": 2.0, "credits_per_unit_exact": "-inf"}, + {"credits_per_unit": 2.0, "credits_per_unit_exact": "not-a-number"}, + # A non-numeric type raises TypeError rather than ValueError. + {"credits_per_unit": 2.0, "credits_per_unit_exact": []}, + # An int too large for a float raises OverflowError, not ValueError. + {"credits_per_unit": 2.0, "credits_per_unit_exact": 10**400}, + # No exact field: the same guards apply to credits_per_unit. + {"credits_per_unit": float("nan")}, + {"credits_per_unit": "garbage"}, + {"credits_per_unit": 10**400}, + ], +) +def test_malformed_price_is_never_advertised_as_free(price_fields): + """A price the plane cannot make sense of must fail conservative — + advertising economicAction=false would tell an agent a consequential + tool is free.""" + assert _priced_tool(**price_fields)["annotations"]["economicAction"] is True + + +def test_valid_zero_price_is_still_not_an_economic_action(): + """The conservative fallback must not swallow a legitimately free tool.""" + assert ( + _priced_tool(credits_per_unit=0.0, credits_per_unit_exact="0")["annotations"][ + "economicAction" + ] + is False + ) + assert ( + _priced_tool(credits_per_unit=0.0)["annotations"]["economicAction"] is False + ) def test_approval_window_is_clamped_to_sentinel_bounds(monkeypatch): From 353224057ef226eda5320d9ac6eb5b1e2eaa4fca Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 01:38:22 +0000 Subject: [PATCH 15/20] 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 514c91a5f436168f8e1d72685fb37d98702c2504 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 01:38:23 +0000 Subject: [PATCH 16/20] fix(state): release the aiosqlite worker thread at interpreter shutdown aiosqlite.Connection composes its worker Thread without daemon=True, and CPython joins non-daemon threads during shutdown. A durable-state SQLite connection nobody closes therefore wedges the process at exit with no traceback and no output. close() is the real cleanup path, but only two callers reach it -- the FastAPI lifespan and the test teardown -- so any script, REPL, or worker entrypoint that opens the backend hangs on exit. This is the failure mode that cancelled CI at GitHub's six-hour limit. That was fixed test-side only, leaving the class live for every other consumer. Making the SQLite backend correctly reachable then made it reachable in earnest. Registers a process-wide backstop that stops tracked connections nobody closed. Two details are load-bearing: - It uses threading._register_atexit, NOT atexit. Plain atexit handlers run *after* the non-daemon join, so the obvious implementation can never break this hang -- confirmed by observing the ordering directly. The stdlib's own concurrent.futures.thread uses the same hook. - It calls Connection.stop rather than close. stop is synchronous and tolerates a missing event loop by design, so no loop must survive until shutdown. Unlike daemonizing the thread, it closes in order and cannot truncate a write to a durable store. A single module-level hook over a registry, rather than a hook per connection, because _register_atexit has no unregister counterpart. Forked children are guarded by owner pid so a child never closes a connection its parent owns. Scope is SQLite only: neither the asyncpg pool nor the redis client spawns a non-daemon thread. Verified as an A/B/A on the acceptance gate -- a subprocess that opens the backend and exits without closing it. With the backstop the process exits 0; without it the same test returns 124. A backstop never seen failing is not known to work. Closes #287 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0195WsXzT9kFxfxkrWQm4zhR --- app/core/durable_state.py | 89 ++++++++++++++++++ scripts/check_doc_references.py | 5 + tests/test_db_urls_durable_state.py | 140 ++++++++++++++++++++++++++++ 3 files changed, 234 insertions(+) diff --git a/app/core/durable_state.py b/app/core/durable_state.py index d89c3791..2716de70 100644 --- a/app/core/durable_state.py +++ b/app/core/durable_state.py @@ -15,8 +15,11 @@ import aiosqlite import asyncio +import atexit import json import logging +import os +import threading from dataclasses import asdict, is_dataclass from datetime import date, datetime, time from decimal import Decimal @@ -55,6 +58,71 @@ def _json_default(value: Any) -> Any: raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable") +# --- SQLite worker-thread shutdown backstop -------------------------------- +# ``aiosqlite.Connection`` composes its worker ``Thread`` without +# ``daemon=True``, and CPython joins non-daemon threads during interpreter +# shutdown. A connection nobody closes therefore wedges the process at exit +# with no traceback and no output — the failure mode that cancelled CI at +# GitHub's six-hour limit before ``close_durable_state`` was wired into the +# test teardown. ``close()`` is still the real cleanup path; this only catches +# consumers that never reach it (a script, a REPL, a worker entrypoint). +# +# Registration goes through ``threading._register_atexit``, NOT ``atexit``. +# Plain ``atexit`` handlers run *after* the non-daemon join, so they can never +# break this particular hang — verified by observation, and the reason the +# obvious implementation does not work. ``threading._register_atexit`` runs +# inside ``threading._shutdown`` before the join; the stdlib's own +# ``concurrent.futures.thread`` relies on it for the same purpose. +_TRACKED_SQLITE_CONNECTIONS: dict[int, tuple[aiosqlite.Connection, int]] = {} +_SQLITE_SHUTDOWN_HOOK_REGISTERED = False + + +def _release_tracked_sqlite_connections() -> None: + """Release worker threads for connections nobody closed.""" + current_pid = os.getpid() + for conn, owner_pid in list(_TRACKED_SQLITE_CONNECTIONS.values()): + # Shutdown hooks are inherited across fork(). A child must never close + # a connection owned by its parent. + if owner_pid != current_pid: + continue + logger.warning( + "Durable-state SQLite connection was not closed; releasing its " + "worker thread at shutdown. Call close_durable_state() on exit." + ) + try: + # ``stop`` rather than ``close``: it is synchronous and tolerates a + # missing event loop by design, enqueueing its close-and-stop work + # with a ``None`` future. No loop has to survive until shutdown. + conn.stop() + except Exception: + logger.debug("Failed to stop SQLite worker at shutdown", exc_info=True) + _TRACKED_SQLITE_CONNECTIONS.clear() + + +def _ensure_sqlite_shutdown_hook() -> None: + """Register the shutdown hook once per process.""" + global _SQLITE_SHUTDOWN_HOOK_REGISTERED + if _SQLITE_SHUTDOWN_HOOK_REGISTERED: + return + register_early = getattr(threading, "_register_atexit", None) + if register_early is not None: + register_early(_release_tracked_sqlite_connections) + else: + # No known CPython lacks it (3.9+), but degrade rather than crash on + # import if a future runtime drops the private hook. + atexit.register(_release_tracked_sqlite_connections) + _SQLITE_SHUTDOWN_HOOK_REGISTERED = True + + +def _track_sqlite_connection(conn: aiosqlite.Connection, owner_pid: int) -> None: + _ensure_sqlite_shutdown_hook() + _TRACKED_SQLITE_CONNECTIONS[id(conn)] = (conn, owner_pid) + + +def _untrack_sqlite_connection(conn: aiosqlite.Connection) -> None: + _TRACKED_SQLITE_CONNECTIONS.pop(id(conn), None) + + class DurableStateStore: """Simple key/value JSON state store backed by PostgreSQL or Redis.""" @@ -79,6 +147,7 @@ def __init__(self): self._redis: redis.Redis | None = None self._pg_pool: asyncpg.Pool | None = None self._sqlite_conn: aiosqlite.Connection | None = None + self._sqlite_owner_pid: int | None = None @property def backend(self) -> str: @@ -131,6 +200,20 @@ def _missing_explicit_backend(self, intended: str, missing_var: str) -> str: mark_durable_state_fell_back(intended) return "memory" + def _register_sqlite_shutdown_backstop(self) -> None: + """Track this connection so interpreter shutdown cannot hang on it.""" + conn = self._sqlite_conn + if conn is None: + return + self._sqlite_owner_pid = os.getpid() + _track_sqlite_connection(conn, self._sqlite_owner_pid) + + def _unregister_sqlite_shutdown_backstop(self) -> None: + """Stop tracking once the connection is closed through the normal path.""" + if self._sqlite_conn is not None: + _untrack_sqlite_connection(self._sqlite_conn) + self._sqlite_owner_pid = None + async def _ensure_ready(self) -> None: if self._initialized: return @@ -171,6 +254,7 @@ async def _ensure_ready(self) -> None: await self._redis.ping() elif self._backend == "sqlite": self._sqlite_conn = await aiosqlite.connect(self._sqlite_url) + self._register_sqlite_shutdown_backstop() self._sqlite_conn.row_factory = aiosqlite.Row await self._sqlite_conn.execute( """ @@ -428,6 +512,11 @@ async def close(self) -> None: self._pg_pool = None if self._sqlite_conn is not None: + # Untrack first: the backstop is keyed on the connection object, and + # clearing the attribute below would leave nothing to look it up by. + # Doing it before the close also means a close that raises cannot + # strand a tracked connection. + self._unregister_sqlite_shutdown_backstop() try: await self._sqlite_conn.close() except Exception: diff --git a/scripts/check_doc_references.py b/scripts/check_doc_references.py index ecaa3ef7..5da82604 100644 --- a/scripts/check_doc_references.py +++ b/scripts/check_doc_references.py @@ -49,6 +49,11 @@ "pythonpath", # MCP SDK class referenced when describing what the SDK transport does. "StreamableHTTPSessionManager", + # Stdlib internals named when explaining interpreter-shutdown ordering. + # Listed as full dotted names on purpose: allowlisting the bare tails + # instead would exempt those common words everywhere in the tree. + "threading._shutdown", + "concurrent.futures.thread", } ) diff --git a/tests/test_db_urls_durable_state.py b/tests/test_db_urls_durable_state.py index f64d4fee..050c4273 100644 --- a/tests/test_db_urls_durable_state.py +++ b/tests/test_db_urls_durable_state.py @@ -2,6 +2,10 @@ from __future__ import annotations +import asyncio +import os +from pathlib import Path + import pytest from app.core.db_urls import ( @@ -11,6 +15,8 @@ sqlite_path_from_url, ) from app.core.durable_state import ( + _TRACKED_SQLITE_CONNECTIONS, + _release_tracked_sqlite_connections, DurableStateConfigError, DurableStateStore, reset_durable_state_for_tests, @@ -295,3 +301,137 @@ def test_auto_without_urls_refuses_production_like(monkeypatch): get_settings.cache_clear() reset_durable_state_for_tests() reset_runtime_degradation() + + +# --- shutdown backstop for the aiosqlite worker thread --------------------- +# aiosqlite.Connection composes a worker Thread with no daemon=True, and +# interpreter shutdown joins non-daemon threads. A connection nobody closes +# therefore hangs the process at exit with no traceback. close() is the real +# cleanup path; these cover the backstop for consumers that never reach it. + +# Opens the SQLite backend and exits WITHOUT calling close_durable_state(). +# Without the backstop this process never terminates. +_LEAK_SCRIPT = """ +import asyncio, os, sys +sys.path.insert(0, {repo!r}) +os.environ["STATE_BACKEND"] = "sqlite" +os.environ["SQLITE_URL"] = {db!r} +os.environ["ENVIRONMENT"] = "development" + +from app.core.durable_state import get_durable_state + +async def main(): + store = get_durable_state() + await store.save_json("k", {{"v": 1}}) + assert store.backend == "sqlite", store.backend + +asyncio.run(main()) +print("WORK_DONE", flush=True) +# Deliberately no close_durable_state() -- that is the whole point. +""" + + +def _run_leak_script(tmp_path, extra_env=None): + """Run the leaking script in a subprocess under a hard timeout. + + Returns (returncode, stdout). A timeout is reported as returncode 124 to + match the shell convention used elsewhere in this repo's verification. + """ + import subprocess + import sys as _sys + + script = _LEAK_SCRIPT.format( + repo=str(Path(__file__).resolve().parent.parent), + db=str(tmp_path / "atexit_probe.db"), + ) + env = {**os.environ, **(extra_env or {})} + env.pop("DATABASE_URL", None) + try: + proc = subprocess.run( + [_sys.executable, "-c", script], + capture_output=True, + text=True, + timeout=60, + env=env, + ) + except subprocess.TimeoutExpired as exc: + return 124, (exc.stdout or b"").decode() if isinstance(exc.stdout, bytes) else (exc.stdout or "") + return proc.returncode, proc.stdout + + +def test_process_exits_when_sqlite_state_is_never_closed(tmp_path): + """The acceptance gate: a leaked connection must not wedge interpreter exit. + + Asserting on the exit code rather than on output is deliberate. A passing + log line reads identically whether the process exited or hung -- that + ambiguity is exactly what let the original six-hour CI hang through. + """ + returncode, stdout = _run_leak_script(tmp_path) + + assert "WORK_DONE" in stdout, f"script did not complete its work: {stdout!r}" + assert returncode != 124, ( + "process hung at exit: the aiosqlite worker thread was never released" + ) + assert returncode == 0, f"unexpected exit code {returncode}" + + +def test_clean_close_untracks_and_releases_the_worker_thread(tmp_path): + """The normal path stays authoritative: close() releases and deregisters.""" + import threading + + reset_durable_state_for_tests() + store = DurableStateStore() + store._state_backend = "sqlite" + store._sqlite_url = str(tmp_path / "clean_close.db") + + async def exercise(): + await store.save_json("k", {"v": 1}) + assert store.backend == "sqlite" + conn = store._sqlite_conn + assert conn is not None + assert _TRACKED_SQLITE_CONNECTIONS, "backstop did not track the connection" + await store.close() + return conn + + conn = asyncio.run(exercise()) + + assert id(conn) not in _TRACKED_SQLITE_CONNECTIONS, ( + "connection still tracked after a clean close" + ) + assert store._sqlite_owner_pid is None + conn._thread.join(timeout=10) + assert not conn._thread.is_alive(), "worker thread outlived close()" + assert conn._thread not in threading.enumerate() + reset_durable_state_for_tests() + + +def test_shutdown_backstop_is_inert_in_a_forked_child(tmp_path, monkeypatch): + """A forked child must never close a connection its parent owns.""" + reset_durable_state_for_tests() + store = DurableStateStore() + store._state_backend = "sqlite" + store._sqlite_url = str(tmp_path / "fork_guard.db") + + async def setup(): + await store.save_json("k", {"v": 1}) + + asyncio.run(setup()) + conn = store._sqlite_conn + assert conn is not None + assert id(conn) in _TRACKED_SQLITE_CONNECTIONS + + # Pretend we are a child that inherited the registry across fork(). + monkeypatch.setattr(os, "getpid", lambda: store._sqlite_owner_pid + 1) + _release_tracked_sqlite_connections() + assert conn._thread.is_alive(), "child closed a connection it does not own" + + # The real owner still releases it. + monkeypatch.undo() + _TRACKED_SQLITE_CONNECTIONS[id(conn)] = (conn, store._sqlite_owner_pid) + _release_tracked_sqlite_connections() + conn._thread.join(timeout=10) + assert not conn._thread.is_alive() + + store._sqlite_conn = None + store._unregister_sqlite_shutdown_backstop() + reset_durable_state_for_tests() From 25ac731c85d004a15f0db893011c0fffad146fd1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 01:38:57 +0000 Subject: [PATCH 17/20] test(crash): exercise every instrumented fault point The harness instruments twelve commit boundaries; six scenarios reached five of them, and PROOF_MATRIX recorded the other seven as a known gap. This closes it with a table-driven boundary suite that kills a worker at each remaining point and pins the disposition it must leave, taking `prove-crash-recovery` from six scenarios to thirteen. Every expectation was checked against the implementing code before being asserted, which is what turned two of them from "observed behaviour" into documentation fixes: - An effect-free local crash is *not* released for retry. `failure-semantics.md` window E said the record is deleted so the key can retry, but `reconcile_stuck_records` scopes that to `operation_kind == "upstream_mcp"` -- a local tool's side-effect ordering is not observable to the reconciler, so the key is held in progress and cannot be reused without an operator. - A reservation stranded before any charge stays stranded. `reconcile_budgets` only reclaims from permits that can no longer admit a charge, because a long-running call is indistinguishable from a dead one and an early reset could let a concurrent request overspend past max_credits. Recorded as a new window F. Both are correct fail-closed choices that were simply never written down, which is the same class of prose-outruns-code defect the doc-reference gate was added for and cannot catch. The new rows assert each record's own durable state rather than the sweep's counters, which are cumulative across the shared database. Three of them leave a needs-review record, so the narrative scenarios that assert exact counters must keep running first; that dependency is now called out in a comment where a future reader will hit it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Qz8gaM1yVHVf2j2m6f1Vtx --- CHANGELOG.md | 17 ++ docs/PROOF_MATRIX.md | 26 ++- docs/failure-semantics.md | 3 +- tests/test_mcp_postgres_multiprocess.py | 267 ++++++++++++++++++++++++ 4 files changed, 304 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95558d7e..dc49e2c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -264,6 +264,23 @@ full release gate; do not backfill a final `v1.2.0` tag. - The upstream effects table permits duplicate call tokens deliberately, so a redispatch after ambiguity would surface as a second row rather than being hidden by a unique constraint. +- **Every instrumented crash boundary is now exercised.** The harness + instruments twelve fault points; six scenarios reached five of them and the + proof matrix recorded the other seven as a known gap. A table-driven boundary + suite now kills a worker at each of the seven and pins the disposition it must + leave, so `prove-crash-recovery` runs thirteen scenarios and the gap is + closed. Each expectation was checked against the implementing code before + being asserted, rather than recorded from observed behaviour. +- **Two conservative behaviours were documented more broadly than they are + implemented, and the docs now say so.** `failure-semantics.md` window E + claimed an effect-free crash releases the idempotency key for retry; that + sweep is scoped to `operation_kind == "upstream_mcp"`, so a local-path crash + in the same window is held in progress and the key cannot be reused without + an operator. A new window F records the matching budget case: a reservation + stranded before any charge stays on a live permit — reclaiming it early could + let a concurrent request over-spend — and is released only when the permit is + revoked or expires. Both are correct fail-closed choices; neither was + written down. - **The pre-checkpoint window is proved by kill too.** A third scenario gates `after_debit_commit` on the remote path, killing the worker while the attempt is still `prepared` and has not yet been told which ledger entry paid for it. diff --git a/docs/PROOF_MATRIX.md b/docs/PROOF_MATRIX.md index f7e2aca0..a018cd73 100644 --- a/docs/PROOF_MATRIX.md +++ b/docs/PROOF_MATRIX.md @@ -136,6 +136,16 @@ the cases apart. A crash before the checkpoint is provably non-delivered and is refunded; a crash after it is unknowable and terminalizes into `delivery_uncertain` instead of waiting for a human — and neither redispatches. +Alongside those six narrative scenarios, a table-driven boundary suite kills a +worker at each of the seven remaining instrumented commit boundaries and pins +the disposition it must leave. Its value is mostly negative: it records that +the local path is deliberately *more* conservative than the remote one. A +provably effect-free local crash is still not released for retry, because the +effect-free sweep is scoped to `operation_kind == "upstream_mcp"`; and a +reservation stranded before any charge stays stranded on a live permit, because +reclaiming it early could let a concurrent request over-spend. Both were +verified against the code rather than inferred from the observed behaviour. + All three remote-path proofs additionally rebuild the full evidence bundle for the recovered receipt and require every check to pass, so the claim is that the recovered evidence *verifies*, not merely that a receipt row exists. Each also @@ -224,12 +234,11 @@ Being explicit about the boundary is what makes the proofs worth anything. - **The crash proof covers instrumented boundaries, not arbitrary failure.** Faults are injected at specific durable commit points. It does not prove survival of a kill at an arbitrary instruction, database crash or failover, - or multi-node high availability. The harness exposes more fault points than - the six CI-run scenarios currently exercise. Of the twelve instrumented - points those scenarios reach five; the other seven — - `before_permit_reserve`, `after_permit_reserve`, `after_idempotency_begin`, - `after_idempotency_complete`, `after_mark_charged`, `before_receipt_commit`, - and `after_audit_commit` — are instrumented but unexercised. + or multi-node high availability. All twelve instrumented fault points are now + reached by a test: five by the narrative scenarios above, and the remaining + seven by a table-driven boundary suite that pins the disposition a kill at + each commit boundary must leave. What is still not proven is a kill at an + *arbitrary* instruction — only at the instrumented boundaries. - **`make prove-trust-plane` runs on SQLite with a hardcoded demo signing seed.** It proves the logic, not the production posture. No target re-runs these assertions against PostgreSQL — see the defect note above. PostgreSQL @@ -255,8 +264,9 @@ Ordered by how much each would strengthen the differentiator per unit of work. channel, is what makes the verifier meaningful against a compromised issuer. 3. **Exercise the remaining crash fault points.** The harness already - instruments more commit boundaries than the six proven scenarios use: - seven of its twelve fault points are not yet reached by a test. + instrumented boundaries are all covered as of 2026-08-15; what remains is + coverage of failure modes the harness cannot inject at all — database crash + or failover, and multi-node high availability. 4. **External anchoring or append-only storage**, which is what would let the project retire the "tamper-evident, not immutable" caveat. 5. **KMS-backed signing custody**, designed in diff --git a/docs/failure-semantics.md b/docs/failure-semantics.md index 1978ff29..de952d53 100644 --- a/docs/failure-semantics.md +++ b/docs/failure-semantics.md @@ -94,7 +94,8 @@ injected at each durable boundary in the tests named below. | **C. after the response, before the receipt commit** (attempt terminal, no receipt) | Terminal-without-receipt sweep; the stored canonical result is **re-hashed byte-exact** before reuse (`dispatch_stored_result_hash_mismatch` on any tamper) | The recorded terminal state is adopted and signed; a recorded `returned_error` completes its refund | Per the recorded state | | **D1. lost COMMIT ack on reserve+prepare** | Recovery re-read adopts only an invariant-identical `prepared` row | Same attempt id returned; no second reservation | Unchanged (`test_lost_commit_ack_recovery_no_double_charge`) | | **D2. lost ack on the final completion** (receipt exists, idempotency record empty) | Receipt-present / response-`NULL` sweep, which runs **before** the generic sweep | The replay record is rebuilt from the signed receipt; an existing `failed_refunded` receipt **blocks** a second budget release | Unchanged | -| **E. before any effect** (no attempt, no debit, no receipt) | Effect-free idempotency sweep | The record is deleted so the same key can genuinely retry | Nothing ever moved | +| **E. before any effect** (no attempt, no debit, no receipt) | Effect-free idempotency sweep | The record is deleted so the same key can genuinely retry. **Governed remote dispatch only** — the sweep is scoped to `operation_kind == "upstream_mcp"`, because a local tool's side-effect ordering is not observable to the reconciler. A local-path crash in the same window is held in progress rather than released, and the key cannot be reused until an operator intervenes. | Nothing ever moved | +| **F. reserved, then died before charging** (budget reserved, no debit) | Permit budget reconciliation, but only once the permit can no longer admit a charge | The reservation is left in place on a still-active permit: a long-running call is indistinguishable from a dead one, so reclaiming early could let a concurrent request over-spend. It is reclaimed when the permit is revoked or expires. | **Nothing charged**; the agent can spend less than authorized, never more | Proven by `tests/test_mcp_dispatch_reconciliation.py` (each window has a named test — `test_crash_between_debit_and_dispatch_reconciles_refund`, diff --git a/tests/test_mcp_postgres_multiprocess.py b/tests/test_mcp_postgres_multiprocess.py index 53574aae..04ef4a04 100644 --- a/tests/test_mcp_postgres_multiprocess.py +++ b/tests/test_mcp_postgres_multiprocess.py @@ -1204,3 +1204,270 @@ async def test_kill_between_debit_and_dispatch_refunds_without_dispatching( assert again["dispatch_prepared_finalized"] == 0 assert again["dispatch_failed_attempts"] == 0 assert await _upstream_snapshot(seeded) == after + + +@dataclass(frozen=True) +class CrashExpectation: + """The durable disposition a kill at one commit boundary must leave.""" + + fault_point: str + executions: int + debits: int + receipts: int + budget_reserved: bool + released_for_retry: bool + why: str + + +# The boundaries the three narrative scenarios above do not reach. Each row is +# the contract for a kill at that point, and every one of them is conservative +# by design: the local path never auto-releases a charged or executed record, +# because a local tool's side effects are not observable to the reconciler the +# way a dispatch attempt's are. +# +# Ordering note: three of these rows leave a record in the sweep's +# needs-review set, and that set is cumulative across the shared database. +# The narrative scenarios above assert exact sweep counters, so they must run +# first. That holds today because pytest runs a module in definition order and +# no shuffling plugin is installed. If one is ever added, those exact-count +# assertions are what will break -- convert them to per-record assertions like +# the ones below rather than reordering around the problem. +LOCAL_CRASH_CONTRACT = ( + CrashExpectation( + fault_point="before_permit_reserve", + executions=0, + debits=0, + receipts=0, + budget_reserved=False, + released_for_retry=False, + why=( + "Provably effect-free, yet deliberately not released: the " + "effect-free sweep is scoped to operation_kind == 'upstream_mcp' " + "because a local tool's side-effect ordering is not knowable to " + "the reconciler. The key stays in progress rather than risking a " + "second execution." + ), + ), + CrashExpectation( + fault_point="after_idempotency_begin", + executions=0, + debits=0, + receipts=0, + budget_reserved=False, + released_for_retry=False, + why="Same effect-free window, one commit later; same fail-closed hold.", + ), + CrashExpectation( + fault_point="after_permit_reserve", + executions=0, + debits=0, + receipts=0, + budget_reserved=True, + released_for_retry=False, + why=( + "The reservation is stranded on a still-active permit and stays " + "stranded. reconcile_budgets only reclaims from permits that can " + "no longer admit a charge, since a long-running call is " + "indistinguishable from a dead one; the agent therefore spends " + "less than authorized, never more." + ), + ), + CrashExpectation( + fault_point="after_mark_charged", + executions=0, + debits=1, + receipts=0, + budget_reserved=True, + released_for_retry=False, + why=( + "Charged before the tool ran. The money moved and no receipt can " + "be reconstructed, so this is held for operator review rather " + "than refunded on a guess." + ), + ), + CrashExpectation( + fault_point="before_receipt_commit", + executions=1, + debits=1, + receipts=0, + budget_reserved=True, + released_for_retry=False, + why=( + "The side effect landed and the charge stands, but nothing was " + "signed. Ambiguous, so it fails closed to review." + ), + ), + CrashExpectation( + fault_point="after_audit_commit", + executions=1, + debits=1, + receipts=0, + budget_reserved=True, + released_for_retry=False, + why=( + "The audit event is durable but the receipt is not: on the local " + "path the audit precedes the receipt, so this is still the " + "unsigned-outcome case." + ), + ), + CrashExpectation( + fault_point="after_idempotency_complete", + executions=1, + debits=1, + receipts=1, + budget_reserved=True, + released_for_retry=True, + why=( + "Everything is durable and only the response to the caller was " + "lost. Nothing to reconcile; the retry replays the stored result." + ), + ), +) + + +async def _local_crash_state(seeded: SeededCall) -> dict[str, object]: + """Durable disposition of one governed local invocation.""" + from app.db.database import get_session_factory + + factory = get_session_factory() + async with factory() as session: + record = ( + await session.execute( + text( + """ + SELECT record_id, (response_json IS NOT NULL) AS completed + FROM idempotency_records + WHERE wallet_id = :wallet_id + AND endpoint = :endpoint + AND idempotency_key = :idempotency_key + """ + ), + { + "wallet_id": seeded.wallet_id, + "endpoint": GOVERNED_MCP_ENDPOINT, + "idempotency_key": seeded.idempotency_key, + }, + ) + ).first() + if record is None: + return {"record_present": False} + counts = { + "record_present": True, + "completed": bool(record.completed), + "executions": int( + ( + await session.execute( + text( + "SELECT COUNT(*) FROM mcp_stress_tool_executions " + "WHERE call_token = :call_token" + ), + {"call_token": seeded.call_token}, + ) + ).scalar_one() + ), + "debits": int( + ( + await session.execute( + text( + "SELECT COUNT(*) FROM ledger_entries " + "WHERE operation_key = :record_id AND action = 'debit'" + ), + {"record_id": record.record_id}, + ) + ).scalar_one() + ), + "receipts": int( + ( + await session.execute( + text( + "SELECT COUNT(*) FROM receipts " + "WHERE idempotency_record_id = :record_id" + ), + {"record_id": record.record_id}, + ) + ).scalar_one() + ), + "permit_spent": str( + Decimal( + ( + await session.execute( + text( + "SELECT spent_credits FROM permits " + "WHERE permit_id = :permit_id" + ), + {"permit_id": seeded.permit_id}, + ) + ).scalar_one() + ).normalize() + ), + } + return counts + + +@pytest.mark.parametrize( + "contract", + LOCAL_CRASH_CONTRACT, + ids=[expectation.fault_point for expectation in LOCAL_CRASH_CONTRACT], +) +@pytest.mark.asyncio(loop_scope="session") +async def test_local_crash_boundary_disposition( + stress_harness: StressHarness, + steady_worker: StressWorker, + contract: CrashExpectation, +) -> None: + """Every instrumented commit boundary lands in exactly one known state. + + The harness instruments twelve fault points; the narrative scenarios above + reach five. This covers the remaining seven so that "the harness exposes + more fault points than the tests exercise" stops being true, and so that + the conservative dispositions -- a stranded reservation, a held review -- + are pinned as intended behaviour rather than left as folklore. + """ + seeded = await _seed_call( + stress_harness, + steady_worker, + scenario=f"boundary-{contract.fault_point.replace('_', '-')}", + ) + fault_worker = await _start_worker( + stress_harness, + name=f"boundary-{contract.fault_point}-{uuid.uuid4().hex[:8]}", + fault_point=contract.fault_point, + ) + first_task: asyncio.Task[httpx.Response] | None = None + try: + first_task = asyncio.create_task(_invoke(fault_worker, seeded)) + marker = await _wait_for_marker(fault_worker) + assert marker["point"] == contract.fault_point + _stop_worker(fault_worker) + await asyncio.gather(first_task, return_exceptions=True) + finally: + _stop_worker(fault_worker) + if first_task is not None: + await asyncio.gather(first_task, return_exceptions=True) + + before = await _local_crash_state(seeded) + assert before["record_present"] is True + assert before["executions"] == contract.executions, contract.why + assert before["debits"] == contract.debits, contract.why + assert before["receipts"] == contract.receipts, contract.why + assert before["permit_spent"] == ("2" if contract.budget_reserved else "0") + + # Reconciliation is asserted through this record's own disposition rather + # than the sweep's counters: the counters are cumulative across every + # record the shared database still holds. + await _reconcile(stress_harness, steady_worker) + after = await _local_crash_state(seeded) + assert after == before, f"reconciliation must not move this state: {contract.why}" + + replay = await _invoke(steady_worker, seeded) + if contract.released_for_retry: + assert "result" in replay.json(), contract.why + else: + _assert_in_progress(replay) + + # The invariant that holds at every boundary, whatever the disposition. + settled = await _local_crash_state(seeded) + assert settled["executions"] <= 1 + assert settled["debits"] <= 1 + assert settled["receipts"] <= 1 + assert settled == before From 6bca94eeebd8ca2b7a1468a5502c7c4486f27db4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 01:40:23 +0000 Subject: [PATCH 18/20] 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 0932b18119092514dfb3f299cff24928994bbc31 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 01:46:40 +0000 Subject: [PATCH 19/20] chore: ignore agent-tooling worktree scratch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checks run in throwaway git worktrees under .claude/worktrees/. Those are full repo copies — including .env.production and coverage artifacts — and must never be committed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FsXYfTHDLiFNQ24R5JPN7H --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index 444f5ee3..c1edadd4 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,8 @@ Thumbs.db # Docker *.log .vercel/ + +# Agent tooling scratch: throwaway git worktrees used to run checks in +# isolation. Full repo copies (including .env.production and coverage +# artifacts) — never commit these. +.claude/worktrees/ From 938ed6351f2d6eecae191c0a7eb3fa7e553b5611 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 01:55:41 +0000 Subject: [PATCH 20/20] test(crash): make the crash scenarios order-independent Review caught that the two pre-existing scenarios asserted the sweep's `idempotency_repaired` / `idempotency_needs_review` counters, which are cumulative over every record the shared database still holds. `stress_harness` is module-scoped and this module deliberately does not truncate between tests, so those assertions silently depended on the order the module ran in -- and this branch made that worse by adding three more scenarios that leave a needs-review record. I had documented the hazard in a comment instead of removing it. That was the weaker choice: both assertions are now made against the seeded call's own record, which is what each scenario is actually claiming. Verified rather than assumed, with pytest-randomly installed locally and then removed so the environment still matches CI: - pre-fix code, shuffled with seed 1: both scenarios FAIL, on exactly the two counter assertions - fixed code, shuffled with seeds 1 and 7: 13 passed - fixed code, definition order: 13 passed; full suite 1666 passed Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Qz8gaM1yVHVf2j2m6f1Vtx --- tests/test_mcp_postgres_multiprocess.py | 31 ++++++++++++++----------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/tests/test_mcp_postgres_multiprocess.py b/tests/test_mcp_postgres_multiprocess.py index 04ef4a04..04f7ff32 100644 --- a/tests/test_mcp_postgres_multiprocess.py +++ b/tests/test_mcp_postgres_multiprocess.py @@ -917,9 +917,13 @@ async def test_receipt_commit_survives_worker_death_and_reconciles( blocked = await _invoke(steady_worker, seeded) _assert_in_progress(blocked) - reconciliation = await _reconcile(stress_harness, steady_worker) - assert reconciliation["idempotency_repaired"] == 1 - assert reconciliation["idempotency_needs_review"] == 0 + # Asserted against this record rather than the sweep's counters: those + # are cumulative over every record the shared database still holds, so + # reading them would couple this scenario to the order the module + # happens to run in. + assert (await _local_crash_state(seeded))["completed"] is False + await _reconcile(stress_harness, steady_worker) + assert (await _local_crash_state(seeded))["completed"] is True replay = await _invoke(steady_worker, seeded) assert replay.status_code == 200 @@ -966,9 +970,10 @@ async def test_post_side_effect_crash_requires_review_without_redispatch( assert before.debit_count == 1 assert before.receipt_ids == () - reconciliation = await _reconcile(stress_harness, steady_worker) - assert reconciliation["idempotency_repaired"] == 0 - assert reconciliation["idempotency_needs_review"] == 1 + # Per record, not the cumulative sweep counters: this scenario's proof + # is that its own record is left untouched, which is order-independent. + await _reconcile(stress_harness, steady_worker) + assert (await _local_crash_state(seeded))["completed"] is False replay = await _invoke(steady_worker, seeded) _assert_in_progress(replay) @@ -1225,13 +1230,13 @@ class CrashExpectation: # because a local tool's side effects are not observable to the reconciler the # way a dispatch attempt's are. # -# Ordering note: three of these rows leave a record in the sweep's -# needs-review set, and that set is cumulative across the shared database. -# The narrative scenarios above assert exact sweep counters, so they must run -# first. That holds today because pytest runs a module in definition order and -# no shuffling plugin is installed. If one is ever added, those exact-count -# assertions are what will break -- convert them to per-record assertions like -# the ones below rather than reordering around the problem. +# Every assertion here is against the seeded call's own record. The sweep's +# counters are cumulative over the shared database -- `stress_harness` is +# module-scoped and this module deliberately does not truncate between tests, +# because a kill-and-recover scenario needs its own prior state intact -- so +# reading them would make each scenario depend on the order the module runs +# in. The narrative scenarios above were converted to the same per-record +# form for that reason. LOCAL_CRASH_CONTRACT = ( CrashExpectation( fault_point="before_permit_reserve",