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 @@ -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": <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
1 change: 1 addition & 0 deletions docs/adcp-3.3-sdk-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions docs/decisioning-capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/adcp/decisioning/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 4 additions & 2 deletions src/adcp/decisioning/platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
95 changes: 95 additions & 0 deletions src/adcp/server/_creative_start_policy.py
Original file line number Diff line number Diff line change
@@ -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},
)
],
)
13 changes: 13 additions & 0 deletions src/adcp/server/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
15 changes: 12 additions & 3 deletions src/adcp/server/mcp_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
10 changes: 5 additions & 5 deletions tests/test_account_change_feed.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading