Skip to content
Draft
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
7 changes: 4 additions & 3 deletions examples/v3_reference_seller/src/platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,10 @@ class V3ReferenceSeller(DecisioningPlatform, SalesPlatform):
"""

capabilities = DecisioningCapabilities(
specialisms=("sales-non-guaranteed",),
channels=("display", "video"),
pricing_models=("cpm",),
specialisms=["sales-non-guaranteed"],
channels=["display", "video"],
pricing_models=["cpm"],
supported_billing=["operator"],
)

def __init__(self, *, sessionmaker: async_sessionmaker) -> None:
Expand Down
34 changes: 34 additions & 0 deletions src/adcp/decisioning/dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,16 @@
}
)

#: Sales specialisms — the subset of :data:`SPEC_SPECIALISM_ENUM` whose
#: wire contracts include a media-buy billing relationship. Used by
#: :func:`validate_platform` to warn when ``capabilities.supported_billing``
#: is empty: the AdCP spec requires ``account.supported_billing`` in the
#: ``get_adcp_capabilities`` response whenever a seller declares ``media_buy``
#: support, and sales-* claims are the billing-relevant surface.
SALES_SPECIALISMS: frozenset[str] = frozenset(
s for s in SPEC_SPECIALISM_ENUM if s.startswith("sales-")
)


# ---------------------------------------------------------------------------
# REQUIRED_METHODS_PER_SPECIALISM — what each specialism must implement
Expand Down Expand Up @@ -630,6 +640,29 @@ def validate_platform(platform: DecisioningPlatform) -> None:
},
)

# supported_billing advisory (sales-* adopters).
# The AdCP spec requires account.supported_billing in the
# get_adcp_capabilities response for any seller declaring media_buy.
# Enforce via UserWarning now; a future minor will promote to AdcpError
# after adopters have had a deprecation window.
sales_specialisms_claimed = [
s for s in platform.capabilities.specialisms if s in SALES_SPECIALISMS
]
if sales_specialisms_claimed and not platform.capabilities.supported_billing:
warnings.warn(
(
f"DecisioningPlatform claims sales-* specialism(s) "
f"{sorted(sales_specialisms_claimed)!r} but "
"capabilities.supported_billing is empty. The AdCP spec requires "
"account.supported_billing in the get_adcp_capabilities response "
"for media_buy sellers. Add supported_billing=[...] to your "
"DecisioningCapabilities — valid values: 'operator', 'agent', "
"'advertiser'. This will become a hard fail in a future minor."
),
UserWarning,
stacklevel=2,
)


def _has_overridden_method(platform: DecisioningPlatform, method_name: str) -> bool:
"""True when the platform subclass provides ``method_name``.
Expand Down Expand Up @@ -1145,6 +1178,7 @@ async def _project_workflow_handoff(

__all__ = [
"REQUIRED_METHODS_PER_SPECIALISM",
"SALES_SPECIALISMS",
"SPEC_SPECIALISM_ENUM",
"compose_caller_identity",
"validate_platform",
Expand Down
86 changes: 86 additions & 0 deletions src/adcp/decisioning/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
DeleteCollectionListResponse,
DeletePropertyListRequest,
DeletePropertyListResponse,
GetAdcpCapabilitiesRequest,
GetBrandIdentityRequest,
GetBrandIdentitySuccessResponse,
GetCollectionListRequest,
Expand Down Expand Up @@ -311,6 +312,48 @@
}


#: Maps each spec specialism slug to the ``supported_protocols`` value it
#: contributes to the ``get_adcp_capabilities`` response. Used by the
#: auto-generated :meth:`PlatformHandler.get_adcp_capabilities` shim so
#: adopters don't have to manually derive which protocol domains their
#: specialisms cover.
#:
#: Meta-claims (``signed-requests``, ``governance-aware-seller``) and
#: sub-feature claims (``audience-sync``) are excluded — they don't
#: directly introduce a standalone protocol domain. ``audience-sync`` gets
#: ``media_buy`` from the co-claimed sales-* specialism; advertising
#: ``media_buy`` without sales tools would be a buyer-visible lie.
SPECIALISM_TO_PROTOCOL: dict[str, str] = {
# Sales archetypes → media_buy
"sales-non-guaranteed": "media_buy",
"sales-guaranteed": "media_buy",
"sales-broadcast-tv": "media_buy",
"sales-social": "media_buy",
"sales-proposal-mode": "media_buy",
"sales-catalog-driven": "media_buy",
# Signals
"signal-marketplace": "signals",
"signal-owned": "signals",
# audience-sync intentionally omitted: it's a sub-feature of media_buy,
# not a standalone protocol domain. A co-claimed sales-* specialism
# drives the media_buy declaration; advertising media_buy without
# sales tools would be a buyer-visible lie.
# Creative
"creative-template": "creative",
"creative-generative": "creative",
"creative-ad-server": "creative",
# Governance agents
"governance-spend-authority": "governance",
"governance-delivery-monitor": "governance",
# Brand rights
"brand-rights": "brand",
# Content / list governance features
"content-standards": "governance",
"property-lists": "governance",
"collection-lists": "governance",
}


async def _resolve_buyer_agent(
registry: BuyerAgentRegistry,
auth_info: AuthInfo | None,
Expand Down Expand Up @@ -752,6 +795,49 @@ def _build_ctx(
buyer_agent=buyer_agent,
)

# ----- Protocol discovery -----

async def get_adcp_capabilities(
self,
params: GetAdcpCapabilitiesRequest | dict[str, Any],
context: ToolContext | None = None,
) -> dict[str, Any]:
"""Auto-generate capabilities from :class:`DecisioningCapabilities`.

Derives ``supported_protocols`` from the platform's claimed
specialisms via :data:`SPECIALISM_TO_PROTOCOL` and projects
``account.supported_billing`` from
:attr:`~adcp.decisioning.DecisioningCapabilities.supported_billing`
when non-empty. Adopters who need to advertise additional
capability details (pricing models, media-buy features, idempotency
declaration, etc.) should override this method and call
:func:`adcp.server.responses.capabilities_response` directly.
"""
from adcp.server.responses import capabilities_response

caps = self._platform.capabilities

# Derive supported_protocols from claimed specialisms.
protocols: set[str] = set()
for specialism in caps.specialisms:
proto = SPECIALISM_TO_PROTOCOL.get(specialism)
if proto is not None:
protocols.add(proto)

# Project account.supported_billing when declared.
account: dict[str, Any] | None = None
if caps.supported_billing:
account = {"supported_billing": list(caps.supported_billing)}

# The spec requires adcp.idempotency. Default to unsupported — adopters
# with replay protection should override this method and pass
# idempotency=store.capability() from their IdempotencyStore.
return capabilities_response(
sorted(protocols),
account=account,
idempotency={"supported": False},
)

# ----- Sales tools -----

async def get_products( # type: ignore[override]
Expand Down
13 changes: 13 additions & 0 deletions src/adcp/decisioning/platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ class DecisioningCapabilities:
:param pricing_models: Pricing models the platform supports —
``'cpm'``, ``'cpc'``, ``'cpa'``, ``'cpcv'``. Surfaced on
capabilities.
:param supported_billing: Billing models this seller supports.
Required by the AdCP spec when declaring ``media_buy`` support.
Valid values: ``'operator'`` (seller invoices the operator),
``'agent'`` (agent consolidates billing), ``'advertiser'``
(seller invoices the advertiser directly). Sales-* adopters
MUST declare at least one. A :class:`UserWarning` fires at
server boot when a sales-* specialism is claimed but this is
empty; a future minor will make it a hard fail.
:param creative_agents: Optional list of creative-agent endpoints
the platform delegates creative review/generation to. Empty
list means "no creative-agent integration; review is in-house."
Expand All @@ -64,6 +72,11 @@ class DecisioningCapabilities:
specialisms: list[str] = field(default_factory=list)
channels: list[str] = field(default_factory=list)
pricing_models: list[str] = field(default_factory=list)
# Billing models this seller supports. Required by the AdCP spec
# (get-adcp-capabilities-response.json account.supported_billing,
# required: ["supported_billing"]) whenever a seller claims a sales-*
# specialism. Valid values: "operator", "agent", "advertiser".
supported_billing: list[str] = field(default_factory=list)
creative_agents: list[Any] = field(default_factory=list)
config: dict[str, Any] = field(default_factory=dict)
governance_aware: bool = False
Expand Down
12 changes: 12 additions & 0 deletions src/adcp/server/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ def capabilities_response(
features: dict[str, Any] | None = None,
idempotency: dict[str, Any] | None = None,
compliance_testing: dict[str, Any] | None = None,
account: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Build a get_adcp_capabilities response.

Expand Down Expand Up @@ -89,6 +90,15 @@ def capabilities_response(
compliance_testing: Optional top-level ``compliance_testing`` block
to advertise compliance-testing capabilities. When provided,
emitted as a sibling of ``adcp`` in the response.
account: Optional ``account`` capabilities block. When the seller
supports ``media_buy``, the AdCP spec requires this object and
its ``supported_billing`` field (values: ``"operator"``,
``"agent"``, ``"advertiser"``). Pass
``{"supported_billing": ["operator"]}`` (or the appropriate
billing model(s)) for any media-buy–capable server. The
:class:`~adcp.decisioning.DecisioningPlatform` framework
auto-populates this from
:attr:`~adcp.decisioning.DecisioningCapabilities.supported_billing`.

Example::

Expand Down Expand Up @@ -129,6 +139,8 @@ def capabilities_response(
resp["features"] = features
if compliance_testing is not None:
resp["compliance_testing"] = compliance_testing
if account is not None:
resp["account"] = account
return resp


Expand Down
61 changes: 60 additions & 1 deletion tests/test_decisioning_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@ def executor():


class _ValidPlatform(DecisioningPlatform):
capabilities = DecisioningCapabilities(specialisms=["sales-non-guaranteed"])
capabilities = DecisioningCapabilities(
specialisms=["sales-non-guaranteed"],
supported_billing=["operator"],
)
accounts = SingletonAccounts(account_id="hello")

def get_products(self, req, ctx):
Expand Down Expand Up @@ -233,6 +236,62 @@ class _NoClaimsPlatform(DecisioningPlatform):
validate_platform(_NoClaimsPlatform())


def test_validate_platform_warns_when_sales_specialism_missing_supported_billing() -> None:
"""Sales-* platform without supported_billing emits UserWarning.

The AdCP spec requires account.supported_billing in the
get_adcp_capabilities response for media_buy sellers. The framework
warns at server boot so adopters discover the gap before shipping."""

class _SalesWithoutBilling(DecisioningPlatform):
capabilities = DecisioningCapabilities(specialisms=["sales-non-guaranteed"])
accounts = SingletonAccounts(account_id="hello")

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, "status": "active"}

def sync_creatives(self, req, ctx):
return {"creatives": []}

def get_media_buy_delivery(self, req, ctx):
return {"deliveries": []}

with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always", UserWarning)
validate_platform(_SalesWithoutBilling())
billing_warnings = [w for w in caught if "supported_billing" in str(w.message)]
assert len(billing_warnings) == 1
assert "media_buy" in str(billing_warnings[0].message)


def test_validate_platform_no_billing_warning_for_non_sales_specialisms() -> None:
"""Signals-only platform does not emit the supported_billing warning
— billing is only required for sales-* (media_buy) sellers."""
from adcp.decisioning.dispatch import validate_platform as _vp

class _SignalsPlatform(DecisioningPlatform):
capabilities = DecisioningCapabilities(specialisms=["signal-marketplace"])
accounts = SingletonAccounts(account_id="hello")

def get_signals(self, req, ctx):
return {"signals": []}

def activate_signal(self, req, ctx):
return {"deployments": []}

with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always", UserWarning)
_vp(_SignalsPlatform())
billing_warnings = [w for w in caught if "supported_billing" in str(w.message)]
assert not billing_warnings, "supported_billing warning must not fire for non-sales platforms"


def test_required_methods_per_specialism_pinned_for_sales() -> None:
"""Contract test — locks the sales core method set so future
spec churn surfaces as a visible test failure. Slugs covered are
Expand Down
Loading
Loading