|
| 1 | +# Agent resolution and publisher pins in AdCP 3.3 |
| 2 | + |
| 3 | +This implementation follows [adcp#7882](https://github.com/adcontextprotocol/adcp/pull/7882) |
| 4 | +at commit `175bcd3b023244aeb97dbdcce8fed674ebd61302`. The protocol change |
| 5 | +is still under review. Historical bundled 3.1/3.2 schemas retain their published |
| 6 | +verifier constraints; these helpers implement the new canonical matching rules. |
| 7 | + |
| 8 | +Agent URLs are the primary selector. Scheme/host case, default ports, dot |
| 9 | +segments, and percent-encoded unreserved characters normalize; trailing slashes |
| 10 | +and tenant paths remain significant. Optional type and id selectors only narrow |
| 11 | +a URL match. Duplicate canonical matches within one collection fail, including |
| 12 | +duplicates with identical ids. House Portfolio operator discovery scans |
| 13 | +`house.agents[]` and all inline `brands[].agents[]`; repeated attestations across |
| 14 | +collections count once when their type and resolved JWKS source agree. |
| 15 | + |
| 16 | +Direct resolver construction and the shared resolver builder now require |
| 17 | +`agent_url`: |
| 18 | + |
| 19 | +```python |
| 20 | +from adcp.signing import BrandJsonJwksResolver |
| 21 | + |
| 22 | +resolver = BrandJsonJwksResolver( |
| 23 | + "https://operator.example.com/brand.json", |
| 24 | + agent_url="https://operator.example.com/sales", |
| 25 | + agent_type="sales", |
| 26 | +) |
| 27 | +``` |
| 28 | + |
| 29 | +Direct resolvers use operator-style collection selection, including all sibling |
| 30 | +collections in a House Portfolio unless `brand_id` selects one inline brand. |
| 31 | +For a relying-party record, select the surface's applicable collection explicitly; |
| 32 | +use `resolve_governance_jwks` for governance rather than an unscoped direct resolver. |
| 33 | +For operator discovery, `async_resolve_agent` and `verify_from_agent_url` invoke |
| 34 | +`get_adcp_capabilities` via MCP by default, or A2A with `protocol="a2a"`. They use |
| 35 | +the advertised `identity.brand_json_url`, enforce origin binding, and check every |
| 36 | +declared key origin. Cross-domain origin binding accepts `authorized_operators` |
| 37 | +only on a House Portfolio; its account-level brand/country scopes do not restrict |
| 38 | +key discovery. Discovery does not reuse onboarding mappings, so each call |
| 39 | +reconfirms the advertised operator record. |
| 40 | + |
| 41 | +[Discovery step 7](https://github.com/adcontextprotocol/adcp/blob/175bcd3b023244aeb97dbdcce8fed674ebd61302/docs/building/by-layer/L1/security.mdx#L1331) |
| 42 | +checks every advertised `identity.key_origins` purpose against the selected |
| 43 | +agent's JWKS source, including purposes other than the signature being verified. |
| 44 | +A different host for any purpose rejects discovery with |
| 45 | +`request_signature_key_origin_mismatch`, even when the active purpose matches. |
| 46 | +The SDK follows this explicit all-purpose rule. The draft's separate guidance |
| 47 | +on origin separation remains in tension with that rule pending clarification. |
| 48 | + |
| 49 | +For webhooks, use the asynchronous discovery helper: |
| 50 | + |
| 51 | +```python |
| 52 | +from adcp.webhooks import verify_webhook_from_agent_url |
| 53 | + |
| 54 | +sender = await verify_webhook_from_agent_url( |
| 55 | + method=request.method, |
| 56 | + url=str(request.url), |
| 57 | + headers=request.headers, |
| 58 | + body=await request.body(), |
| 59 | + agent_url=media_buy.seller_url, |
| 60 | + publisher_domains=media_buy.publisher_domains, |
| 61 | +) |
| 62 | +``` |
| 63 | + |
| 64 | +The seller URL and applicable publishers must come from your own media-buy |
| 65 | +record. The helper discovers the operator's JWKS, intersects it with every |
| 66 | +applicable publisher's `adagents.json` pin, and refreshes publisher records before |
| 67 | +rejecting a pin mismatch. A pin never supplies a key missing from the operator |
| 68 | +JWKS. Matching uses RFC 7638 thumbprints, so a `kid` alone or a different key with |
| 69 | +the same `kid` does not qualify. Pins reject signatures created at or after their |
| 70 | +`revoked_at` timestamp. Operator key-origin checks |
| 71 | +still apply. An unknown `kid` triggers one JWKS refetch, with a 30-second cooldown. |
| 72 | + |
| 73 | +Sellers omitting `identity.brand_json_url` use the 3.x fallback: first their host's |
| 74 | +`/.well-known/brand.json`, then the registrable domain on a host 404. This path |
| 75 | +skips the advertised-record origin and key-origin checks. A malformed or |
| 76 | +unreachable advertised record never downgrades to the fallback. Webhook discovery |
| 77 | +failures return `webhook_signature_key_unknown` and log the specific |
| 78 | +`request_signature_*` cause. Fallback document resolution follows at most one |
| 79 | +`authoritative_location` or `house` indirection and rejects indirection chains. |
| 80 | + |
| 81 | +For a receiver that already resolves operator keys, `WebhookVerifyOptions` |
| 82 | +accepts `publisher_pins` keyed by the applicable publisher domain. Supply |
| 83 | +`refresh_publisher_pins`, a synchronous callback returning fresh pins for the same |
| 84 | +publisher set. A missing pin uses `None`; an empty pin accepts no key. Configure |
| 85 | +the operator JWKS resolver and capabilities `expected_key_origins` alongside it. |
| 86 | + |
| 87 | +Governance issuer matching is canonical. `resolve_governance_jwks` takes the |
| 88 | +authenticated buyer's exact record, `issuer`, and the governed request's |
| 89 | +`brand_domain`. It selects one applicable brand collection, preserving explicit |
| 90 | +brand overrides, and never searches sibling brands. For signed requests, pass |
| 91 | +`VerifiedSigner.operator_brand_json`, which `verify_from_agent_url` retains from |
| 92 | +discovery. Do not refetch a different document from the operator's host. |
0 commit comments