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 @@ -55,6 +55,7 @@ the SDK-relevant inventory is [docs/adcp-3.3-sdk-inventory.md](docs/adcp-3.3-sdk
| Generated enums name each value once | The `_1` alias members codegen emitted when an `anyOf` arm repeated an enum value (`System.zip_1`, `Field17.ad_request_url_1`, 19 enums on 10.0.0b4) are gone; each value has one member, the first-declared one. Code that referenced an alias name (`System.zip_1`) must use the real member (`System.zip`); `System("zip")` and `member.value` are unchanged. |
| `list_account_changes` pages are checked and projected per caller | AdCP 3.3 requires non-empty `changed_paths` on every `adcp` / `seller_operator` change record (`[""]` for a whole-resource create, delete or purge) and lets the originating request's `idempotency_key` reach only the principal that sent it. A handler on 3.3 or later that returns such a record without `changed_paths` now fails the call with `VALIDATION_ERROR` (`field` `/changes/N/changed_paths`), whatever the validation mode; and every record loses `idempotency_key` unless it is an `adcp` record built with `adcp.account_change_feed.attribute_account_change(record, originator)` whose originator is the caller (`ToolContext.tenant_id` + `caller_identity`). Persist the originator when the mutation commits and attribute records when serving the page. `WebhookSender.prepare_account_change_recorded` / `send_account_change_recorded` also accept the change record itself (with `subscriber_id=`), refuse one without `changed_paths`, and never carry its `idempotency_key`. |
| `control_media_buy` refuses an undeclared `start_requires_all_creatives_approved: true` | AdCP 3.3 lets a buyer hold a package's first enablement for serving until every assigned creative is approved, and requires a seller without `media_buy.features.creative_start_policy: true` to reject `true` with `UNSUPPORTED_FEATURE` before mutation. For a handler on 3.3 or later, the dispatcher now answers such a request itself (`recovery: "correctable"`, `field` `packages[N].start_requires_all_creatives_approved` for the first opted-in package, `details.fields` listing every one, `mutation_outcome: "not_applied"`); the handler never sees it. `false` and omission still reach the handler. To accept `true`, honor the gate and declare the feature: `MediaBuy(features=Features(creative_start_policy=True))` on `DecisioningCapabilities` (or from `get_adcp_capabilities_for_request` for one tenant), or `media_buy.features.creative_start_policy: true` in your own `get_adcp_capabilities` response, which the dispatcher reads only for an opted-in request. `create_media_buy` and `update_media_buy` are not checked: 3.3 withdraws the field from their packages. |
| The client refuses `start_requires_all_creatives_approved: true` to a seller known not to declare it | AdCP 3.3 forbids buyers from sending `true` without the seller's `media_buy.features.creative_start_policy: true`. On a session negotiated at 3.3 or later, `control_media_buy` now returns a failed `TaskResult` before anything is sent when the client's cached capabilities lack the feature: `dispatched=False`, `adcp_error.code` `UNSUPPORTED_FEATURE`, `adcp_error.field` naming the first opted-in package, `adcp_error.details` with `feature` and every opted-in `fields` entry (the seller's own vocabulary). A client that has not discovered the seller (a pinned `server_version`) still sends, and the seller decides. `false` and omission are unaffected, as are sessions below 3.3. Check `client.supports("creative_start_policy")` before opting in. `ADCPFeatureUnsupportedError` gains optional `field` and `details`, which such a failed result carries. |
| `get_adcp_capabilities` reports `adcp.implementation` | `serve()` (and `adcp.decisioning.serve()`) now adds AdCP 3.3 `adcp.implementation.sdks[]` to capabilities served at 3.3 or later: one entry `{"name": "adcp", "version": <installed adcp version>, "components": [...]}`, listing only components the server provably wires agent-wide (`transport` always; `types` for Decisioning platforms; `schema_validation` with strict request and response validation and no `2.5` in `supported_versions`; `authentication` with `auth=BearerTokenAuth(...)` and no widened discovery set; `idempotency` when every advertised mutating task is `@IdempotencyStore.wrap`ped; `webhooks` / `signing` only when the SDK owns and signs every outbound webhook; `lifecycle` never automatically). A 3.0 to 3.2 caller never sees it. Tests that compare a whole 3.3 capabilities response need the new key. Opt out with `serve(report_implementation=False)` (or `ServeConfig(report_implementation=False)`); add components or other SDKs with `ImplementationReport(extra_components=[...], extra_sdks=[...])`; or declare the block yourself (`Adcp(implementation=...)` on `DecisioningCapabilities`, `capabilities_response(implementation=...)`, or in your own handler's response), which the SDK never overwrites. `adcp.server.adcp_sdk_entry()` builds this SDK's entry for a hand-written block. `DecisioningCapabilities.adcp.implementation` was previously dropped from the projection; it is now emitted. |

## Reporting storage contract (adcp-reporting-storage)
Expand Down
2 changes: 1 addition & 1 deletion docs/adcp-3.3-sdk-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ adcontextprotocol/adcp changesets released in it.
| `report_media_buy_issue`, `get_media_buy_issue` (new tasks) | Seller-side issue reports with acknowledgement and resolution status; product service commitments (`creative_review_sla`, `escalation_sla`, business calendars). Their responses compose neither envelope (pinned in `tests/test_task_message_lattice.py`). | types generated; client, handler, adapters, MCP tool list, CLI wired |
| Reporting subscriptions | Experimental `media_buy.reporting_subscriptions`: `reporting_subscriptions` on `sync_principal` / `get_principal`, `subscription_coverage[]` on `get_reporting_status`, `configuration_tasks[]` and `subscriptions` on `media_buy.reporting_delivery`, `enums/reporting-dimension.json`, `principal.changed` reason `reporting_subscription_changed`. | generated; no SDK-side subscription engine yet |
| `control_media_buy` responses | The response arms are open (`additionalProperties: true`). `tests/test_discovery_payload_size.py` now proves compaction keeps closed objects closed on the reporting `Limitation`. | generated |
| `start_requires_all_creatives_approved` | `control_media_buy` `packages[]` (`media-buy/package-control.json`); capability `media_buy.features.creative_start_policy`; package action `update_creative_start_policy`; `get_media_buys` readback | Initial-start gate: hold first enablement for serving until every current assignment is approved. A seller that does not declare the capability MUST reject `true` with `UNSUPPORTED_FEATURE` before mutation; `false` is the default. AdCP 3.3 withdraws the field from `create_media_buy` / `update_media_buy` packages (adcontextprotocol/adcp#8767), whose open schemas ignore it. | generated; the dispatcher refuses `true` before the handler runs when the handler is on 3.3 or later and its capabilities (the request-scoped `DecisioningCapabilities`, or the handler's own `get_adcp_capabilities` response) do not declare the feature: done (3), #1869. Honoring the gate is the seller's |
| `start_requires_all_creatives_approved` | `control_media_buy` `packages[]` (`media-buy/package-control.json`); capability `media_buy.features.creative_start_policy`; package action `update_creative_start_policy`; `get_media_buys` readback | Initial-start gate: hold first enablement for serving until every current assignment is approved. A seller that does not declare the capability MUST reject `true` with `UNSUPPORTED_FEATURE` before mutation; `false` is the default. AdCP 3.3 withdraws the field from `create_media_buy` / `update_media_buy` packages (adcontextprotocol/adcp#8767), whose open schemas ignore it. | generated; the dispatcher refuses `true` before the handler runs when the handler is on 3.3 or later and its capabilities (the request-scoped `DecisioningCapabilities`, or the handler's own `get_adcp_capabilities` response) do not declare the feature: done (3), #1869. The client refuses `true` before sending (`dispatched=False`, same `UNSUPPORTED_FEATURE` vocabulary) when its cached capabilities for a 3.3 session lack the feature, and sends when it has not discovered the seller: done (3), #1876. Honoring the gate is the seller's |
| Other beta.1 additions | Canonical product installments, invoice lines on account financials, selection-dependent price adjustments (`pricing-options/price-adjustments.json`, `INVALID_REQUEST` details `error-details/price-adjustments-mismatch.json`), signal delivery methods and pricing groups, portable gender targeting, product execution readiness, audience-evidence targets, purchase-rights scope (withdrawn in 3.3.0-beta.2, section 12). | generated |

## 12. 3.3.0-beta.2 delta
Expand Down
22 changes: 22 additions & 0 deletions docs/media-buy-action-rights.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,28 @@ assessment plus the generated request model for that task. A
`seller_managed` action uses the ordinary asynchronous task lifecycle; it does
not introduce a seller-review MediaBuy status.

### Creative start policy

`packages[].start_requires_all_creatives_approved: true` on `control_media_buy`
holds a package's first enablement for serving until every assigned creative is
approved. AdCP 3.3 forbids sending `true` to a seller whose capabilities do not
declare `media_buy.features.creative_start_policy: true`. When the client has
already discovered the seller (its cached `get_adcp_capabilities` response, the
same one version negotiation uses), a call that sets `true` without that
declaration returns a failed `TaskResult` before anything is sent:
`dispatched=False`, `adcp_error.code="UNSUPPORTED_FEATURE"`, `adcp_error.field`
naming the first opted-in package
(`packages[0].start_requires_all_creatives_approved`), and `adcp_error.details`
carrying `feature` (`media_buy.features.creative_start_policy`) and every
opted-in `fields` entry. The vocabulary matches the seller's own refusal.
`client.supports("creative_start_policy")` answers the question up front.

When the client has not discovered the seller (a pinned `server_version` skips
discovery), it sends the request and the seller decides; a conforming seller
refuses with the same `UNSUPPORTED_FEATURE`. `false` and omission are always
sent, and a session negotiated below 3.3 is unaffected: the field does not
exist there, and version conversion refuses `true` as before.

Always send the latest MediaBuy `revision` and an idempotency key. If an
`ACTION_NOT_ALLOWED` race returns `currently_available_actions`, pass that echo
to `reassess_media_buy_action()` for an immediate explanation, then refresh the
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
"""Refuse an undeclared ``start_requires_all_creatives_approved: true`` (#1869).
"""Refuse an undeclared ``start_requires_all_creatives_approved: true``.

AdCP 3.3 lets a buyer hold a package's first enablement for serving until every
assigned creative is approved: ``packages[].start_requires_all_creatives_approved``
on ``control_media_buy``. Opting in requires the seller's
``media_buy.features.creative_start_policy: true``; a seller that does not
declare it MUST reject ``true`` with ``UNSUPPORTED_FEATURE`` before mutation.
``false`` is the default and always allowed.

The rule follows from the seller's own capabilities, so the dispatcher applies
it before the handler runs. Without it, a seller that does not know the field
ignores it and the buy starts serving before its creatives are approved, the
opposite of what the buyer asked for.
``media_buy.features.creative_start_policy: true``. Buyers MUST NOT send
``true`` without it, and a seller that does not declare it MUST reject ``true``
with ``UNSUPPORTED_FEATURE`` before mutation. ``false`` is the default and
always allowed.

Both halves follow from the seller's declared capabilities, so the SDK applies
them: the server dispatcher before the handler runs (#1869), and the client
before sending when it has already discovered the seller (#1876). Without the
seller half, a seller that does not know the field ignores it and the buy
starts serving before its creatives are approved, the opposite of what the
buyer asked for.

``create_media_buy`` and ``update_media_buy`` are not gated: AdCP 3.3 withdraws
the field from their packages (adcontextprotocol/adcp#8767), and their open
Expand Down Expand Up @@ -57,8 +60,35 @@ def declares_creative_start_policy(capabilities: Any) -> bool:
return _member(features, "creative_start_policy") is True


def refuse_undeclared_creative_start_policy_request(params: Any, capabilities: Any) -> None:
"""Buyer side: fail before sending to a seller known not to declare the feature.

``capabilities`` is the client's cached discovery response, or ``None``
when it has not discovered the seller; such a request is sent and the
seller decides.

Raises:
ADCPFeatureUnsupportedError: ``dispatched=False``, ``field`` naming
the first opted-in package and ``details`` listing every one.
"""
if capabilities is None:
return
fields = opted_in_fields(params)
if not fields or declares_creative_start_policy(capabilities):
return

from adcp.exceptions import ADCPFeatureUnsupportedError

raise ADCPFeatureUnsupportedError(
["creative_start_policy"],
dispatched=False,
field=fields[0],
details={"feature": POLICY_FEATURE, "fields": fields},
)


async def refuse_undeclared_creative_start_policy(handler: Any, params: Any, context: Any) -> None:
"""Fail a ``control_media_buy`` that opts in when the seller has not declared it.
"""Seller side: fail a ``control_media_buy`` that opts in without the declaration.

``params`` is the request as the handler will receive it, a model or a
dict. The seller's capabilities are read only when a package sets the
Expand Down
14 changes: 12 additions & 2 deletions src/adcp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,15 @@

from adcp.reporting.subscriptions.facade import FleetReportingClient, ReportingClient

from adcp._creative_start_policy import refuse_undeclared_creative_start_policy_request
from adcp._null_clear import preserve_explicit_nulls
from adcp._response_limit import (
DEFAULT_MAX_RESPONSE_BYTES,
reset_call_max_response_bytes,
set_call_max_response_bytes,
)
from adcp._version import (
is_adcp_version_at_least,
normalize_to_release_precision,
resolve_adcp_version,
resolve_adcp_version_alias,
Expand Down Expand Up @@ -1090,15 +1092,19 @@ async def _prepare_versioned_task(self, tool: str, params: dict[str, Any]) -> Co
# the task's request is sent.
exc.dispatched = False
message = str(exc)
details: dict[str, Any] = {"wire_response_received": False}
details: dict[str, Any] = {**(exc.details or {}), "wire_response_received": False}
for name in ("pointer", "source_version", "target_version", "reason"):
if getattr(exc, name, None) is not None:
details[name] = getattr(exc, name)
adcp_error: dict[str, Any] = {"code": "UNSUPPORTED_FEATURE", "message": message}
if exc.field is not None:
adcp_error["field"] = exc.field
adcp_error["details"] = details
result = TaskResult[Any](
status=TaskStatus.FAILED,
success=False,
error=message,
adcp_error={"code": "UNSUPPORTED_FEATURE", "message": message, "details": details},
adcp_error=adcp_error,
idempotency_key=params.get("idempotency_key"),
dispatched=False,
metadata={
Expand Down Expand Up @@ -1142,6 +1148,10 @@ async def _convert_outgoing_request(
# Major-only advertisements predate minor negotiation.
supported = ["3.0"] if 3 in declared.get("major_versions", []) else []
target = negotiate_version(supported, maximum=maximum)
if tool == "control_media_buy" and is_adcp_version_at_least(target, "3.3-beta.0"):
# Buyers MUST NOT opt a package in to a seller known not to declare
# media_buy.features.creative_start_policy (#1876).
refuse_undeclared_creative_start_policy_request(params, self._capabilities)
prepared = deepcopy(params)
prepared["adcp_version"] = target
try:
Expand Down
7 changes: 7 additions & 0 deletions src/adcp/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,8 @@ def __init__(
agent_uri: str | None = None,
*,
dispatched: Literal[False] | None = None,
field: str | None = None,
details: dict[str, Any] | None = None,
):
"""Initialize feature unsupported error.

Expand All @@ -581,11 +583,16 @@ def __init__(
agent_uri: Optional agent URI for context.
dispatched: Pass ``False`` only where no bytes of the task's
request left the process. See :func:`is_pre_dispatch_error`.
field: The request path the refusal names, in the AdCP
``error.field`` form (``packages[0].start_requires_all_creatives_approved``).
details: Extra AdCP ``error.details`` for the failed task result.
"""
if dispatched is False:
self.dispatched = False
self.unsupported_features = unsupported_features
self.declared_features = declared_features or []
self.field = field
self.details = details

missing = ", ".join(unsupported_features)
message = f"Seller does not support: {missing}"
Expand Down
2 changes: 1 addition & 1 deletion src/adcp/server/mcp_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -3568,6 +3568,7 @@ def create_tool_caller(
"""
from pydantic import ValidationError

from adcp._creative_start_policy import refuse_undeclared_creative_start_policy
from adcp._version import normalize_to_release_precision
from adcp.canonical_formats import (
CanonicalFormatLegacyResolutionError,
Expand All @@ -3592,7 +3593,6 @@ def create_tool_caller(
normalize_lenient_creative_batch,
partial_creative_batch,
)
from adcp.server._creative_start_policy import refuse_undeclared_creative_start_policy
from adcp.server.helpers import inject_context
from adcp.server.idempotency.store import _CREATIVE_BATCH_HASH_SOURCE
from adcp.server.version_policy import project_supported_versions, resolve_supported_versions
Expand Down
Loading
Loading