Skip to content

Commit c66ff72

Browse files
bokelleyclaude
andauthored
feat(decisioning): SignalsPlatform + AudiencePlatform Protocols (breadth sprint Batch 1) (#332)
* feat(decisioning): SignalsPlatform + AudiencePlatform Protocols (breadth sprint Batch 1) 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) <noreply@anthropic.com> * fix(decisioning): address expert review of #332 — drop dead aliases + brittle test 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9d29754 commit c66ff72

7 files changed

Lines changed: 608 additions & 13 deletions

File tree

src/adcp/decisioning/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,11 @@ def create_media_buy(
7676
create_adcp_server_from_platform,
7777
serve,
7878
)
79+
from adcp.decisioning.specialisms import (
80+
AudiencePlatform,
81+
SalesPlatform,
82+
SignalsPlatform,
83+
)
7984
from adcp.decisioning.state import (
8085
GovernanceContextJWS,
8186
Proposal,
@@ -101,6 +106,7 @@ def create_media_buy(
101106
"Account",
102107
"AccountStore",
103108
"AdcpError",
109+
"AudiencePlatform",
104110
"AuthInfo",
105111
"CollectionList",
106112
"DecisioningCapabilities",
@@ -118,7 +124,9 @@ def create_media_buy(
118124
"PropertyListReference",
119125
"RequestContext",
120126
"ResourceResolver",
127+
"SalesPlatform",
121128
"SalesResult",
129+
"SignalsPlatform",
122130
"SingletonAccounts",
123131
"StateReader",
124132
"TaskHandoff",

src/adcp/decisioning/dispatch.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,29 @@
191191
"sync_catalogs",
192192
}
193193
),
194+
# Signals specialisms — third-party data brokers and first-party
195+
# data providers share the same SignalsPlatform Protocol surface.
196+
"signal-marketplace": frozenset(
197+
{
198+
"get_signals",
199+
"activate_signal",
200+
}
201+
),
202+
"signal-owned": frozenset(
203+
{
204+
"get_signals",
205+
"activate_signal",
206+
}
207+
),
208+
# Audience-sync — first-party CRM audience push with delta upsert.
209+
# ``poll_audience_statuses`` is an adopter-internal helper not
210+
# surfaced as a wire tool; ``sync_audiences`` is the only required
211+
# method for spec coverage.
212+
"audience-sync": frozenset(
213+
{
214+
"sync_audiences",
215+
}
216+
),
194217
}
195218

196219

src/adcp/decisioning/specialisms/__init__.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,25 @@
99
1010
Public surface re-exported from :mod:`adcp.decisioning.specialisms`:
1111
12-
* :class:`SalesPlatform` — covers all 9 ``sales-*`` specialisms
13-
(non-guaranteed, guaranteed, broadcast-tv, streaming-tv, social,
14-
exchange, proposal-mode, catalog-driven, retail-media) under one
15-
unified hybrid shape.
12+
* :class:`SalesPlatform` — covers the spec ``sales-*`` slugs
13+
(non-guaranteed, guaranteed, broadcast-tv, social, proposal-mode,
14+
catalog-driven) under one unified hybrid shape.
15+
* :class:`SignalsPlatform` — covers ``signal-marketplace`` +
16+
``signal-owned``. Two methods: ``get_signals`` (catalog discovery)
17+
and ``activate_signal`` (provisioning onto destination platforms).
18+
* :class:`AudiencePlatform` — covers ``audience-sync``. Two methods:
19+
``sync_audiences`` (push first-party CRM audiences with delta
20+
upsert) and ``poll_audience_statuses`` (batch state read).
1621
17-
Other specialism Protocols (audience, signals, creative-*, governance,
18-
property-lists, etc.) are added as adopters need them — first
19-
:class:`SalesPlatform` because that's the v6.0 vertical-slice the
20-
foundation PR proves out.
22+
Remaining specialism Protocols (creative-*, governance-*,
23+
brand-rights, content-standards, property-lists, collection-lists)
24+
are added in subsequent breadth-sprint PRs as adopters need them.
2125
"""
2226

2327
from __future__ import annotations
2428

29+
from adcp.decisioning.specialisms.audience import AudiencePlatform
2530
from adcp.decisioning.specialisms.sales import SalesPlatform
31+
from adcp.decisioning.specialisms.signals import SignalsPlatform
2632

27-
__all__ = ["SalesPlatform"]
33+
__all__ = ["AudiencePlatform", "SalesPlatform", "SignalsPlatform"]
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"""AudiencePlatform Protocol — covers the ``audience-sync`` specialism.
2+
3+
Used standalone (LiveRamp, Oracle Data Cloud, Salesforce CDP) or
4+
composed with ``sales-social`` (Snap/Meta/TikTok). The framework owns
5+
cross-platform threading + idempotency + cross-tenant scoping; the
6+
adopter answers "given this audience, what happened on my system?"
7+
8+
The slug mirrors ``schemas/cache/enums/specialism.json``.
9+
10+
Two methods:
11+
12+
* :meth:`sync_audiences` — push audiences to the platform (creates,
13+
updates, deletes per the wire spec)
14+
* :meth:`poll_audience_statuses` — batch-poll current status for one
15+
or more audiences
16+
17+
Mirrors the JS-side ``AudiencePlatform`` interface at
18+
``src/lib/server/decisioning/specialisms/audiences.ts``.
19+
"""
20+
21+
from __future__ import annotations
22+
23+
from collections.abc import Mapping, Sequence
24+
from typing import TYPE_CHECKING, Any, Generic, Protocol, runtime_checkable
25+
26+
from typing_extensions import TypeVar
27+
28+
if TYPE_CHECKING:
29+
from adcp.decisioning.context import RequestContext
30+
from adcp.decisioning.types import MaybeAsync
31+
from adcp.types import (
32+
SyncAudiencesAudience,
33+
SyncAudiencesSuccessResponse,
34+
)
35+
36+
#: Per-platform metadata generic; matches ``RequestContext[TMeta]`` and
37+
#: ``Account[TMeta]`` upstream.
38+
TMeta = TypeVar("TMeta", default=dict[str, Any])
39+
40+
# Note on adopter-facing row types: the wire schema doesn't export a
41+
# top-level ``Audience`` type — the row shape is defined inline on
42+
# ``SyncAudiencesRequest.audiences[]``. Adopters import
43+
# :class:`adcp.types.SyncAudiencesAudience` directly for typing.
44+
# The wire success response is :class:`adcp.types.SyncAudiencesSuccessResponse`,
45+
# which wraps per-audience result rows in ``{audiences: [...]}`` with
46+
# the spec's status enum (``created`` / ``updated`` / ``unchanged`` /
47+
# ``deleted`` / ``failed``; note ``rejected`` is NOT a valid wire
48+
# status — use ``failed`` for buyer-rejected audiences).
49+
50+
51+
@runtime_checkable
52+
class AudiencePlatform(Protocol, Generic[TMeta]):
53+
"""Sync first-party CRM audiences with delta upsert semantics.
54+
55+
Methods may be sync (return ``T`` directly) or async (return
56+
``Awaitable[T]``); the dispatch adapter detects via
57+
:func:`asyncio.iscoroutinefunction` and runs sync methods on a
58+
thread pool.
59+
60+
Throw :class:`adcp.decisioning.AdcpError` for buyer-fixable
61+
rejection (``AUDIENCE_TOO_SMALL``, ``REFERENCE_NOT_FOUND``, etc.);
62+
the framework projects to the wire structured-error envelope.
63+
"""
64+
65+
def sync_audiences(
66+
self,
67+
audiences: Sequence[SyncAudiencesAudience],
68+
ctx: RequestContext[TMeta],
69+
) -> MaybeAsync[SyncAudiencesSuccessResponse]:
70+
"""Push audiences to the platform.
71+
72+
Framework handles batching, idempotency, and cross-tenant
73+
scoping; the adopter handles match-rate computation and
74+
activation lifecycle.
75+
76+
Sync acknowledgment with status changes via
77+
``ctx.publish_status_change``: return per-audience result rows
78+
immediately (``'pending'`` / ``'matching'`` are valid sync
79+
outcomes). The match-rate computation and activation pipeline
80+
run in the background — call
81+
``ctx.publish_status_change(resource_type='audience', ...)``
82+
from the platform's webhook handler / job queue / cron when
83+
each audience reaches a terminal state.
84+
85+
:param audiences: List of audience rows projected from the
86+
wire ``SyncAudiencesRequest.audiences[]`` field. Adopter
87+
ergonomic — receives the list directly rather than the
88+
full request.
89+
:raises adcp.decisioning.AdcpError: for buyer-fixable
90+
rejection (e.g., ``AUDIENCE_TOO_SMALL``).
91+
"""
92+
...
93+
94+
def poll_audience_statuses(
95+
self,
96+
audience_ids: Sequence[str],
97+
ctx: RequestContext[TMeta],
98+
) -> MaybeAsync[Mapping[str, str]]:
99+
"""Batch-poll current status for one or more audiences.
100+
101+
Sync — this is a state-read, not a mutating operation. Useful
102+
for buyer-side polling outside the framework's task envelope
103+
(e.g., querying long-lived audiences) and for adapter code
104+
that needs to check N audiences at once.
105+
106+
Returns a ``dict[audience_id, AudienceStatus]``. Audiences not
107+
found are omitted from the map (callers handle missing keys);
108+
raise ``AdcpError(code='REFERENCE_NOT_FOUND')`` only when the
109+
entire batch is unresolvable for the tenant.
110+
111+
Single-audience polling is
112+
``poll_audience_statuses([id], ctx).get(id)``. The batch shape
113+
composes with upstream identity-graph APIs that natively
114+
return per-audience-id arrays — adopters do NOT need to wrap
115+
a single-id lookup over an N-call loop.
116+
117+
Adopter-internal helper — not surfaced as a wire tool. Used
118+
by adopter code orchestrating cross-platform audience flows
119+
and by the framework's optional bulk-status middleware.
120+
"""
121+
...
122+
123+
124+
__all__ = ["AudiencePlatform"]
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""SignalsPlatform Protocol — covers ``signal-marketplace`` + ``signal-owned``.
2+
3+
A platform claiming either ``signal-marketplace`` (third-party data
4+
brokers — LiveRamp, Oracle Data Cloud, third-party DMPs) or
5+
``signal-owned`` (first-party data providers — publisher first-party
6+
data, retailer customer-graph) implements the methods on this Protocol.
7+
The slugs mirror ``schemas/cache/enums/specialism.json``.
8+
9+
Two methods:
10+
11+
* :meth:`get_signals` — sync catalog discovery
12+
* :meth:`activate_signal` — sync provisioning onto destination platforms
13+
14+
Async story: ``activate_signal`` is sync at the wire level — its
15+
response union has no ``Submitted`` arm. Long-running activation
16+
pipelines (identity-graph match: 5–30 min, destination provisioning:
17+
hours) return :class:`ActivateSignalSuccessResponse` immediately with
18+
``deployments`` rows in ``pending`` state, then emit
19+
``ctx.publish_status_change(resource_type='signal', ...)`` events as
20+
each deployment reaches ``activating`` / ``deployed`` / ``failed``.
21+
22+
Mirrors the JS-side ``SignalsPlatform`` interface at
23+
``src/lib/server/decisioning/specialisms/signals.ts``.
24+
"""
25+
26+
from __future__ import annotations
27+
28+
from typing import TYPE_CHECKING, Any, Generic, Protocol, runtime_checkable
29+
30+
from typing_extensions import TypeVar
31+
32+
if TYPE_CHECKING:
33+
from adcp.decisioning.context import RequestContext
34+
from adcp.decisioning.types import MaybeAsync
35+
from adcp.types import (
36+
ActivateSignalRequest,
37+
ActivateSignalSuccessResponse,
38+
GetSignalsRequest,
39+
GetSignalsResponse,
40+
)
41+
42+
#: Per-platform metadata generic; matches ``RequestContext[TMeta]`` and
43+
#: ``Account[TMeta]`` upstream so a platform parameterizing
44+
#: ``SignalsPlatform[TenantMeta]`` gets ``ctx.account.metadata``-style
45+
#: typed access inside method bodies.
46+
TMeta = TypeVar("TMeta", default=dict[str, Any])
47+
48+
49+
@runtime_checkable
50+
class SignalsPlatform(Protocol, Generic[TMeta]):
51+
"""Catalog discovery + activation for marketplace / owned signals.
52+
53+
Methods may be sync (return ``T`` directly) or async (return
54+
``Awaitable[T]``); the dispatch adapter detects via
55+
:func:`asyncio.iscoroutinefunction` and runs sync methods on a
56+
thread pool so a blocking sync handler doesn't serialize the event
57+
loop.
58+
59+
Throw :class:`adcp.decisioning.AdcpError` for buyer-fixable
60+
rejection (``SIGNAL_NOT_FOUND``, ``POLICY_VIOLATION``,
61+
``INVALID_REQUEST``, etc.); the framework projects to the wire
62+
structured-error envelope.
63+
"""
64+
65+
def get_signals(
66+
self,
67+
req: GetSignalsRequest,
68+
ctx: RequestContext[TMeta],
69+
) -> MaybeAsync[GetSignalsResponse]:
70+
"""Catalog discovery — query your signal index, return signals
71+
matching the buyer's filters (industry, intent type, audience
72+
size, etc.).
73+
74+
Sync at the wire level — :class:`GetSignalsResponse` has no
75+
async envelope. Platforms with slow catalog stores need
76+
internal caches.
77+
78+
:raises adcp.decisioning.AdcpError: ``code='POLICY_VIOLATION'``
79+
when the buyer doesn't have rights to the requested data
80+
category.
81+
"""
82+
...
83+
84+
def activate_signal(
85+
self,
86+
req: ActivateSignalRequest,
87+
ctx: RequestContext[TMeta],
88+
) -> MaybeAsync[ActivateSignalSuccessResponse]:
89+
"""Provision a signal onto one or more destination platforms
90+
(Snap, Meta, TikTok, etc.).
91+
92+
Returns the success-arm shape immediately with ``deployments``
93+
rows in their current state — ``'pending'`` is a valid sync
94+
return for slow activation pipelines.
95+
96+
Subsequent state changes (per-deployment ``activating`` /
97+
``deployed`` / ``failed``) flow via
98+
``ctx.publish_status_change(resource_type='signal',
99+
resource_id=signal_agent_segment_id, payload=...)`` as each
100+
destination's identity-graph match completes.
101+
102+
Use ``req.action='deactivate'`` for GDPR/CCPA-compliant
103+
teardown when campaigns end.
104+
105+
:raises adcp.decisioning.AdcpError: ``code='SIGNAL_NOT_FOUND'``
106+
(unknown ``signal_agent_segment_id``),
107+
``code='POLICY_VIOLATION'`` (buyer lacks rights to activate
108+
this data), or ``code='INVALID_REQUEST'`` (missing or
109+
unrecognized destination).
110+
"""
111+
...
112+
113+
114+
__all__ = ["SignalsPlatform"]

tests/test_decisioning_dispatch.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -277,18 +277,22 @@ def test_spec_specialism_enum_matches_schema_cache() -> None:
277277

278278
def test_validate_platform_warns_on_unenforced_spec_specialism() -> None:
279279
"""Spec-recognized specialism that the v6.0 framework doesn't yet
280-
enforce (e.g. ``signal-marketplace``) emits an "unenforced
280+
enforce (e.g. ``creative-ad-server``) emits an "unenforced
281281
specialism" UserWarning — distinct from the "novel" warning, since
282-
it's a real claim, just not method-checked."""
282+
it's a real claim, just not method-checked.
283+
284+
Use ``creative-ad-server`` here because ``signal-marketplace`` /
285+
``audience-sync`` got method-coverage rules in the breadth-sprint
286+
Batch 1; ``creative-*`` are still pending until Batch 2."""
283287

284288
class _UnenforcedSpecPlatform(DecisioningPlatform):
285-
capabilities = DecisioningCapabilities(specialisms=["signal-marketplace"])
289+
capabilities = DecisioningCapabilities(specialisms=["creative-ad-server"])
286290
accounts = SingletonAccounts(account_id="hello")
287291

288292
with warnings.catch_warnings(record=True) as caught:
289293
warnings.simplefilter("always", UserWarning)
290294
validate_platform(_UnenforcedSpecPlatform())
291-
matched = [w for w in caught if "signal-marketplace" in str(w.message)]
295+
matched = [w for w in caught if "creative-ad-server" in str(w.message)]
292296
assert len(matched) == 1
293297
assert "spec-recognized" in str(matched[0].message)
294298

0 commit comments

Comments
 (0)