Skip to content

Commit 4400a70

Browse files
bokelleyclaude
andcommitted
feat(decisioning): ProposalManager v1 — Protocol + MockProposalManager forwarder + tenant binding
Foundation for the two-platform composition established in the product architecture design doc (PR #502, docs/proposals/product-architecture.md). Adopters can now wire a separate ProposalManager that handles get_products / refine, while DecisioningPlatform handles create_media_buy and lifecycle. The recipe (typed implementation_config) is the contract between them. v1 surface: - ProposalManager Protocol (sync/async, capability-gated refine) - ProposalCapabilities dataclass (sales_specialism + flags) - Recipe Pydantic base with recipe_kind discriminator - MockProposalManager v1 default forwarder (symmetric with Phase 2's upstream_for mock-mode dispatch on DecisioningPlatform) - proposal_manager= kwarg on serve() / create_adcp_server_from_platform - Dispatcher routing in PlatformHandler.get_products: routes to ProposalManager when wired (with refine/get_products selection by buying_mode + capability + method-presence); falls through to platform.get_products otherwise — backward-compat by construction Out of scope (deferred to subsequent PRs, called out in module doc): - Session cache for in-flight proposals - finalize transition (buying_mode='refine' + action='finalize') - expires_at enforcement - capability_overlap declaration on Recipe + framework validation - Recipe persistence through buy lifecycle (hydration in create_media_buy / update_media_buy / get_delivery) - Per-tenant ProposalManager binding via PlatformRouter - MIGRATION.md updates for v3 reference seller Tests: 16 new in tests/test_proposal_manager.py covering Protocol conformance, capability validation, MockProposalManager forwarding (respx-mocked), dispatcher routing with/without proposal_manager, adopter subclass dispatch, sync+async support, and refine routing across all four (capability, method, buying_mode, fall-through) cases. Full regression suite (3718 tests) green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b584e70 commit 4400a70

7 files changed

Lines changed: 1283 additions & 5 deletions

File tree

examples/hello_proposal_manager.py

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
"""Hello-proposal-manager — the v1 two-platform composition demo.
2+
3+
Wires a :class:`MockProposalManager` (proposal side) and a trivial
4+
:class:`DecisioningPlatform` (execution side) together. The
5+
:class:`MockProposalManager` forwards ``get_products`` requests to a
6+
running mock-server (``bin/adcp.js mock-server sales-non-guaranteed``);
7+
the platform handles ``create_media_buy`` directly without consulting
8+
the mock-server.
9+
10+
This is the on-ramp shape for adopters whose proposal logic isn't
11+
ready yet but who already have an adapter for their upstream:
12+
13+
* The mock-server provides product fixtures + stub recipes.
14+
* The platform's ``create_media_buy`` translates buyer requests into
15+
upstream calls (here, it's a no-op stub).
16+
* As the adopter writes real proposal logic, they replace
17+
:class:`MockProposalManager` with their own
18+
:class:`ProposalManager` subclass — incrementally, one slice at a
19+
time.
20+
21+
**Prerequisite:** start the mock-server before running this example::
22+
23+
npx -y adcp-mock-server@latest sales-non-guaranteed --port 4500
24+
25+
Then::
26+
27+
uv run python examples/hello_proposal_manager.py
28+
29+
The server answers ``get_products`` by forwarding to the mock-server
30+
on port 4500; ``create_media_buy`` runs entirely in this process.
31+
Tail the mock-server log to see the proposal-side traffic; tail this
32+
process's log to see the execution-side traffic.
33+
34+
See ``docs/proposals/product-architecture.md`` for the full design
35+
context — § "The two-platform composition" + § "Shape 3 — Mock-backed".
36+
"""
37+
38+
from __future__ import annotations
39+
40+
import os
41+
from typing import Any
42+
43+
from adcp.decisioning import (
44+
DecisioningCapabilities,
45+
DecisioningPlatform,
46+
MockProposalManager,
47+
RequestContext,
48+
SalesPlatform,
49+
SingletonAccounts,
50+
serve,
51+
)
52+
from adcp.decisioning.capabilities import (
53+
Account as CapabilitiesAccount,
54+
)
55+
from adcp.decisioning.capabilities import (
56+
Adcp,
57+
IdempotencySupported,
58+
MediaBuy,
59+
)
60+
61+
62+
class HelloDecisioningPlatform(DecisioningPlatform, SalesPlatform):
63+
"""Trivial execution-side platform.
64+
65+
Handles ``create_media_buy`` / ``update_media_buy`` /
66+
``sync_creatives`` / ``get_media_buy_delivery`` with stub responses.
67+
Does NOT implement ``get_products`` — the wired
68+
:class:`MockProposalManager` handles that surface.
69+
70+
In production, this is where the adopter's adapter code lives —
71+
translating wire ``CreateMediaBuyRequest`` payloads into upstream
72+
calls (GAM, Kevel, Meta, etc.) and projecting the results back
73+
onto the wire response shape.
74+
"""
75+
76+
capabilities = DecisioningCapabilities(
77+
specialisms=["sales-non-guaranteed"],
78+
adcp=Adcp(
79+
major_versions=[3],
80+
idempotency=IdempotencySupported(
81+
supported=True,
82+
replay_ttl_seconds=86400,
83+
),
84+
),
85+
account=CapabilitiesAccount(supported_billing=["operator"]),
86+
media_buy=MediaBuy(supported_pricing_models=["cpm"]),
87+
)
88+
accounts = SingletonAccounts(account_id="hello-proposal-manager-acct")
89+
90+
def create_media_buy(
91+
self,
92+
req: Any,
93+
ctx: RequestContext[Any],
94+
) -> dict[str, Any]:
95+
"""Stub create_media_buy — returns a synthetic media_buy_id.
96+
97+
Production: this is where the adopter's adapter code calls the
98+
upstream API (the recipe attached to the request's products
99+
carries the upstream-specific config the adapter consumes).
100+
"""
101+
del ctx
102+
idem_key = getattr(req, "idempotency_key", "unknown")
103+
return {
104+
"media_buy_id": f"mb_demo_{idem_key}",
105+
"status": "active",
106+
"packages": [],
107+
}
108+
109+
def update_media_buy(
110+
self,
111+
media_buy_id: str,
112+
patch: Any,
113+
ctx: RequestContext[Any],
114+
) -> dict[str, Any]:
115+
del patch, ctx
116+
return {"media_buy_id": media_buy_id, "status": "active", "packages": []}
117+
118+
def sync_creatives(
119+
self,
120+
req: Any,
121+
ctx: RequestContext[Any],
122+
) -> dict[str, Any]:
123+
del ctx
124+
creatives = getattr(req, "creatives", None) or []
125+
return {
126+
"creatives": [
127+
{
128+
"creative_id": (
129+
c.creative_id if hasattr(c, "creative_id") else c.get("creative_id")
130+
),
131+
"approval_status": "approved",
132+
}
133+
for c in creatives
134+
],
135+
}
136+
137+
def get_media_buy_delivery(
138+
self,
139+
req: Any,
140+
ctx: RequestContext[Any],
141+
) -> dict[str, Any]:
142+
del ctx
143+
return {
144+
"media_buy_deliveries": [
145+
{
146+
"media_buy_id": getattr(req, "media_buy_id", "mb_unknown"),
147+
"totals": {"impressions": 0, "spend": 0.0},
148+
},
149+
],
150+
}
151+
152+
153+
if __name__ == "__main__":
154+
# Mock-server URL — adopters in production point this at the
155+
# appropriate `bin/adcp.js mock-server <specialism>` instance, or
156+
# at a fixture-server in their own infra. Override via env var
157+
# for CI / local dev.
158+
mock_url = os.environ.get(
159+
"ADCP_MOCK_PROPOSAL_URL",
160+
"http://localhost:4500",
161+
)
162+
163+
# The proposal manager — forwards get_products to the running
164+
# mock-server. Adopters replace this with their own ProposalManager
165+
# subclass as their proposal logic comes online.
166+
proposal_manager = MockProposalManager(
167+
mock_upstream_url=mock_url,
168+
sales_specialism="sales-non-guaranteed",
169+
)
170+
171+
# serve() composes the two platforms. The dispatcher routes
172+
# get_products to the proposal manager and create_media_buy /
173+
# update_media_buy / etc. to the platform.
174+
serve(
175+
HelloDecisioningPlatform(),
176+
proposal_manager=proposal_manager,
177+
name="hello-proposal-manager",
178+
port=3001,
179+
auto_emit_completion_webhooks=False,
180+
)

src/adcp/decisioning/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,13 @@ def create_media_buy(
121121
resolve_property_list,
122122
validate_property_list_config,
123123
)
124+
from adcp.decisioning.proposal_manager import (
125+
MockProposalManager,
126+
ProposalCapabilities,
127+
ProposalManager,
128+
SalesSpecialism,
129+
)
130+
from adcp.decisioning.recipe import Recipe
124131
from adcp.decisioning.refine import (
125132
RefinementOutcome,
126133
RefinementStatus,
@@ -313,13 +320,16 @@ def __init__(self, *args: object, **kwargs: object) -> None:
313320
"MediaBuyNotFoundError",
314321
"MediaBuyStore",
315322
"MockAdServer",
323+
"MockProposalManager",
316324
"NoAuth",
317325
"OAuthCredential",
318326
"PermissionDeniedError",
319327
"PgTaskRegistry",
320328
"PlatformRouter",
321329
"PostgresTaskRegistry",
322330
"Proposal",
331+
"ProposalCapabilities",
332+
"ProposalManager",
323333
"PropertyList",
324334
"PropertyListFetcher",
325335
"PropertyListReference",
@@ -339,11 +349,13 @@ def __init__(self, *args: object, **kwargs: object) -> None:
339349
"project_refine_response",
340350
"RateLimitedBuyerAgentRegistry",
341351
"RateLimitedError",
352+
"Recipe",
342353
"RequestContext",
343354
"ResolveContext",
344355
"ResourceResolver",
345356
"SalesPlatform",
346357
"SalesResult",
358+
"SalesSpecialism",
347359
"ServiceUnavailableError",
348360
"SignalsPlatform",
349361
"SingletonAccounts",

src/adcp/decisioning/handler.py

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@
162162

163163
from adcp.decisioning.platform import DecisioningPlatform
164164
from adcp.decisioning.property_list import PropertyListFetcher
165+
from adcp.decisioning.proposal_manager import ProposalManager
165166
from adcp.decisioning.registry import BuyerAgent, BuyerAgentRegistry
166167
from adcp.decisioning.resolve import ResourceResolver
167168
from adcp.decisioning.state import StateReader
@@ -708,6 +709,7 @@ def __init__(
708709
buyer_agent_registry: BuyerAgentRegistry | None = None,
709710
config_store: ProductConfigStore | None = None,
710711
property_list_fetcher: PropertyListFetcher | None = None,
712+
proposal_manager: ProposalManager | None = None,
711713
) -> None:
712714
super().__init__()
713715
self._platform = platform
@@ -721,6 +723,7 @@ def __init__(
721723
self._buyer_agent_registry = buyer_agent_registry
722724
self._config_store = config_store
723725
self._property_list_fetcher = property_list_fetcher
726+
self._proposal_manager = proposal_manager
724727

725728
# Cache whether the platform's create_media_buy accepts 'configs'
726729
# so we only pay the inspect.signature cost at construction time.
@@ -1068,10 +1071,24 @@ async def get_products( # type: ignore[override]
10681071
params: GetProductsRequest,
10691072
context: ToolContext | None = None,
10701073
) -> GetProductsResponse:
1071-
"""Invoke the platform's ``get_products`` method and apply fields projection.
1074+
"""Invoke ``get_products`` on the wired ProposalManager when present,
1075+
else fall through to the platform's ``get_products`` method.
1076+
1077+
Routing precedence:
1078+
1079+
1. If a :class:`ProposalManager` is wired AND
1080+
``params.buying_mode == 'refine'`` AND the manager declares
1081+
:attr:`ProposalCapabilities.refine` AND implements
1082+
``refine_products`` → route to ``proposal_manager.refine_products``.
1083+
2. If a :class:`ProposalManager` is wired (any buying_mode) →
1084+
route to ``proposal_manager.get_products``.
1085+
3. Otherwise (no proposal_manager wired) → fall through to
1086+
``platform.get_products``. Existing adopters who haven't
1087+
wired a ProposalManager keep their current behaviour
1088+
unchanged — backward-compat by construction.
10721089
10731090
When ``params.fields`` is set the framework drops unrequested product
1074-
fields after the platform method returns, always retaining the eight
1091+
fields after the method returns, always retaining the eight
10751092
schema-required fields. When ``params.fields`` is ``None`` the
10761093
response passes through unchanged.
10771094
"""
@@ -1096,7 +1113,12 @@ async def get_products( # type: ignore[override]
10961113
if buying_mode_attr is not None
10971114
else None
10981115
)
1099-
if mode == "refine":
1116+
if mode == "refine" and self._proposal_manager is None:
1117+
# Refine routing through the platform is only consulted when
1118+
# no ProposalManager is wired. With a ProposalManager wired,
1119+
# the refine decision is owned by the proposal-side surface
1120+
# (selected via _select_proposal_method below) and the
1121+
# platform's refine_get_products() is irrelevant.
11001122
from adcp.decisioning.types import AdcpError
11011123

11021124
if not has_refine_support(self._platform):
@@ -1124,9 +1146,19 @@ async def get_products( # type: ignore[override]
11241146
# AdcpErrors propagate unmodified; only the platform call is deadline-
11251147
# wrapped.
11261148
deadline = resolve_time_budget(params.time_budget)
1149+
1150+
target: Any
1151+
method_name: str
1152+
if self._proposal_manager is not None:
1153+
target = self._proposal_manager
1154+
method_name = self._select_proposal_method(params)
1155+
else:
1156+
target = self._platform
1157+
method_name = "get_products"
1158+
11271159
coro = _invoke_platform_method(
1128-
self._platform,
1129-
"get_products",
1160+
target,
1161+
method_name,
11301162
params,
11311163
ctx,
11321164
executor=self._executor,
@@ -1185,6 +1217,40 @@ async def get_products( # type: ignore[override]
11851217
response = _project_product_fields(response, params.fields)
11861218
return response
11871219

1220+
def _select_proposal_method(self, params: GetProductsRequest) -> str:
1221+
"""Choose between ``get_products`` and ``refine_products`` on the
1222+
wired ProposalManager.
1223+
1224+
Refine is dispatched only when all three conditions hold:
1225+
1226+
1. The request's ``buying_mode`` is ``'refine'``.
1227+
2. The manager's ``capabilities.refine`` flag is True.
1228+
3. The manager subclass implements ``refine_products``
1229+
(``hasattr`` covers the Protocol's "present-or-absent"
1230+
semantics).
1231+
1232+
Otherwise routes to ``get_products``. Adopters whose
1233+
``get_products`` handler also handles refine internally keep
1234+
working without declaring the refine capability.
1235+
"""
1236+
manager = self._proposal_manager
1237+
if manager is None:
1238+
return "get_products"
1239+
buying_mode = getattr(params, "buying_mode", None)
1240+
# ``buying_mode`` may be a string or a generated enum (the
1241+
# Pydantic model coerces). Normalize via ``getattr(.., 'value',
1242+
# buying_mode)`` so both shapes compare cleanly.
1243+
buying_mode_str = getattr(buying_mode, "value", buying_mode)
1244+
if buying_mode_str != "refine":
1245+
return "get_products"
1246+
caps = getattr(manager, "capabilities", None)
1247+
refine_supported = bool(getattr(caps, "refine", False))
1248+
if not refine_supported:
1249+
return "get_products"
1250+
if not hasattr(manager, "refine_products"):
1251+
return "get_products"
1252+
return "refine_products"
1253+
11881254
async def create_media_buy( # type: ignore[override]
11891255
self,
11901256
params: CreateMediaBuyRequest,

0 commit comments

Comments
 (0)