Skip to content
Closed
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
49 changes: 49 additions & 0 deletions src/adcp/decisioning/dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,54 @@ def validate_platform(platform: DecisioningPlatform) -> None:
)


def validate_capabilities_response_shape(platform: DecisioningPlatform) -> None:
"""Boot-time validator — spec invariants for the capabilities response.

Invariant: ``account.supported_billing`` must be non-empty whenever
any claimed specialism maps to the ``media_buy`` protocol. The spec
requires this field when ``media_buy`` is in ``supported_protocols``;
the framework's auto-projection in
:meth:`PlatformHandler.get_adcp_capabilities` silently drops the
``account`` block when ``supported_billing`` is empty, producing a
wire-invalid capabilities response.

Called from :func:`~adcp.decisioning.serve.create_adcp_server_from_platform`
after :func:`validate_platform`.

:raises AdcpError: if any spec invariant is violated.
"""
# Lazy import avoids a circular dependency: handler.py imports
# dispatch.py for _build_request_context / _invoke_platform_method.
from adcp.decisioning.handler import SPECIALISM_TO_PROTOCOLS

caps = platform.capabilities
media_buy_specialisms = [
s for s in caps.specialisms if "media_buy" in SPECIALISM_TO_PROTOCOLS.get(s, frozenset())
]

if media_buy_specialisms and not caps.supported_billing:
raise AdcpError(
"INVALID_REQUEST",
message=(
"capabilities.supported_billing must be non-empty when any specialism "
"maps to the media_buy protocol. "
f"The specialism(s) {sorted(media_buy_specialisms)!r} trigger this "
"requirement (the SDK enforces this as a boot-time invariant derived "
"from spec intent: account.supported_billing is required by spec prose "
"whenever media_buy is in supported_protocols, even though the JSON "
"Schema does not encode it as a hard conditional). "
'Fix: add supported_billing=["operator", "agent"] '
"(or a subset) to your DecisioningCapabilities. "
"Note: audience-sync also maps to media_buy and triggers this check."
),
recovery="terminal",
details={
"media_buy_specialisms": sorted(media_buy_specialisms),
"supported_billing": caps.supported_billing,
},
)


def _has_overridden_method(platform: DecisioningPlatform, method_name: str) -> bool:
"""True when the platform subclass provides ``method_name``.

Expand Down Expand Up @@ -1147,5 +1195,6 @@ async def _project_workflow_handoff(
"REQUIRED_METHODS_PER_SPECIALISM",
"SPEC_SPECIALISM_ENUM",
"compose_caller_identity",
"validate_capabilities_response_shape",
"validate_platform",
]
3 changes: 2 additions & 1 deletion src/adcp/decisioning/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
from concurrent.futures import ThreadPoolExecutor
from typing import TYPE_CHECKING, Any

from adcp.decisioning.dispatch import validate_platform
from adcp.decisioning.dispatch import validate_capabilities_response_shape, validate_platform
from adcp.decisioning.handler import PlatformHandler
from adcp.decisioning.task_registry import InMemoryTaskRegistry
from adcp.decisioning.types import AdcpError
Expand Down Expand Up @@ -260,6 +260,7 @@ def create_adcp_server_from_platform(
# validation diagnostic includes the wiring context. Failure here
# propagates to the caller.
validate_platform(platform)
validate_capabilities_response_shape(platform)

handler = PlatformHandler(
platform,
Expand Down
111 changes: 111 additions & 0 deletions tests/test_decisioning_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
_invoke_platform_method,
_project_handoff,
compose_caller_identity,
validate_capabilities_response_shape,
validate_platform,
)
from adcp.decisioning.types import Account, TaskHandoff
Expand Down Expand Up @@ -1061,3 +1062,113 @@ async def create_media_buy(self, req, ctx):
assert isinstance(result, dict)
assert result["status"] == "submitted"
assert "task_type" not in result


# ---- validate_capabilities_response_shape ----


class _MediaBuyPlatformMissingSupportedBilling(DecisioningPlatform):
"""Claims sales-non-guaranteed (media_buy) but omits supported_billing."""

capabilities = DecisioningCapabilities(
specialisms=["sales-non-guaranteed"],
)
accounts = SingletonAccounts(account_id="test")

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": []}


class _MediaBuyPlatformWithSupportedBilling(DecisioningPlatform):
"""Claims sales-non-guaranteed (media_buy) with supported_billing populated."""

capabilities = DecisioningCapabilities(
specialisms=["sales-non-guaranteed"],
supported_billing=["operator", "agent"],
)
accounts = SingletonAccounts(account_id="test")

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": []}


class _SignalsOnlyPlatform(DecisioningPlatform):
"""Claims signal-marketplace only — no media_buy; supported_billing not required."""

capabilities = DecisioningCapabilities(
specialisms=["signal-marketplace"],
)
accounts = SingletonAccounts(account_id="test")

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

def activate_signal(self, req, ctx):
return {"signal_id": "s_1"}


class _AudienceSyncPlatformMissingSupportedBilling(DecisioningPlatform):
"""Claims audience-sync (also maps to media_buy) but omits supported_billing."""

capabilities = DecisioningCapabilities(
specialisms=["audience-sync"],
)
accounts = SingletonAccounts(account_id="test")

def sync_audiences(self, req, ctx):
return {"audiences": []}


def test_validate_capabilities_shape_fails_media_buy_without_supported_billing() -> None:
"""Fail-fast: media_buy-mapped specialism claimed without supported_billing."""
with pytest.raises(AdcpError) as exc_info:
validate_capabilities_response_shape(_MediaBuyPlatformMissingSupportedBilling())
err = exc_info.value
assert err.code == "INVALID_REQUEST"
assert err.recovery == "terminal"
assert "supported_billing" in str(err)
assert err.details["media_buy_specialisms"] == ["sales-non-guaranteed"]
assert err.details["supported_billing"] == []


def test_validate_capabilities_response_shape_passes_when_supported_billing_populated() -> None:
"""Happy path: supported_billing populated — no error raised."""
validate_capabilities_response_shape(_MediaBuyPlatformWithSupportedBilling())


def test_validate_capabilities_response_shape_passes_for_non_media_buy_specialism() -> None:
"""Signals-only platform with empty supported_billing is spec-conformant."""
validate_capabilities_response_shape(_SignalsOnlyPlatform())


def test_validate_capabilities_shape_fails_audience_sync_without_supported_billing() -> None:
"""audience-sync maps to media_buy — same invariant applies."""
with pytest.raises(AdcpError) as exc_info:
validate_capabilities_response_shape(_AudienceSyncPlatformMissingSupportedBilling())
err = exc_info.value
assert err.code == "INVALID_REQUEST"
assert err.details["media_buy_specialisms"] == ["audience-sync"]
38 changes: 37 additions & 1 deletion tests/test_decisioning_serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ class _SalesPlatformWithRequiredMethods(DecisioningPlatform):
required SalesPlatform methods are stubbed so ``validate_platform``
passes; the test focuses on the webhook gate."""

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 @@ -376,6 +379,39 @@ class _UnsafeGovernancePlatform(DecisioningPlatform):
assert "governance" in str(exc_info.value).lower()


def test_create_propagates_capabilities_shape_failure() -> None:
"""validate_capabilities_response_shape failure surfaces from
create_adcp_server_from_platform — media_buy specialism with no
supported_billing is caught before the handler is constructed."""

class _SalesWithoutBilling(DecisioningPlatform):
capabilities = DecisioningCapabilities(
specialisms=["sales-non-guaranteed"],
# supported_billing intentionally absent — spec invariant violated
)
accounts = SingletonAccounts(account_id="x")

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 pytest.raises(AdcpError) as exc_info:
create_adcp_server_from_platform(_SalesWithoutBilling())
assert exc_info.value.code == "INVALID_REQUEST"
assert "supported_billing" in str(exc_info.value)


# ---- Custom state_reader / resource_resolver plumbing (D15) ----


Expand Down
Loading