Skip to content

feat(signing): default replay store, signed-fetch preset, migration guide#272

Merged
bokelley merged 4 commits intomainfrom
bokelley/signing-defaults-and-migration
Apr 25, 2026
Merged

feat(signing): default replay store, signed-fetch preset, migration guide#272
bokelley merged 4 commits intomainfrom
bokelley/signing-defaults-and-migration

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

Summary

Three buyer/seller ergonomic upgrades to bring the Python SDK to parity with the TypeScript ergonomics shipped in adcp-client#917. All three are backwards compatible — existing callers that pass explicit stores or use `ADCPClient(signing=...)` are unaffected.

1. `VerifyOptions.replay_store` defaults to in-memory

`replay_store` was `ReplayStore | None = None`, and the verifier silently skipped the check when `None` — the exact security regression AdCP verifier checklist step 12 exists to prevent. Now defaults to `field(default_factory=InMemoryReplayStore)` so omitting it gives you working replay protection out of the box. Pass `replay_store=None` explicitly if you want to bypass (uncommon — typically only short-lived integration tests).

`revocation_checker` and `revocation_list` stay `None` — most agents don't track revocations at runtime, and the verifier correctly skips when both are absent.

2. `install_signing_event_hook` + `signing_operation` preset

Buyer-side preset for adapters that don't use `ADCPClient`. The high-level client already wires the signing event hook internally — this exposes the same shape as a public API for raw-httpx orchestrators, edge proxies, and anything the high-level client doesn't fit:

```python
import httpx
from adcp.signing import SigningConfig, install_signing_event_hook, signing_operation

client = httpx.AsyncClient()
install_signing_event_hook(
client,
signing=SigningConfig(private_key=..., key_id="my-agent-2026"),
seller_capability=seller_caps.request_signing,
)

async with client:
with signing_operation("create_media_buy"):
resp = await client.post("https://seller.example.com/mcp\", json=payload)
```

Supports both static `seller_capability` and a sync/async `capability_provider` callable for lazy/dynamic lookups.

3. `docs/request-signing-migration.md`

Mirrors the structure of adcp-go's `MIGRATION.md`:

  • Bootstrap (key generation, JWKS publication, capability advertisement).
  • Staged enforcement: `supported_for` → `warn_for` → `required_for`. Includes the step-B shadow-mode shim and explicit warnings about the shim surviving into production.
  • Routine + compromise key rotation paths.
  • Common pitfalls (body-modifying intermediaries, redirect handling, clock skew, SSRF, replay caps, ContextVar leaks into background tasks).
  • Pre-enforcement checklist.

README signing section gains short pointers to the new preset and the migration guide.

Why this PR

Follow-up from the adcp#3064 docs review. After landing the TypeScript ergonomics in adcp-client#917, @benminer and I scoped parallel work for Python and Go so the docs can cite consistent ergonomics across all three SDKs:

  • TypeScript (adcp-client#917, merged): defaults on `verifySignatureAsAuthenticator` + `createExpressVerifier`, default fetch on `buildAgentSigningFetch`, new `createAgentSignedFetch` preset.
  • Python (this PR): default `replay_store`, `install_signing_event_hook` preset, migration guide.
  • Go (next): `NewSignedHTTPClient` preset, default in-memory stores. Coming as a separate PR on `adcp-go`.

Test plan

  • 15 new tests:
    • `tests/conformance/signing/test_verifier_defaults.py` (5): default replay store is in-memory, explicit `None` preserved, per-instance isolation, default store actually dedups, revocation stays optional.
    • `tests/conformance/signing/test_install_signing_event_hook.py` (10): signs `required_for` ops with a verifier round-trip, skips ops not in any list, skips when `current_operation` ContextVar unset, skips `get_adcp_capabilities` bootstrap carve-out, supports sync + async capability providers, validates exactly-one input contract, `signing_operation` resets ContextVar on normal + exception exit, appends to existing event hooks without clobbering.
  • Full suite: 2188 passed (was 2173) on Python 3.14. No regressions.
  • `ruff check` clean across `src/adcp/signing/`.
  • `mypy` clean across the modified files.

Migration impact

None. Existing callers:

  • `VerifyOptions(replay_store=my_store, ...)` → unchanged.
  • `VerifyOptions(...)` without `replay_store` → previously had no replay protection, now has in-memory protection. This is the security-by-default change; anyone affected was relying on a footgun.
  • `ADCPClient(signing=...)` → unchanged.

cc @benminer — this lands the Python half of the cross-SDK ergonomics work we discussed on adcp#3064.

🤖 Generated with Claude Code

…uide

Three buyer/seller ergonomic upgrades to bring the Python SDK to parity
with the TypeScript ergonomics shipped in adcp-client #917:

1. VerifyOptions.replay_store defaults to a fresh InMemoryReplayStore
   when omitted. Defaulting to None silently disabled replay protection
   for callers who forgot to wire one — exactly the regression AdCP
   verifier checklist step 12 exists to prevent. Each VerifyOptions
   instance gets its own default store via field(default_factory=...);
   pass replay_store=None explicitly if you genuinely want to bypass
   the check (uncommon — typically only short-lived integration tests).

2. install_signing_event_hook + signing_operation context manager.
   Buyer-side preset for adapters that don't use ADCPClient — same
   shape ADCPClient uses internally, exposed as a public surface so
   raw-httpx orchestrators get the same auto-sign UX. Supports both
   static seller_capability and a sync/async capability_provider
   callable for lazy lookups.

3. docs/request-signing-migration.md mirrors adcp-go's MIGRATION.md
   shape — bootstrap, staged enforcement (supported_for → warn_for →
   required_for), key rotation (routine + compromise paths), common
   pitfalls, pre-enforcement checklist. README signing section gains
   short pointers to the new preset and the migration guide.

Tests: 15 new (5 for VerifyOptions defaults, 10 for the buyer hook).
Full suite: 2188 passed (was 2173). ruff clean. mypy clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ync detect

`hasattr(result, "__await__")` returns true for `unittest.mock.Mock`
because Mock synthesizes any attribute access — a sync Mock returning a
RequestSigning would be detected as awaitable and crash on `await`.
`inspect.isawaitable` covers coroutines, futures, and any
__await__-defining object correctly.

Adds tests:
- Mock provider regression (catches the hasattr footgun).
- Provider returning None skips signing.
- covers_content_digest='forbidden' omits content-digest coverage.

Tests: 18/18 in the new suite. Full suite: 2191 passed (was 2188).

Review: #272
Flagged by python-expert + code-reviewer subagents.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bokelley and others added 2 commits April 24, 2026 20:06
Python 3.11 mypy in CI inferred `capability: RequestSigning` from the
first assignment branch (`seller_capability is not None`), then rejected
the second branch where `capability = await result` produces
`RequestSigning | None`. The local mypy run with implicit-reveal
inferred the union and let it through.

Annotate `capability: RequestSigning | None` at first reference so the
union is the explicit ground truth on both branches. No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
a2a-sdk 1.0.2 (released 2026-04-24) calls `field.label` on the
protobuf C-extension FieldDescriptor in `proto_utils.py:217`. Newer
protobuf releases (current 7.34.1) no longer expose `label` on the
upb-backed FieldDescriptor, so every A2A test that exercises
proto_utils fails with:

    AttributeError: 'google._upb._message.FieldDescriptor' object has no attribute 'label'

CI started installing 1.0.2 transitively the moment it dropped, which
took #272 red on Python 3.11 (12 A2A tests failing). The signing
work in this PR is unaffected — failures are 100% pre-existing
A2A integration tests broken by the dependency bump.

Pin <1.0.2 with an explanatory comment so the next maintainer can
lift the bound once a2a-sdk ships a fix. This is an unrelated
infrastructure pin bundled into this PR purely to unblock CI;
fine to revert once the upstream lands a release that handles
the new protobuf FieldDescriptor surface.

Verified locally: full 2191-test suite passes with a2a-sdk 1.0.1,
fails on a2a-sdk 1.0.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bokelley
Copy link
Copy Markdown
Contributor Author

CI fix landed: `5ff17696` pins `a2a-sdk<1.0.2` to work around an upstream regression that started breaking CI here (and presumably any other PR opened against `main`) the moment `a2a-sdk 1.0.2` was published.

Root cause: `a2a-sdk 1.0.2` (`proto_utils.py:217`) calls `field.label` on the protobuf C-extension `FieldDescriptor`. Newer protobuf releases (current 7.34.1) no longer expose `label` on the upb-backed `FieldDescriptor`, so 12 A2A tests fail with:

```
AttributeError: 'google._upb._message.FieldDescriptor' object has no attribute 'label'
```

I confirmed locally — full 2191-test suite passes with `a2a-sdk 1.0.1`, fails with `a2a-sdk 1.0.2`. None of the failing tests touch this PR's signing code.

The pin is unrelated to the actual signing work in this PR. Happy to:

  1. Leave it bundled here so the PR can merge as-is, with a separate follow-up to lift the bound when `a2a-sdk` ships a fix.
  2. Extract it to a tiny standalone PR you can land first, then I rebase.

Whichever's easier — let me know.

@bokelley bokelley merged commit 52019b8 into main Apr 25, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant