Skip to content

feat(decisioning): let a raised AdcpError reject a request with every error (#1780) - #1810

Merged
bokelley merged 1 commit into
mainfrom
claude/issue-1780-multi-error-raise-v9
Nov 2, 2026
Merged

bokelley merged 1 commit into
mainfrom
claude/issue-1780-multi-error-raise-v9

Conversation

@bokelley

@bokelley bokelley commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Problem

A seller that validates a whole create_media_buy / update_media_buy request finds several problems at once, but a decisioning platform can only report them by raising AdcpError, which carries one code, message and field. build_mcp_error_result and the A2A failed-task builder wrote errors: [<that one error>], even when the caller put the whole list in details["errors"] (the slot AdcpError's docstring named for multi-error preflight). The issue's repro printed 1 [None]. Sellers had to lift the list in a response enhancer themselves.

Change

  • AdcpError(..., errors=[...]): entries are AdcpErrors, generated Error models, or wire mappings with a string code and message. The raised error still fills the envelope adcp_error, and every entry goes into the payload errors[]. An entry without recovery takes the raised error's. Entry details go through sanitize_error_details like to_wire(). A malformed entry raises at construction (TypeError / ValueError).
  • AdcpError.from_errors([...]) builds the raise from the list, with the first entry filling adcp_error, so adcp_error == errors[0]. It returns a base AdcpError even when the first entry is a subclass.
  • AdcpError.errors_to_wire() returns the payload list: every entry, or [to_wire()] when there is no list.
  • The details["errors"] slot is lifted. When errors= is not passed and every details["errors"] entry is a mapping with a string code and message, those entries fill errors[]. Any other shape (strings, {"path", "msg"} dicts, an empty list) is left alone, as before. adcp_error.details is unchanged.
  • build_mcp_error_result, the A2A executor's failed-task DataPart and translate_error(..., "a2a") write the whole list. A single-error raise still mirrors adcp_error into errors[] (Raised AdcpError only fills adcp_error, so storyboards that read errors[0] fail #1558/fix(server): mirror a raised AdcpError into the payload errors array #1592), so existing output is unchanged.
  • Docs: a short "Rejecting a request with every problem" section in docs/handler-authoring.md.
raise AdcpError.from_errors([
    AdcpError("BUDGET_TOO_LOW", message="budget below minimum", recovery="correctable", field="packages[0].budget"),
    AdcpError("INVALID_REQUEST", message="end_time before start_time", recovery="correctable", field="end_time"),
])

Not covered: an async TaskHandoff failure still records one error through registry.fail(task_id, exc.to_wire()), so tasks/get and the failure webhook report errors: [adcp_error]. Carrying the list there means a task-registry storage change, so it's left as a follow-up.

Adopter note: this change is additive. A seller whose response enhancer already lifts details.errors into errors[] (as the Scope3 sales agent's project_wire_errors does) gets the list from the SDK now. An enhancer that replaces errors keeps working; one that appends would duplicate the entries and should be removed.

Tests

tests/test_adcp_error_multi.py (28 tests) covers:

  • construction, from_errors with all three entry types, and the subclass entry;
  • malformed entries, sanitization, and copies;
  • the details["errors"] lift, including the shapes it must leave alone;
  • the issue's repro through build_mcp_error_result;
  • context echo and response enhancer;
  • end to end through _register_tool;
  • translate_error A2A data, and the A2A executor's failed task.

Backport of #1804 (SDK 10, next) to SDK 9: same commit, cherry-picked cleanly. Additive, no breaking change. On this branch the 149 targeted tests and make lint pass locally; make typecheck-all passes locally too.

Closes #1780

🤖 Generated with Claude Code

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon verdict: Approve

Approve — clean, additive multi-error support on AdcpError.

No blocking or Medium findings. The reviewer confirmed:

  • Entries are normalized and sanitized consistently with to_wire().
  • Malformed entries fail-closed at construction.
  • Single-error output is unchanged (backward compatible).
  • The details['errors'] lift is well-guarded.
  • Correct feat(decisioning) semver signal on an additive-only public surface; errors[] is an existing wire array, so no version-translation pathway is required.
  • Local mypy deferred to CI per the PR body.

Decision-table walkthrough:

  • Row 1 (critical/high): no findings — skip.
  • Row 2 (gated paths): gated_paths is false — skip.
  • Row 3 (deleted high-risk): all high-risk files are (modified), none deleted — skip.
  • Row 4 (medium data-loss/schema/infra): no medium findings — skip.
  • Row 5 (high_risk modified + medium): high_risk is true but there are zero medium findings — skip.
  • Row 6 (sticky escalate): no prior decision — skip.
  • Row 7 (no-auto-approve team): no team match — skip.
  • Row 8 (≥3 medium): zero medium findings — skip.
  • Row 9: approve.

The high_risk flag is heuristic; all matches are (modified) with no medium-or-higher concern, so the modifications are presumed safe per the flag-reading rules. review_decision: REVIEW_REQUIRED does not block because gated_paths is false.

… error (#1780)

A seller that validates a whole request could only raise one AdcpError,
so the payload errors[] carried one entry even when the caller listed
every problem in details["errors"].

AdcpError takes errors=[...] (AdcpError, generated Error or wire mapping
entries) and AdcpError.from_errors([...]) builds the raise from the list,
the first entry filling adcp_error. build_mcp_error_result, the A2A
failed-task builder and translate_error write the whole list to errors[];
adcp_error is unchanged. A details["errors"] list whose entries carry a
string code and message is lifted into errors[] as well. An entry
without recovery takes the raised error's; entry details are sanitized
like to_wire().

A single-error raise still mirrors adcp_error into errors[] (#1558).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@bokelley
bokelley force-pushed the claude/issue-1780-multi-error-raise-v9 branch from 64c1ae0 to 50248bb Compare November 2, 2026 04:14

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon verdict: Approve

Approve — additive multi-error support on AdcpError, no blocking or medium findings.

What I checked:

  • Semver signal is correct: feat: for additive public-API growth (errors= kwarg, from_errors(), errors_to_wire()), no removed/renamed exports, no required→optional flips, no enum member removal — non-breaking, so a minor cut is appropriate.
  • Wire-shape fidelity preserved: envelope adcp_error vs payload errors[] split maintained; single-error mirror unchanged on both MCP and A2A; _lift_detail_errors is shape-guarded and only touches the documented slot.
  • Fail-closed construction: malformed/non-mapping entries raise ValueError/TypeError at construction; per-entry sanitization via sanitize_error_details.
  • DR-0032/0033 pathway: errors[] enrichment is a backward-compatible response projection (not a mutating request field), so no version-translation pathway is owed.
  • Tests pin the behavior: A2A projection assertions (L538-546), end-to-end executor test (L570-578), and test_from_errors_envelope_is_errors_zero (L489-498) locking adcp_error == errors[0].

High-risk flag: true, but all three matches are (modified) files with no medium-or-higher findings — presumed safe per the modified-file rule. No deletions.

Gate posture: gated_paths is false; no no-auto-approve team match. review_decision is REVIEW_REQUIRED, but the deprecated protected-branch row no longer forces comment, and gated_paths=false means row 2 does not fire. Subsequent pass on a prior approve with no new findings. Falls through to row 9.

@bokelley
bokelley merged commit 272fced into main Nov 2, 2026
47 checks passed
@bokelley
bokelley deleted the claude/issue-1780-multi-error-raise-v9 branch November 2, 2026 05:29
@aao-ipr-bot aao-ipr-bot Bot mentioned this pull request Nov 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A raised AdcpError can return only one error, so a seller cannot reject a request with every problem in errors[]

1 participant