Repository navigation
Nine rows of REQUEST_TO_WEBHOOK_CODE are unreachable — an if-chain above the lookup decides instead #1354
Description
What is wrong
adcp.signing.errors.REQUEST_TO_WEBHOOK_CODE is the table a reader consults to learn
which webhook-profile code the verifier emits for a given request-family failure. Nine of
its 29 rows never execute. _retag_to_webhook
(src/adcp/signing/webhook_verifier.py:290-307) tests a prefix and a four-element set
before it reads the table, and collapses all nine key-discovery codes to
webhook_signature_key_unknown:
def _retag_to_webhook(exc: SignatureVerificationError) -> SignatureVerificationError:
webhook_code: str | None
if exc.code.startswith("request_signature_brand_") or exc.code in {
"request_signature_agent_not_in_brand_json",
"request_signature_capabilities_unreachable",
"request_signature_key_origin_mismatch",
"request_signature_key_origin_missing",
}:
logger.warning("webhook key discovery failed: %s", exc.code)
webhook_code = WEBHOOK_SIGNATURE_KEY_UNKNOWN
else:
webhook_code = REQUEST_TO_WEBHOOK_CODE.get(exc.code)The nine rows, and which branch shadows each
Row (errors.py:157-165) |
Webhook code the table declares | Shadowed by | Code actually emitted |
|---|---|---|---|
request_signature_brand_json_url_missing |
webhook_signature_brand_json_url_missing |
startswith("request_signature_brand_") |
webhook_signature_key_unknown |
request_signature_brand_json_unreachable |
webhook_signature_brand_json_unreachable |
startswith("request_signature_brand_") |
webhook_signature_key_unknown |
request_signature_brand_json_malformed |
webhook_signature_brand_json_malformed |
startswith("request_signature_brand_") |
webhook_signature_key_unknown |
request_signature_brand_json_ambiguous |
webhook_signature_brand_json_ambiguous |
startswith("request_signature_brand_") |
webhook_signature_key_unknown |
request_signature_brand_origin_mismatch |
webhook_signature_brand_origin_mismatch |
startswith("request_signature_brand_") |
webhook_signature_key_unknown |
request_signature_agent_not_in_brand_json |
webhook_signature_agent_not_in_brand_json |
set member 1 | webhook_signature_key_unknown |
request_signature_capabilities_unreachable |
webhook_signature_capabilities_unreachable |
set member 2 | webhook_signature_key_unknown |
request_signature_key_origin_mismatch |
webhook_signature_key_origin_mismatch |
set member 3 | webhook_signature_key_unknown |
request_signature_key_origin_missing |
webhook_signature_key_origin_missing |
set member 4 | webhook_signature_key_unknown |
Five rows die to the prefix test; four to the explicit set. The nine
webhook_signature_* strings in the right-hand column are declared in errors.py, named
by the table, and produced by nothing.
Run every row through the function that reads it:
from adcp.signing.errors import REQUEST_TO_WEBHOOK_CODE, SignatureVerificationError
from adcp.signing.webhook_verifier import _retag_to_webhook
for request_code, table_code in sorted(REQUEST_TO_WEBHOOK_CODE.items()):
emitted = _retag_to_webhook(SignatureVerificationError(request_code)).code
if emitted != table_code:
print(f"DEAD {request_code:<45} table={table_code:<45} emitted={emitted}")Against adcp==8.0.0:
DEAD request_signature_agent_not_in_brand_json table=webhook_signature_agent_not_in_brand_json emitted=webhook_signature_key_unknown
DEAD request_signature_brand_json_ambiguous table=webhook_signature_brand_json_ambiguous emitted=webhook_signature_key_unknown
DEAD request_signature_brand_json_malformed table=webhook_signature_brand_json_malformed emitted=webhook_signature_key_unknown
DEAD request_signature_brand_json_unreachable table=webhook_signature_brand_json_unreachable emitted=webhook_signature_key_unknown
DEAD request_signature_brand_json_url_missing table=webhook_signature_brand_json_url_missing emitted=webhook_signature_key_unknown
DEAD request_signature_brand_origin_mismatch table=webhook_signature_brand_origin_mismatch emitted=webhook_signature_key_unknown
DEAD request_signature_capabilities_unreachable table=webhook_signature_capabilities_unreachable emitted=webhook_signature_key_unknown
DEAD request_signature_key_origin_mismatch table=webhook_signature_key_origin_mismatch emitted=webhook_signature_key_unknown
DEAD request_signature_key_origin_missing table=webhook_signature_key_origin_missing emitted=webhook_signature_key_unknown
dead rows = 9 / 29
Which side is right: all nine rows disagree with the spec, and the if-chain matches it
AdCP 3.2.1 security.mdx gives the webhook key-discovery chain one rejection code for
all four of its hops. § "Webhook callbacks" → JWKS discovery:
JWKS discovery. The buyer knows the seller's agent URL from the AdCP integration
it's already using. Buyer resolves:
- Seller agent URL
A→ fetch/.well-known/brand.jsonat the operator domain ofA
with SSRF validation per Webhook URL validation. […]- In the fetched brand.json, find the
agents[]entry whoseurlbyte-for-byte
matchesA.- Fetch that entry's
jwks_uri(or default to/.well-known/jwks.jsonat the origin
ofA) with SSRF validation. […]- Resolve
keyidon the incomingSignature-Inputto a JWK in the fetched set. On
kidmiss, refetch once (subject to the 30-second cooldown between refetches)
before rejecting withwebhook_signature_key_unknown. […]
No code is named for hops 1, 2 or 3. Webhook verifier checklist step 7 repeats it and
folds the agents[] resolution in explicitly:
- Resolve
keyidto a JWK via the JWKS discovery steps above. Onkidmiss, refetch
once (30-second cooldown between refetches) before rejecting
(webhook_signature_key_unknown). Reject ifkeyidcannot be resolved to a specific
agents[]entry in the signer's brand.json.
§ "Webhook error taxonomy" then enumerates the profile's codes — 17 rows — and carries no
brand.json, capabilities or key-origin row:
Failure Code SignatureorSignature-Inputheader malformed or one without the otherwebhook_signature_header_malformedRequired sig-param absent webhook_signature_params_incompletetagnotadcp/webhook-signing/v1webhook_signature_tag_invalidalgnot in allowlistwebhook_signature_alg_not_allowedSignature window invalid webhook_signature_window_invalidRequired covered components missing (including content-digest)webhook_signature_components_incompletekeyidnot in seller JWKS after one refetchwebhook_signature_key_unknownJWK adcp_use∉ {webhook-signing,request-signing}, absent, orkey_opslacksverifywebhook_signature_key_purpose_invalidkeyid∈revoked_kidswebhook_signature_key_revokedRevocation list not refreshed within grace webhook_signature_revocation_staleCryptographic verification failed webhook_signature_invalidcontent-digestmismatchwebhook_signature_digest_mismatchBody contains duplicate object keys (parser-differential attack class) webhook_body_malformed@authoritydoes not match signed@target-uriauthority component (cross-vhost replay)webhook_target_uri_malformedNonce already seen within window webhook_signature_replayedPer-keyid replay cache exceeded cap webhook_signature_rate_abuseRegistered auth mode does not match signature mode on received webhook webhook_mode_mismatch
The asymmetry with the request profile is deliberate and visible in the spec: the request
profile has its own discovery-chain rejection-code table giving every hop a distinct code
and a structured detail shape —
|
request_signature_capabilities_unreachable| Capabilities fetch failed (DNS, TCP, TLS, timeout, non-2xx) |agent_url,http_status,dns_error,last_attempt_at| […] |
|request_signature_brand_json_malformed| brand.json failed strict-parse (duplicate keys, body cap exceeded, or non-JSON content) |brand_json_url,parse_error| […] |
|request_signature_brand_origin_mismatch| Agent eTLD+1 ≠brand_json_urleTLD+1 andauthorized_operators[]does not delegate |agent_url,agent_etld1,brand_json_url_etld1| […] |
|request_signature_agent_not_in_brand_json| Canonical Agent URL does not match any canonicalagents[].urlin resolved brand.json |agent_url,brand_json_url| […] |
|request_signature_key_origin_mismatch| Resolvedjwks_urihost ≠ declaredidentity.key_origins.{purpose}|purpose,expected_origin,actual_origin| […] |
A webhook sender learns that its key did not resolve, and nothing about the receiver's
walk to reach that conclusion.
Verdict, per row: all nine disagree with 3.2.1. Each declares a webhook code the
taxonomy does not define. The conformance corpus confirms it — in
adcp/_compliance/3.2.1/, all nine request_signature_* discovery codes appear and none
of the nine webhook_signature_* mirrors appears anywhere:
$ grep -rho "webhook_signature_[a-z_]*" src/adcp/_compliance/3.2.1/ | sort -u
webhook_signature_alg_not_allowed webhook_signature_key_revoked
webhook_signature_components_incomplete webhook_signature_key_unknown
webhook_signature_digest_mismatch webhook_signature_params_incomplete
webhook_signature_header_malformed webhook_signature_rate_abuse
webhook_signature_invalid webhook_signature_replayed
webhook_signature_key_purpose_invalid webhook_signature_revocation_stale
webhook_signature_valid webhook_signature_tag_invalid
webhook_signature_window_invalid
So the behavior on the wire is correct today, and the table is the artifact that is wrong.
The sentence that most likely produced the nine mirrors is the taxonomy section's own lead:
"Codes parallel the request-signing error taxonomy, prefixed
webhook_". The table that follows it is the enumeration, and the checklist is the
binding rule.
The rule the if-chain encoded, and how a table expresses it. The if-chain's content was
"every key-discovery hop rejects with one code". A table states that by mapping nine keys
to one value — collapsing inside the table rather than above it. The one thing the
if-chain carried that a bare mapping does not is the diagnostic log: the operator needs the
real cause even though the sender does not get it. That is derivable from the table and
needs no second list — a collapse is exactly a row whose webhook code does not keep the
request code's suffix:
webhook_code: str | None = REQUEST_TO_WEBHOOK_CODE.get(exc.code)
if webhook_code is not None and webhook_code.removeprefix("webhook_") != exc.code.removeprefix(
"request_"
):
logger.warning("webhook profile has no code for %r; emitting %r", exc.code, webhook_code)Why this is API DESIGN: the fourth instance of one rule with several spellings
A table and a conditional branch both decide the same mapping, and the branch wins. The
table is the auditable artifact — a maintainer adding a webhook conformance test reads it,
and nine of its rows tell them to expect codes the verifier cannot produce. Nothing in
errors.py says the lookup is conditional.
This is the fourth defect of this exact shape in src/adcp/signing/ and its callers:
- Three independent
WWW-Authenticateemitters, one of them emitting the wrong
challenge. - Two address gates making non-equivalent decisions, the weaker one guarding
publisher-supplied input. - The duplicate-key rule spelled per-header, which left the header carrying the
credential unchecked. - This table, shadowed by an if-chain.
Each is one rule with several spellings, where adding another is easy and getting one
wrong is invisible. Fixing the instance is cheap; what stops the fifth is making one
artifact the decider and giving it a test that fails when a second decider appears.
In this subsystem the rule has four spellings, not two. Besides the table and the
if-chain:
verify_webhook_from_agent_url(webhook_verifier.py:421-430) builds its own
inline{resolver code -> request code}dict purely to compose a log string, then
raisesWEBHOOK_SIGNATURE_KEY_UNKNOWNunconditionally. A third author wrote the
collapse a third time — independent corroboration that the collapse is the intended
behavior.check_key_origin_consistency(code_family="webhook")(key_origins.py:56-63) keeps
two dicts of its own and raiseswebhook_signature_key_origin_mismatch/_missing
directly. This is the spelling that disagrees with the other three, and it emits two
of the nine codes the taxonomy does not define. No caller in the package passes
code_family="webhook":
$ grep -rn "code_family" src/
src/adcp/signing/key_origins.py:30 ``webhook_signature_key_origin_*`` codes; pass ``code_family="webhook"``
src/adcp/signing/key_origins.py:72 code_family: CodeFamily = "request",
src/adcp/signing/key_origins.py:98 code_family:
src/adcp/signing/key_origins.py:121 _MISSING_CODE[code_family],
src/adcp/signing/key_origins.py:134 _MISMATCH_CODE[code_family],
The two production call sites (verifier.py:801,809, agent_resolver.py:502,506) all take
the default "request".
Consequence for a consumer
A receiver cannot learn the webhook profile's emitted taxonomy by reading the table that
exists to publish it. Nine of the strings it names are unreachable, two of those are
reachable only through a public parameter no code path uses, and nothing signals that any
of this is the case. Code written from the table — a conformance assertion, a match over
error codes, an alert routing rule — is written against codes that never arrive.
Fix
Submitted as a PR: the nine rows map to WEBHOOK_SIGNATURE_KEY_UNKNOWN, the if-chain
goes, the nine unreachable constants go, key_origins.py reads the webhook family from
the same table, and a table-driven test grades every row through _retag_to_webhook so a
future second decider fails the build.
Adjacent, not fixed here
The webhook taxonomy in the spec also omits webhook twins for four request codes the SDK
declares and the table maps — webhook_signature_required,
webhook_signature_components_unexpected, webhook_signature_jwks_unavailable,
webhook_signature_jwks_untrusted. Those read as spec omissions rather than SDK
inventions (a webhook arriving unsigned on a route that requires a signature needs a code),
and all four are reachable today. Left alone.
Activity
bokelley commentedon Oct 3, 2026
Triage
Classification: Bug
Bucket(s): signing (no matching label in repo)
Status: deferred
Duplicate / open-PR gate: PR #1370 (
fix(signing)!: make REQUEST_TO_WEBHOOK_CODE the only webhook code decider) already closes this issue and was opened by the same author moments after filing.Blocked-on: #1370 — resurfaces on merge.
Note: PR #1373 (
fix(signing): report the discovery-chain code the spec assigns each resolver failure) closes a separate related issue (#1358) and touches overlapping files; itsmergeable_stateis currentlyblocked, likely due to ordering with #1370.Triaged by Claude Code. Session: https://claude.ai/code/session_01Y8VGLDLt9ecem7ojEs1vrw
Generated by Claude Code
fix(signing)!: make REQUEST_TO_WEBHOOK_CODE the only webhook code dec…