From 9611140ac2bfc7f181a61e38a1547a61621b67cf Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 8 Nov 2026 14:07:15 +0000 Subject: [PATCH] feat(server)!: refuse an undeclared creative start policy on control_media_buy A control_media_buy package that sets start_requires_all_creatives_approved: true now reaches the handler only when the seller declares media_buy.features.creative_start_policy: true. Otherwise the dispatcher answers UNSUPPORTED_FEATURE before the handler runs (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), as AdCP 3.3 requires. false and omission are always allowed and read no capabilities. The declaration is the seller's own: PlatformHandler reads its request-scoped DecisioningCapabilities (get_adcp_capabilities_for_request included), and any other ADCPHandler reads its own get_adcp_capabilities response, through the new ADCPHandler._declared_capabilities_for_request hook. The check applies to handlers on AdCP 3.3 or later, like changed_paths enforcement. create_media_buy and update_media_buy are not checked: AdCP 3.3 withdraws the field from their packages (adcontextprotocol/adcp#8767) and their open schemas ignore it (DR-0009). BREAKING CHANGE: control_media_buy refuses packages[].start_requires_all_creatives_approved: true with UNSUPPORTED_FEATURE before the handler runs when the seller's capabilities do not declare media_buy.features.creative_start_policy: true. A seller that honors the gate declares the feature. Closes #1869 Co-Authored-By: Claude Opus 5.5 --- MIGRATION_v9_to_v10.md | 1 + docs/adcp-3.3-sdk-inventory.md | 1 + docs/decisioning-capabilities.md | 9 + src/adcp/decisioning/handler.py | 7 + src/adcp/decisioning/platform.py | 6 +- src/adcp/server/_creative_start_policy.py | 95 +++++ src/adcp/server/base.py | 13 + src/adcp/server/mcp_tools.py | 15 +- tests/test_account_change_feed.py | 10 +- ...control_media_buy_creative_start_policy.py | 336 ++++++++++++++++++ 10 files changed, 483 insertions(+), 10 deletions(-) create mode 100644 src/adcp/server/_creative_start_policy.py create mode 100644 tests/test_control_media_buy_creative_start_policy.py diff --git a/MIGRATION_v9_to_v10.md b/MIGRATION_v9_to_v10.md index c4ae388358..1b49f1e1f1 100644 --- a/MIGRATION_v9_to_v10.md +++ b/MIGRATION_v9_to_v10.md @@ -54,6 +54,7 @@ the SDK-relevant inventory is [docs/adcp-3.3-sdk-inventory.md](docs/adcp-3.3-sdk | Permissive adagents.json resolution requires the fetched domain | `resolve_properties_for_agent(doc, agent_url, mode="permissive")` raises `ValueError` without `publisher_domain=`. Pass the domain the document was fetched for: a bare entry's top-level properties are then kept only when every identifier is a `type: "domain"` under that domain, so a file on another host can no longer claim a publisher's site, app bundle or DOOH venue through a bare entry. Properties identified by bundle, store or venue IDs, and properties whose `publisher_domain` names another domain, are dropped from the bare fallback. `allow_any_domain=True` restores the SDK 9 result. Typed selectors and strict mode are unchanged. See [docs/adagents-discovery.md](docs/adagents-discovery.md) for `properties_for_publisher_domain` and `verify_cross_domain_properties`. | | 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. | | `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": , "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) diff --git a/docs/adcp-3.3-sdk-inventory.md b/docs/adcp-3.3-sdk-inventory.md index c772a48075..8facbc33c3 100644 --- a/docs/adcp-3.3-sdk-inventory.md +++ b/docs/adcp-3.3-sdk-inventory.md @@ -246,6 +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 | | 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 diff --git a/docs/decisioning-capabilities.md b/docs/decisioning-capabilities.md index a9d3947167..4e18de3d6d 100644 --- a/docs/decisioning-capabilities.md +++ b/docs/decisioning-capabilities.md @@ -91,6 +91,15 @@ alongside the spec; they line up. - **`media_buy.features`** — `inline_creative_management`, `property_list_filtering`, `catalog_management` flags that gate buyer-side flow. +- **`media_buy.features.creative_start_policy`** — declare it only when you + honor `packages[].start_requires_all_creatives_approved: true` on + `control_media_buy`: hold the package's first enablement for serving until + every current creative assignment is approved, and echo the policy on + `get_media_buys`. Without it the framework refuses `true` with + `UNSUPPORTED_FEATURE` (`field` `packages[N].start_requires_all_creatives_approved`) + before your platform runs, as AdCP 3.3 requires; `false` always reaches you. + The check reads the request-scoped capabilities, so a tenant-specific + declaration from `get_adcp_capabilities_for_request` counts. - **`media_buy.portfolio.publisher_domains`** — the publishers you actually represent, not every domain whose `adagents.json` names you. AdCP 3.3 bounds this counterparty-asserted list (and `signals.data_provider_domains`): at most diff --git a/src/adcp/decisioning/handler.py b/src/adcp/decisioning/handler.py index 72e6f94500..d5163cd30a 100644 --- a/src/adcp/decisioning/handler.py +++ b/src/adcp/decisioning/handler.py @@ -2062,6 +2062,13 @@ async def _effective_capabilities_for_request( ) return caps, has_scoped_caps + async def _declared_capabilities_for_request( + self, params: Any, context: ToolContext | None = None + ) -> DecisioningCapabilities: + """Admission reads the same scoped capabilities discovery projects.""" + capabilities, _ = await self._effective_capabilities_for_request(params, context) + return capabilities + async def _handoff_webhook_kwargs( self, params: Any, diff --git a/src/adcp/decisioning/platform.py b/src/adcp/decisioning/platform.py index 3c67171e12..f9ae9888b8 100644 --- a/src/adcp/decisioning/platform.py +++ b/src/adcp/decisioning/platform.py @@ -444,8 +444,10 @@ def get_adcp_capabilities_for_request( valid capability block depends on request context, such as tenant publisher domains or whether the tenant has an active webhook-signing credential. The framework also calls this hook for push-configured - operations so admission uses the exact capability set advertised to - that tenant. + operations, and for a ``control_media_buy`` that sets a package's + ``start_requires_all_creatives_approved`` to ``true`` (refused unless + ``media_buy.features.creative_start_policy`` is ``true``), so admission + uses the exact capability set advertised to that tenant. Return ``None`` to use :attr:`capabilities` unchanged, or return a complete :class:`DecisioningCapabilities` instance for this request. diff --git a/src/adcp/server/_creative_start_policy.py b/src/adcp/server/_creative_start_policy.py new file mode 100644 index 0000000000..e58b4de996 --- /dev/null +++ b/src/adcp/server/_creative_start_policy.py @@ -0,0 +1,95 @@ +"""Refuse an undeclared ``start_requires_all_creatives_approved: true`` (#1869). + +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. + +``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 +schemas ignore it (DR-0009). +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +#: The package field that opts a package into the initial-start gate. +POLICY_FIELD = "start_requires_all_creatives_approved" + +#: The capability a seller declares to accept ``true``. +POLICY_FEATURE = "media_buy.features.creative_start_policy" + + +def _member(value: Any, name: str) -> Any: + if isinstance(value, Mapping): + return value.get(name) + return getattr(value, name, None) + + +def opted_in_fields(params: Any) -> list[str]: + """Field paths of every package that sets the policy to ``true``.""" + packages = _member(params, "packages") + if not isinstance(packages, list): + return [] + return [ + f"packages[{index}].{POLICY_FIELD}" + for index, package in enumerate(packages) + if _member(package, POLICY_FIELD) is True + ] + + +def declares_creative_start_policy(capabilities: Any) -> bool: + """Whether ``capabilities`` declare ``media_buy.features.creative_start_policy: true``. + + Reads a ``get_adcp_capabilities`` response (dict or model) or + :class:`~adcp.decisioning.DecisioningCapabilities`. + """ + features = _member(_member(capabilities, "media_buy"), "features") + return _member(features, "creative_start_policy") is True + + +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. + + ``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 + policy to ``true``. + """ + fields = opted_in_fields(params) + if not fields: + return + capabilities = await handler._declared_capabilities_for_request(params, context) + if declares_creative_start_policy(capabilities): + return + + from adcp.exceptions import ADCPTaskError + from adcp.types import Error, Recovery + + raise ADCPTaskError( + operation="control_media_buy", + errors=[ + Error( + code="UNSUPPORTED_FEATURE", + message=( + f"{POLICY_FIELD}: true requires {POLICY_FEATURE}, " + "which this seller does not declare" + ), + field=fields[0], + suggestion=( + f"Omit {POLICY_FIELD} or send false; check {POLICY_FEATURE} in " + "get_adcp_capabilities before opting in" + ), + recovery=Recovery.correctable, + details={"feature": POLICY_FEATURE, "fields": fields}, + ) + ], + ) diff --git a/src/adcp/server/base.py b/src/adcp/server/base.py index 6263809abc..af0e9635a1 100644 --- a/src/adcp/server/base.py +++ b/src/adcp/server/base.py @@ -751,6 +751,19 @@ async def get_adcp_capabilities( """ return self._not_supported("get_adcp_capabilities") + async def _declared_capabilities_for_request( + self, params: Any, context: TContext | None = None + ) -> Any: + """The capabilities this agent declares, for a dispatcher admission check. + + The dispatcher reads them before running a request that a declared + feature gates (``control_media_buy`` with + ``start_requires_all_creatives_approved: true``). The default is this + agent's own ``get_adcp_capabilities`` response, so an agent that does + not implement discovery declares nothing. + """ + return await self.get_adcp_capabilities(GetAdcpCapabilitiesRequest(), context) + async def sync_agent_notification_configs( self, params: SyncAgentNotificationConfigsRequest | dict[str, Any], diff --git a/src/adcp/server/mcp_tools.py b/src/adcp/server/mcp_tools.py index c1a25b77d6..1b3a34521d 100644 --- a/src/adcp/server/mcp_tools.py +++ b/src/adcp/server/mcp_tools.py @@ -3345,8 +3345,12 @@ def _apply_unknown_field_policy( ) -def _requires_changed_paths(application_version: str | None) -> bool: - """Whether the handler's release requires ``changed_paths`` (AdCP 3.3 and later).""" +def _handler_runs_adcp_33(application_version: str | None) -> bool: + """Whether the handler runs on AdCP 3.3 or later, where 3.3 seller rules apply. + + Gates ``changed_paths`` on ``list_account_changes`` and the + ``creative_start_policy`` check on ``control_media_buy``. + """ from adcp._version import normalize_to_release_precision if application_version is None: @@ -3377,7 +3381,7 @@ def _project_account_change_feed( return project_list_account_changes_response( result, reader=ctx, - require_changed_paths=_requires_changed_paths(application_version), + require_changed_paths=_handler_runs_adcp_33(application_version), ) except AccountChangeRecordError as exc: from adcp.exceptions import ADCPTaskError @@ -3588,6 +3592,7 @@ 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 @@ -4251,6 +4256,10 @@ async def _call_tool( ) ], ) from exc + if method_name == "control_media_buy" and _handler_runs_adcp_33(application_version): + # A seller without media_buy.features.creative_start_policy must + # refuse an opted-in package before mutation (#1869). + await refuse_undeclared_creative_start_policy(handler, call_params, ctx) result: Any token = partial_creative_batch.set(creative_batch) hash_token = _CREATIVE_BATCH_HASH_SOURCE.set( diff --git a/tests/test_account_change_feed.py b/tests/test_account_change_feed.py index 3771491456..cbb8f77c13 100644 --- a/tests/test_account_change_feed.py +++ b/tests/test_account_change_feed.py @@ -29,7 +29,7 @@ ) from adcp.exceptions import ADCPTaskError from adcp.server.base import ADCPHandler, ToolContext -from adcp.server.mcp_tools import _requires_changed_paths, create_tool_caller +from adcp.server.mcp_tools import _handler_runs_adcp_33, create_tool_caller from adcp.types import ListAccountChangesResponse, ListCreativesResponse from adcp.types.domains.core.account_change import AccountChange from adcp.validation.client_hooks import ValidationHookConfig @@ -268,10 +268,10 @@ async def test_dispatch_refuses_record_without_changed_paths( def test_changed_paths_enforced_from_3_3() -> None: - assert _requires_changed_paths("3.3-beta.5") - assert _requires_changed_paths("3.3") - assert _requires_changed_paths(None) - assert not _requires_changed_paths("3.2") + assert _handler_runs_adcp_33("3.3-beta.5") + assert _handler_runs_adcp_33("3.3") + assert _handler_runs_adcp_33(None) + assert not _handler_runs_adcp_33("3.2") @pytest.mark.asyncio diff --git a/tests/test_control_media_buy_creative_start_policy.py b/tests/test_control_media_buy_creative_start_policy.py new file mode 100644 index 0000000000..7025f499f5 --- /dev/null +++ b/tests/test_control_media_buy_creative_start_policy.py @@ -0,0 +1,336 @@ +"""``control_media_buy`` refuses an undeclared creative start policy (#1869). + +AdCP 3.3: a seller without ``media_buy.features.creative_start_policy: true`` +MUST reject ``packages[].start_requires_all_creatives_approved: true`` with +``UNSUPPORTED_FEATURE`` before mutation; ``false`` is always allowed. The +dispatcher enforces it from the seller's own declared capabilities. +""" + +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor +from typing import Any + +import anyio +import pytest +from mcp import ClientSession +from mcp.shared.memory import create_client_server_memory_streams + +from adcp import ADCPClient +from adcp.decisioning import ( + DecisioningCapabilities, + DecisioningPlatform, + InMemoryTaskRegistry, + SingletonAccounts, +) +from adcp.decisioning.capabilities import Features, LifecycleTool, MediaBuy, SupportedProtocol +from adcp.decisioning.handler import PlatformHandler +from adcp.exceptions import ADCPTaskError +from adcp.server import ADCPHandler, create_mcp_server +from adcp.server.mcp_tools import create_tool_caller +from adcp.server.responses import capabilities_response +from adcp.types import ControlMediaBuyRequest +from adcp.validation import ValidationHookConfig + +FIELD = "start_requires_all_creatives_approved" + + +def _request(*policies: bool | None, adcp_version: str = "3.3-beta.5") -> dict[str, Any]: + packages = [] + for index, policy in enumerate(policies): + package: dict[str, Any] = {"package_id": f"pkg_{index}"} + if policy is not None: + package[FIELD] = policy + packages.append(package) + return { + "adcp_version": adcp_version, + "idempotency_key": "a" * 16, + "account": {"account_id": "acct_1"}, + "media_buy_id": "mb_1", + "revision": 1, + "packages": packages, + } + + +def _capabilities(*, creative_start_policy: bool | None = None) -> dict[str, Any]: + response = capabilities_response(["media_buy"]) + response["adcp"]["idempotency"] = {"supported": False} + if creative_start_policy is not None: + response["media_buy"]["features"]["creative_start_policy"] = creative_start_policy + return response + + +class _StartPolicySeller(ADCPHandler[Any]): + """A 3.3 seller; ``capabilities`` is what its discovery answers, if anything.""" + + advertised_tools = {"get_adcp_capabilities", "control_media_buy"} + + def __init__(self, capabilities: dict[str, Any] | None = None) -> None: + self.capabilities = capabilities + self.discovery_calls = 0 + self.received: list[Any] = [] + + async def get_adcp_capabilities(self, params, context=None): + self.discovery_calls += 1 + if self.capabilities is None: + return await super().get_adcp_capabilities(params, context) + return self.capabilities + + async def control_media_buy(self, params, context=None): + self.received.append(params) + return {"status": "completed", "media_buy_id": "mb_1", "revision": 2} + + +def _assert_refused(exc: ADCPTaskError, *fields: str) -> None: + [error] = exc.errors + assert error.code == "UNSUPPORTED_FEATURE" + assert error.recovery == "correctable" + assert error.field == fields[0] + assert error.mutation_outcome == "not_applied" + assert error.details == { + "feature": "media_buy.features.creative_start_policy", + "fields": list(fields), + } + + +@pytest.mark.asyncio +async def test_issue_repro_refuses_true_before_the_handler_runs() -> None: + seller = _StartPolicySeller() + call = create_tool_caller(seller, "control_media_buy") + + with pytest.raises(ADCPTaskError) as raised: + await call(_request(True)) + + _assert_refused(raised.value, f"packages[0].{FIELD}") + assert seller.received == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "capabilities", + [None, _capabilities(), _capabilities(creative_start_policy=False)], + ids=["no-discovery", "no-feature", "feature-false"], +) +async def test_true_is_refused_unless_the_seller_declares_the_feature(capabilities) -> None: + seller = _StartPolicySeller(capabilities) + + with pytest.raises(ADCPTaskError) as raised: + await create_tool_caller(seller, "control_media_buy")(_request(True)) + + _assert_refused(raised.value, f"packages[0].{FIELD}") + assert seller.received == [] + + +@pytest.mark.asyncio +async def test_true_reaches_a_seller_that_declares_the_feature() -> None: + seller = _StartPolicySeller(_capabilities(creative_start_policy=True)) + + await create_tool_caller(seller, "control_media_buy")(_request(True)) + + [received] = seller.received + assert received["packages"][0][FIELD] is True + + +@pytest.mark.asyncio +@pytest.mark.parametrize("policies", [(False,), (None,), (False, None)]) +async def test_false_or_omitted_is_always_allowed_without_reading_capabilities(policies) -> None: + seller = _StartPolicySeller() + + await create_tool_caller(seller, "control_media_buy")(_request(*policies)) + + assert len(seller.received) == 1 + assert seller.discovery_calls == 0 + + +@pytest.mark.asyncio +async def test_field_names_the_first_opted_in_package_and_details_list_all() -> None: + seller = _StartPolicySeller() + + with pytest.raises(ADCPTaskError) as raised: + await create_tool_caller(seller, "control_media_buy")(_request(False, True, None, True)) + + _assert_refused(raised.value, f"packages[1].{FIELD}", f"packages[3].{FIELD}") + assert seller.received == [] + + +@pytest.mark.asyncio +async def test_a_handler_on_3_2_is_not_gated() -> None: + # The field is a 3.3 addition; a 3.2 seller's open schema ignores it (DR-0009). + seller = _StartPolicySeller() + + await create_tool_caller(seller, "control_media_buy", adcp_version="3.2")( + _request(True, adcp_version="3.2") + ) + + assert len(seller.received) == 1 + assert seller.discovery_calls == 0 + + +@pytest.mark.asyncio +async def test_create_and_update_media_buy_are_not_gated() -> None: + # AdCP 3.3 withdraws the field from create/update packages (adcp#8767). + received: list[str] = [] + + class _LegacySeller(ADCPHandler[Any]): + async def update_media_buy(self, params, context=None): + received.append("update_media_buy") + return {"media_buy_id": "mb_1", "status": "active"} + + await create_tool_caller(_LegacySeller(), "update_media_buy")( + { + "adcp_version": "3.3-beta.5", + "idempotency_key": "a" * 16, + "account": {"account_id": "acct_1"}, + "media_buy_id": "mb_1", + "packages": [{"package_id": "pkg_0", FIELD: True}], + } + ) + + assert received == ["update_media_buy"] + + +@pytest.mark.asyncio +async def test_the_refusal_reaches_a_buyer_over_mcp() -> None: + seller = _StartPolicySeller(_capabilities()) + server = create_mcp_server( + seller, validation=ValidationHookConfig(requests="strict", responses="strict") + ) + async with create_client_server_memory_streams() as (client_streams, server_streams): + async with anyio.create_task_group() as task_group: + task_group.start_soon( + server._lowlevel_server.run, + *server_streams, + server._lowlevel_server.create_initialization_options(), + True, + ) + async with ClientSession(*client_streams) as session: + await session.initialize() + client = ADCPClient.from_mcp_client(session) + result = await client.control_media_buy( + ControlMediaBuyRequest.model_validate(_request(True)) + ) + task_group.cancel_scope.cancel() + + assert not result.success + assert result.adcp_error is not None + assert result.adcp_error["code"] == "UNSUPPORTED_FEATURE" + assert result.adcp_error["field"] == f"packages[0].{FIELD}" + assert result.mutation_outcome == "not_applied" + assert seller.received == [] + + +# --------------------------------------------------------------------------- +# Decisioning platforms: the platform's (request-scoped) capabilities decide. +# --------------------------------------------------------------------------- + + +def _media_buy(*, creative_start_policy: bool | None = None) -> MediaBuy: + features = ( + None + if creative_start_policy is None + else Features(creative_start_policy=creative_start_policy) + ) + return MediaBuy(lifecycle_tools=[LifecycleTool.control_media_buy], features=features) + + +class _Platform(DecisioningPlatform): + capabilities = DecisioningCapabilities( + supported_protocols=[SupportedProtocol.media_buy], + specialisms=["sales-non-guaranteed"], + media_buy=_media_buy(), + ) + accounts = SingletonAccounts(account_id="acct_1") + + def __init__(self) -> None: + self.received: list[ControlMediaBuyRequest] = [] + + def get_products(self, req, ctx): + return {"products": []} + + def create_media_buy(self, req, ctx): + return {"media_buy_id": "mb_1"} + + def update_media_buy(self, media_buy_id, patch, ctx): + return {"media_buy_id": media_buy_id} + + def sync_creatives(self, req, ctx): + return {"creatives": []} + + def get_media_buy_delivery(self, req, ctx): + return {"media_buy_deliveries": []} + + def control_media_buy(self, req, ctx): + self.received.append(req) + return {"status": "completed", "media_buy_id": "mb_1", "revision": 2} + + +class _DeclaringPlatform(_Platform): + capabilities = DecisioningCapabilities( + supported_protocols=[SupportedProtocol.media_buy], + specialisms=["sales-non-guaranteed"], + media_buy=_media_buy(creative_start_policy=True), + ) + + +class _TenantScopedPlatform(_Platform): + """Declares the feature only in the capabilities scoped to this request.""" + + def __init__(self) -> None: + super().__init__() + self.scoped_for: list[Any] = [] + + def get_adcp_capabilities_for_request(self, params=None, context=None): + self.scoped_for.append(params) + return _DeclaringPlatform.capabilities + + +@pytest.fixture +def executor(): + pool = ThreadPoolExecutor(max_workers=2, thread_name_prefix="test-creative-start-") + yield pool + pool.shutdown(wait=True) + + +def _caller(platform: DecisioningPlatform, executor: ThreadPoolExecutor): + handler = PlatformHandler(platform, executor=executor, registry=InMemoryTaskRegistry()) + return create_tool_caller(handler, "control_media_buy") + + +@pytest.mark.asyncio +async def test_platform_without_the_feature_refuses_true(executor) -> None: + platform = _Platform() + + with pytest.raises(ADCPTaskError) as raised: + await _caller(platform, executor)(_request(True)) + + _assert_refused(raised.value, f"packages[0].{FIELD}") + assert platform.received == [] + + +@pytest.mark.asyncio +async def test_platform_without_the_feature_accepts_false(executor) -> None: + platform = _Platform() + + await _caller(platform, executor)(_request(False)) + + assert len(platform.received) == 1 + + +@pytest.mark.asyncio +async def test_platform_declaring_the_feature_receives_true(executor) -> None: + platform = _DeclaringPlatform() + + await _caller(platform, executor)(_request(True)) + + [received] = platform.received + assert received.packages[0].start_requires_all_creatives_approved is True + + +@pytest.mark.asyncio +async def test_request_scoped_capabilities_decide(executor) -> None: + platform = _TenantScopedPlatform() + + await _caller(platform, executor)(_request(True)) + + assert len(platform.received) == 1 + assert isinstance(platform.scoped_for[0], ControlMediaBuyRequest)