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
8 changes: 5 additions & 3 deletions examples/hello_seller_signals.py
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
6 changes: 2 additions & 4 deletions schemas/cache/3.0/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,7 @@
"response_schema": "signals/activate-signal-response.json",
"async_response_schemas": [],
"specialisms": [
"signal_marketplace",
"signal_owned"
"signal_marketplace"
]
},
"build_creative": {
Expand Down Expand Up @@ -1181,10 +1180,9 @@
"get_signals"
],
"exercised_tools": [
"activate_signal",
"get_adcp_capabilities",
"get_signals"
]
}
}
}
}
2 changes: 2 additions & 0 deletions src/adcp/decisioning/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ def create_media_buy(
ContentStandardsPlatform,
CreativeAdServerPlatform,
CreativeBuilderPlatform,
OwnedSignalsPlatform,
PropertyListsPlatform,
SalesPlatform,
SignalsPlatform,
Expand Down Expand Up @@ -358,6 +359,7 @@ def __init__(self, *args: object, **kwargs: object) -> None:
"MockProposalManager",
"NoAuth",
"OAuthCredential",
"OwnedSignalsPlatform",
"PermissionDeniedError",
"PgProposalStore",
"PgTaskRegistry",
Expand Down
6 changes: 3 additions & 3 deletions src/adcp/decisioning/dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -217,7 +218,6 @@
"signal-owned": frozenset(
{
"get_signals",
"activate_signal",
}
),
# Audience-sync — first-party CRM audience push with delta upsert.
Expand Down
15 changes: 13 additions & 2 deletions src/adcp/decisioning/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,11 @@
"activate_signal",
}
)
_OWNED_SIGNALS_ADVERTISED_TOOLS: frozenset[str] = frozenset(
{
"get_signals",
}
)
_AUDIENCE_ADVERTISED_TOOLS: frozenset[str] = frozenset(
{
"sync_audiences",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions src/adcp/decisioning/platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 7 additions & 2 deletions src/adcp/decisioning/platform_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -113,6 +114,7 @@
ContentStandardsPlatform,
CreativeAdServerPlatform,
CreativeBuilderPlatform,
OwnedSignalsPlatform,
PropertyListsPlatform,
SalesPlatform,
SignalsPlatform,
Expand All @@ -135,6 +137,7 @@
# to enumerate them.
_KNOWN_SPECIALISM_PROTOCOLS: tuple[type, ...] = (
SalesPlatform,
OwnedSignalsPlatform,
SignalsPlatform,
AudiencePlatform,
CreativeBuilderPlatform,
Expand Down Expand Up @@ -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():
Expand Down
12 changes: 8 additions & 4 deletions src/adcp/decisioning/specialisms/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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",
Expand All @@ -81,6 +84,7 @@
"ContentStandardsPlatform",
"CreativeAdServerPlatform",
"CreativeBuilderPlatform",
"OwnedSignalsPlatform",
"PropertyListsPlatform",
"SalesPlatform",
"SignalsPlatform",
Expand Down
41 changes: 28 additions & 13 deletions src/adcp/decisioning/specialisms/signals.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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``.
"""

Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -111,4 +126,4 @@ def activate_signal(
...


__all__ = ["SignalsPlatform"]
__all__ = ["OwnedSignalsPlatform", "SignalsPlatform"]
30 changes: 30 additions & 0 deletions tests/test_decisioning_advertised_per_specialism.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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(
Expand Down
23 changes: 23 additions & 0 deletions tests/test_decisioning_capabilities_projection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""

Expand Down Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions tests/test_decisioning_handler_shims.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading