fix: A2A top-level failures return failed Task with AdCP envelope, not JSON-RPC InternalError - #1547
fix: A2A top-level failures return failed Task with AdCP envelope, not JSON-RPC InternalError#1547numarasSigmaSoftware wants to merge 154 commits into
Conversation
…t JSON-RPC InternalError Spec: AdCP 3.1.x building/operating/transport-errors.mdx 'Layer Separation' — application/task failures belong in the task response body as a failed Task carrying the two-layer error envelope; JSON-RPC errors are reserved for genuine transport faults. on_message_send's outer exception handler built the correct failed Task with the processing_error DataPart, then discarded it and raised InternalError. It now returns the failed Task; A2AError still re-raises onto JSON-RPC. Typed AdCPErrors keep their wire codes; untyped failures normalize to the standard SERVICE_UNAVAILABLE mapping (ERROR_CODE_MAPPING INTERNAL_ERROR -> SERVICE_UNAVAILABLE). Also fixes a latent NameError risk (identity initialized before the try block). New wire-envelope tests (test_a2a_error_routing.py) plus de-pinned tests that asserted the old raise; shared envelope-extraction and NL-request helpers added to tests/a2a_helpers.py. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review: correct fix, right spec grounding — but not mergeable until the behavior is tested at the right altitudeThe core change is right and it's grounded. I verified it against the pinned What's missing is not correctness of the mechanism — it's that the behavior this 1. The outer
|
|
Re-reviewed at 8fca519. The prior review's blocker is closed: the change is now regression-locked at both the integration and BDD altitude. I verified the locks by reverting the fix — the integration test then errors with Non-blocking, worth a quick pass:
Separately (pre-existing, not this PR): the protocol-webhook delivery path ( |
- Reword UNSUPPORTED_FEATURE recovery comment: 'correctable' matches AdCP error-code.json enumMetadata — it is spec-conformant, not a divergence. - make_nl_send_message_request now wraps create_a2a_text_message so there is one NL Message builder; integration and BDD sites use it (ratchets the test duplication baseline 89 -> 88). - New generic BDD Then step asserts the two-layer envelope on ctx['wire_error_envelope'] via assert_envelope_shape, wired into the @T-UC-002-ext-nl-unsupported scenario (mutation-checked: wrong recovery fails the scenario). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Addressed the three non-blocking items in e6ef756:
Verified: On the pre-existing protocol-webhook SSRF gap ( |
KonstantinMirin
left a comment
There was a problem hiding this comment.
Review — PR #1547
Overview — The core fix is correct and well-grounded: an untyped top-level failure in on_message_send now returns a failed Task carrying the two-layer AdCP envelope instead of raising a JSON-RPC InternalError, exactly as AdCP 3.1.0-beta.3 building/operating/transport-errors.mdx §"Layer Separation" (dist/docs/3.1.0-beta.3/...) mandates. Every prior-round item is resolved — the webhook-reason drop is fixed via a shared _fail_task_with_webhook with a required error=, the identity=None hoist is now regression-locked, and the behavior is graded live at both the integration and BDD altitude (revert-verified by two maintainers). What remains is one within-scope DRY pattern: the "read the failed-Task envelope" operation is now implemented three different ways.
Should fix
Failed-Task envelope read is reimplemented instead of shared (DRY). This PR adds a new reader and a new hand-rolled dispatch that both duplicate logic that already exists, and it splits the read across test layers so the artifact-name contract is pinned in only one of them. Three sites of one pattern:
tests/a2a_helpers.pyextract_processing_error_envelopere-implements the protobuf decode linejson.loads(json_format.MessageToJson(part.data))that is the entire body of the pre-existingtests/utils/a2a_helpers.py::extract_data_from_artifact. The new helper's real value is its assertions (artifact present +name == "processing_error"+ single DataPart); the decode should delegate:# extract_processing_error_envelope(task): keep the assertions, then return extract_data_from_artifact(task.artifacts[0])
- The reader is split by layer: unit tests call the strict
extract_processing_error_envelope(asserts theprocessing_errorartifact name), while the new integration test and the new BDD when-step read the same wire artifact with the looserextract_data_from_artifactand never assert the artifact-name / single-DataPart shape — so the contract this PR relies on is pinned only at the unit altitude, not at the two altitudes graded as the merge bar. After (1) makes the strict reader delegate, route the new integration/BDD sites through it so all three altitudes pin the same shape. - The new BDD when-step (
tests/bdd/steps/domain/uc002_create_media_buy.py::when_buyer_sends_nl_a2a_request) hand-rolls the handler build +_get_auth_token/_resolve_a2a_identityinjection +asyncio.run(on_message_send(...))+ theTASK_STATE_FAILED → extract_data_from_artifact → _envelope_to_adcp_errorreconstruction — which is exactly the failed-Task branch oftests/harness/_base.py::_run_a2a_handler. The harness genuinely has no NL entry point today, so this is gap-driven rather than a lazy copy — but the reconstruction block will drift against the harness reader. Extract a shared_read_failed_a2a_task(task) -> (envelope, error)intests/harness/_base.pyand call it from both_run_a2a_handlerand the when-step; ideally give the harness an NL dispatch path so the when-step dispatches through a transport per the project's "BDD dispatches through the harness" convention.
Call to action: land (1) as a trivial delegate, route the new sites through the strict reader for (2), and factor the shared failed-Task reader for (3) — so the next edit to the wire shape changes one place, not three.
Nice to have
extract_processing_error_envelopeandmake_nl_send_message_request(a message builder) live intests/a2a_helpers.py, while the established A2A message builders (create_a2a_text_message,create_a2a_message_with_skill) andextract_data_from_artifactlive intests/utils/a2a_helpers.py. Two importable modules share the basenamea2a_helpers, so every import has to disambiguate by path. Co-locating the new NL builder / strict reader with the existing family makes A2A helper construction one grep target.extract_processing_error_envelope(task) -> dictis untyped ontaskand returns a baredict;def extract_processing_error_envelope(task: Task) -> dict[str, Any]is more precise and matches the rest of the module.- Untyped internal crashes normalize to base
AdCPError→ wireSERVICE_UNAVAILABLEwithrecovery="terminal", whereas that code's canonical recovery indist/schemas/3.1.0-beta.3/enums/error-code.jsonistransient. This is not a spec violation — transport-errors.mdx §"Recovery Behavior" makes the explicitrecoveryfield authoritative, and "don't retry a genuine bug" is a defensible per-instance choice — but a one-line note at the base_default_recoveryciting that section would record it as deliberate rather than incidental. - The all-skills-failed branch now routes through
_fail_task_with_webhook("; ".join(error_messages))but has no dedicated assertion that it still forwards the joined reason; the new top-level site is pinned, this leg isn't. A one-lineerror=assertion in the existing all-skills-failed test closes the single point where a future edit could silently drop that reason.
Notes / prior-review follow-ups
- Prior blocker (wrong altitude / not grounded in a scenario) — resolved:
@T-UC-002-ext-nl-unsupportedruns live on the A2A leg (not xfail) and the matching integration case drives the real pipeline; both assert on the wire envelope and were revert-verified. - Webhook drops the failure reason (prior item #2) — resolved: both emission sites route through
_fail_task_with_webhook(task, error)witherrorrequired. - Untested
identity=Nonehoist (prior item #3) — resolved:test_auth_extraction_failure_returns_failed_task_before_identity_resolutionlocks it. - ChrisHuie's three follow-up items (comment reword, single NL builder, BDD wire-envelope hardening) — resolved in
e6ef756a5; the NL builder now delegates tocreate_a2a_text_messageand the duplication baseline ratcheted 89 → 88. - A2A-only scoping of the BDD scenario is correct here (JSON-RPC-vs-Task is an A2A-only distinction); cross-transport widening is tracked in #1574 / #1430 and not re-raised.
- The protocol-webhook SSRF gap noted earlier is pre-existing and out of this PR's scope; being filed separately.
Addresses KonstantinMirin's round-2 review on prebid#1547 (one should-fix, four nice-to-haves): - extract_processing_error_envelope keeps its artifact-contract assertions (processing_error name + single DataPart) but delegates the protobuf decode to extract_data_from_artifact instead of re-implementing it; typed (task: Task) -> dict[str, Any]. - The new integration test and the BDD NL when-step now read the failed Task through the strict reader, so the outer-handler artifact contract is pinned at unit, integration, AND bdd altitudes. - New shared _read_failed_a2a_task(task) -> (envelope, error) in tests/harness/_base.py wraps the TASK_STATE_FAILED -> envelope -> _envelope_to_adcp_error reconstruction; called by both _run_a2a_handler (loose read: skill failures carry error_result artifacts) and the BDD when-step (expect_processing_error=True). Harness fallback semantics preserved exactly. (The 'harness NL dispatch path' ideal is deferred per the review's own call-to-action scoping.) - PR-added helpers (strict reader, make_nl_send_message_request, make_mock_a2a_identity) co-located into tests/utils/a2a_helpers.py with the established A2A message-builder family; tests/a2a_helpers.py keeps only the pre-existing make_a2a_context. - _default_recovery='terminal' on the AdCPError base now cites transport-errors.mdx 'Recovery Behavior' recording the deliberate divergence from error-code.json's transient for SERVICE_UNAVAILABLE. - The all-skills-failed test now pins that _fail_task_with_webhook forwards the joined per-skill reason as the webhook error= kwarg. Verification: make quality (5199 passed, 8 skipped, 26 xfailed); tests/integration/test_a2a_error_responses.py 23 passed; test_idempotency_wire_matrix -k missing_key 2 passed (harness failed- branch canary); bdd uc002 -k unsupported 1 passed live; duplication ratchet unchanged (src 36 / tests 88 / scripts 0). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Addressed the round-2 review in 7d540d0. Should fix — one reader, three altitudes:
Nice-to-haves — all four landed:
Verification: |
Three precision follow-ups on the round-2 remediation, self-caught before re-review: - _read_failed_a2a_task: branch on expect_processing_error BEFORE the task.artifacts guard, so strict mode actually reaches the strict reader's 'artifact present' assertion on an artifact-less failed Task (previously that leg silently degraded to the loose fallback and the docstring overpromised). Loose-read semantics unchanged; the BDD when-step drops its now-dead None-guard. - make_mock_a2a_identity typed -> ResolvedIdentity (TYPE_CHECKING import) — was the only unannotated helper in the otherwise fully-typed tests/utils/a2a_helpers.py. - New test_all_skills_failed_webhook_carries_joined_reasons: two failed skill invocations in one message pin the '; '.join of webhook reasons — the single-skill integration assertion is degenerate for the join itself. Mutation-verified: reverting the join to first-message-only fails this test. Verification: make quality (5200 passed, 8 skipped, 26 xfailed); test_a2a_error_responses.py 23 passed; idempotency wire-matrix canary 2 passed; bdd uc002 unsupported 1 passed; strict/loose reader legs spot-checked (AssertionError vs AdCPError fallback). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Follow-up hardening in a5248fe — three precision gaps I caught self-reviewing 7d540d0 before your next pass:
|
|
Re-reviewed at a5248fe — all prior-round items addressed and diff-verified; the core fix is correct and regression-locked at both the integration and BDD altitude (both revert-verified). No blockers. Items by priority: SHOULD-FIX — the new BDD scenario is dropped on the next feature recompile. SHOULD-FIX / FOLD-IN — DRY on the new test helpers.
NIT — the strict-mode reorder in the last commit has no test. — the outer-handler comment overstates the spec. The comment (and PR body) say application/task-execution failures "MUST" return in the task response body, but the Layer Separation table lists "internal crash" under the transport (JSON-RPC) layer. The typed path ( Pre-existing, out of scope for this PR (worth a dedicated cleanup). The ~16× inline auth-mock setup in |
…er-leg pins Addresses ChrisHuie's re-review at a5248fe on prebid#1547: - BDD scenario survives recompiles (should-fix 1): @T-UC-002-ext-nl-unsupported now carries @hand-edited (+ # HAND-EDITED comment), which classify_scenario_pair maps to LEGACY-PRESERVE instead of LEGACY-DELETE on the next compile_bdd.py --merge. Verified with the compiler's own classifier; new guard test_bdd_scenario_merge_durability.py pins the classification so a dropped tag goes red instead of the scenario silently vanishing. - Helper DRY (should-fix 2): the three remaining byte-identical mock-identity constructions (test_a2a_transport_contract.py, test_a2a_create_media_buy_push_config_validation.py x2) now route through the shared helper; the _make_nl_request/_make_nl_message aliases are gone (direct calls); helper renamed make_mock_a2a_identity -> make_test_a2a_identity since it returns a real factory-built identity, not a unittest.mock. - Reader-leg pins (nit): direct tests for _read_failed_a2a_task — strict mode raises AssertionError on an artifact-less failed Task (mutation-verified: reverting the branch reorder fails it), loose mode keeps the (None, AdCPError) fallback. - Spec-accuracy (comment): the outer-handler comment now scopes the MUST to typed application failures per the Layer Separation table, and labels routing untyped internal crashes to the failed-Task body as a deliberate choice on SHOULD-level ground, not a spec mandate. Verification: make quality (5203 passed, 8 skipped, 26 xfailed); test_a2a_error_responses.py 23 passed; bdd uc002 unsupported 1 passed (with the new tag); idempotency wire-matrix canary 2 passed; classify_scenario_pair(scenario, None) == LEGACY-PRESERVE proven directly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Addressed the round-3 items in c51e76c: SHOULD-FIX — BDD scenario dropped on recompile: went with your first option — SHOULD-FIX — helper DRY: all three byte-identical copies now route through the shared helper ( NIT — strict-mode reorder untested: added the two direct pins — strict + artifact-less failed Task raises Comment/PR-body overstatement: the outer-handler comment now scopes the MUST to typed application failures per the Layer Separation table and explicitly labels the untyped-internal-crash routing a deliberate choice on SHOULD-level ground; the PR body's Spec Grounding paragraph is updated to match. Thanks for confirming the Pre-existing items: filed as #1597 (authed_handler fixture, ~24-file identity duplication, envelope-reader shadows, and the recovery-split / raw- Verification: |
Re-review — verified at head
|
…mments) Addresses the six non-blocking polish items from the head-51d7b331 re-review: - Rename residual `_MOCK_IDENTITY` -> `_TEST_IDENTITY` in test_a2a_transport_contract.py (~9 sites) to finish the make_test_a2a_identity rename; drop the stale `_MOCK_` label on a real factory identity. - Rename the merge-durability guard to test_architecture_bdd_scenario_merge_ durability.py so it's catalogued in the tests/unit/test_architecture_*.py glob. - Guard failure message now names the `# HAND-EDITED` comment path too, not just the @hand-edited tag (both are markers per _has_hand_edited_marker). - Add a bijection check: every @hand-edited/# HAND-EDITED scenario under tests/bdd/features/ must be registered and vice-versa, closing the "added a marked scenario, forgot to register it" gap the classify check alone can't see. Routed through the shared assert_violations_match_allowlist() helper to satisfy the no-handrolled-allowlist-diff meta-guard; mutation-verified (empty registry -> red). - Regenerate .duplication-baseline tests 88 -> 87 (the main merge reshaped conftest and dropped a block; baseline was stale). - Two spec-precision comment sweeps: scope _internal_error_for's Layer-Separation attribution to typed failures and hedge the untyped-normalized case as a deliberate SHOULD-level choice; cross-reference exceptions.py's _default_recovery note from the untyped SERVICE_UNAVAILABLE/terminal test comment. The pre-existing dead-defensive asserts in extract_processing_error_envelope are left with the prebid#1586 test-helper consolidation as noted. make quality: 5204 passed, 8 skipped, 26 xfailed; architecture guards 363 passed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Addressed all six polish items in 1. 2. Merge-durability guard — three changes:
3. 4. Spec-precision comment sweeps — both:
Follow-up (not this PR) — left the dead-defensive artifact-shape asserts in
|
… webhook
Addresses the two round-5 blockers plus the should-fix and nit.
BLOCKER 1 — skill failures escaped as JSON-RPC transport errors.
`_handle_explicit_skill` raised `MethodNotFoundError` (unknown skill) and
`UnsupportedOperationError` (unimplemented skills: approve_creative,
get_media_buy_status, optimize_media_buy, create_creative, assign_creative) —
both `A2AError`s that leaked onto the JSON-RPC layer even though the
`message/send` method is valid and routing failed inside application skill
dispatch. Per AdCP 3.1.0-beta.3 transport-errors.mdx "Layer Separation" these
are application-layer failures that belong in the task body. Now raise typed
`AdCPCapabilityNotSupportedError` (UNSUPPORTED_FEATURE/correctable), which the
dispatcher wraps into a failed-skill result — so accumulated results from
earlier skills in a multi-skill message are preserved, not discarded. The three
RPC-method-level `UnsupportedOperationError`s (list_tasks, resubscribe, extended
card) stay JSON-RPC. Removed the now-unused `MethodNotFoundError` import. Added
table-driven wire assertions across the dispatch registry.
BLOCKER 2 — immediate failed Tasks emitted a second, malformed webhook.
`_fail_task_with_webhook` rebuilt a Task from just the error string
(`{"error": "..."}`), dropping the code/recovery/artifacts, and fired for an
immediate terminal response that the a2a-guide.mdx terminal-state rule says
needs no webhook. Replaced with `_mark_task_failed` (marks FAILED, no webhook);
both immediate-failure paths now return the failed Task synchronously with no
notification. `_send_protocol_webhook` no longer flattens to `{"error": str}` —
for a `failed` final state it forwards the Task's structured artifact (the
two-layer envelope) via the new `_first_artifact_data`, so any genuinely async
failure webhook preserves structure. Added a zero-delivery test (immediate
failure) and a serialized-payload test (async failure); both mutation-verified.
SHOULD-FIX — BDD durability guard could not detect its claimed omission.
The marker-derived checks can't see a scenario missing BOTH a marker and a
registry entry. Added an independent-source check that derives the
hand-maintained candidate inventory from bdd-traceability.yaml (scenarios
grounded in spec prose — upstream_refs → *.mdx — which have no adcp-req render)
and requires each to be marked and registered. Mutation-verified against a
spec-grounded orphan scenario.
NIT — the untyped-crash test overstated the spec; its docstring now notes an
internal crash is transport-layer per the table and the failed-Task routing is
the project's deliberate uniform-envelope choice, not a mandate.
Updated all tests that codified the old JSON-RPC/webhook behavior
(test_error_format_consistency, test_a2a_handler_correctness,
test_a2a_skill_invocation, test_a2a_error_responses).
make quality: 5206 passed, 8 skipped, 26 xfailed; a2a integration + BDD green;
duplication baseline unchanged (tests 87).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Addressed both blockers, the should-fix, and the nit in [BLOCKER] Skill failures escaping as JSON-RPC — fixed. [BLOCKER] Second, malformed webhook — fixed. [SHOULD-FIX] Durability guard blind spot — fixed. [NIT] Untyped-crash test overstatement — fixed. Also updated every test that codified the old JSON-RPC/webhook behavior ( Verification: |
…h results Addresses the round-6 review (two blockers + should-fixes) at head 1bc4185. BLOCKER 1 — immediate completed tasks sent a duplicate webhook. on_message_send unconditionally webhooked the terminal status, so an auto-approved create_media_buy sent a completed webhook that both violated the a2a-guide.mdx terminal-state rule (no push when the initial response is already terminal) AND duplicated the async workflow-step completion webhook from the context manager. on_message_send now notifies ONLY for a non-terminal (submitted) initial response; the legitimate completed/failed webhook comes from the async approval/workflow transition, which the E2E tests now target. BLOCKER 2 — mixed submitted+failed batches discarded the failure envelope. The submitted scan short-circuited BEFORE artifacts were built, returning SUBMITTED with zero artifacts and dropping the failed skill's UNSUPPORTED_FEATURE envelope. Artifacts for all results are now built first; task status is chosen by precedence failed > submitted > completed, so a mixed batch is terminal-failed with every result preserved. Single-skill submitted keeps the "no artifacts until approved" convention. Regression-tested in both invocation orders. SHOULD-FIX — registry bijection: the dispatch map is hoisted to _skill_handler_map(); the transport suite asserts a registry↔test bijection (all 18 skills), drives every unimplemented stub with valid params to reach its terminal UNSUPPORTED_FEATURE branch, and pins the deliberate agent-card advertised subset (create_creative/assign_creative are registered but not advertised). SHOULD-FIX — unknown-skill observability: the unknown-skill check moved inside the logged boundary so record_boundary_error fires exactly once (asserted). SHOULD-FIX — lossy webhook / duplicate decoders: _first_artifact_data (first DataPart only) replaced by _task_artifacts_data (ALL artifacts, keyed by name), shared by completed-status detection and the webhook payload; the async-failure webhook test now verifies the REAL serialized wire Task (two DataParts survive), not the mocked builder. SHOULD-FIX — merge-durability guard: candidate discovery is now extension- agnostic (any spec-artifact ref — .mdx/.yaml/.yml/.json or URL), catching a .yaml-grounded orphan the .mdx-only rule missed. Updated all tests that codified the old behavior. Every production change is mutation-verified. Verification: unit 5228 passed; a2a integration 43; BDD nl-unsupported; E2E webhook-payload-types 10/10 on the Docker stack; duplication baseline unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Verified all seven findings against the code and fixed them in [BLOCKER] Immediate completed → duplicate webhook — fixed. [BLOCKER] Mixed submitted+failed dropped the failure envelope — fixed. Confirmed: the submitted scan short-circuited before artifacts were built (returning SUBMITTED + zero artifacts). Artifacts for all results are now built first; status is chosen by precedence failed > submitted > completed (preserve every result), so a mixed batch is terminal-failed with both the [SHOULD-FIX] 13/18 registry oracle — fixed. Hoisted the dispatch map to [SHOULD-FIX] Unknown skills bypassed observability — fixed. Confirmed the check raised before the logged [SHOULD-FIX] Lossy final webhook + duplicate decoders — fixed. Replaced [SHOULD-FIX] Guard ignored YAML/JSON-grounded scenarios — fixed. Candidate discovery is now extension-agnostic (any spec-artifact ref — [NIT→SHOULD-FIX] PR description — updated to match the head: no webhook for immediate terminal, skill failures → typed failed Tasks, current helpers, refreshed spec-grounding + verification. Verification: |
…(M5) D5: provenance pointers were unverified prose -- the vendored error-code.json fixture claimed to be the pinned v3.1.1 enum but had drifted on 7+ enumMetadata descriptions/suggestions (CREDENTIAL_IN_ARGS, IDEMPOTENCY_EXPIRED, MEDIA_BUY_NOT_FOUND, PACKAGE_NOT_FOUND, PERMISSION_DENIED, REQUOTE_REQUIRED, SIGNAL_NOT_FOUND) plus an unversioned $id, and 4 escape-hatch registry entries cited local beads ids an outside contributor can't resolve. - Re-vendor tests/fixtures/adcp_schemas_pinned/enums/error-code.json verbatim from `git -C ~/projects/adcp show v3.1.1:dist/schemas/3.1.1/enums/error-code.json` (checked for impact against PR prebid#1547's competing edit first -- its patch targets a stale pre-3.1.1 baseline, not a live conflict) - Add a SHA-256 completeness pin (test_guards_error_code_fixture_pin.py) so future drift is a red test, not a review find - Fix the two stale @04f59d2d5 citations in tests/harness/transport.py to @v3.1.1, matching exceptions.py's existing correction - File prebid#1871 for set_adapter_channels (an actionable, closable gap) and cite it in place of the unresolvable salesagent-689e; drop the salesagent-rldj citations on the three process-boundary-structural entries (set_supported_versions/set_build_version/set_idempotency_posture), matching the registry's own no-ticket precedent (break_tenant_config_db) - Add an enforcement guard: escape-hatch declarations can never cite a local beads id, with positive/negative meta-tests
Upstream prebid#1868 replaced the independently vendored schema fixture tree with the installed adcp SDK's own tree, and added a guard (test_pinned_schema_single_source.py) that fails if pinned_schema.py resolves anywhere outside the SDK package. This branch had built the opposite: a 246-file vendored snapshot at 04f59d2d5 plus a hand-declared supplement carrying the 3.1.1 AUTH_MISSING/AUTH_INVALID split. Upstream's direction wins and the supplement turns out to be unnecessary — the SDK's enum already ships both codes with metadata byte-identical to what was vendored. Resolutions: - tests/helpers/pinned_schema.py — take upstream's module wholesale; re-add pinned_error_code_metadata()/pinned_error_code_suggestion() on top of its load(), so the seven consumers of the auth contract keep one accessor and it now reads the SDK tree. Measured before migrating: recovery is identical across all 66 shared codes, suggestion diverges on exactly 4 (MEDIA_BUY_NOT_FOUND, PACKAGE_NOT_FOUND, REQUOTE_REQUIRED, CREDENTIAL_IN_ARGS) and no caller of these helpers reads a divergent code. AUTH_MISSING/AUTH_INVALID exist only in the SDK tree, so this is also the only source that can grade the split at all. - tests/fixtures/adcp_schemas_pinned/ — take upstream's two files; the 244 vendored schemas and _manifest.py go, and the auth supplement with them. That retires the pin-fidelity correction this PR's description had to carry. - tests/unit/test_pinned_schema_provenance.py — deleted; four of its five tests graded the supplement and the manifest digests, which no longer exist. The fifth pinned a property that survives — that the auth codes production emits are in the vocabulary the oracle grades against — and moves to test_architecture_error_recovery_enum_conformance.py rather than being dropped. - src/core/tools/products.py — take upstream's removal of the property-list try/except. It relabelled implementation faults as AdCPValidationError with recovery="transient" against the enum's "correctable"; this branch had only stopped it interpolating str(e). Removing the handler is the stronger fix and subsumes the scrub. - src/a2a_server/adcp_a2a_server.py — take upstream's deletion of _reconstruct_response_object and its read of the stamped artifact_data["message"] (rebuilding an outbound payload handed pydantic before-validators a reference to the dict about to go on the wire — the list_creatives format_id defect). Keep this branch's failure arm, which reads errors[0].message off the envelope; both arms now read the payload rather than re-deriving it. - tests/integration/test_a2a_skill_invocation.py — keep upstream's new test_artifact_text_part_is_the_data_part_message (it grades the resolution above) and drop the artifact loop this branch had already made dead by rejecting multi-skill messages up front. Verified: make quality 5948 passed; tests/harness 148 passed; a2a integration 75 passed, including upstream's new stamped-message test.
…pped The merge put two independently-correct changes on the same seam. This branch widened `_prepare_rest_request` to return `(client, identity, headers)` so a presented-token test can drive the real production dependency path; upstream added CapabilitiesEnv._run_rest_request against the older two-value contract. Both merged cleanly and the result raised "too many values to unpack" on every REST capabilities call, which the harness reports as a failed request rather than an error — so it surfaced as an assertion about success, not an arity bug. Unpacks three and forwards the headers to the GET. Forwarding is the part that matters beyond the arity: a presented token is carried ONLY by those headers, so a caller that unpacked correctly but dropped them would send the request unauthenticated and silently grade the wrong path. Swept the other REST dispatchers: _base and media_buy_dual already unpack three and forward; media_buy_dual's override delegates to one of those two. This was the only site.
|
Merged The collision was semantic, not textual. #1868 replaced the independently vendored schema fixture tree with the installed SDK's own tree and added Taking #1868's direction also removed the reason the supplement existed. Measured before migrating:
So the SDK tree is not merely an acceptable source for the auth split, it is the only one that can grade it at all. Three resolutions took
One guard was deleted rather than repaired: four of The merge surfaced one real defect, in Local verification on |
Five review items, all the same shape: a decision made in one place and not
the sibling place that shares its reason.
**A cancel that reported a stop that never happened.** `a2a_task_id` was
threaded for `create_media_buy` only, while the push-notification injection
already covered `("create_media_buy", "sync_creatives")` — two literals for one
set, and only one of them listed sync_creatives. So a submitted sync got its
webhook config but no persisted outer task id, `_durable_cancel_step` missed,
and `on_cancel_task` stamped CANCELED on the in-memory copy and returned it
while the creative-approval step kept running and later fired its completed
webhook. The buyer was told the task was canceled and then received a
completion for it.
Both halves are fixed. The set is now one definition (`_ASYNC_TASK_SKILLS`),
read by the push-notification injection and the task-id threading alike,
because they select the same skills for the same reason: a skill that can
notify asynchronously is one that can be polled and canceled. And a cancel
with no durable counterpart now REFUSES (`TaskNotCancelableError`) rather than
fabricating CANCELED — the in-memory map is one process's view, so cancelling
only that cannot be reported as cancelling the work. `external_task_id` is
threaded skill handler -> raw -> impl -> the workflow step, so
`resolve_webhook_task_id` keys on the id the buyer actually holds.
`update_media_buy` can also return `status="submitted"` and persists no
external id, so it has the same defect — flagged rather than folded in,
because it persists `request_data=req` + `request_metadata` and needs a
different thread than the one-line kwarg.
**A callback that registered and could never be delivered.** The scheme axis
was single-sourced; the host axis was not. Registration applied the
ADCP_TESTING loopback allowance and protocol delivery did not, so 5 of the 32
ENVIRONMENT x ADCP_TESTING x URL combinations accepted a webhook URL at
registration that delivery then refused. Fail-safe, and therefore silent: no
SSRF hole, just a buyer whose webhooks never arrive. Both gates now read one
`_apply_host_seam`, and `test_registration_and_delivery_agree_on_host` pins
agreement across the matrix — mutation-verified: restoring the old delivery
rule reddens 11 of 39 rows. Production still refuses plaintext and metadata
under ADCP_TESTING, since the scheme rule keys on is_production() separately.
Corrected `_matches_development_test_host`'s docstring, which claimed the
symmetry that did not hold.
**An SSRF refusal nobody could see.** The only failure exit in the send loop
that wrote no delivery-log row and no audit line, and it discarded the
validator's reason into `_` — so a buyer's webhooks stopped with nothing in
the delivery log. Now records what every sibling exit records. The reason is
operator-facing and goes to the log, the row and the audit trail, never the
wire.
**A database outage that logged nothing conclusive.** Retry exhaustion
returned the same `ApprovalFinalization(applied=False)` as "there is
legitimately nothing to reconcile", the caught exception was never logged, and
2 of 3 call sites passed no `exhausted_message`. `exhausted_message` is now
required and the exception is logged with it — this module exists to recover
approvals after an outage, so its own outage path is the one that must not be
silent.
Ratchets held rather than raised: both fixes tripped C901, so the added blocks
were extracted (`_record_refused_unsafe_url`, `_sync_step_request_data`) —
no baseline moved. The new envelope-free raise is enumerated in the docstring
and the AST table that grades it.
Verified: make quality 5989 passed; affected integration slice 230 passed.
Both new pins mutation-verified against the defect each names.
| if not _retryable_database_error(exc): | ||
| raise | ||
| if attempt == _FINALIZATION_ATTEMPTS: | ||
| logger.error(exhausted_message, exc_info=exc) |
…k id
Same defect as sync_creatives, found by sweeping the pattern rather than the
site: `_update_media_buy_impl` can answer with `UpdateMediaBuySubmitted`
(status="submitted"), so the buyer is handed a `task_*` id, and the workflow
step it persisted carried `{"protocol": ...}` and nothing else. The buyer's id
resolved to no durable step, so tasks/cancel could not find it and the
completion webhook keyed on `step_id`.
Threaded the same way create_media_buy already does — via `request_metadata`,
which `create_workflow_step` merges into `request_data` after serialization,
which is exactly where `resolve_webhook_task_id` reads. The two keys now come
from one `_update_workflow_metadata` rather than an inline dict, so the next
key added is added once.
**This also turns on push-notification injection for update_media_buy**, and
that is deliberate rather than a side effect. The set is defined by what a
skill can RETURN, and update_media_buy is async-capable on both axes: it takes
a `push_notification_config` and it can return submitted. Previously the A2A
`MessageSendConfiguration.pushNotificationConfig` was never injected for it, so
a buyer who asked to be notified about an async update was silently not
notified. Unifying the set fixes that with the same change; keeping two sets to
avoid it would restore the exact drift that hid the sync_creatives gap.
`_ASYNC_TASK_SKILLS` now has a membership pin derived from the returnable
result, so a fourth async skill cannot join the dispatch without joining the
set — mutation-verified: dropping update_media_buy from the set reddens it.
Verified: make quality 5990 passed; a2a/update/webhook/approval integration
slice 399 passed. No ratchet moved.
…host-seam change
**Item 2 — approved-execution eligibility.** Four routes each hard-coded a slice
of `APPROVED_EXECUTION_SOURCE_STATUSES`, and their union was the canonical set,
so no site read as wrong. The consequence was live: approving a
`pending_creatives` or `draft` buy through either admin route matched neither
`== "pending_approval"` pre-filter, fell to the plain-workflow branch, and
terminalized the step while telling the operator "approved successfully" — buy
never executed, creative gate never run, execution claim never taken.
`prepare_media_buy_approval_execution` now owns the whole decision and the four
literals are gone. It reports NOT_EXECUTABLE (nothing to execute; finish the
step yourself) distinctly from CLAIM_REFUSED (it WAS a candidate and another
request already claimed it) — collapsing those two is what made the routes
pre-filter in the first place.
The four sites were NOT four slices of one predicate, which is the part worth
naming: `creatives.py` excluded `pending_approval` deliberately, because a
creative approval must not stand in for the human approval it is not. That
exclusion is now declared beside the canonical set with its reasoning, and
derived through `execution_source_statuses_for(trigger)` — a bare
`{"pending_creatives", "draft"}` at a call site was that derivation hand-copied
with the reasoning nowhere. The reject route also stops assigning
`media_buy.status` directly; `reject_pending_execution` applies the same source
guard in the UPDATE, so a reject can no longer land after execution was claimed
and no longer ignores two of the three source states.
**Item 3 — reverted.** My host-seam change was wrong in the dangerous direction.
`validate_protocol_webhook_url` is not only the delivery gate: it is also the
buyer-supplied callback gate at `media_buy_create.py:200`. Unifying it onto
registration's permissive side to close the divergence widened an SSRF boundary,
which `test_create_media_buy_callback_validation` caught — it passes at
c0d7d2a and fails at 9eaa7a1, so this was a regression I introduced and
pushed, not a pre-existing failure.
Behaviour is back to c0d7d2a. What survives is the docstring correction (the
claimed host-axis symmetry genuinely does not hold) and a test that
CHARACTERIZES the divergence in both directions: delivery must never be the
permissive gate, and with ADCP_TESTING off the two must agree. So either gate
changing reddens it, without asserting a property we have decided not to force.
Closing it properly means tightening REGISTRATION, tracked separately.
Verified: make quality 5979 passed; approval + callback + finalization 77
passed; no ratchet moved. The route-level pin is mutation-verified — restoring
the `pending_approval`-only pre-filter reddens it.
…media-buy skills [order R1-18]
… dropped [order R2-2]
…letion [order R2-3]
get_task, complete_task and list_tasks resolved workflow steps by tenant alone, so a same-tenant sibling principal who learned (or enumerated) a step id could read its stored response_data, see another buyer's task ids and summaries, or terminalize its workflow. get_by_external_task_id already stated the rule for the A2A durable get/cancel; these three MCP tools are the same authorization boundary and now carry the same scope. WorkflowRepository grows one home for what "principal-scoped" means (_principal_scoped_steps: the tenant join every read carries, PLUS DBContext.principal_id), used by get_by_external_task_id, get_by_step_id_or_raise, list_by_tenant and count_by_tenant. principal_id is a REQUIRED keyword on all four — a default would mean "unscoped" at every call site that forgot it, which is the defect being closed. The count carries the same scope as the page: a correctly filtered list with a tenant-wide total still discloses how many tasks another buyer has. Plain get_by_step_id is deliberately left tenant-only. Its callers are admin routes under require_tenant_access and internal paths that have already authorized (the A2A cancel re-read runs inside the principal-checked _owned_durable_step); requiring a principal there would force admin callers to invent one. Graded by three cross-principal isolation tests against the real DB, beside the existing A2A ones. Mutation-checked: dropping the DBContext.principal_id predicate reddens all five principal-isolation tests with data-level failures (get_task discloses response_data, complete_task DID NOT RAISE and terminalizes the step), while the two tenant-isolation tests stay green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Round addressed at Already landed before this round
Fixed this round
One correction
The refusal path returns before loading anything, and the caller's re-SELECT is on the refusal branch, after a Filed, self-assigned
Description correctionsAll five accepted; applied to the description in the same push as this reply. The Layer Separation bullet no longer invents "(SHOULD-level)" and no longer describes a crash-location split the code does not implement — Verification
Not verified: one |
The typed model declares `PushNotificationConfig.url` as a Pydantic `AnyUrl`, not a `str`. The shared validator type-guarded `isinstance(url, str)` before coercing — correct for create, whose wrappers deserialize JSON into a dict of plain strings, and fatal for update, which passes `req.push_notification_config` through as the model. Every buyer updating a media buy with a callback got VALIDATION_ERROR on a perfectly good URL. Caught by E2E (`test_complete_campaign_lifecycle_with_webhooks`), which is the only suite that drives a real update-with-webhook over the wire. Coerce before the guard, so both entry points give the same verdict for the same URL. The reason this shipped is the more useful part. The only test was a REJECTION test — "update rejects a private callback" — and a rejection test passes just as well when everything is rejected. It could not distinguish "rejects unsafe" from "rejects all", so it went green on a validator that refused every input. The new unit test grades BOTH directions on BOTH input shapes, and its last case asserts the two shapes' verdicts are EQUAL rather than separately expected, so a future change that moves one path alone reddens even if each case still looks sensible on its own. Mutation-verified: removing the coercion reddens exactly the accept case and the parity case, while the rejection cases stay green — reproducing the blind spot that let this through. `AUDITED_INTERPOLATED_OPT_INS` line keys re-derived (2558→2567, 2914→2923): same two entries, same reasons, shifted by this file's edit. No allowlist growth. Verified: make quality 5999 passed; callback + persistence + the new unit file 13 passed; no ratchet moved.
sync_creatives creates ONE workflow step PER CREATIVE (_create_sync_workflow_steps loops creatives_needing_approval), and an earlier commit in this PR stamped the SAME external_task_id on every one of them. get_by_external_task_id resolves that key with an unordered .first() and no cardinality guard, so tasks/cancel canceled one arbitrary step of N, committed, and returned CANCELED while the others kept running and later fired their own webhooks. Revert the threading rather than build set-resolution or a parent/child step model. With no external_task_id the sync task id resolves to no durable step, and the refusal branch this PR added returns an honest refusal instead of a false CANCELED. sync_creatives STAYS in _ASYNC_TASK_SKILLS — that set also governs push-notification injection, which is correct and unchanged. Only the task-id threading is reverted; _TASK_ID_BEARING_SKILLS is now derived by subtracting sync_creatives instead of create_media_buy, and names exactly the skills whose handlers accept a2a_task_id. Accepted cost, stated so it is not rediscovered as a defect: sync completion webhooks key on step_id rather than the buyer's task_* id. That is the pre-existing behavior, already graded by tests/unit/test_creative_webhook_correlation.py. Behavioural proof: test_cancel_of_multi_creative_sync_task_refuses drives a real 3-creative sync through the A2A boundary and cancels the returned task id on a fresh handler (the cross-process leg). Verified mutation-sensitive — against the pre-revert source the same test fails "DID NOT RAISE TaskNotFoundError", i.e. it observed the false CANCELED. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Re-review addressed at Blocker — revertedYou are right, and the failure is worse than the threading being incomplete: Both remedies you offered — resolve the id to the whole set, or a parent step owning children — are new mechanisms, on a PR already carrying six subsystems. So I took a third option: stop threading the id into Accepted cost, stated so it is not rediscovered later: Mutation-verified against the pre-revert source: the new test fails One correction to my own order, found during that proof. I specified the test assert Worth recording because it is the substantive fact underneath: The rest — filed as #2001, deliberatelyNine of your nine findings target code my remediation added. Last round that number was 1 of 20. My fixes are now producing the next round's findings faster than they retire them, so continuing to fix in-round is the thing most likely to generate a round three. So #2001 carries all eight remaining items with your evidence intact: the
Those three are the ones I would most want re-checked when #2001 lands, because in each case I verified at the layer I changed rather than the layer where the effect has to appear. Your three withdrawalsNoted and agreed on all three, and the two that shipped are already reverted. On the webhook direction specifically — the asymmetry and the false "vice versa" docstring were real, and the surviving verbatim copy at Trackers#1803 and #1809 now owned; the eight sibling #1807 is not closable — I checked before closing. On your creative-status note: agreed, and the local gap is the live part — the pinned wire enum has six values, |
Problem
The A2A server surfaced application-layer failures on the JSON-RPC transport layer, emitted webhooks that contradicted the AdCP terminal-state rules, leaked raw exception text (credentials/connection strings) onto the wire, and lost the buyer's task identity across the async workflow boundary (poll/cancel/webhook correlation).
Spec Grounding (AdCP 3.1.1; prose tree rendered at
dist/docs/3.1.1/)building/operating/transport-errors.mdx"Layer Separation": application/task errors (including unknown/unsupported skills, caught after routing) belong in the task response body as a failed Task with the two-layer envelope; JSON-RPC/MethodNotFoundis reserved for transport faults and infra-before-dispatch. The table attransport-errors.mdx:11-18listsinternal crashunder the transport layer, with no RFC keyword — an earlier revision of this description wrote "(SHOULD-level)", which the spec does not say, and described a split by WHERE the crash surfaces, which the code does not implement:_dispatch_under_sanitize_seamcatches only(AdCPError, ValueError, PermissionError), so an untyped skill-handler crash reaches the same transport-layerInternalErroras a boundary crash. The inner seam's own docstring states this correctly.building/operating/transport-errors.mdx"Security Considerations" § Seller Requirements: error responses MUST NOT include hostnames, database/SQL text, credentials, or upstream internals;suggestioncarries generic guidance only. This grounds the boundary sanitizer (safe_adcp_error) including its synthesized suggestion.building/by-layer/L0/a2a-guide.mdx"Webhook Trigger Rules": no webhook is sent when the initial response is already terminal; webhooks fire only for non-terminal initial responses and later async transitions, which carry the full Task's artifacts.canceledis listed among the final states ("Cancellation confirmed").tasks/get/tasks/cancelsemantics are A2A-protocol-native (A2A spec Task Management:TaskNotFoundError,TaskNotCancelableErrorfor terminal tasks; SDKdefault_request_handleras reference cross-check) — AdCP prose defines no cancel contract of its own.building/operating/transport-errors.mdxJSON-RPCerror.data:error.datais a sanctioned transport-envelope location anderror.data.adcp_erroris a MUST-check in the client detection order. This grounds the widened auth emission — every A2A method's auth rejection (message/send, tasks/get, tasks/cancel, the four push-notification-config arms) stays a JSON-RPC error AND attaches the two-layer envelope asdata. "Stays on the JSON-RPC wire" and "carries the envelope" are orthogonal.Whether the buyer receives that
datadepends on the method name they call. The app runs withenable_v0_3_compat=True, and dispatch is selected by method name, so the v0.3 aliases reacha2a/compat/v0_3/jsonrpc_adapter.py, whosehandle_requesthas noexcept A2AErrorarm — onlyexcept Exception -> CoreInternalError(message=str(e)), which takes nodata. Measured on an auth rejection:GetTask/CancelTaskreturn-32600+ the envelope;tasks/get/tasks/cancelreturn-32603+data: null. The installedadcpclient emits the v0.3 names, so that is the common path. Nothing leaks (the flattened message is the already-scrubbed text) but the buyer loses the code they are told to branch on. Tracked in A2A: the a2a-sdk v0.3 compat adapter flattens every typed A2AError to -32603, losing the spec's error codes #1670 and now graded in BOTH directions by_TASK_METHOD_DISPATCHintests/unit/test_a2a_transport_contract.py— an earlier revision of this description asserted the envelope reached the wire without that qualification, and the test was parametrized over only the two v1.0 names that preservedata.error-code.jsonenumMetadata):CONFIGURATION_ERRORisterminalrecovery, suggestion "surface to a human at the seller — the buyer cannot resolve a seller-side deployment misconfiguration and MUST NOT auto-retry". Grounds_resolve_a2a_identity's no-tenant branch: an authenticated principal with no resolvable tenant is a seller-side deployment issue, not a buyer credentials problem, so it emitsCONFIGURATION_ERRORrather thanAUTH_REQUIREDfor that specific condition. Note this is a known, currently-tracked divergence fromrequire_tenant(src/core/auth.py), which raisesAUTH_REQUIREDfor the same underlying "no tenant" condition across its ~18_implcall sites — reconciling the two is open follow-up work, not resolved by this PR.adcpSDK's own schema tree (adcp/_schemas/3.1/enums/error-code.json; adcp 6.6.0 -> spec 3.1.1): sellers MUST emitAUTH_MISSINGwhen no standardAuthorizationheader is present andAUTH_INVALIDwhen that header is present but rejected. The implementation applies that split uniformly at A2A, MCP, and REST wire boundaries; direct_implhelpers retain deprecatedAUTH_REQUIREDonly when credential-presence state is unavailable. (Earlier revisions of this description grounded the split on a separately vendored schema tree — first at thev3.1.1release tag467fd93d, a citation that was wrong because the pin was advanced to the tag while only 2 of 245 vendored files had been re-fetched from it, then reverted to04f59d2d5with the two auth codes recorded as an explicit, content-checked supplement. fix: pin AdCP schema validation to the installed SDK and fix the wire-serialization bugs it surfaced #1868 has since made the SDK's own tree the single pin — enforced bytests/unit/test_pinned_schema_single_source.py— and that tree ships both codes withenumMetadatabyte-identical to what was vendored, so the vendored tree and its supplement are gone and the SDK's enum is what the Spec-Grounding Gate reads.)AUTH_REQUIREDis marked Deprecated in the pinnederror-code.jsonenum, whileauthentication.mdx:290-297still illustrates the unauthenticated case with it. This PR follows the enum (emitting theAUTH_MISSING/AUTH_INVALIDsplit at the wire boundaries and retainingAUTH_REQUIREDonly where credential-presence state is unavailable). Noting the conflict here so the next reader does not have to re-derive which side to take.dist/compliance/3.1.1/universal/error-compliance.yaml,security.yamlandpagination-integrity-list-accounts.yaml; none carries anAUTH_*error-code scenario. Notesecurity.yaml:95(unauth_rejection) DOES grade the unauthenticated path —http_status_in [401, 403]andon_401_require_header: www-authenticate— so the surface is not entirely ungraded, only the code split is.git grep -in "www-authenticate" -- src/returns zero at this head and at the base whileAdCPAuthenticationError._default_status_code = 401; that gap is tracked separately. The normative enum mandate is graded by the in-repo cross-transport wire tests.Changes
Error routing (application failures → failed Tasks):
on_message_send's outerAdCPErrorarm returns the failed Task with theprocessing_errorenvelope artifact (TextPart + authoritative DataPart); the bareExceptionarm re-raises as a sanitized JSON-RPCInternalError, and genuineA2AErrors still re-raise as JSON-RPC.AdCPCapabilityNotSupportedError(UNSUPPORTED_FEATURE/correctable) → surfaced as failed Tasks.MethodNotFoundErroris reserved for unknown JSON-RPC methods. The unknown-skill check runs inside the logged boundary sorecord_boundary_errorfires exactly once.UNSUPPORTED_FEATUREfailed Task): one skill per message until per-skill child Tasks land (A2A Task identity vs workflow-step identity: response Task and webhook Task differ #1614), so a sibling failure can never terminalize a Task that carries an accepted, persisted operation.Boundary sanitization (one policy across A2A, MCP, REST, and webhook paths):
safe_adcp_erroris the shared scrub for A2A failed-Task / JSON-RPC, MCP, REST, and webhook push boundaries. It is: client-correctable typed errors pass through with their controlled message, EXCEPTAdCPValidationError, whose text is untrusted by default and opts in via_wire_safe_message=True(see "Buyer-visible message change" below); the internal/SERVICE_UNAVAILABLEbucket (baseAdCPError,AdCPAdapterError+,AdCPConfigurationError) gets its message and suggestion replaced with static text (code/recovery preserved; suggestion synthesized so the envelope keeps the graded top-levelsuggestion); untyped exceptions never exposestr(exc)._internal_error_forbuildserror.messagefrom the scrubbed error, not the original, so a typed adapter error carrying a DB URL is clean in botherror.messageanderror.data.AdCPAdapterError(str(e))raise sites (also protects MCP/REST).Cross-transport authentication and observability hardening:
AUTH_MISSINGversus terminalAUTH_INVALID, including exact recovery and suggestion in both envelope layers.x-adcp-tenanthints; a resolved principal is required before tenant-scoped sink writes.AUTH_OPTIONAL_SKILLSpolicy and shared by all three boundaries.list_accountsmoves auth-optional -> auth-required on the boundaries that previously admitted it unauthenticated:accounts/tasks/list_accounts.mdxopens "Returns all accounts the AUTHENTICATED agent can operate on this vendor agent", andprotocol/required-tasks.mdxmarks it Conditional for account-id namespaces withrequire_operator_auth: true. Storyboard status: ungraded for auth —dist/compliance/3.1.1/universal/pagination-integrity-list-accounts.yamlgrades pagination, not the auth requirement — so the in-repo cross-transport tests are what hold it.Webhook / terminal-state contract:
submittedinitial responses notify. The legitimate completed/failed webhook is the async workflow-step transition (context manager)._send_push_notificationssends exactly one webhook per step status change — the object-mapping and registered-config rows are opt-in gates, not per-item targets (they previously multiplied identical sends; caught live by the E2E single-completed-webhook pin).{"error": ...}).Task identity correlation + durable task management (#1544 B6):
task_*id is threadedon_message_send → … → _create_media_buy_impland persisted on the workflow step'srequest_data.external_task_id; completion webhooks key on it.tasks/gettreats the persisted step as the source of truth: a non-terminal in-memory task is reconciled against the durable step, so an approval that completed in another process (or before a restart) is visible to the original poll. Terminal durable outcomes rebuild the Task with the stored result artifact.tasks/cancelis durable and race-safe: the transition is a single conditional UPDATE (cancel_if_cancellable,WHERE status IN cancellable— the pre-side-effect statuses; excludesapproved/in_progress) so a cancel racing an approval can never overwrite a committed decision or strand a real order (zero-row ⇒TaskNotCancelableErrorwith the fresh status). Terminal in-memory tasks also refuse cancel.get_by_external_task_id(…, principal_id=…)): a same-tenant sibling principal who learns a task id can neither read itsresponse_datanor cancel its workflow.Admin approval + policy routes (status mutation moved into the repository):
WorkflowStep.statusfrom the route. Every decision goes through one atomic conditional UPDATE inWorkflowRepository(claim_approval/reject_if_approvable/transition_if_nonterminal, all delegating to_atomic_transition), so two concurrent approvers cannot both run the adapter work: the loser gets zero rows → 409, never a silent second execution.APPROVABLE_STEP_STATUSESis the one definition of "can be approved" (requires_approval,pending_approval, and the legacyapprovalalias kept until the alias is normalized away, Normalize legacy 'approval' workflow-step status to canonical 'requires_approval' #1659). The media-buy detail route, the workflow routes and the workflows summary all read it instead of inline literals — a subset literal silently hid or undercounted live approvals.require_tenant_access(a server-side membership check, replacing an inline role test that only blocked cross-tenant for one role), filters onstep_type == "policy_review"so an arbitrary workflow step cannot be driven terminal through it, and fails loudly on an unknown action instead of falling through.session_user_email), so an OAuth session is never recorded as a dict repr.Tests
tests/unit/test_a2a_error_routing.py: untyped crash →SERVICE_UNAVAILABLE; typed error keeps its code; auth-extraction failure → failed Task, no webhook; all-skills-failed preserves per-skill envelopes with no webhook; immediate completed → no webhook; multi-skill rejection pin.tests/unit/test_error_boundary_translation.py: internal-bucket typed message scrubbed (code/recovery preserved); client-correctable message preserved; full JSON-RPC wire scrub (error.message+error.databoth clean for a typed adapter error carrying a secret); typed/untyped envelope shape parity.tests/unit/test_a2a_transport_contract.py: table-driven wire assertions across the full dispatch registry, registry↔test bijection, agent-card advertised-subset pin.tests/unit/test_context_manager_webhook_dedup.py: 2 mappings × 2 configs → exactly 1 send; both gates preserved (mutation-verified against the pre-fix loop).test_a2a_skill_invocation.py): durable get rebuild + tenant isolation + principal isolation (get & cancel); durable cancel persists and is visible to a later poll; terminal step/in-memory refuse cancel; stale in-memory WORKING reconciled to the terminal durable outcome via publicon_get_task; cancel/approval race (deterministic TOCTOU interleaving on real PostgreSQL) preserves the terminal decision.@T-UC-002-ext-nl-unsupported) and E2E (test_a2a_webhook_payload_types.py): wire envelope + exactly one completed webhook (grace-window pin that caught the live duplicate); Task/TaskStatusUpdateEvent wire serialization pinned.test_architecture_bdd_scenario_merge_durability.py: source-less scenarios must be marked AND registered (bijection scoped to the source-less candidate universe).Verification
make qualitygreen; every production fix mutation-verified (revert each → its test reddens).assert_failed_task_envelope, which delegates the envelope assertion to the canonicalassert_envelope_shapeso both layers must agree. The secret-leak oracle's token set (_SECRET_TOKENS) was previously the sole, ungraded definition of a leak — a self-test parametrized over that same constant would delete its own case instead of reddening when a token was dropped, silently defanging all ~30 downstream callers.test_secret_scrub_oracle.pycloses that by stating the expected leak fragments (_EXPECTED_LEAK_FRAGMENTS) INDEPENDENTLY of_SECRET_TOKENS, so narrowing or emptying the shared set now reddens rather than passing vacuously. Each oracle has known-bad self-tests, so weakening a pin reddens something.Buyer-visible message change
safe_adcp_errorscrubs anAdCPValidationError's message by default, because businessvalidators frequently interpolate values a raise site never audited. That default is right for
interpolated text and pure collateral damage for a bare string literal: a constant interpolates
nothing, so there is nothing to leak, and genericizing it costs the buyer the one diagnostic they
could act on — for errors whose
recoveryiscorrectable, i.e. exactly the ones the buyer isexpected to fix themselves.
31 bare-literal raise sites had accumulated and were all silently downgraded the moment the scrub
landed. Nothing failed: the wire contract stayed valid (code/recovery/status/field all preserved),
only the human-readable half regressed, which no assertion covered. They now carry
_wire_safe_message=True; 39 sites opted in, 0 bare literals remain, andtest_architecture_static_validation_message_opt_in.pyholds that at zero with a deliberatelyEMPTY allowlist. Interpolated messages stay out of scope by design — those need a per-site audit
and keep scrubbing until it happens.
This changes bytes on the wire. Any consumer string-matching the generic scrub text
("The request could not be validated; review the submitted fields and resubmit.") will now see the
raise site's own message for these 31 conditions. That is the intent, but it is a behavior change,
stated here rather than shipped quietly.
Grounding (
dist/schemas/3.1.1/core/error.json):messagecarries no wording constraint —"Human-readable error message" is the entire schema description — and the file's own canonical
example echoes the buyer's rejected value in BOTH
messageanddetails.rejected_value, documentedthere as "echoed for buyer-side diagnostic clarity".
error-handling.mdxnames only twoMUST-be-generic cases: not-found uniformity (
:203), unestablished identity (:416-417), and seller internals (:519). None coversa static literal.
detailsis withheld together withmessageon the scrubbed path, deliberately: both are built bythe same raise site from the same values with the same absent audit, so one flag governs both
channels. Forwarding
normalize_to_adcp_error(exc).detailsthere would re-emit the very payload thebranch exists to withhold — on that branch
normalize_to_adcp_errorreturns the same instance itwas given. Both halves of the symmetry now have an oracle.
Also in this round
validate_protocol_webhook_urldecided HTTPSfrom
ENVIRONMENT == "development"while its two siblings use_require_https()/_strict_mode()— the two rules disagree in 7 of 10 environment combinations, always register-permissive /
deliver-strict.
docker-compose.ymlnever setsENVIRONMENT, so on the default stack anhttp://reporting webhook registered with a success response and then silently never delivered. It was
also the only one of the three that ignored
ADCP_TESTING. This is the SCHEME axis of the samedefect
_matches_development_test_hostalready fixed on the HOST axis._approval_creative_gatereturns(False, ())when a buy has nocreative assignments — unsatisfied precisely BECAUSE nothing is assigned — so the shared message
told the operator to wait for an empty set. Zero now gets its own copy naming the action.
_atomic_transition's tenant predicate has a failing oracle. Deleting it reddened nothingacross the suite:
test_architecture_workflow_tenant_isolationmatchesselect(WorkflowStep),while that method scopes with
select(WorkflowStep.step_id)and writes withupdate(WorkflowStep).It is the single tenant boundary for every conditional transition on the repository. The oracle
asserts the OTHER tenant's persisted status, not the return value —
get_by_step_idis itselftenant-scoped, so a cross-tenant UPDATE that applies still returns
None. Widening the guardmatcher is test: workflow tenant-isolation guard misses select(Model.column) and update(Model) — _atomic_transition escaped it #1803.
identity mocks,
asyncio.runandTransportResult— four chances to drift, one of which it hadalready taken. The scenario also gained the assertion it was missing: that the failure returns as
a FAILED Task rather than a JSON-RPC error, which is this PR's actual thesis and which asserting
the error code alone cannot distinguish.
already drifted, so they caught only re-inlines of the wording that was already correct; and both
scanned line-by-line, which a formatter-wrapped copy of a >120-char message escapes by
construction. They now match on the drift-stable prefix, read literals off the AST, and share one
helper.
AUTH_OPTIONAL_SKILLSgrounding corrected. The comment claimedrequired-tasks.mdxdocumentsall four entries as Required discovery tasks. It documents three;
list_authorized_propertieswasREMOVED from the spec in v3 and survives only in a migration note. Membership is unchanged (both
transports already treated it as public), and the v2-compat lifecycle question is list_authorized_properties is a v2 task the spec removed in v3 — still implemented and still auth-optional #1804.
Follow-ups filed this round: #1803 (guard matcher), #1804 (v2-compat surface).
🤖 Generated with Claude Code
Multi-skill batching
AdCP 3.1.1 is silent on batching multiple application skills in one A2A message, and the 3.1.1 conformance storyboards do not grade it. This PR therefore treats one skill per message as a local side-effect-safety constraint until per-skill child Tasks are available: rejecting a batch before dispatch prevents a sibling failure from terminalizing a Task while accepted work continues.