Summary
adcp.decisioning.dispatch._invoke_platform_method wraps any uncaught exception from a platform delegate as INTERNAL_ERROR with recovery="terminal". For genuine internals (database failures, NoneType bugs, etc.) that's correct.
But it also catches pydantic.ValidationError, which is request-validation failure — semantically INVALID_REQUEST with recovery="correctable". The current shape is technically wrong (terminal vs correctable, internal vs request) and the wire envelope on A2A specifically degrades to a generic "Task failed" message that strips the field information a buyer needs to fix the request.
Repro
A buyer sends an update_media_buy patch with a field shape that fails the platform's stricter request schema (e.g., the platform extends the library type with extra="forbid" and the buyer includes an extra field, or a type mismatch in a nested object). The delegate calls RequestModel.model_validate(patch), Pydantic raises ValidationError, no caller catches it.
Wire result on A2A: "Task failed" (no adcp_error.code, no field, no recovery hint).
Wire result on MCP: INTERNAL_ERROR: "Platform method 'update_media_buy' raised ValidationError" (terminal recovery — wrong; buyer can correct the payload).
Proposed framework behavior
In _invoke_platform_method (or wherever the catch-all wrapping lives), distinguish pydantic.ValidationError from generic Exception:
except pydantic.ValidationError as exc:
raise AdcpError(
code="INVALID_REQUEST",
message=_first_error_message(exc),
recovery="correctable",
field=_first_error_loc_as_dotted_path(exc),
details={"validation_errors": exc.errors(include_url=False)},
)
except Exception as exc:
raise AdcpError(code="INTERNAL_ERROR", ..., recovery="terminal")
This matches the framework's own behavior on request-body validation failures (when the JSON body fails the library schema, the framework already emits the proper INVALID_REQUEST envelope). The bug is just that ValidationErrors raised inside the delegate — typically from a seller's extended request schema — aren't routed the same way.
Workaround (already shipped seller-side)
We extended our @translate_adcp_errors decorator at core/platforms/_delegate.py to also catch pydantic.ValidationError and translate to INVALID_REQUEST before it escapes to the framework: bokelley/salesagent#330.
Every seller that extends RequestModel (which is the recommended pattern in the SDK docs — LibraryX as ParentX; class X(ParentX): ...) will hit this same gap. Fixing at the framework level prevents the eight-line-of-boilerplate-per-seller workaround.
Tests
Cross-transport contract test pattern that exercises real ASGI envelopes:
import httpx
from core.main import build_app
async def test_validation_error_translates_to_invalid_request(transport):
app = build_app(...)
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app)) as client:
response = await client.post(transport_url, json=bad_request_body)
adcp_error = extract_adcp_error_from_envelope(response, transport)
assert adcp_error["code"] == "INVALID_REQUEST"
assert adcp_error["recovery"] == "correctable"
assert adcp_error["field"] # populated from validation errors
(See bokelley/salesagent#330 — tests/integration/test_delegate_wire_envelope_cross_transport.py — for a working version.)
Environment
adcp Python SDK: pinned per salesagent's uv.lock (2026-05-11)
- AdCP spec: 3.0.9
Summary
adcp.decisioning.dispatch._invoke_platform_methodwraps any uncaught exception from a platform delegate asINTERNAL_ERRORwithrecovery="terminal". For genuine internals (database failures, NoneType bugs, etc.) that's correct.But it also catches
pydantic.ValidationError, which is request-validation failure — semanticallyINVALID_REQUESTwithrecovery="correctable". The current shape is technically wrong (terminal vs correctable, internal vs request) and the wire envelope on A2A specifically degrades to a generic"Task failed"message that strips the field information a buyer needs to fix the request.Repro
A buyer sends an update_media_buy patch with a field shape that fails the platform's stricter request schema (e.g., the platform extends the library type with
extra="forbid"and the buyer includes an extra field, or a type mismatch in a nested object). The delegate callsRequestModel.model_validate(patch), Pydantic raisesValidationError, no caller catches it.Wire result on A2A:
"Task failed"(noadcp_error.code, nofield, no recovery hint).Wire result on MCP:
INTERNAL_ERROR: "Platform method 'update_media_buy' raised ValidationError"(terminal recovery — wrong; buyer can correct the payload).Proposed framework behavior
In
_invoke_platform_method(or wherever the catch-all wrapping lives), distinguishpydantic.ValidationErrorfrom genericException:This matches the framework's own behavior on request-body validation failures (when the JSON body fails the library schema, the framework already emits the proper
INVALID_REQUESTenvelope). The bug is just thatValidationErrors raised inside the delegate — typically from a seller's extended request schema — aren't routed the same way.Workaround (already shipped seller-side)
We extended our
@translate_adcp_errorsdecorator atcore/platforms/_delegate.pyto also catchpydantic.ValidationErrorand translate toINVALID_REQUESTbefore it escapes to the framework: bokelley/salesagent#330.Every seller that extends
RequestModel(which is the recommended pattern in the SDK docs —LibraryX as ParentX; class X(ParentX): ...) will hit this same gap. Fixing at the framework level prevents the eight-line-of-boilerplate-per-seller workaround.Tests
Cross-transport contract test pattern that exercises real ASGI envelopes:
(See bokelley/salesagent#330 —
tests/integration/test_delegate_wire_envelope_cross_transport.py— for a working version.)Environment
adcpPython SDK: pinned per salesagent'suv.lock(2026-05-11)