Skip to content

feat(errors): AdCP 3.3 mutation_outcome and unsupported-targeting-dimension details (#1642) - #1811

Merged
bokelley merged 1 commit into
nextfrom
claude/issue-1642-mutation-outcome
Nov 2, 2026
Merged

bokelley merged 1 commit into
nextfrom
claude/issue-1642-mutation-outcome

Conversation

@bokelley

@bokelley bokelley commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Spec

  1. core/error.json mutation_outcome (not_applied, unknown, applied): whether a failed state-changing request changed seller state. It goes only on errors that terminate a mutation, never on reads or warnings. not_applied requires pre-commit evidence. Receivers MUST read an unrecognized, empty or non-string value as unknown.
  2. UNSUPPORTED_FEATURE for an unsupported or removed targeting dimension SHOULD carry error-details/unsupported-targeting-dimension.json, with error.field at the targeting path (adcp#8052).

Change

Seller

  • AdcpError(..., mutation_outcome=...), typed by the exported MutationOutcome literal. to_wire(), build_mcp_error_result, the A2A failed-task DataPart and translate_error's A2A data all project it, so it reaches both adcp_error and errors[]. An ADCPTaskError projects its first entry's claim. The decisioning dispatcher keeps the claim when it re-raises a task error as AdcpError. from_errors takes the first entry's claim.
  • The framework sets not_applied only where it holds pre-commit evidence:
    • in create_tool_caller, which serves both MCP and A2A, on a structured rejection raised before a mutating handler (IDEMPOTENT_TASKS) runs: schema and model validation, version gates, pre-validation hooks and legacy adapters (mark_not_applied);
    • on IDEMPOTENCY_CONFLICT from IdempotencyStore and the PG reservation, which is detected on lookup before anything is written.
  • A claim the raise already makes is kept. Errors raised by the handler itself, and the INTERNAL_ERROR wrap of an unexpected exception, carry no claim, as the spec requires. Reads never carry one.
  • Deliberately not stamped: decisioning PlatformHandler rejections raised before _invoke_platform_method. Some of those paths take reservations (proposal consumption) before the platform call, so "before the platform method" is not "before any write". Adopters set mutation_outcome themselves where they have the evidence.
  • UnsupportedTargetingDimensionError subclasses UnsupportedFeatureError, so except UnsupportedFeatureError still catches it. It emits UNSUPPORTED_FEATURE, correctable, with the details validated against the generated UnsupportedTargetingDimensionDetails. field is required. A replacement_dimension becomes the default suggestion. unsupported_targeting_dimension_details(error) reads the details back.

Buyer

  • normalize_mutation_outcome(error) is exported at top level. It returns None when the field is absent; any present value outside the three literals reads as "unknown".
  • New accessors: AdcpErrorInfo.mutation_outcome, ADCPTaskError.mutation_outcome (first entry), and TaskResult.mutation_outcome (from adcp_error, else the first errors[] entry).

Older callers: the 3.2 and 3.0 core/error.json schemas allow additional properties, so the field needs no gating.

Docs:

  • a handler-authoring.md section;
  • the inventory rows are marked done;
  • a MIGRATION_v9_to_v10.md row, since pre-dispatch rejections of mutating tools now carry a new key in adcp_error / errors[].

Tests

tests/test_mutation_outcome.py (32 tests) covers:

  • projection on MCP, A2A data, task errors and dispatch re-raise;
  • mark_not_applied;
  • through create_tool_caller:
    • a hook rejection and a strict schema rejection on create_media_buy → not_applied;
    • the same hook rejection on get_products → no claim;
    • a handler raise → no claim;
    • a handler's own unknown kept;
  • IDEMPOTENCY_CONFLICT through IdempotencyStore.wrap;
  • buyer normalization (absent, each value, unrecognized, empty, non-string, null), ADCPTaskError and TaskResult;
  • UnsupportedTargetingDimensionError shape, validation and reader.

The public API snapshot was regenerated (+normalize_mutation_outcome).

Closes #1642

🤖 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 — additive AdCP 3.3 mutation_outcome support plus UnsupportedTargetingDimensionError, no blocking or medium findings.

What I checked:

  • Dispatch boundary correctness: dispatched[0] is set at mcp_tools.py:4120 immediately before the single await method(call_params, ctx) call, so mark_not_applied (mcp_tools.py:536) only claims not_applied on pre-dispatch rejections and never on handler raises. Verified by tests at test_mutation_outcome.py:851-897.
  • Fail-closed normalization: buyer-side normalize_mutation_outcome maps unknown/empty/non-string to "unknown" (never discarded); parametrized at test_mutation_outcome.py:921-935.
  • Semver signal: feat: with all exports additive. Snapshot carries the one new top-level name normalize_mutation_outcome; decisioning additions aren't captured by snapshot_modules(). No public-surface break.

Risk posture: high_risk is true but all matched files are (modified) with no medium-or-worse finding, so the heuristic does not escalate (no deletions, no gated paths, no team gate). gated_paths is false; no no-auto-approve team match. Rows 1–8 do not fire → row 9 approve.

Base automatically changed from claude/issue-1780-multi-error-raise to next November 2, 2026 02:46
@bokelley
bokelley force-pushed the claude/issue-1642-mutation-outcome branch from 4cfaa1d to 523df2e Compare November 2, 2026 02:46

@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 pass, additive AdCP 3.3 error.mutation_outcome support.

No critical/high/medium findings. Walking the decision table top-down:

  • Rows 1 (no critical/high), 4/8 (no medium) do not fire.
  • Row 2: gated_paths is false (and review_decision is APPROVED anyway).
  • Row 3/5: high_risk is true but all high-risk reasons are (modified) with no medium finding on them; no (deleted) entries. No escalation trigger.
  • Row 6: prior decision was approve, not escalate — not sticky.
  • Row 7: no no-auto-approve team match.
  • Falls through to row 9 → approve.

What I checked: change is additive (new optional kwargs, exports, and properties), so feat: is the correct semver signal — no public-surface removal, no required→optional flip, no enum member removal. The mutating-tool gate was verified: call_tool only marks not_applied when is_mutating(method_name) and dispatch has not fired (mcp_tools.py:3670-3681, 4150-4151), so a post-dispatch handler raise keeps no claim. _error_entry_to_wire and to_wire propagate mutation_outcome to both adcp_error and errors[] consistently; mark_not_applied invalidates cached error_info. DR-0032/0033 minor-release pathway is covered: new UnsupportedTargetingDimensionError provides the refused pathway, and test_mutation_outcome.py (+301) covers framework-set, handler-kept, read, idempotency-conflict, and buyer-normalization paths. Public API snapshot fixture updated.

@bokelley
bokelley force-pushed the claude/issue-1642-mutation-outcome branch 2 times, most recently from 5c6067a to 9c786b4 Compare November 2, 2026 04:25

@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 pass — no critical/high/medium findings. This matches the prior approve decision on an earlier head; the delta equals the full PR and the reviewer re-verified it end to end.

What was checked:

  • Additive AdCP 3.3 error.mutation_outcome and UnsupportedTargetingDimensionError under feat(errors): — additive across the public surface, with public_api_snapshot.json and __all__ updated, so no semver-break signal is required.
  • Fail-safe dispatch gate: dispatched[0]=True set immediately before method(), so mark_not_applied fires only on pre-handler rejections of is_mutating tools — no not_applied claim once the handler runs.
  • mark_not_applied mutates ADCPTaskError.errors in place and invalidates the @cached_property error_info (exceptions.py:715) — the one sanctioned mutation of the otherwise-immutable list.
  • _mutation_outcome projection reaches adcp_error, errors[], A2A DataPart, and translate_error data; dispatch re-raise preserves the first-entry claim.
  • Minor-release pathway (DR-0032/0033): 3.2/3.0 core/error.json keep the field open, so it is droppable on the response path; buyer normalize_mutation_outcome (absent→None, non-string/empty/unrecognized→"unknown") is covered by parametrized tests at test_mutation_outcome.py:924-930.

high_risk is true but all reasons are (modified) with zero medium-or-higher findings, so no escalation trigger applies (rows 3/5 require a deletion or a medium finding). gated_paths is false; review_decision is APPROVED. No no-auto-approve team match. None of rows 1–8 fire → row 9 approve.

@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 AdCP 3.3 error.mutation_outcome field + UnsupportedTargetingDimensionError.

What I checked:

  • Semver signal: feat(errors): is correct. New optional kwarg (default None), new read-only properties, new exports — no removed/renamed/retyped public surface, no required→optional flips on public models, no enum member removals. Non-breaking, so release-please cutting a minor is correct.
  • Mutation-outcome dispatch gate (mcp_tools.py): dispatched[0]=True is set only immediately before await method(...), after all pre-dispatch validation/hooks/version-conversion. Pre-dispatch rejection → not_applied; handler raise stays unclaimed → INTERNAL_ERROR wrap; except Exception correctly leaves cancellation alone. IDEMPOTENCY_CONFLICT marks not_applied at the lookup site.
  • DR-0032 wire-pathway: field is droppable for 3.2 peers (open error schema, informational — dropping does not change spend/targeting/caps/account identity), documented in MIGRATION_v9_to_v10.md. Pathway test present (test_mutation_outcome.py).
  • Largest-file rule: test_mutation_outcome.py L921-935 pins the buyer-read normalization matrix — verified safe.
  • Public API snapshot: delta complete (top-level only).

Decision table: No critical/high/medium findings (row 1 N/A). gated_paths false (row 2 N/A). All high-risk reasons are (modified) with no medium finding, so rows 3/5 N/A. No data-loss/schema/infra medium (row 4 N/A). Prior decision was approve, not escalate (row 6 N/A). No no-auto-approve team match (row 7 N/A). Zero medium findings (<3, row 8 N/A). Falls through to row 9 → approve.

…ension details (#1642)

AdcpError(mutation_outcome=...) projects error.mutation_outcome onto
adcp_error and errors[] on MCP and A2A, and translate_error's A2A data;
an ADCPTaskError projects its first entry's claim, and the decisioning
dispatcher keeps it when it re-raises a task error as AdcpError.

The framework sets not_applied only where it holds pre-commit evidence:
a structured rejection create_tool_caller raises before a mutating
handler runs (schema and model validation, version gates, pre-validation
hooks) and IDEMPOTENCY_CONFLICT, detected on lookup before anything is
written. A claim the raise already makes is kept; errors a handler
raises, and the INTERNAL_ERROR wrap of an unexpected exception, carry no
claim. Reads never carry it.

UnsupportedTargetingDimensionError (an UnsupportedFeatureError) emits
UNSUPPORTED_FEATURE with error-details/unsupported-targeting-dimension
validated against the generated model and error.field at the targeting
path; unsupported_targeting_dimension_details reads it back.

Buyers read TaskResult.mutation_outcome, ADCPTaskError.mutation_outcome
and AdcpErrorInfo.mutation_outcome, normalized by
normalize_mutation_outcome: absent is None, an unrecognized, empty or
non-string value is "unknown".

The 3.2 and 3.0 core/error.json schemas allow additional properties, so
the field needs no gating for older callers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@bokelley
bokelley force-pushed the claude/issue-1642-mutation-outcome branch from 9c786b4 to 1dcc988 Compare November 2, 2026 05:42

@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 — AdCP 3.3 mutation_outcome plumbing is sound and purely additive on the public surface.

What I checked:

  • No critical/high/medium findings in this run; clean pass consistent with the prior approve (head 9c786b4).
  • Public-surface change is additive: new kw-only AdcpError(mutation_outcome=...), new exports, snapshot regenerated — feat: is the correct semver signal, no breaking-change gate triggered.
  • create_tool_caller sets not_applied only on pre-dispatch rejections of mutating tools, gated by a single dispatched[0] = True at the sole dispatch point (mcp_tools.py:4201); INTERNAL_ERROR legacy-projection wrap and reads carry no framework claim; IDEMPOTENCY_CONFLICT stamped at lookup in store.py/reservation.py.
  • mark_not_applied invalidates the error_info cached_property; projection reaches MCP, A2A and errors[]; buyer-side normalize_mutation_outcome reads unrecognized/empty/non-string as "unknown" per spec (tests/test_mutation_outcome.py:921-968).
  • 32 new tests cover every branch.

Gate evaluation:

  • No no-auto-approve team match.
  • gated_paths: false; review_decision: APPROVED.
  • high_risk: true but all reasons are (modified) with NO medium-or-worse findings — row 5 does not fire; no (deleted) entries (row 3 n/a).
  • Prior decision was approve, so sticky-escalation (row 6) n/a.

None of rows 1–8 fire → row 9 approve.

@bokelley
bokelley merged commit 40a82f5 into next Nov 2, 2026
41 checks passed
@bokelley
bokelley deleted the claude/issue-1642-mutation-outcome branch November 2, 2026 06:47
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.

1 participant