From 5255d2654f518637af2637a3dae6099c34acc9ee Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 00:21:31 +0000 Subject: [PATCH 01/11] 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 02/11] 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 03/11] =?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 04/11] 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 05/11] =?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 06/11] =?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 07/11] 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 08/11] =?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 09/11] =?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 10/11] 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 0932b18119092514dfb3f299cff24928994bbc31 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 01:46:40 +0000 Subject: [PATCH 11/11] 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/