diff --git a/MIGRATION_v9_to_v10.md b/MIGRATION_v9_to_v10.md index 1b49f1e1f..04076f59a 100644 --- a/MIGRATION_v9_to_v10.md +++ b/MIGRATION_v9_to_v10.md @@ -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": , "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 8facbc33c..a022425eb 100644 --- a/docs/adcp-3.3-sdk-inventory.md +++ b/docs/adcp-3.3-sdk-inventory.md @@ -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 diff --git a/docs/media-buy-action-rights.md b/docs/media-buy-action-rights.md index c82e8d9da..9c1dbef84 100644 --- a/docs/media-buy-action-rights.md +++ b/docs/media-buy-action-rights.md @@ -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 diff --git a/src/adcp/server/_creative_start_policy.py b/src/adcp/_creative_start_policy.py similarity index 64% rename from src/adcp/server/_creative_start_policy.py rename to src/adcp/_creative_start_policy.py index e58b4de99..0d5e007a2 100644 --- a/src/adcp/server/_creative_start_policy.py +++ b/src/adcp/_creative_start_policy.py @@ -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 @@ -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 diff --git a/src/adcp/client.py b/src/adcp/client.py index 8b3b1fdf1..d20d3efdf 100644 --- a/src/adcp/client.py +++ b/src/adcp/client.py @@ -28,6 +28,7 @@ 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, @@ -35,6 +36,7 @@ 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, @@ -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={ @@ -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: diff --git a/src/adcp/exceptions.py b/src/adcp/exceptions.py index 0548ab9d5..5903f2896 100644 --- a/src/adcp/exceptions.py +++ b/src/adcp/exceptions.py @@ -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. @@ -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}" diff --git a/src/adcp/server/mcp_tools.py b/src/adcp/server/mcp_tools.py index 1b3a34521..f4ed0ccde 100644 --- a/src/adcp/server/mcp_tools.py +++ b/src/adcp/server/mcp_tools.py @@ -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, @@ -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 diff --git a/tests/test_control_media_buy_creative_start_policy.py b/tests/test_control_media_buy_creative_start_policy.py index 7025f499f..12bc52b36 100644 --- a/tests/test_control_media_buy_creative_start_policy.py +++ b/tests/test_control_media_buy_creative_start_policy.py @@ -1,15 +1,19 @@ -"""``control_media_buy`` refuses an undeclared creative start policy (#1869). +"""``control_media_buy`` refuses an undeclared creative start policy. 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. +``UNSUPPORTED_FEATURE`` before mutation, and buyers MUST NOT send it; ``false`` +is always allowed. The dispatcher enforces the seller half from the seller's +own declared capabilities (#1869); the client enforces the buyer half from the +seller's discovered capabilities (#1876). """ from __future__ import annotations +from collections.abc import Awaitable, Callable from concurrent.futures import ThreadPoolExecutor from typing import Any +from unittest.mock import AsyncMock import anyio import pytest @@ -29,7 +33,13 @@ 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.types import ( + AgentConfig, + ControlMediaBuyRequest, + GetAdcpCapabilitiesResponse, + Protocol, +) +from adcp.types.core import TaskResult, TaskStatus from adcp.validation import ValidationHookConfig FIELD = "start_requires_all_creatives_approved" @@ -38,8 +48,12 @@ def _request(*policies: bool | None, adcp_version: str = "3.3-beta.5") -> dict[str, Any]: packages = [] for index, policy in enumerate(policies): + # A package control needs at least one control; pause stands in for + # an omitted policy. package: dict[str, Any] = {"package_id": f"pkg_{index}"} - if policy is not None: + if policy is None: + package["paused"] = False + else: package[FIELD] = policy packages.append(package) return { @@ -189,9 +203,9 @@ async def update_media_buy(self, params, context=None): assert received == ["update_media_buy"] -@pytest.mark.asyncio -async def test_the_refusal_reaches_a_buyer_over_mcp() -> None: - seller = _StartPolicySeller(_capabilities()) +async def _over_mcp( + seller: ADCPHandler[Any], call: Callable[[ClientSession], Awaitable[Any]] +) -> Any: server = create_mcp_server( seller, validation=ValidationHookConfig(requests="strict", responses="strict") ) @@ -205,17 +219,33 @@ async def test_the_refusal_reaches_a_buyer_over_mcp() -> None: ) 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)) - ) + result = await call(session) task_group.cancel_scope.cancel() + return result - 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" + +@pytest.mark.asyncio +async def test_the_refusal_reaches_the_wire_over_mcp() -> None: + # A raw tools/call: the SDK client would refuse this itself (#1876). + seller = _StartPolicySeller(_capabilities()) + + result = await _over_mcp( + seller, lambda session: session.call_tool("control_media_buy", _request(True)) + ) + + assert result.is_error + error = dict(result.structured_content["adcp_error"]) + assert "creative_start_policy" in error.pop("message") + assert error == { + "code": "UNSUPPORTED_FEATURE", + "recovery": "correctable", + "field": f"packages[0].{FIELD}", + "mutation_outcome": "not_applied", + "details": { + "feature": "media_buy.features.creative_start_policy", + "fields": [f"packages[0].{FIELD}"], + }, + } assert seller.received == [] @@ -334,3 +364,132 @@ async def test_request_scoped_capabilities_decide(executor) -> None: assert len(platform.received) == 1 assert isinstance(platform.scoped_for[0], ControlMediaBuyRequest) + + +# --------------------------------------------------------------------------- +# Buyer side (#1876): the client refuses before sending to a seller it knows +# does not declare the feature, and sends when it does not know. +# --------------------------------------------------------------------------- + + +def _buyer( + protocol: Protocol, + *, + server_version: str = "3.3-beta.5", + capabilities: dict[str, Any] | None = None, +) -> tuple[ADCPClient, AsyncMock]: + client = ADCPClient( + AgentConfig(id="seller", agent_uri="https://seller.example", protocol=protocol), + server_version=server_version, + account_policy="off", + ) + if capabilities is not None: + client._capabilities = GetAdcpCapabilitiesResponse.model_validate(capabilities) + wire = AsyncMock( + return_value=TaskResult[Any]( + status=TaskStatus.COMPLETED, + success=True, + data={"status": "completed", "media_buy_id": "mb_1", "revision": 2}, + ) + ) + if protocol is Protocol.MCP: + client.adapter._call_mcp_tool_wire = wire + else: + client.adapter._call_a2a_tool_wire = wire + return client, wire + + +def _sent_packages(wire: AsyncMock) -> list[dict[str, Any]]: + tool, params = wire.await_args.args[:2] + assert tool == "control_media_buy" + return params["packages"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("protocol", [Protocol.MCP, Protocol.A2A]) +@pytest.mark.parametrize("declared", [None, False], ids=["no-feature", "feature-false"]) +async def test_client_refuses_true_to_a_seller_known_not_to_declare(protocol, declared) -> None: + client, wire = _buyer(protocol, capabilities=_capabilities(creative_start_policy=declared)) + + result = await client.control_media_buy( + ControlMediaBuyRequest.model_validate(_request(False, True, None, True)) + ) + + assert not result.success + assert result.status == TaskStatus.FAILED + assert result.dispatched is False + assert result.idempotency_key == "a" * 16 + assert result.adcp_error["code"] == "UNSUPPORTED_FEATURE" + assert result.adcp_error["field"] == f"packages[1].{FIELD}" + assert result.adcp_error["details"] == { + "feature": "media_buy.features.creative_start_policy", + "fields": [f"packages[1].{FIELD}", f"packages[3].{FIELD}"], + "wire_response_received": False, + } + wire.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("protocol", [Protocol.MCP, Protocol.A2A]) +async def test_client_sends_true_to_a_seller_that_declares(protocol) -> None: + client, wire = _buyer(protocol, capabilities=_capabilities(creative_start_policy=True)) + + result = await client.control_media_buy(ControlMediaBuyRequest.model_validate(_request(True))) + + assert result.success, result.error + assert _sent_packages(wire)[0][FIELD] is True + + +@pytest.mark.asyncio +@pytest.mark.parametrize("protocol", [Protocol.MCP, Protocol.A2A]) +async def test_client_sends_true_when_capabilities_are_unknown(protocol) -> None: + # A pinned server_version skips discovery; the seller decides. + client, wire = _buyer(protocol) + assert client.capabilities is None + + await client.control_media_buy(ControlMediaBuyRequest.model_validate(_request(True))) + + assert _sent_packages(wire)[0][FIELD] is True + + +@pytest.mark.asyncio +@pytest.mark.parametrize("policies", [(False,), (None,)]) +async def test_client_sends_false_or_omitted_to_any_seller(policies) -> None: + client, wire = _buyer(Protocol.MCP, capabilities=_capabilities()) + + result = await client.control_media_buy( + ControlMediaBuyRequest.model_validate(_request(*policies)) + ) + + assert result.success, result.error + wire.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_a_3_2_session_is_not_gated() -> None: + # The field does not exist in 3.2: version conversion decides, as before. + client, wire = _buyer(Protocol.MCP, server_version="3.2", capabilities=_capabilities()) + + result = await client.control_media_buy(ControlMediaBuyRequest.model_validate(_request(True))) + + assert result.adcp_error is not None + assert "feature" not in result.adcp_error["details"] + assert "field" not in result.adcp_error + assert result.adcp_error["details"]["target_version"] == "3.2" + wire.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_a_discovering_client_refuses_without_calling_the_seller() -> None: + seller = _StartPolicySeller(_capabilities()) + + async def control(session: ClientSession) -> Any: + client = ADCPClient.from_mcp_client(session) + return await client.control_media_buy(ControlMediaBuyRequest.model_validate(_request(True))) + + result = await _over_mcp(seller, control) + + assert result.dispatched is False + assert result.adcp_error["field"] == f"packages[0].{FIELD}" + assert seller.discovery_calls == 1 + assert seller.received == []