diff --git a/src/adcp/decisioning/dispatch.py b/src/adcp/decisioning/dispatch.py index 9751deec..c84821b9 100644 --- a/src/adcp/decisioning/dispatch.py +++ b/src/adcp/decisioning/dispatch.py @@ -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``. @@ -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", ] diff --git a/src/adcp/decisioning/serve.py b/src/adcp/decisioning/serve.py index d5ea606b..271d4d9a 100644 --- a/src/adcp/decisioning/serve.py +++ b/src/adcp/decisioning/serve.py @@ -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 @@ -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, diff --git a/tests/test_decisioning_dispatch.py b/tests/test_decisioning_dispatch.py index 5725700c..a9f4fe3d 100644 --- a/tests/test_decisioning_dispatch.py +++ b/tests/test_decisioning_dispatch.py @@ -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 @@ -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"] diff --git a/tests/test_decisioning_serve.py b/tests/test_decisioning_serve.py index a232cf79..747046b0 100644 --- a/tests/test_decisioning_serve.py +++ b/tests/test_decisioning_serve.py @@ -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): @@ -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) ----