Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions MIGRATION_v9_to_v10.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ the SDK-relevant inventory is [docs/adcp-3.3-sdk-inventory.md](docs/adcp-3.3-sdk
| 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. |
| 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). |
| Reporting and artifact webhooks without `authentication` must be signed | On a request served at AdCP 3.3, `reporting_webhook` / `artifact_webhook` may omit `authentication`; the seller then signs every delivery under the registration's `signing_profile` (`adcp/webhook-signing/v1` when omitted). `WebhookSender.send_media_buy_delivery`, `prepare_media_buy_delivery` + `send_prepared`, and `prepare_artifact_webhook` / `send_artifact_webhook` with the new `registration=` argument now raise `WebhookSigningProfileUnavailableError` before any network I/O when such a registration would be sent by a bearer or HMAC sender (SDK 9 sent the sender's bearer token or HMAC anyway) or names `web-bot-auth`, which the SDK does not yet sign. Deliver to these registrations with `WebhookSender.from_jwk` / `from_pem`. Check registrations on receipt with `adcp.webhooks.webhook_signing_profile_for(registration, adcp_version=...)`, which raises `WebhookRegistrationError` (`INVALID_REQUEST`) for a missing `authentication` below 3.3, for `authentication` combined with `signing_profile`, and for an unknown profile. `PreparedWebhook` gains `required_signing_profile` (default `None`). `WebhookReceiver` now drops `aggregated_totals` from a `media_buy_delivery` result before parsing instead of passing it through; read totals from `media_buy_deliveries[]`. See [docs/webhooks/reporting-operation-id-3.2.md](docs/webhooks/reporting-operation-id-3.2.md#adcp-33-registrations-without-authentication). |
| 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`. |
| Delivery rows may omit `impressions` | Every `by_*` delivery-metrics row makes `impressions` optional. Treat absence as "not measured", never as zero. |
| 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). |
| 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. |
Expand Down
8 changes: 4 additions & 4 deletions docs/adcp-3.3-sdk-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ it adopted the bundle, and what is left to the codegen/wire-fidelity (2) and
| `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 |
| `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 |
| `later_proposals` | `request_proposals` responses, `OpportunityContext`, `core/later-proposals-state.json`, enum `later-proposal-reason` | Experimental `open_opportunities`. | generated |
| `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 |
| `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 |
| `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 |
| `change_cutoff` | `core/installment-deadlines.json` | Installment change cutoff. | generated |
| `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) |
Expand Down Expand Up @@ -116,7 +116,7 @@ it adopted the bundle, and what is left to the codegen/wire-fidelity (2) and
| `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 |
| `ext.adcp` reserved | `extensions/adcp.json` `AdcpOwnedExtensions` | Reserved namespace for the working group. | generated |
| `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 |
| `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 |
| `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 |
| `error-details/agent-permission-denied.json` `Reason` | new nested enum | | generated |
| `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 |
| 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 |
Expand Down Expand Up @@ -154,8 +154,8 @@ Every new declaration is optional; 3.2 behaviour stands when undeclared.
| ~~`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: not built, #1641 closed as withdrawn (moved to 4.0, adcontextprotocol/adcp#8537) |
| `CREATIVE_MERGE_REJECTED` | `error-details/creative-merge-rejected.json` (`Rejection`, reason enum) | `sync_creatives` merges | `CreativeMergeRejectedError`, `creative_merge_rejected_details`: done (3), #1668 |
| `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 |
| `UNSUPPORTED_FEATURE` + `unsupported-targeting-dimension` | `error-details/unsupported-targeting-dimension.json` (`SupportedDimension`) | replaces adapter-minted `UNSUPPORTED_TARGETING_DIMENSION` / `DEPRECATED_TARGETING_DIMENSION` | generated; (3), #1642 |
| `mutation_outcome` | `core/error.json` | every error (`not_applied`, `unknown`, `applied`) | generated; (3), #1642 |
| `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 |
| `mutation_outcome` | `core/error.json` | every error (`not_applied`, `unknown`, `applied`) | generated; done (3), #1642 |
| ~~`definition_changed`~~ (withdrawn in 3.3.0-beta.2, section 12) | `enums/proposal-refinement-reason.json` | `refine_proposals` `unable` with `drifted_pins[]` | generated |
| `REVISION_UNREADABLE` with `reporting_revision_id` | 3.2.4 | already in SDK 9 | done |
| `request_body_malformed` | A2A hardening (strict unsigned-body parsing, Part shape rules, task-continuation binding) becomes MUST | | done (3), #1659 |
Expand Down
43 changes: 43 additions & 0 deletions docs/handler-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -829,6 +829,49 @@ the list as `AdcpError("INVALID_REQUEST", message="2 problems", errors=problems)
A list in `details["errors"]` whose entries carry a string `code` and
`message` is lifted into `errors[]` too.

### Did the failed mutation apply? (`mutation_outcome`)

AdCP 3.3 lets an error on a state-changing request say whether seller state
changed, so the buyer knows whether to re-plan:

- `not_applied`: you hold pre-commit evidence that nothing was written, such
as a typed rejection from validation that runs before the first write.
- `unknown`: some of the change may have committed, and the idempotency claim
is fenced so a same-key resend cannot run the mutation again.
- `applied`: the change committed and the error reports a later failure.

Leave it unset on reads, on warnings and whenever you make no claim. A
timeout, a crash, or anything raised during or after a write is not
pre-commit evidence.

```python
raise AdcpError("BUDGET_TOO_LOW", message="budget below minimum",
recovery="correctable", field="packages[0].budget",
mutation_outcome="not_applied")
```

The framework sets `not_applied` itself on the rejections it raises before a
mutating handler runs (schema and model validation, version gates,
pre-validation hooks) and on `IDEMPOTENCY_CONFLICT`. It never sets the field
on an error your method raises, and never on the `INTERNAL_ERROR` that wraps an
unexpected exception.

Reject an unsupported or removed targeting dimension with
`UnsupportedTargetingDimensionError`, which emits `UNSUPPORTED_FEATURE` with
the `unsupported-targeting-dimension` details:

```python
from adcp.decisioning import UnsupportedTargetingDimensionError

raise UnsupportedTargetingDimensionError(
unsupported_dimension="geo_metros",
field="packages[0].targeting_overlay.geo_metros",
deprecated=True,
replacement_dimension="geo_regions",
mutation_outcome="not_applied",
)
```

## Response builders

Manual `model_dump()` on response Pydantic objects is error-prone —
Expand Down
3 changes: 3 additions & 0 deletions src/adcp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@ def _resolve_version() -> str:
"AccountRequestError",
"ConfigurationError",
"extract_adcp_error_info",
"normalize_mutation_outcome",
"is_pre_dispatch_error",
"is_pre_dispatch_error",
"IdempotencyConflictError",
Expand Down Expand Up @@ -1688,6 +1689,7 @@ def get_adcp_version() -> str:
"AdcpErrorInfo",
"BuyerReasonInfo",
"extract_adcp_error_info",
"normalize_mutation_outcome",
"AccountNotFoundError",
"AccountSetupRequiredError",
"AccountPaymentRequiredError",
Expand Down Expand Up @@ -1974,6 +1976,7 @@ def get_adcp_version() -> str:
RegistryValidationIssue,
extract_adcp_error_info,
is_pre_dispatch_error,
normalize_mutation_outcome,
)
from adcp.feed_mirror import (
EventHandler,
Expand Down
6 changes: 6 additions & 0 deletions src/adcp/decisioning/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,11 @@ def create_media_buy(
ServiceUnavailableError,
TermsRejectedError,
UnsupportedFeatureError,
UnsupportedTargetingDimensionError,
ValidationError,
creative_merge_rejected_details,
rights_channel_violation_details,
unsupported_targeting_dimension_details,
)
from adcp.decisioning.helpers import ref_account_id
from adcp.decisioning.implementation_config import ProductConfigStore
Expand Down Expand Up @@ -278,6 +280,7 @@ def create_media_buy(
AdcpError,
DiscoveryResult,
MaybeAsync,
MutationOutcome,
SalesResult,
SyncAccountsResultRow,
SyncGovernanceEntry,
Expand Down Expand Up @@ -401,6 +404,7 @@ def __init__(self, *args: object, **kwargs: object) -> None:
"AccountStoreUpsert",
"AccountStoreUpsertRequest",
"AdcpError",
"MutationOutcome",
"ApiKey",
"ApiKeyCredential",
"AuthContext",
Expand Down Expand Up @@ -531,6 +535,8 @@ def __init__(self, *args: object, **kwargs: object) -> None:
"TranslationMap",
"UNKNOWN_UPDATE_ACTION",
"UnsupportedFeatureError",
"UnsupportedTargetingDimensionError",
"unsupported_targeting_dimension_details",
"TermsRejectedError",
"CreativeMergeRejectedError",
"RightsChannelViolationError",
Expand Down
3 changes: 2 additions & 1 deletion src/adcp/decisioning/dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -737,7 +737,7 @@ def _task_error_to_adcp_error(exc: ADCPTaskError) -> AdcpError:
both paths answer with the same code, message, recovery, field,
suggestion and sanitized details.
"""
from adcp.server.translate import _extract_structured_fields
from adcp.server.translate import _extract_structured_fields, _mutation_outcome

code, message, recovery, field, suggestion, details, _errors = _extract_structured_fields(exc)
return AdcpError(
Expand All @@ -747,6 +747,7 @@ def _task_error_to_adcp_error(exc: ADCPTaskError) -> AdcpError:
field=field,
suggestion=suggestion,
details=details,
mutation_outcome=typing.cast(Any, _mutation_outcome(exc)),
)


Expand Down
Loading
Loading