Skip to content

Commit 523df2e

Browse files
bokelleyclaude
andcommitted
feat(errors): AdCP 3.3 mutation_outcome and unsupported-targeting-dimension 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>
1 parent 0439d67 commit 523df2e

17 files changed

Lines changed: 630 additions & 8 deletions

‎MIGRATION_v9_to_v10.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ the SDK-relevant inventory is [docs/adcp-3.3-sdk-inventory.md](docs/adcp-3.3-sdk
3535
| `PrincipalService` accepts `3.3` async payloads | The default `accepted_async_adcp_versions` is `("3.2", "3.3")` (was `("3.2",)`), since the SDK emits 3.3-line payloads. A seller pinned to 3.2 passes `accepted_async_adcp_versions=("3.2",)`. |
3636
| Schema defaults appear in dumps for new 3.3 fields | `GetProductsRequest.include_collections` (`false`) and `proposal_refinement.hold_extension` (`false`) carry schema defaults, so a dumped model declares them. Tests that compare whole dictionaries need the new keys; a 3.3 → 3.2 downgrade drops them with a recorded loss. |
3737
| 3.3 → 3.2 downgrades apply semantic rules | A buyer below 3.3 of a seller answering in 3.3 no longer gets `VERSION_UNSUPPORTED` for these responses: `by_spot` rows without `aired_at` (`scheduled` / `preempted`) and delivery rows without `impressions` are omitted (losses `row_omitted_not_aired`, `row_omitted_impressions_not_measured`), never sent with `impressions: 0`; `media_buy.features.catalog_ingestion` and a `price_breakdown` carrying an `operator_fee` adjustment are dropped; `age_determination_bases` moves to `ext.adcp.age_determination_bases`, and a 3.2 response upgraded to 3.3 reads it back into an absent top-level field. Requests the 3.2 contract cannot express fail with `ADCPVersionConversionError` (`UNSUPPORTED_FEATURE` on the client) before sending: a `reporting_webhook` / `artifact_webhook` without `authentication` (keep sending it while you buy from 3.2 sellers; the SDK never invents one) and a daypart with `start_time` / `end_time` (use `start_hour` / `end_hour`; the SDK never rounds). The downgrade's final check now validates formats as the SDK validators do: earlier betas let such a request through invalid when it carried `start_time: "asap"`. See [docs/cross-version-conversion.md](docs/cross-version-conversion.md#33--32-semantic-rules). |
38+
| Errors can state `mutation_outcome` | AdCP 3.3 `error.mutation_outcome` says whether a failed state-changing request changed seller state. A structured rejection the framework raises before a mutating handler runs (schema or model validation, version gates, pre-validation hooks) and `IDEMPOTENCY_CONFLICT` now carry `"mutation_outcome": "not_applied"` in `adcp_error` and `errors[]`; a test that compares whole error envelopes sees the new key. Set it on your own raises with `AdcpError(..., mutation_outcome=...)` only with the evidence the spec requires; leave it unset otherwise. Buyers read `TaskResult.mutation_outcome`, `ADCPTaskError.mutation_outcome` or `AdcpErrorInfo.mutation_outcome` (unrecognized values read as `"unknown"`). Reject an unsupported or removed targeting dimension with `UnsupportedTargetingDimensionError` (`UNSUPPORTED_FEATURE` with the `unsupported-targeting-dimension` details) instead of minting `UNSUPPORTED_TARGETING_DIMENSION` / `DEPRECATED_TARGETING_DIMENSION`. |
3839
| Delivery rows may omit `impressions` | Every `by_*` delivery-metrics row makes `impressions` optional. Treat absence as "not measured", never as zero. |
3940
| Pixel trackers for AdCP 3.3 playback events are dropped when downgraded to the v1 url-tracker shape | `downgrade_pixel_tracker` returns `PixelTrackerDowngrade(v1=None, ...)` with a `PIXEL_TRACKER_LOSSY_DOWNGRADE` advisory for `start`, `first_quartile`, `midpoint`, `third_quartile` and `complete`, and for their 3.2 interim form (`event: custom` with `custom_event_name` set to one of them). SDK 9 put the interim form in the `impression_tracker` slot, which counted every impression as a quartile view. Check `result.v1 is not None` before reading it. `downgrade_pixel_trackers` leaves dropped trackers out of `items`. See the AdCP [3.2 to 3.3 migration guide](https://docs.adcontextprotocol.org/docs/reference/migration/3-2-to-3-3#hosted-video-and-audio-playback-trackers). |
4041
| Pixel trackers for AdCP 3.3 playback events reach 3.2 peers in the interim `custom` form | The 3.3 → 3.2 conversion (a buyer's request to a 3.2 seller, a seller's response to a 3.2 buyer) rewrites a `pixel_tracker` whose `event` is `start`, `first_quartile`, `midpoint`, `third_quartile` or `complete` to `event: "custom"` with `custom_event_name` set to that value, and records a `playback_event_as_custom` loss on the `event` pointer. Earlier SDK 10 betas refused it with `ADCPVersionConversionError` (`UNSUPPORTED_FEATURE` on the client, `VERSION_UNSUPPORTED` on the server). A 3.2 seller that does not recognize the custom name no-ops it, or rejects it (`tracker_contract_mismatch`) under a `complete: true` tracker execution contract that does not list it. Upgrades leave the `custom` form as sent: a tracker execution selector matches it, not the 3.3 event. Nothing to change unless you match on conversion loss reasons. |

‎docs/adcp-3.3-sdk-inventory.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ it adopted the bundle, and what is left to the codegen/wire-fidelity (2) and
5555
| `hold_policy`, `extend_hold`, `holds`, `hold_rank`, `hold_placed_at`, `proposal_initiator` | `core/hold-policy.json`; `Product`, `CanonicalProduct`, `Proposal` (`refine_proposals`), `ProposalRefinement.Action.extend_hold`; capabilities `proposal_refinement.hold_extension`, `hold_semantics` | Experimental holds. `hold_extension` has a schema default `false`, so the generated capability model emits it. | generated |
5656
| `include_collections` / `collections[]` | `get_products`, `list_products`, `request_proposals`, `refine_proposals` requests and responses; `GetBrandIdentityResponse.collections`; `Collection` gains `images`, `sample_content`, `url`, `publisher_domain`; `CollectionDistribution.followers` | Experimental collection cards. `include_collections` defaults to `false` and is materialized on dump; the 3.3 → 3.2 downgrade drops it with a recorded loss. | generated |
5757
| `later_proposals` | `request_proposals` responses, `OpportunityContext`, `core/later-proposals-state.json`, enum `later-proposal-reason` | Experimental `open_opportunities`. | generated |
58-
| `mutation_outcome` | `core/error.json` (`not_applied`, `unknown`, `applied`) | Every error can state whether the mutation applied. | generated; populate from the dispatcher on mutation errors: (3), #1642 |
58+
| `mutation_outcome` | `core/error.json` (`not_applied`, `unknown`, `applied`) | Every error can state whether the mutation applied. | generated; `AdcpError(mutation_outcome=...)` projected on MCP, A2A and `errors[]`; the framework sets `not_applied` on pre-dispatch rejections of mutating tools and on `IDEMPOTENCY_CONFLICT`; buyer `TaskResult.mutation_outcome` / `AdcpErrorInfo.mutation_outcome`: done (3), #1642 |
5959
| `operator_fee` | enum `adjustment-kind` | New price adjustment kind; send only to 3.3 peers. | generated; the 3.2 downgrade omits a price breakdown carrying it: done (3), #1667 |
6060
| `change_cutoff` | `core/installment-deadlines.json` | Installment change cutoff. | generated |
6161
| `budget.periods[]`, `budget_period_id` | `governance/sync-plans-request.json` `Period`; `check_governance` request/response; capability `governance.supports_budget_periods`; `x-entity-types` `governance_budget_period` | Experimental budget periods; buyer sends `periods[]` only to 3.3 peers. | generated; governance conversion: (3) |
@@ -116,7 +116,7 @@ it adopted the bundle, and what is left to the codegen/wire-fidelity (2) and
116116
| `request_signing.profiles`, `operation_sources`; `webhook_signing.profiles` | capabilities `RequestSigning.profiles` (`adcp/request-signing/v1`, `web-bot-auth`), `operation_sources` (`mcp_tools_call`, `a2a_invocation_skill`) | `required_for` enforced over A2A with `operation_sources` as the advertisement. | A2A hardening enforcement: done (3), #1659 (33 vectors pass); advertising `operation_sources`: (3), #1637 |
117117
| `ext.adcp` reserved | `extensions/adcp.json` `AdcpOwnedExtensions` | Reserved namespace for the working group. | generated |
118118
| `media_buy.features.catalog_ingestion` | `core/catalog-ingestion-capability.json` (`IngestionMode`, `ItemStatusReporting`); requires `catalog_management: true` | An object: fails 3.2 capability schemas (booleans only); the 3.3 → 3.2 downgrade must coarsen or drop it. `media_buy.features` is no longer boolean-only. | generated; downgrade drops it (`field_unavailable`): done (3), #1667 |
119-
| `error-details/unsupported-targeting-dimension.json` | under `UNSUPPORTED_FEATURE`; adapters minting `UNSUPPORTED_TARGETING_DIMENSION` / `DEPRECATED_TARGETING_DIMENSION` should migrate | | generated; decisioning error minting: (3), #1642 |
119+
| `error-details/unsupported-targeting-dimension.json` | under `UNSUPPORTED_FEATURE`; adapters minting `UNSUPPORTED_TARGETING_DIMENSION` / `DEPRECATED_TARGETING_DIMENSION` should migrate | | generated; `UnsupportedTargetingDimensionError` and `unsupported_targeting_dimension_details`: done (3), #1642 |
120120
| `error-details/agent-permission-denied.json` `Reason` | new nested enum | | generated |
121121
| `brand.json` fetch cap 2 MiB | all fetch paths; `MAX_JWKS_BYTES`, `MAX_CAPABILITIES_BYTES` unchanged | 3.2 verifiers may reject files between 256 KiB and 2 MiB. | done (3), #1654: 2 MiB, depth 32, 10 s total deadline, bounded concurrent fetches; follow-ups on #1635 |
122122
| Property `domain` / `subdomain` lowercase ASCII hostnames | property schemas; invalid values → `INVALID_REQUEST` | | `PlatformHandler.create_property_list` / `update_property_list` reject invalid `domain` / `subdomain` identifiers with `INVALID_REQUEST` (`validate_property_list_identifiers`); `MCPPropertyListFetcher` rejects a snapshot carrying one instead of canonicalizing it; sender normalizer `adcp.property_lists.normalize_property_list_identifiers`: done (3), #1668 |
@@ -154,8 +154,8 @@ Every new declaration is optional; 3.2 behaviour stands when undeclared.
154154
| ~~`REFERENCE_DEFINITION_CHANGED`~~ (withdrawn in 3.3.0-beta.2, section 12) | `error-details/reference-definition-changed.json` | `accept_proposal` when a pinned definition drifted; send only to 3.3 peers | generated; until then the 3.2 downgrade keeps it, since 3.2 `error.code` is open (#1667); minting and 3.2 downgrade mapping: (3), #1641 |
155155
| `CREATIVE_MERGE_REJECTED` | `error-details/creative-merge-rejected.json` (`Rejection`, reason enum) | `sync_creatives` merges | `CreativeMergeRejectedError`, `creative_merge_rejected_details`: done (3), #1668 |
156156
| `RIGHTS_CHANNEL_VIOLATION` | `enums/error-code.json` | rights constraints `channels` / `excluded_channels` | `RightsChannelViolationError` (details `rights_id`, `channel`, `capability_id`; usable as an advisory `errors[]` entry via `to_wire()`), `rights_channel_violation_details`: done (3), #1668 |
157-
| `UNSUPPORTED_FEATURE` + `unsupported-targeting-dimension` | `error-details/unsupported-targeting-dimension.json` (`SupportedDimension`) | replaces adapter-minted `UNSUPPORTED_TARGETING_DIMENSION` / `DEPRECATED_TARGETING_DIMENSION` | generated; (3), #1642 |
158-
| `mutation_outcome` | `core/error.json` | every error (`not_applied`, `unknown`, `applied`) | generated; (3), #1642 |
157+
| `UNSUPPORTED_FEATURE` + `unsupported-targeting-dimension` | `error-details/unsupported-targeting-dimension.json` (`SupportedDimension`) | replaces adapter-minted `UNSUPPORTED_TARGETING_DIMENSION` / `DEPRECATED_TARGETING_DIMENSION` | generated; `UnsupportedTargetingDimensionError`: done (3), #1642 |
158+
| `mutation_outcome` | `core/error.json` | every error (`not_applied`, `unknown`, `applied`) | generated; done (3), #1642 |
159159
| ~~`definition_changed`~~ (withdrawn in 3.3.0-beta.2, section 12) | `enums/proposal-refinement-reason.json` | `refine_proposals` `unable` with `drifted_pins[]` | generated |
160160
| `REVISION_UNREADABLE` with `reporting_revision_id` | 3.2.4 | already in SDK 9 | done |
161161
| `request_body_malformed` | A2A hardening (strict unsigned-body parsing, Part shape rules, task-continuation binding) becomes MUST | | done (3), #1659 |

‎docs/handler-authoring.md‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -829,6 +829,49 @@ the list as `AdcpError("INVALID_REQUEST", message="2 problems", errors=problems)
829829
A list in `details["errors"]` whose entries carry a string `code` and
830830
`message` is lifted into `errors[]` too.
831831

832+
### Did the failed mutation apply? (`mutation_outcome`)
833+
834+
AdCP 3.3 lets an error on a state-changing request say whether seller state
835+
changed, so the buyer knows whether to re-plan:
836+
837+
- `not_applied`: you hold pre-commit evidence that nothing was written, such
838+
as a typed rejection from validation that runs before the first write.
839+
- `unknown`: some of the change may have committed, and the idempotency claim
840+
is fenced so a same-key resend cannot run the mutation again.
841+
- `applied`: the change committed and the error reports a later failure.
842+
843+
Leave it unset on reads, on warnings and whenever you make no claim. A
844+
timeout, a crash, or anything raised during or after a write is not
845+
pre-commit evidence.
846+
847+
```python
848+
raise AdcpError("BUDGET_TOO_LOW", message="budget below minimum",
849+
recovery="correctable", field="packages[0].budget",
850+
mutation_outcome="not_applied")
851+
```
852+
853+
The framework sets `not_applied` itself on the rejections it raises before a
854+
mutating handler runs (schema and model validation, version gates,
855+
pre-validation hooks) and on `IDEMPOTENCY_CONFLICT`. It never sets the field
856+
on an error your method raises, and never on the `INTERNAL_ERROR` that wraps an
857+
unexpected exception.
858+
859+
Reject an unsupported or removed targeting dimension with
860+
`UnsupportedTargetingDimensionError`, which emits `UNSUPPORTED_FEATURE` with
861+
the `unsupported-targeting-dimension` details:
862+
863+
```python
864+
from adcp.decisioning import UnsupportedTargetingDimensionError
865+
866+
raise UnsupportedTargetingDimensionError(
867+
unsupported_dimension="geo_metros",
868+
field="packages[0].targeting_overlay.geo_metros",
869+
deprecated=True,
870+
replacement_dimension="geo_regions",
871+
mutation_outcome="not_applied",
872+
)
873+
```
874+
832875
## Response builders
833876

834877
Manual `model_dump()` on response Pydantic objects is error-prone —

‎src/adcp/__init__.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,7 @@ def _resolve_version() -> str:
293293
"AccountRequestError",
294294
"ConfigurationError",
295295
"extract_adcp_error_info",
296+
"normalize_mutation_outcome",
296297
"is_pre_dispatch_error",
297298
"is_pre_dispatch_error",
298299
"IdempotencyConflictError",
@@ -1682,6 +1683,7 @@ def get_adcp_version() -> str:
16821683
"AdcpErrorInfo",
16831684
"BuyerReasonInfo",
16841685
"extract_adcp_error_info",
1686+
"normalize_mutation_outcome",
16851687
"AccountNotFoundError",
16861688
"AccountSetupRequiredError",
16871689
"AccountPaymentRequiredError",
@@ -1968,6 +1970,7 @@ def get_adcp_version() -> str:
19681970
RegistryValidationIssue,
19691971
extract_adcp_error_info,
19701972
is_pre_dispatch_error,
1973+
normalize_mutation_outcome,
19711974
)
19721975
from adcp.feed_mirror import (
19731976
EventHandler,

‎src/adcp/decisioning/__init__.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,9 +104,11 @@ def create_media_buy(
104104
ServiceUnavailableError,
105105
TermsRejectedError,
106106
UnsupportedFeatureError,
107+
UnsupportedTargetingDimensionError,
107108
ValidationError,
108109
creative_merge_rejected_details,
109110
rights_channel_violation_details,
111+
unsupported_targeting_dimension_details,
110112
)
111113
from adcp.decisioning.helpers import ref_account_id
112114
from adcp.decisioning.implementation_config import ProductConfigStore
@@ -270,6 +272,7 @@ def create_media_buy(
270272
AdcpError,
271273
DiscoveryResult,
272274
MaybeAsync,
275+
MutationOutcome,
273276
SalesResult,
274277
SyncAccountsResultRow,
275278
SyncGovernanceEntry,
@@ -390,6 +393,7 @@ def __init__(self, *args: object, **kwargs: object) -> None:
390393
"AccountStoreUpsert",
391394
"AccountStoreUpsertRequest",
392395
"AdcpError",
396+
"MutationOutcome",
393397
"ApiKey",
394398
"ApiKeyCredential",
395399
"AuthContext",
@@ -517,6 +521,8 @@ def __init__(self, *args: object, **kwargs: object) -> None:
517521
"TranslationMap",
518522
"UNKNOWN_UPDATE_ACTION",
519523
"UnsupportedFeatureError",
524+
"UnsupportedTargetingDimensionError",
525+
"unsupported_targeting_dimension_details",
520526
"TermsRejectedError",
521527
"CreativeMergeRejectedError",
522528
"RightsChannelViolationError",

‎src/adcp/decisioning/dispatch.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -737,7 +737,7 @@ def _task_error_to_adcp_error(exc: ADCPTaskError) -> AdcpError:
737737
both paths answer with the same code, message, recovery, field,
738738
suggestion and sanitized details.
739739
"""
740-
from adcp.server.translate import _extract_structured_fields
740+
from adcp.server.translate import _extract_structured_fields, _mutation_outcome
741741

742742
code, message, recovery, field, suggestion, details, _errors = _extract_structured_fields(exc)
743743
return AdcpError(
@@ -747,6 +747,7 @@ def _task_error_to_adcp_error(exc: ADCPTaskError) -> AdcpError:
747747
field=field,
748748
suggestion=suggestion,
749749
details=details,
750+
mutation_outcome=typing.cast(Any, _mutation_outcome(exc)),
750751
)
751752

752753

0 commit comments

Comments
 (0)