Skip to content

Commit 8ef730d

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 bc6ad13 commit 8ef730d

7 files changed

Lines changed: 1272 additions & 4 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.registry import (
125132
ApiKeyCredential,
126133
BillingMode,
@@ -304,13 +311,16 @@ def __init__(self, *args: object, **kwargs: object) -> None:
304311
"MediaBuyNotFoundError",
305312
"MediaBuyStore",
306313
"MockAdServer",
314+
"MockProposalManager",
307315
"NoAuth",
308316
"OAuthCredential",
309317
"PermissionDeniedError",
310318
"PgTaskRegistry",
311319
"PlatformRouter",
312320
"PostgresTaskRegistry",
313321
"Proposal",
322+
"ProposalCapabilities",
323+
"ProposalManager",
314324
"PropertyList",
315325
"PropertyListFetcher",
316326
"PropertyListReference",
@@ -323,11 +333,13 @@ def __init__(self, *args: object, **kwargs: object) -> None:
323333
"validate_property_list_config",
324334
"RateLimitedBuyerAgentRegistry",
325335
"RateLimitedError",
336+
"Recipe",
326337
"RequestContext",
327338
"ResolveContext",
328339
"ResourceResolver",
329340
"SalesPlatform",
330341
"SalesResult",
342+
"SalesSpecialism",
331343
"ServiceUnavailableError",
332344
"SignalsPlatform",
333345
"SingletonAccounts",

src/adcp/decisioning/handler.py

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@
157157

158158
from adcp.decisioning.platform import DecisioningPlatform
159159
from adcp.decisioning.property_list import PropertyListFetcher
160+
from adcp.decisioning.proposal_manager import ProposalManager
160161
from adcp.decisioning.registry import BuyerAgent, BuyerAgentRegistry
161162
from adcp.decisioning.resolve import ResourceResolver
162163
from adcp.decisioning.state import StateReader
@@ -703,6 +704,7 @@ def __init__(
703704
buyer_agent_registry: BuyerAgentRegistry | None = None,
704705
config_store: ProductConfigStore | None = None,
705706
property_list_fetcher: PropertyListFetcher | None = None,
707+
proposal_manager: ProposalManager | None = None,
706708
) -> None:
707709
super().__init__()
708710
self._platform = platform
@@ -716,6 +718,7 @@ def __init__(
716718
self._buyer_agent_registry = buyer_agent_registry
717719
self._config_store = config_store
718720
self._property_list_fetcher = property_list_fetcher
721+
self._proposal_manager = proposal_manager
719722

720723
# Cache whether the platform's create_media_buy accepts 'configs'
721724
# so we only pay the inspect.signature cost at construction time.
@@ -1063,10 +1066,24 @@ async def get_products( # type: ignore[override]
10631066
params: GetProductsRequest,
10641067
context: ToolContext | None = None,
10651068
) -> GetProductsResponse:
1066-
"""Invoke the platform's ``get_products`` method and apply fields projection.
1069+
"""Invoke ``get_products`` on the wired ProposalManager when present,
1070+
else fall through to the platform's ``get_products`` method.
1071+
1072+
Routing precedence:
1073+
1074+
1. If a :class:`ProposalManager` is wired AND
1075+
``params.buying_mode == 'refine'`` AND the manager declares
1076+
:attr:`ProposalCapabilities.refine` AND implements
1077+
``refine_products`` → route to ``proposal_manager.refine_products``.
1078+
2. If a :class:`ProposalManager` is wired (any buying_mode) →
1079+
route to ``proposal_manager.get_products``.
1080+
3. Otherwise (no proposal_manager wired) → fall through to
1081+
``platform.get_products``. Existing adopters who haven't
1082+
wired a ProposalManager keep their current behaviour
1083+
unchanged — backward-compat by construction.
10671084
10681085
When ``params.fields`` is set the framework drops unrequested product
1069-
fields after the platform method returns, always retaining the eight
1086+
fields after the method returns, always retaining the eight
10701087
schema-required fields. When ``params.fields`` is ``None`` the
10711088
response passes through unchanged.
10721089
"""
@@ -1078,9 +1095,19 @@ async def get_products( # type: ignore[override]
10781095
# AdcpErrors propagate unmodified; only the platform call is deadline-
10791096
# wrapped.
10801097
deadline = resolve_time_budget(params.time_budget)
1098+
1099+
target: Any
1100+
method_name: str
1101+
if self._proposal_manager is not None:
1102+
target = self._proposal_manager
1103+
method_name = self._select_proposal_method(params)
1104+
else:
1105+
target = self._platform
1106+
method_name = "get_products"
1107+
10811108
coro = _invoke_platform_method(
1082-
self._platform,
1083-
"get_products",
1109+
target,
1110+
method_name,
10841111
params,
10851112
ctx,
10861113
executor=self._executor,
@@ -1139,6 +1166,40 @@ async def get_products( # type: ignore[override]
11391166
response = _project_product_fields(response, params.fields)
11401167
return response
11411168

1169+
def _select_proposal_method(self, params: GetProductsRequest) -> str:
1170+
"""Choose between ``get_products`` and ``refine_products`` on the
1171+
wired ProposalManager.
1172+
1173+
Refine is dispatched only when all three conditions hold:
1174+
1175+
1. The request's ``buying_mode`` is ``'refine'``.
1176+
2. The manager's ``capabilities.refine`` flag is True.
1177+
3. The manager subclass implements ``refine_products``
1178+
(``hasattr`` covers the Protocol's "present-or-absent"
1179+
semantics).
1180+
1181+
Otherwise routes to ``get_products``. Adopters whose
1182+
``get_products`` handler also handles refine internally keep
1183+
working without declaring the refine capability.
1184+
"""
1185+
manager = self._proposal_manager
1186+
if manager is None:
1187+
return "get_products"
1188+
buying_mode = getattr(params, "buying_mode", None)
1189+
# ``buying_mode`` may be a string or a generated enum (the
1190+
# Pydantic model coerces). Normalize via ``getattr(.., 'value',
1191+
# buying_mode)`` so both shapes compare cleanly.
1192+
buying_mode_str = getattr(buying_mode, "value", buying_mode)
1193+
if buying_mode_str != "refine":
1194+
return "get_products"
1195+
caps = getattr(manager, "capabilities", None)
1196+
refine_supported = bool(getattr(caps, "refine", False))
1197+
if not refine_supported:
1198+
return "get_products"
1199+
if not hasattr(manager, "refine_products"):
1200+
return "get_products"
1201+
return "refine_products"
1202+
11421203
async def create_media_buy( # type: ignore[override]
11431204
self,
11441205
params: CreateMediaBuyRequest,

0 commit comments

Comments
 (0)