diff --git a/examples/hello_seller_signals.py b/examples/hello_seller_signals.py index d1756762d..7c72ed459 100644 --- a/examples/hello_seller_signals.py +++ b/examples/hello_seller_signals.py @@ -1,8 +1,10 @@ """Hello-seller-signals — minimal SignalsPlatform adopter. -The smallest possible ``signal-marketplace`` (or ``signal-owned``) -seller. Two methods: ``get_signals`` for catalog discovery and -``activate_signal`` for provisioning to destination platforms. +The smallest possible ``signal-marketplace`` seller. Two methods: +``get_signals`` for catalog discovery and ``activate_signal`` for +provisioning to destination platforms. ``signal-owned`` sellers can be +discovery-only when their owned signals are already usable on seller +inventory. This is the template for signal-marketplace adopters (LiveRamp, Nielsen, 1P data providers). diff --git a/schemas/cache/3.0/manifest.json b/schemas/cache/3.0/manifest.json index 1115fdb36..08dc5d6c1 100644 --- a/schemas/cache/3.0/manifest.json +++ b/schemas/cache/3.0/manifest.json @@ -20,8 +20,7 @@ "response_schema": "signals/activate-signal-response.json", "async_response_schemas": [], "specialisms": [ - "signal_marketplace", - "signal_owned" + "signal_marketplace" ] }, "build_creative": { @@ -1181,10 +1180,9 @@ "get_signals" ], "exercised_tools": [ - "activate_signal", "get_adcp_capabilities", "get_signals" ] } } -} \ No newline at end of file +} diff --git a/src/adcp/decisioning/__init__.py b/src/adcp/decisioning/__init__.py index b1ef7b117..552e849ba 100644 --- a/src/adcp/decisioning/__init__.py +++ b/src/adcp/decisioning/__init__.py @@ -191,6 +191,7 @@ def create_media_buy( ContentStandardsPlatform, CreativeAdServerPlatform, CreativeBuilderPlatform, + OwnedSignalsPlatform, PropertyListsPlatform, SalesPlatform, SignalsPlatform, @@ -358,6 +359,7 @@ def __init__(self, *args: object, **kwargs: object) -> None: "MockProposalManager", "NoAuth", "OAuthCredential", + "OwnedSignalsPlatform", "PermissionDeniedError", "PgProposalStore", "PgTaskRegistry", diff --git a/src/adcp/decisioning/dispatch.py b/src/adcp/decisioning/dispatch.py index 049ad046f..fb96e2ec2 100644 --- a/src/adcp/decisioning/dispatch.py +++ b/src/adcp/decisioning/dispatch.py @@ -206,8 +206,9 @@ "sync_catalogs", } ), - # Signals specialisms — third-party data brokers and first-party - # data providers share the same SignalsPlatform Protocol surface. + # Signals specialisms. Marketplace/provisioned signals require + # activation onto destinations; seller-owned signals are already + # usable on that seller's inventory, so discovery is sufficient. "signal-marketplace": frozenset( { "get_signals", @@ -217,7 +218,6 @@ "signal-owned": frozenset( { "get_signals", - "activate_signal", } ), # Audience-sync — first-party CRM audience push with delta upsert. diff --git a/src/adcp/decisioning/handler.py b/src/adcp/decisioning/handler.py index af5e8c05a..21e74b609 100644 --- a/src/adcp/decisioning/handler.py +++ b/src/adcp/decisioning/handler.py @@ -267,6 +267,11 @@ "activate_signal", } ) +_OWNED_SIGNALS_ADVERTISED_TOOLS: frozenset[str] = frozenset( + { + "get_signals", + } +) _AUDIENCE_ADVERTISED_TOOLS: frozenset[str] = frozenset( { "sync_audiences", @@ -337,6 +342,9 @@ # ContentStandardsPlatform optional analyzer reads "get_media_buy_artifacts", "get_creative_features", + # signal-owned platforms expose discovery-only owned signals; + # activate_signal remains required for signal-marketplace. + "activate_signal", # AudiencePlatform adopter-internal helper (not wire-served, but # listed here for symmetry should a future shim wire it) "poll_audience_statuses", @@ -375,9 +383,11 @@ "creative-generative": _CREATIVE_ADVERTISED_TOOLS, "creative-template": _CREATIVE_ADVERTISED_TOOLS, "creative-ad-server": _CREATIVE_ADVERTISED_TOOLS, - # Signals — marketplace + owned share the same wire surface. + # Signals — marketplace/provisioned signals need activation; + # seller-owned signals are discovery-only because they are already + # usable on that seller's inventory. "signal-marketplace": _SIGNALS_ADVERTISED_TOOLS, - "signal-owned": _SIGNALS_ADVERTISED_TOOLS, + "signal-owned": _OWNED_SIGNALS_ADVERTISED_TOOLS, # Audience. "audience-sync": _AUDIENCE_ADVERTISED_TOOLS, # Governance — spend-authority + delivery-monitor share the @@ -2261,6 +2271,7 @@ async def activate_signal( # type: ignore[override] context: ToolContext | None = None, ) -> ActivateSignalSuccessResponse: """Provision a signal onto destination platforms.""" + self._require_platform_method("activate_signal") tool_ctx = context or ToolContext() account = await self._resolve_account(getattr(params, "account", None), tool_ctx) ctx = self._build_ctx(tool_ctx, account) diff --git a/src/adcp/decisioning/platform.py b/src/adcp/decisioning/platform.py index 1049316f5..4dca3e8cb 100644 --- a/src/adcp/decisioning/platform.py +++ b/src/adcp/decisioning/platform.py @@ -116,8 +116,8 @@ class DecisioningCapabilities: :param specialisms: AdCP specialism slugs the platform claims — e.g. ``['sales-non-guaranteed', 'sales-broadcast-tv']``, - ``['audience-sync']``, ``['signal-marketplace', - 'signal-owned']``. Each maps to a ``Protocol`` class under + ``['audience-sync']``, ``['signal-marketplace']``, or + ``['signal-owned']``. Each maps to a ``Protocol`` class under :mod:`adcp.decisioning.specialisms`. Drives method-conformance validation at boot AND projects to the wire ``specialisms`` field. diff --git a/src/adcp/decisioning/platform_router.py b/src/adcp/decisioning/platform_router.py index 72ac96495..7b2396373 100644 --- a/src/adcp/decisioning/platform_router.py +++ b/src/adcp/decisioning/platform_router.py @@ -75,7 +75,8 @@ ---------------------- At construction time the router walks the specialism Protocol classes -(``SalesPlatform``, ``AudiencePlatform``, ``SignalsPlatform``, etc.) +(``SalesPlatform``, ``AudiencePlatform``, ``SignalsPlatform``, +``OwnedSignalsPlatform``, etc.) declared in :mod:`adcp.decisioning.specialisms` and synthesizes a delegating method for each method any child platform implements. New specialism Protocols added to the SDK are picked up automatically — no @@ -113,6 +114,7 @@ ContentStandardsPlatform, CreativeAdServerPlatform, CreativeBuilderPlatform, + OwnedSignalsPlatform, PropertyListsPlatform, SalesPlatform, SignalsPlatform, @@ -135,6 +137,7 @@ # to enumerate them. _KNOWN_SPECIALISM_PROTOCOLS: tuple[type, ...] = ( SalesPlatform, + OwnedSignalsPlatform, SignalsPlatform, AudiencePlatform, CreativeBuilderPlatform, @@ -165,7 +168,9 @@ def _protocol_method_names(proto: type) -> frozenset[str]: Annotation-only attributes (``foo: int``) are NOT picked up because no Protocol in :mod:`adcp.decisioning.specialisms` declares attribute-only members; if that changes, broaden the walk to - ``__annotations__`` too. + ``__annotations__`` too. Direct Protocol inheritance does not need + special handling here because inherited method providers are listed + separately in :data:`_KNOWN_SPECIALISM_PROTOCOLS`. """ declared: set[str] = set() for name, value in vars(proto).items(): diff --git a/src/adcp/decisioning/specialisms/__init__.py b/src/adcp/decisioning/specialisms/__init__.py index 001a9de6b..ee0627eb8 100644 --- a/src/adcp/decisioning/specialisms/__init__.py +++ b/src/adcp/decisioning/specialisms/__init__.py @@ -12,9 +12,12 @@ * :class:`SalesPlatform` — covers the spec ``sales-*`` slugs (non-guaranteed, guaranteed, broadcast-tv, social, proposal-mode, catalog-driven) under one unified hybrid shape. -* :class:`SignalsPlatform` — covers ``signal-marketplace`` + - ``signal-owned``. Two methods: ``get_signals`` (catalog discovery) - and ``activate_signal`` (provisioning onto destination platforms). +* :class:`SignalsPlatform` — covers ``signal-marketplace``. Two + methods: ``get_signals`` (catalog discovery) and ``activate_signal`` + (provisioning onto destination platforms). +* :class:`OwnedSignalsPlatform` — covers ``signal-owned``. One method: + ``get_signals`` (publisher-owned signal catalog discovery; signals + are already usable on seller inventory). * :class:`AudiencePlatform` — covers ``audience-sync``. Two methods: ``sync_audiences`` (push first-party CRM audiences with delta upsert) and ``poll_audience_statuses`` (batch state read). @@ -71,7 +74,7 @@ PropertyListsPlatform, ) from adcp.decisioning.specialisms.sales import SalesPlatform -from adcp.decisioning.specialisms.signals import SignalsPlatform +from adcp.decisioning.specialisms.signals import OwnedSignalsPlatform, SignalsPlatform __all__ = [ "AudiencePlatform", @@ -81,6 +84,7 @@ "ContentStandardsPlatform", "CreativeAdServerPlatform", "CreativeBuilderPlatform", + "OwnedSignalsPlatform", "PropertyListsPlatform", "SalesPlatform", "SignalsPlatform", diff --git a/src/adcp/decisioning/specialisms/signals.py b/src/adcp/decisioning/specialisms/signals.py index fa89d9895..635ce5150 100644 --- a/src/adcp/decisioning/specialisms/signals.py +++ b/src/adcp/decisioning/specialisms/signals.py @@ -1,14 +1,17 @@ -"""SignalsPlatform Protocol — covers ``signal-marketplace`` + ``signal-owned``. +"""SignalsPlatform Protocols for marketplace and owned signals. -A platform claiming either ``signal-marketplace`` (third-party data -brokers — LiveRamp, Oracle Data Cloud, third-party DMPs) or -``signal-owned`` (first-party data providers — publisher first-party -data, retailer customer-graph) implements the methods on this Protocol. -The slugs mirror ``schemas/cache/enums/specialism.json``. +``signal-marketplace`` covers marketplace/provisioned signals that need +buyer-triggered destination activation. ``signal-owned`` covers +publisher-owned first-party signals that are already usable on that +seller inventory and only need catalog discovery. The slugs mirror +``schemas/cache/enums/specialism.json``. -Two methods: +Common method: * :meth:`get_signals` — sync catalog discovery + +Marketplace-only method: + * :meth:`activate_signal` — sync provisioning onto destination platforms Async story: ``activate_signal`` is sync at the wire level — its @@ -19,7 +22,7 @@ ``ctx.publish_status_change(resource_type='signal', ...)`` events as each deployment reaches ``activating`` / ``deployed`` / ``failed``. -Mirrors the JS-side ``SignalsPlatform`` interface at +Mirrors the JS-side signals interfaces at ``src/lib/server/decisioning/specialisms/signals.ts``. """ @@ -41,14 +44,15 @@ #: Per-platform metadata generic; matches ``RequestContext[TMeta]`` and #: ``Account[TMeta]`` upstream so a platform parameterizing -#: ``SignalsPlatform[TenantMeta]`` gets ``ctx.account.metadata``-style -#: typed access inside method bodies. +#: ``SignalsPlatform[TenantMeta]`` or +#: ``OwnedSignalsPlatform[TenantMeta]`` gets +#: ``ctx.account.metadata``-style typed access inside method bodies. TMeta = TypeVar("TMeta", default=dict[str, Any]) @runtime_checkable -class SignalsPlatform(Protocol, Generic[TMeta]): - """Catalog discovery + activation for marketplace / owned signals. +class OwnedSignalsPlatform(Protocol, Generic[TMeta]): + """Catalog discovery for seller-owned first-party signals. Methods may be sync (return ``T`` directly) or async (return ``Awaitable[T]``); the dispatch adapter detects via @@ -81,6 +85,17 @@ def get_signals( """ ... + +@runtime_checkable +class SignalsPlatform(OwnedSignalsPlatform[TMeta], Protocol, Generic[TMeta]): + """Catalog discovery + activation for marketplace/provisioned signals. + + Use this Protocol for ``signal-marketplace``. Use + :class:`OwnedSignalsPlatform` for ``signal-owned`` platforms where + returned signals are already usable in later media-buy targeting and + there is no buyer-triggered destination provisioning step. + """ + def activate_signal( self, req: ActivateSignalRequest, @@ -111,4 +126,4 @@ def activate_signal( ... -__all__ = ["SignalsPlatform"] +__all__ = ["OwnedSignalsPlatform", "SignalsPlatform"] diff --git a/tests/test_decisioning_advertised_per_specialism.py b/tests/test_decisioning_advertised_per_specialism.py index 2d62355c6..02e21ea7c 100644 --- a/tests/test_decisioning_advertised_per_specialism.py +++ b/tests/test_decisioning_advertised_per_specialism.py @@ -101,6 +101,14 @@ def activate_signal(self, req, ctx): return {} +class _OwnedSignalsOnlyPlatform(DecisioningPlatform): + capabilities = DecisioningCapabilities(specialisms=["signal-owned"]) + accounts = SingletonAccounts(account_id="owned-signals-only") + + def get_signals(self, req, ctx): + return {"signals": []} + + class _CreativeOnlyPlatform(DecisioningPlatform): capabilities = DecisioningCapabilities(specialisms=["creative-generative"]) accounts = SingletonAccounts(account_id="creative-only") @@ -168,6 +176,28 @@ def test_signals_only_does_not_advertise_sales_tools(executor) -> None: assert not leaked, f"signals-only leaked: {sorted(leaked)}" +def test_owned_signals_only_advertises_discovery_not_activation(executor) -> None: + handler = PlatformHandler( + _OwnedSignalsOnlyPlatform(), + executor=executor, + registry=InMemoryTaskRegistry(), + ) + tools = {tool["name"] for tool in get_tools_for_handler(handler)} + + assert "get_signals" in tools + assert "activate_signal" not in tools + + forbidden = { + "get_products", + "create_media_buy", + "build_creative", + "acquire_rights", + "check_governance", + } + leaked = forbidden & tools + assert not leaked, f"owned-signals-only leaked: {sorted(leaked)}" + + def test_creative_only_does_not_advertise_sales_or_signals_tools(executor) -> None: """Mirror test for the creative path — AudioStack/Stability AI shape.""" handler = PlatformHandler( diff --git a/tests/test_decisioning_capabilities_projection.py b/tests/test_decisioning_capabilities_projection.py index 0d693a081..814f2c5e7 100644 --- a/tests/test_decisioning_capabilities_projection.py +++ b/tests/test_decisioning_capabilities_projection.py @@ -64,6 +64,16 @@ class _SignalsPlatform(DecisioningPlatform): accounts = SingletonAccounts(account_id="test") +class _OwnedSignalsPlatform(DecisioningPlatform): + """Minimal signal-owned platform — discovery-only signals claim.""" + + capabilities = DecisioningCapabilities( + specialisms=["signal-owned"], + supported_billing=["agent"], + ) + accounts = SingletonAccounts(account_id="test") + + class _BarePlatform(DecisioningPlatform): """Platform with no specialisms claimed at all (pure meta).""" @@ -133,6 +143,19 @@ def test_signals_only_platform_emits_signals_protocol(executor: ThreadPoolExecut assert response["account"]["supported_billing"] == ["agent"] +def test_owned_signals_only_platform_emits_signals_protocol( + executor: ThreadPoolExecutor, +) -> None: + """A platform claiming only ``signal-owned`` projects to the + signals protocol even though the method surface is discovery-only.""" + handler = _build_handler(_OwnedSignalsPlatform(), executor) + response = asyncio.run(handler.get_adcp_capabilities()) + + assert response["supported_protocols"] == ["signals"] + assert "media_buy" not in response + assert response["account"]["supported_billing"] == ["agent"] + + def test_bare_platform_emits_empty_supported_protocols(executor: ThreadPoolExecutor) -> None: """A platform with no specialisms emits an empty ``supported_protocols`` list — the projection refuses to silently diff --git a/tests/test_decisioning_handler_shims.py b/tests/test_decisioning_handler_shims.py index 883381856..d8af5fe0b 100644 --- a/tests/test_decisioning_handler_shims.py +++ b/tests/test_decisioning_handler_shims.py @@ -415,6 +415,37 @@ def build_creative(self, req, ctx): assert "preview_creative" in str(exc_info.value) +@pytest.mark.asyncio +async def test_activate_signal_unsupported_for_owned_signals_without_method( + executor, +) -> None: + """``activate_signal`` is not part of the required + ``signal-owned`` contract. A discovery-only owned-signal platform + should surface ``UNSUPPORTED_FEATURE`` if called directly rather + than leaking an AttributeError as ``INTERNAL_ERROR``.""" + + class _OwnedSignalsWithoutActivation(DecisioningPlatform): + capabilities = DecisioningCapabilities(specialisms=["signal-owned"]) + accounts = SingletonAccounts(account_id="hello") + + def get_signals(self, req, ctx): + return {"signals": []} + + # Deliberately no activate_signal — signal-owned is discovery-only. + + handler = PlatformHandler( + _OwnedSignalsWithoutActivation(), + executor=executor, + registry=InMemoryTaskRegistry(), + ) + from adcp.types import ActivateSignalRequest + + with pytest.raises(AdcpError) as exc_info: + await handler.activate_signal(ActivateSignalRequest.model_construct(), ToolContext()) + assert exc_info.value.code == "UNSUPPORTED_FEATURE" + assert "activate_signal" in str(exc_info.value) + + @pytest.mark.asyncio async def test_get_creative_features_unsupported_when_platform_lacks_method( executor, diff --git a/tests/test_decisioning_specialisms.py b/tests/test_decisioning_specialisms.py index 094bb6c6b..a9d0eec0e 100644 --- a/tests/test_decisioning_specialisms.py +++ b/tests/test_decisioning_specialisms.py @@ -1,8 +1,9 @@ """Per-specialism Protocol tests. -Covers ``SignalsPlatform`` (signal-marketplace, signal-owned) and -``AudiencePlatform`` (audience-sync). The ``SalesPlatform`` Protocol -is exercised end-to-end by the foundation tests +Covers ``SignalsPlatform`` (signal-marketplace), +``OwnedSignalsPlatform`` (signal-owned), and ``AudiencePlatform`` +(audience-sync). The ``SalesPlatform`` Protocol is exercised end-to-end +by the foundation tests (``test_decisioning_handler.py``, ``test_hello_seller_integration.py``); this file fills the breadth-sprint Batch 1 coverage for the two specialisms shipped alongside it. @@ -19,6 +20,9 @@ from __future__ import annotations +import json +from pathlib import Path + import pytest from adcp.decisioning import ( @@ -31,6 +35,7 @@ CreativeBuilderPlatform, DecisioningCapabilities, DecisioningPlatform, + OwnedSignalsPlatform, PropertyListsPlatform, SalesPlatform, SignalsPlatform, @@ -46,7 +51,7 @@ def test_specialism_protocols_are_publicly_exported() -> None: - """All ten Protocol classes (Batches 0–4) are on + """All public Protocol classes (Batches 0–4) are on ``adcp.decisioning.__all__`` so adopters import from the canonical public surface, not the internal ``adcp.decisioning.specialisms.*`` modules. @@ -57,6 +62,7 @@ def test_specialism_protocols_are_publicly_exported() -> None: import adcp.decisioning as dx assert "SalesPlatform" in dx.__all__ + assert "OwnedSignalsPlatform" in dx.__all__ assert "SignalsPlatform" in dx.__all__ assert "AudiencePlatform" in dx.__all__ assert "CreativeBuilderPlatform" in dx.__all__ @@ -66,6 +72,7 @@ def test_specialism_protocols_are_publicly_exported() -> None: assert "ContentStandardsPlatform" in dx.__all__ assert "PropertyListsPlatform" in dx.__all__ assert "CollectionListsPlatform" in dx.__all__ + assert dx.OwnedSignalsPlatform is OwnedSignalsPlatform assert dx.SignalsPlatform is SignalsPlatform assert dx.AudiencePlatform is AudiencePlatform assert dx.CreativeBuilderPlatform is CreativeBuilderPlatform @@ -109,6 +116,18 @@ def get_signals(self, req, ctx): assert not isinstance(_Partial(), SignalsPlatform) +def test_owned_signals_platform_runtime_checkable_with_get_signals_only() -> None: + """``signal-owned`` supports discovery-only owned signal catalogs.""" + + class _OwnedSignalsImpl: + def get_signals(self, req, ctx): + return {"signals": []} + + impl = _OwnedSignalsImpl() + assert isinstance(impl, OwnedSignalsPlatform) + assert not isinstance(impl, SignalsPlatform) + + def test_validate_platform_enforces_signal_marketplace_methods() -> None: """A platform claiming ``signal-marketplace`` without implementing ``get_signals`` + ``activate_signal`` fails fast at server boot.""" @@ -128,9 +147,8 @@ def get_signals(self, req, ctx): assert "activate_signal" in missing_methods -def test_validate_platform_enforces_signal_owned_methods() -> None: - """``signal-owned`` shares the SignalsPlatform Protocol surface — - same required-method enforcement.""" +def test_validate_platform_enforces_signal_owned_get_signals_only() -> None: + """``signal-owned`` requires catalog discovery but not activation.""" class _PartialSignalOwnedPlatform(DecisioningPlatform): capabilities = DecisioningCapabilities(specialisms=["signal-owned"]) @@ -141,8 +159,20 @@ class _PartialSignalOwnedPlatform(DecisioningPlatform): validate_platform(_PartialSignalOwnedPlatform()) assert exc_info.value.code == "INVALID_REQUEST" missing_methods = {m["method"] for m in exc_info.value.details["missing"]} - assert "get_signals" in missing_methods - assert "activate_signal" in missing_methods + assert missing_methods == {"get_signals"} + + +def test_validate_platform_passes_for_signal_owned_without_activate_signal() -> None: + """Seller-owned signals are already usable in media-buy targeting.""" + + class _OwnedSignalPlatform(DecisioningPlatform): + capabilities = DecisioningCapabilities(specialisms=["signal-owned"]) + accounts = SingletonAccounts(account_id="hello") + + def get_signals(self, req, ctx): + return {"signals": []} + + validate_platform(_OwnedSignalPlatform()) def test_validate_platform_passes_for_complete_signals_platform() -> None: @@ -161,13 +191,35 @@ def activate_signal(self, req, ctx): validate_platform(_CompleteSignalsPlatform()) -def test_signal_marketplace_and_signal_owned_share_method_set() -> None: - """Both signal specialisms gate on the same two methods. Drift in - REQUIRED_METHODS_PER_SPECIALISM here surfaces as a visible test - failure since they should track together.""" - expected = {"get_signals", "activate_signal"} - assert REQUIRED_METHODS_PER_SPECIALISM["signal-marketplace"] == expected - assert REQUIRED_METHODS_PER_SPECIALISM["signal-owned"] == expected +def test_signal_marketplace_and_signal_owned_method_sets_are_distinct() -> None: + """Marketplace signals provision to destinations; owned signals do not.""" + + assert REQUIRED_METHODS_PER_SPECIALISM["signal-marketplace"] == { + "get_signals", + "activate_signal", + } + assert REQUIRED_METHODS_PER_SPECIALISM["signal-owned"] == {"get_signals"} + + +def test_signal_owned_manifest_exercises_discovery_only() -> None: + """Bundled conformance manifest must match SDK validation. + + A ``signal-owned`` platform is discovery-only, so the manifest should + not make conformance runners exercise marketplace activation for that + specialism. + """ + + manifest_path = Path(__file__).resolve().parents[1] / "schemas/cache/3.0/manifest.json" + manifest = json.loads(manifest_path.read_text()) + + signal_owned = manifest["specialisms"]["signal_owned"] + assert signal_owned["entry_point_tools"] == ["get_signals"] + assert signal_owned["exercised_tools"] == [ + "get_adcp_capabilities", + "get_signals", + ] + assert "signal_owned" not in manifest["tools"]["activate_signal"]["specialisms"] + assert "signal_marketplace" in manifest["tools"]["activate_signal"]["specialisms"] # ---- AudiencePlatform ----