From 581c5b83eadb6cbc231494137513239993d94889 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 30 Apr 2026 20:46:45 -0400 Subject: [PATCH 1/2] feat(decisioning): SignalsPlatform + AudiencePlatform Protocols (breadth sprint Batch 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First batch of the breadth-sprint per the parity audit (8 missing specialism Protocols). Ports two from JS reference at ``src/lib/server/decisioning/specialisms/{signals,audiences}.ts``. New Protocols: * ``SignalsPlatform`` (src/adcp/decisioning/specialisms/signals.py) — covers ``signal-marketplace`` (third-party data brokers like LiveRamp, Oracle Data Cloud) AND ``signal-owned`` (first-party data providers like publisher first-party data, retailer customer-graph). Two methods: ``get_signals`` (sync catalog discovery) and ``activate_signal`` (provisioning onto destination platforms). Activation is sync at the wire level — no Submitted arm. Long-running activation pipelines (identity-graph match: 5-30 min) return the success-arm shape with ``deployments`` rows in ``pending`` state and drive lifecycle via ``ctx.publish_status_change``. * ``AudiencePlatform`` (src/adcp/decisioning/specialisms/audience.py) — covers ``audience-sync``. Two methods: ``sync_audiences`` (wire-required; push first-party CRM audiences with delta upsert) and ``poll_audience_statuses`` (adopter-internal; batch state read for cross-platform orchestration). Match-rate computation runs in the adopter's background; per-audience terminal state via ``publish_status_change``. Required-method coverage in ``REQUIRED_METHODS_PER_SPECIALISM``: * ``signal-marketplace``, ``signal-owned`` — both gate on ``{get_signals, activate_signal}`` (shared Protocol). * ``audience-sync`` — gates only on ``sync_audiences`` since ``poll_audience_statuses`` is adopter-internal. Public re-exports added at ``adcp.decisioning.__all__``: ``SalesPlatform``, ``SignalsPlatform``, ``AudiencePlatform``. Closes a small drift bug — ``SalesPlatform`` was referenced in the quickstart docstring but never actually re-exported through the public surface. Test coverage in ``tests/test_decisioning_specialisms.py`` (13 new tests): * ``runtime_checkable`` conformance per Protocol. * ``validate_platform`` required-method enforcement. * Public-export pinning (drift-guard against ``adcp.decisioning.__all__``). * Cross-specialism composition (claiming sales-* + signal-* together satisfies both Protocols). * ``audience-sync`` minimal-implementation passes (only ``sync_audiences`` required). One existing test updated: ``test_validate_platform_warns_on_unenforced_spec_specialism`` switched its canonical "spec-recognized but unenforced" example from ``signal-marketplace`` (now enforced) to ``creative-ad-server`` (still pending until Batch 2 ships Creative Protocols). Remaining specialism Protocols (creative-*, governance-*, brand-rights, content-standards, property-lists, collection-lists) are queued for subsequent breadth-sprint PRs. 2221 tests pass (up from 2208). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/adcp/decisioning/__init__.py | 8 + src/adcp/decisioning/dispatch.py | 23 ++ src/adcp/decisioning/specialisms/__init__.py | 24 +- src/adcp/decisioning/specialisms/audience.py | 129 +++++++++ src/adcp/decisioning/specialisms/signals.py | 114 ++++++++ tests/test_decisioning_dispatch.py | 12 +- tests/test_decisioning_specialisms.py | 284 +++++++++++++++++++ 7 files changed, 581 insertions(+), 13 deletions(-) create mode 100644 src/adcp/decisioning/specialisms/audience.py create mode 100644 src/adcp/decisioning/specialisms/signals.py create mode 100644 tests/test_decisioning_specialisms.py diff --git a/src/adcp/decisioning/__init__.py b/src/adcp/decisioning/__init__.py index 4ecaf34e3..20888871c 100644 --- a/src/adcp/decisioning/__init__.py +++ b/src/adcp/decisioning/__init__.py @@ -76,6 +76,11 @@ def create_media_buy( create_adcp_server_from_platform, serve, ) +from adcp.decisioning.specialisms import ( + AudiencePlatform, + SalesPlatform, + SignalsPlatform, +) from adcp.decisioning.state import ( GovernanceContextJWS, Proposal, @@ -101,6 +106,7 @@ def create_media_buy( "Account", "AccountStore", "AdcpError", + "AudiencePlatform", "AuthInfo", "CollectionList", "DecisioningCapabilities", @@ -118,7 +124,9 @@ def create_media_buy( "PropertyListReference", "RequestContext", "ResourceResolver", + "SalesPlatform", "SalesResult", + "SignalsPlatform", "SingletonAccounts", "StateReader", "TaskHandoff", diff --git a/src/adcp/decisioning/dispatch.py b/src/adcp/decisioning/dispatch.py index a61491c16..957b10d57 100644 --- a/src/adcp/decisioning/dispatch.py +++ b/src/adcp/decisioning/dispatch.py @@ -191,6 +191,29 @@ "sync_catalogs", } ), + # Signals specialisms — third-party data brokers and first-party + # data providers share the same SignalsPlatform Protocol surface. + "signal-marketplace": frozenset( + { + "get_signals", + "activate_signal", + } + ), + "signal-owned": frozenset( + { + "get_signals", + "activate_signal", + } + ), + # Audience-sync — first-party CRM audience push with delta upsert. + # ``poll_audience_statuses`` is an adopter-internal helper not + # surfaced as a wire tool; ``sync_audiences`` is the only required + # method for spec coverage. + "audience-sync": frozenset( + { + "sync_audiences", + } + ), } diff --git a/src/adcp/decisioning/specialisms/__init__.py b/src/adcp/decisioning/specialisms/__init__.py index e8a0d7135..9e1ad8cb8 100644 --- a/src/adcp/decisioning/specialisms/__init__.py +++ b/src/adcp/decisioning/specialisms/__init__.py @@ -9,19 +9,25 @@ Public surface re-exported from :mod:`adcp.decisioning.specialisms`: -* :class:`SalesPlatform` — covers all 9 ``sales-*`` specialisms - (non-guaranteed, guaranteed, broadcast-tv, streaming-tv, social, - exchange, proposal-mode, catalog-driven, retail-media) under one - unified hybrid shape. +* :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:`AudiencePlatform` — covers ``audience-sync``. Two methods: + ``sync_audiences`` (push first-party CRM audiences with delta + upsert) and ``poll_audience_statuses`` (batch state read). -Other specialism Protocols (audience, signals, creative-*, governance, -property-lists, etc.) are added as adopters need them — first -:class:`SalesPlatform` because that's the v6.0 vertical-slice the -foundation PR proves out. +Remaining specialism Protocols (creative-*, governance-*, +brand-rights, content-standards, property-lists, collection-lists) +are added in subsequent breadth-sprint PRs as adopters need them. """ from __future__ import annotations +from adcp.decisioning.specialisms.audience import AudiencePlatform from adcp.decisioning.specialisms.sales import SalesPlatform +from adcp.decisioning.specialisms.signals import SignalsPlatform -__all__ = ["SalesPlatform"] +__all__ = ["AudiencePlatform", "SalesPlatform", "SignalsPlatform"] diff --git a/src/adcp/decisioning/specialisms/audience.py b/src/adcp/decisioning/specialisms/audience.py new file mode 100644 index 000000000..a96aab81a --- /dev/null +++ b/src/adcp/decisioning/specialisms/audience.py @@ -0,0 +1,129 @@ +"""AudiencePlatform Protocol — covers the ``audience-sync`` specialism. + +Used standalone (LiveRamp, Oracle Data Cloud, Salesforce CDP) or +composed with ``sales-social`` (Snap/Meta/TikTok). The framework owns +cross-platform threading + idempotency + cross-tenant scoping; the +adopter answers "given this audience, what happened on my system?" + +The slug mirrors ``schemas/cache/enums/specialism.json``. + +Two methods: + +* :meth:`sync_audiences` — push audiences to the platform (creates, + updates, deletes per the wire spec) +* :meth:`poll_audience_statuses` — batch-poll current status for one + or more audiences + +Mirrors the JS-side ``AudiencePlatform`` interface at +``src/lib/server/decisioning/specialisms/audiences.ts``. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import TYPE_CHECKING, Any, Generic, Protocol, runtime_checkable + +from typing_extensions import TypeVar + +if TYPE_CHECKING: + from adcp.decisioning.context import RequestContext + from adcp.decisioning.types import MaybeAsync + from adcp.types import ( + SyncAudiencesAudience, + SyncAudiencesSuccessResponse, + ) + +#: Per-platform metadata generic; matches ``RequestContext[TMeta]`` and +#: ``Account[TMeta]`` upstream. +TMeta = TypeVar("TMeta", default=dict[str, Any]) + +#: Adopter-facing audience-row shape. Wire schema doesn't export a +#: top-level ``Audience`` type; the spec defines it inline on +#: ``SyncAudiencesRequest.audiences[]``. The SDK re-exports the +#: extracted shape as :class:`SyncAudiencesAudience` from +#: :mod:`adcp.types`. +Audience = "SyncAudiencesAudience" + +#: Adopter-facing per-audience response-row shape. The wire success +#: response wraps these in ``{audiences: [...]}``; adopters return the +#: list and the framework wraps. ``status`` enum is the wire spec's +#: ``'created' | 'updated' | 'unchanged' | 'deleted' | 'failed'``. +#: Note: ``'rejected'`` is NOT a valid wire status — use ``'failed'`` +#: for buyer-rejected audiences. +SyncAudiencesRow = "SyncAudiencesSuccessResponse.audiences[number]" + + +@runtime_checkable +class AudiencePlatform(Protocol, Generic[TMeta]): + """Sync first-party CRM audiences with delta upsert semantics. + + Methods may be sync (return ``T`` directly) or async (return + ``Awaitable[T]``); the dispatch adapter detects via + :func:`asyncio.iscoroutinefunction` and runs sync methods on a + thread pool. + + Throw :class:`adcp.decisioning.AdcpError` for buyer-fixable + rejection (``AUDIENCE_TOO_SMALL``, ``REFERENCE_NOT_FOUND``, etc.); + the framework projects to the wire structured-error envelope. + """ + + def sync_audiences( + self, + audiences: Sequence[SyncAudiencesAudience], + ctx: RequestContext[TMeta], + ) -> MaybeAsync[SyncAudiencesSuccessResponse]: + """Push audiences to the platform. + + Framework handles batching, idempotency, and cross-tenant + scoping; the adopter handles match-rate computation and + activation lifecycle. + + Sync acknowledgment with status changes via + ``ctx.publish_status_change``: return per-audience result rows + immediately (``'pending'`` / ``'matching'`` are valid sync + outcomes). The match-rate computation and activation pipeline + run in the background — call + ``ctx.publish_status_change(resource_type='audience', ...)`` + from the platform's webhook handler / job queue / cron when + each audience reaches a terminal state. + + :param audiences: List of audience rows projected from the + wire ``SyncAudiencesRequest.audiences[]`` field. Adopter + ergonomic — receives the list directly rather than the + full request. + :raises adcp.decisioning.AdcpError: for buyer-fixable + rejection (e.g., ``AUDIENCE_TOO_SMALL``). + """ + ... + + def poll_audience_statuses( + self, + audience_ids: Sequence[str], + ctx: RequestContext[TMeta], + ) -> MaybeAsync[Mapping[str, str]]: + """Batch-poll current status for one or more audiences. + + Sync — this is a state-read, not a mutating operation. Useful + for buyer-side polling outside the framework's task envelope + (e.g., querying long-lived audiences) and for adapter code + that needs to check N audiences at once. + + Returns a ``dict[audience_id, AudienceStatus]``. Audiences not + found are omitted from the map (callers handle missing keys); + raise ``AdcpError(code='REFERENCE_NOT_FOUND')`` only when the + entire batch is unresolvable for the tenant. + + Single-audience polling is + ``poll_audience_statuses([id], ctx).get(id)``. The batch shape + composes with upstream identity-graph APIs that natively + return per-audience-id arrays — adopters do NOT need to wrap + a single-id lookup over an N-call loop. + + Adopter-internal helper — not surfaced as a wire tool. Used + by adopter code orchestrating cross-platform audience flows + and by the framework's optional bulk-status middleware. + """ + ... + + +__all__ = ["AudiencePlatform"] diff --git a/src/adcp/decisioning/specialisms/signals.py b/src/adcp/decisioning/specialisms/signals.py new file mode 100644 index 000000000..fa89d9895 --- /dev/null +++ b/src/adcp/decisioning/specialisms/signals.py @@ -0,0 +1,114 @@ +"""SignalsPlatform Protocol — covers ``signal-marketplace`` + ``signal-owned``. + +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``. + +Two methods: + +* :meth:`get_signals` — sync catalog discovery +* :meth:`activate_signal` — sync provisioning onto destination platforms + +Async story: ``activate_signal`` is sync at the wire level — its +response union has no ``Submitted`` arm. Long-running activation +pipelines (identity-graph match: 5–30 min, destination provisioning: +hours) return :class:`ActivateSignalSuccessResponse` immediately with +``deployments`` rows in ``pending`` state, then emit +``ctx.publish_status_change(resource_type='signal', ...)`` events as +each deployment reaches ``activating`` / ``deployed`` / ``failed``. + +Mirrors the JS-side ``SignalsPlatform`` interface at +``src/lib/server/decisioning/specialisms/signals.ts``. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Generic, Protocol, runtime_checkable + +from typing_extensions import TypeVar + +if TYPE_CHECKING: + from adcp.decisioning.context import RequestContext + from adcp.decisioning.types import MaybeAsync + from adcp.types import ( + ActivateSignalRequest, + ActivateSignalSuccessResponse, + GetSignalsRequest, + GetSignalsResponse, + ) + +#: 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. +TMeta = TypeVar("TMeta", default=dict[str, Any]) + + +@runtime_checkable +class SignalsPlatform(Protocol, Generic[TMeta]): + """Catalog discovery + activation for marketplace / owned signals. + + Methods may be sync (return ``T`` directly) or async (return + ``Awaitable[T]``); the dispatch adapter detects via + :func:`asyncio.iscoroutinefunction` and runs sync methods on a + thread pool so a blocking sync handler doesn't serialize the event + loop. + + Throw :class:`adcp.decisioning.AdcpError` for buyer-fixable + rejection (``SIGNAL_NOT_FOUND``, ``POLICY_VIOLATION``, + ``INVALID_REQUEST``, etc.); the framework projects to the wire + structured-error envelope. + """ + + def get_signals( + self, + req: GetSignalsRequest, + ctx: RequestContext[TMeta], + ) -> MaybeAsync[GetSignalsResponse]: + """Catalog discovery — query your signal index, return signals + matching the buyer's filters (industry, intent type, audience + size, etc.). + + Sync at the wire level — :class:`GetSignalsResponse` has no + async envelope. Platforms with slow catalog stores need + internal caches. + + :raises adcp.decisioning.AdcpError: ``code='POLICY_VIOLATION'`` + when the buyer doesn't have rights to the requested data + category. + """ + ... + + def activate_signal( + self, + req: ActivateSignalRequest, + ctx: RequestContext[TMeta], + ) -> MaybeAsync[ActivateSignalSuccessResponse]: + """Provision a signal onto one or more destination platforms + (Snap, Meta, TikTok, etc.). + + Returns the success-arm shape immediately with ``deployments`` + rows in their current state — ``'pending'`` is a valid sync + return for slow activation pipelines. + + Subsequent state changes (per-deployment ``activating`` / + ``deployed`` / ``failed``) flow via + ``ctx.publish_status_change(resource_type='signal', + resource_id=signal_agent_segment_id, payload=...)`` as each + destination's identity-graph match completes. + + Use ``req.action='deactivate'`` for GDPR/CCPA-compliant + teardown when campaigns end. + + :raises adcp.decisioning.AdcpError: ``code='SIGNAL_NOT_FOUND'`` + (unknown ``signal_agent_segment_id``), + ``code='POLICY_VIOLATION'`` (buyer lacks rights to activate + this data), or ``code='INVALID_REQUEST'`` (missing or + unrecognized destination). + """ + ... + + +__all__ = ["SignalsPlatform"] diff --git a/tests/test_decisioning_dispatch.py b/tests/test_decisioning_dispatch.py index 01a74a697..528ef61ee 100644 --- a/tests/test_decisioning_dispatch.py +++ b/tests/test_decisioning_dispatch.py @@ -277,18 +277,22 @@ def test_spec_specialism_enum_matches_schema_cache() -> None: def test_validate_platform_warns_on_unenforced_spec_specialism() -> None: """Spec-recognized specialism that the v6.0 framework doesn't yet - enforce (e.g. ``signal-marketplace``) emits an "unenforced + enforce (e.g. ``creative-ad-server``) emits an "unenforced specialism" UserWarning — distinct from the "novel" warning, since - it's a real claim, just not method-checked.""" + it's a real claim, just not method-checked. + + Use ``creative-ad-server`` here because ``signal-marketplace`` / + ``audience-sync`` got method-coverage rules in the breadth-sprint + Batch 1; ``creative-*`` are still pending until Batch 2.""" class _UnenforcedSpecPlatform(DecisioningPlatform): - capabilities = DecisioningCapabilities(specialisms=["signal-marketplace"]) + capabilities = DecisioningCapabilities(specialisms=["creative-ad-server"]) accounts = SingletonAccounts(account_id="hello") with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always", UserWarning) validate_platform(_UnenforcedSpecPlatform()) - matched = [w for w in caught if "signal-marketplace" in str(w.message)] + matched = [w for w in caught if "creative-ad-server" in str(w.message)] assert len(matched) == 1 assert "spec-recognized" in str(matched[0].message) diff --git a/tests/test_decisioning_specialisms.py b/tests/test_decisioning_specialisms.py new file mode 100644 index 000000000..3396d2248 --- /dev/null +++ b/tests/test_decisioning_specialisms.py @@ -0,0 +1,284 @@ +"""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 +(``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. + +Three test surfaces per Protocol: + +1. ``runtime_checkable`` conformance — a class implementing the + methods passes ``isinstance`` against the Protocol. +2. ``validate_platform`` required-method enforcement — claiming the + slug without the methods fails server boot. +3. Public exports — the Protocol is on ``adcp.decisioning.__all__`` + so adopters import from the canonical surface. +""" + +from __future__ import annotations + +import pytest + +from adcp.decisioning import ( + AudiencePlatform, + DecisioningCapabilities, + DecisioningPlatform, + SalesPlatform, + SignalsPlatform, + SingletonAccounts, +) +from adcp.decisioning.dispatch import ( + REQUIRED_METHODS_PER_SPECIALISM, + validate_platform, +) +from adcp.decisioning.types import AdcpError + +# ---- Public exports ---- + + +def test_specialism_protocols_are_publicly_exported() -> None: + """The three Protocol classes are on ``adcp.decisioning.__all__`` + so adopters import from the canonical public surface, not the + internal ``adcp.decisioning.specialisms.*`` modules.""" + import adcp.decisioning as dx + + assert "SalesPlatform" in dx.__all__ + assert "SignalsPlatform" in dx.__all__ + assert "AudiencePlatform" in dx.__all__ + assert dx.SignalsPlatform is SignalsPlatform + assert dx.AudiencePlatform is AudiencePlatform + + +# ---- SignalsPlatform ---- + + +def test_signals_platform_runtime_checkable() -> None: + """A class with ``get_signals`` + ``activate_signal`` passes + ``isinstance`` against :class:`SignalsPlatform` thanks to the + Protocol's ``@runtime_checkable`` decoration.""" + + class _SignalsImpl: + def get_signals(self, req, ctx): + return {"signals": []} + + def activate_signal(self, req, ctx): + return {"deployments": []} + + assert isinstance(_SignalsImpl(), SignalsPlatform) + + +def test_signals_platform_runtime_check_fails_when_methods_missing() -> None: + """A class missing ``activate_signal`` does NOT pass the + isinstance check. ``runtime_checkable`` matches by attribute name + presence.""" + + class _Partial: + def get_signals(self, req, ctx): + return {"signals": []} + + # Missing: activate_signal + + assert not isinstance(_Partial(), 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.""" + + class _PartialSignalsPlatform(DecisioningPlatform): + capabilities = DecisioningCapabilities(specialisms=["signal-marketplace"]) + accounts = SingletonAccounts(account_id="hello") + + # Implements only get_signals; missing activate_signal. + def get_signals(self, req, ctx): + return {"signals": []} + + with pytest.raises(AdcpError) as exc_info: + validate_platform(_PartialSignalsPlatform()) + assert exc_info.value.code == "INVALID_REQUEST" + missing_methods = {m["method"] for m in exc_info.value.details["missing"]} + 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.""" + + class _PartialSignalOwnedPlatform(DecisioningPlatform): + capabilities = DecisioningCapabilities(specialisms=["signal-owned"]) + accounts = SingletonAccounts(account_id="hello") + # Implements neither method. + + with pytest.raises(AdcpError) as exc_info: + 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 + + +def test_validate_platform_passes_for_complete_signals_platform() -> None: + """Happy path — fully-implemented signals platform passes.""" + + class _CompleteSignalsPlatform(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": []} + + 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 + + +# ---- AudiencePlatform ---- + + +def test_audience_platform_runtime_checkable() -> None: + """A class with ``sync_audiences`` + ``poll_audience_statuses`` + passes ``isinstance`` against :class:`AudiencePlatform`.""" + + class _AudienceImpl: + def sync_audiences(self, audiences, ctx): + return {"audiences": []} + + def poll_audience_statuses(self, audience_ids, ctx): + return {} + + assert isinstance(_AudienceImpl(), AudiencePlatform) + + +def test_validate_platform_enforces_audience_sync_required_method() -> None: + """A platform claiming ``audience-sync`` without implementing + ``sync_audiences`` fails fast. ``poll_audience_statuses`` is + NOT required (adopter-internal helper).""" + + class _PartialAudiencePlatform(DecisioningPlatform): + capabilities = DecisioningCapabilities(specialisms=["audience-sync"]) + accounts = SingletonAccounts(account_id="hello") + # Missing sync_audiences entirely. + + with pytest.raises(AdcpError) as exc_info: + validate_platform(_PartialAudiencePlatform()) + assert exc_info.value.code == "INVALID_REQUEST" + missing_methods = {m["method"] for m in exc_info.value.details["missing"]} + assert "sync_audiences" in missing_methods + + +def test_validate_platform_passes_for_audience_sync_with_only_required_method() -> None: + """``poll_audience_statuses`` is adopter-internal — not required + for spec coverage. A platform implementing only ``sync_audiences`` + passes validation.""" + + class _MinimalAudiencePlatform(DecisioningPlatform): + capabilities = DecisioningCapabilities(specialisms=["audience-sync"]) + accounts = SingletonAccounts(account_id="hello") + + def sync_audiences(self, audiences, ctx): + return {"audiences": []} + + validate_platform(_MinimalAudiencePlatform()) + + +def test_audience_sync_required_methods_pinned() -> None: + """Contract test — the ``audience-sync`` required-method set is + deliberately narrow (``sync_audiences`` only; + ``poll_audience_statuses`` is adopter-internal). + REQUIRED_METHODS_PER_SPECIALISM tracks the wire-required surface, + not the full Protocol.""" + assert REQUIRED_METHODS_PER_SPECIALISM["audience-sync"] == {"sync_audiences"} + + +# ---- Cross-specialism: validate_platform doesn't conflate slugs ---- + + +def test_signals_platform_can_compose_with_sales() -> None: + """A platform claiming both ``sales-non-guaranteed`` and + ``signal-marketplace`` must satisfy both Protocols' required + methods. Cross-specialism composition is supported.""" + + class _ComposedPlatform(DecisioningPlatform): + capabilities = DecisioningCapabilities( + specialisms=["sales-non-guaranteed", "signal-marketplace"] + ) + accounts = SingletonAccounts(account_id="hello") + + # Sales-* methods + def get_products(self, req, ctx): + return {"products": []} + + def create_media_buy(self, req, ctx): + return {} + + def update_media_buy(self, media_buy_id, patch, ctx): + return {} + + def sync_creatives(self, req, ctx): + return {} + + def get_media_buy_delivery(self, req, ctx): + return {} + + # Signals methods + def get_signals(self, req, ctx): + return {"signals": []} + + def activate_signal(self, req, ctx): + return {"deployments": []} + + validate_platform(_ComposedPlatform()) + + +def test_sales_platform_protocol_still_runtime_checkable() -> None: + """Round-trip: the existing ``SalesPlatform`` Protocol still works + (Batch 1 didn't accidentally break the v6.0 baseline).""" + + class _SalesImpl: + def get_products(self, req, ctx): + return {"products": []} + + def create_media_buy(self, req, ctx): + return {} + + def update_media_buy(self, media_buy_id, patch, ctx): + return {} + + def sync_creatives(self, req, ctx): + return {} + + def get_media_buy_delivery(self, req, ctx): + return {} + + # Optional methods left unimplemented — runtime_checkable + # checks attribute presence; methods on the Protocol that + # aren't on the impl fail isinstance. + + # Required methods present, optional missing — runtime_checkable + # matches by full attribute set so this is False (acceptable; the + # base SalesPlatform declares 9 methods and runtime_checkable + # requires all of them). + # The validate_platform path uses a narrower required-set check, + # which is what production servers actually rely on. + assert REQUIRED_METHODS_PER_SPECIALISM["sales-non-guaranteed"] == { + "get_products", + "create_media_buy", + "update_media_buy", + "sync_creatives", + "get_media_buy_delivery", + } + # Smoke check that SalesPlatform symbol is still importable as a + # Protocol (not redefined or shadowed). + assert hasattr(SalesPlatform, "_is_protocol") From 20a7139c66d7e22ac4432fb50715f1eb3990ffef Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 30 Apr 2026 20:59:39 -0400 Subject: [PATCH 2/2] =?UTF-8?q?fix(decisioning):=20address=20expert=20revi?= =?UTF-8?q?ew=20of=20#332=20=E2=80=94=20drop=20dead=20aliases=20+=20brittl?= =?UTF-8?q?e=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two converging expert-review findings: P1 (audience.py:45,53): two string-literal globals were port artifacts from the JS reference that never compiled to anything meaningful in Python: Audience = "SyncAudiencesAudience" SyncAudiencesRow = "SyncAudiencesSuccessResponse.audiences[number]" The first is a string constant masquerading as a forward-ref but unimported; the second is TypeScript indexed-access syntax (``T['audiences'][number]``) which has no Python meaning and would raise NameError if ``typing.get_type_hints`` ever resolved it. Replaced with a comment block pointing adopters at the canonical ``adcp.types.SyncAudiencesAudience`` / ``adcp.types.SyncAudiencesSuccessResponse`` imports. P2 (test_decisioning_specialisms.py:284): the smoke check pinned on ``hasattr(SalesPlatform, '_is_protocol')`` — a private CPython typing internal that's brittle against typing-module changes. Replaced with an ``isinstance`` check against a minimal-but-complete shim that exercises all 9 SalesPlatform methods. Same invariant via a durable public assertion. Punt list (P2/P3 reviewer findings) deferred to follow-up: * JS exports ``Audience``, ``SyncAudiencesRow``, ``AudienceStatus`` type aliases that Python doesn't re-export. Adopters import directly from ``adcp.types`` today; not blocking. * Cross-language adopter-shape divergence on ``sync_audiences`` (JS returns rows, Python returns full response). Pick one in a follow-up RFC; both produce the same wire output. 13 tests pass; mypy + ruff clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/adcp/decisioning/specialisms/audience.py | 23 +++++------- tests/test_decisioning_specialisms.py | 38 ++++++++++++++++++-- 2 files changed, 44 insertions(+), 17 deletions(-) diff --git a/src/adcp/decisioning/specialisms/audience.py b/src/adcp/decisioning/specialisms/audience.py index a96aab81a..a0a0e4275 100644 --- a/src/adcp/decisioning/specialisms/audience.py +++ b/src/adcp/decisioning/specialisms/audience.py @@ -37,20 +37,15 @@ #: ``Account[TMeta]`` upstream. TMeta = TypeVar("TMeta", default=dict[str, Any]) -#: Adopter-facing audience-row shape. Wire schema doesn't export a -#: top-level ``Audience`` type; the spec defines it inline on -#: ``SyncAudiencesRequest.audiences[]``. The SDK re-exports the -#: extracted shape as :class:`SyncAudiencesAudience` from -#: :mod:`adcp.types`. -Audience = "SyncAudiencesAudience" - -#: Adopter-facing per-audience response-row shape. The wire success -#: response wraps these in ``{audiences: [...]}``; adopters return the -#: list and the framework wraps. ``status`` enum is the wire spec's -#: ``'created' | 'updated' | 'unchanged' | 'deleted' | 'failed'``. -#: Note: ``'rejected'`` is NOT a valid wire status — use ``'failed'`` -#: for buyer-rejected audiences. -SyncAudiencesRow = "SyncAudiencesSuccessResponse.audiences[number]" +# Note on adopter-facing row types: the wire schema doesn't export a +# top-level ``Audience`` type — the row shape is defined inline on +# ``SyncAudiencesRequest.audiences[]``. Adopters import +# :class:`adcp.types.SyncAudiencesAudience` directly for typing. +# The wire success response is :class:`adcp.types.SyncAudiencesSuccessResponse`, +# which wraps per-audience result rows in ``{audiences: [...]}`` with +# the spec's status enum (``created`` / ``updated`` / ``unchanged`` / +# ``deleted`` / ``failed``; note ``rejected`` is NOT a valid wire +# status — use ``failed`` for buyer-rejected audiences). @runtime_checkable diff --git a/tests/test_decisioning_specialisms.py b/tests/test_decisioning_specialisms.py index 3396d2248..ecbcd517e 100644 --- a/tests/test_decisioning_specialisms.py +++ b/tests/test_decisioning_specialisms.py @@ -279,6 +279,38 @@ def get_media_buy_delivery(self, req, ctx): "sync_creatives", "get_media_buy_delivery", } - # Smoke check that SalesPlatform symbol is still importable as a - # Protocol (not redefined or shadowed). - assert hasattr(SalesPlatform, "_is_protocol") + + # Smoke check that SalesPlatform symbol is still a runtime-checkable + # Protocol (not redefined or shadowed). We verify by isinstance + # against a minimal-but-complete impl rather than checking + # ``_is_protocol`` (a private CPython typing internal — brittle + # against typing-module changes). + class _SalesShim: + def get_products(self, req, ctx): + return {"products": []} + + def create_media_buy(self, req, ctx): + return {} + + def update_media_buy(self, media_buy_id, patch, ctx): + return {} + + def sync_creatives(self, req, ctx): + return {} + + def get_media_buy_delivery(self, req, ctx): + return {} + + def get_media_buys(self, req, ctx): + return {} + + def provide_performance_feedback(self, req, ctx): + return {} + + def list_creative_formats(self, req, ctx): + return {} + + def list_creatives(self, req, ctx): + return {} + + assert isinstance(_SalesShim(), SalesPlatform)