Skip to content

Commit 85dceb5

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 1dfdcd5 commit 85dceb5

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,
@@ -297,13 +304,16 @@ def __init__(self, *args: object, **kwargs: object) -> None:
297304
"MediaBuyNotFoundError",
298305
"MediaBuyStore",
299306
"MockAdServer",
307+
"MockProposalManager",
300308
"NoAuth",
301309
"OAuthCredential",
302310
"PermissionDeniedError",
303311
"PgTaskRegistry",
304312
"PlatformRouter",
305313
"PostgresTaskRegistry",
306314
"Proposal",
315+
"ProposalCapabilities",
316+
"ProposalManager",
307317
"PropertyList",
308318
"PropertyListFetcher",
309319
"PropertyListReference",
@@ -315,11 +325,13 @@ def __init__(self, *args: object, **kwargs: object) -> None:
315325
"validate_property_list_config",
316326
"RateLimitedBuyerAgentRegistry",
317327
"RateLimitedError",
328+
"Recipe",
318329
"RequestContext",
319330
"ResolveContext",
320331
"ResourceResolver",
321332
"SalesPlatform",
322333
"SalesResult",
334+
"SalesSpecialism",
323335
"ServiceUnavailableError",
324336
"SignalsPlatform",
325337
"SingletonAccounts",

src/adcp/decisioning/handler.py

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

157157
from adcp.decisioning.platform import DecisioningPlatform
158158
from adcp.decisioning.property_list import PropertyListFetcher
159+
from adcp.decisioning.proposal_manager import ProposalManager
159160
from adcp.decisioning.registry import BuyerAgent, BuyerAgentRegistry
160161
from adcp.decisioning.resolve import ResourceResolver
161162
from adcp.decisioning.state import StateReader
@@ -702,6 +703,7 @@ def __init__(
702703
buyer_agent_registry: BuyerAgentRegistry | None = None,
703704
config_store: ProductConfigStore | None = None,
704705
property_list_fetcher: PropertyListFetcher | None = None,
706+
proposal_manager: ProposalManager | None = None,
705707
) -> None:
706708
super().__init__()
707709
self._platform = platform
@@ -715,6 +717,7 @@ def __init__(
715717
self._buyer_agent_registry = buyer_agent_registry
716718
self._config_store = config_store
717719
self._property_list_fetcher = property_list_fetcher
720+
self._proposal_manager = proposal_manager
718721

719722
# Cache whether the platform's create_media_buy accepts 'configs'
720723
# so we only pay the inspect.signature cost at construction time.
@@ -1062,21 +1065,45 @@ async def get_products( # type: ignore[override]
10621065
params: GetProductsRequest,
10631066
context: ToolContext | None = None,
10641067
) -> GetProductsResponse:
1065-
"""Invoke the platform's ``get_products`` method and apply fields projection.
1068+
"""Invoke ``get_products`` on the wired ProposalManager when present,
1069+
else fall through to the platform's ``get_products`` method.
1070+
1071+
Routing precedence:
1072+
1073+
1. If a :class:`ProposalManager` is wired AND
1074+
``params.buying_mode == 'refine'`` AND the manager declares
1075+
:attr:`ProposalCapabilities.refine` AND implements
1076+
``refine_products`` → route to ``proposal_manager.refine_products``.
1077+
2. If a :class:`ProposalManager` is wired (any buying_mode) →
1078+
route to ``proposal_manager.get_products``.
1079+
3. Otherwise (no proposal_manager wired) → fall through to
1080+
``platform.get_products``. Existing adopters who haven't
1081+
wired a ProposalManager keep their current behaviour
1082+
unchanged — backward-compat by construction.
10661083
10671084
When ``params.fields`` is set the framework drops unrequested product
1068-
fields after the platform method returns, always retaining the eight
1085+
fields after the method returns, always retaining the eight
10691086
schema-required fields. When ``params.fields`` is ``None`` the
10701087
response passes through unchanged.
10711088
"""
10721089
tool_ctx = context or ToolContext()
10731090
account = await self._resolve_account(params.account, tool_ctx)
10741091
ctx = self._build_ctx(tool_ctx, account)
1092+
1093+
target: Any
1094+
method_name: str
1095+
if self._proposal_manager is not None:
1096+
target = self._proposal_manager
1097+
method_name = self._select_proposal_method(params)
1098+
else:
1099+
target = self._platform
1100+
method_name = "get_products"
1101+
10751102
response = cast(
10761103
"GetProductsResponse",
10771104
await _invoke_platform_method(
1078-
self._platform,
1079-
"get_products",
1105+
target,
1106+
method_name,
10801107
params,
10811108
ctx,
10821109
executor=self._executor,
@@ -1106,6 +1133,40 @@ async def get_products( # type: ignore[override]
11061133
response = _project_product_fields(response, params.fields)
11071134
return response
11081135

1136+
def _select_proposal_method(self, params: GetProductsRequest) -> str:
1137+
"""Choose between ``get_products`` and ``refine_products`` on the
1138+
wired ProposalManager.
1139+
1140+
Refine is dispatched only when all three conditions hold:
1141+
1142+
1. The request's ``buying_mode`` is ``'refine'``.
1143+
2. The manager's ``capabilities.refine`` flag is True.
1144+
3. The manager subclass implements ``refine_products``
1145+
(``hasattr`` covers the Protocol's "present-or-absent"
1146+
semantics).
1147+
1148+
Otherwise routes to ``get_products``. Adopters whose
1149+
``get_products`` handler also handles refine internally keep
1150+
working without declaring the refine capability.
1151+
"""
1152+
manager = self._proposal_manager
1153+
if manager is None:
1154+
return "get_products"
1155+
buying_mode = getattr(params, "buying_mode", None)
1156+
# ``buying_mode`` may be a string or a generated enum (the
1157+
# Pydantic model coerces). Normalize via ``getattr(.., 'value',
1158+
# buying_mode)`` so both shapes compare cleanly.
1159+
buying_mode_str = getattr(buying_mode, "value", buying_mode)
1160+
if buying_mode_str != "refine":
1161+
return "get_products"
1162+
caps = getattr(manager, "capabilities", None)
1163+
refine_supported = bool(getattr(caps, "refine", False))
1164+
if not refine_supported:
1165+
return "get_products"
1166+
if not hasattr(manager, "refine_products"):
1167+
return "get_products"
1168+
return "refine_products"
1169+
11091170
async def create_media_buy( # type: ignore[override]
11101171
self,
11111172
params: CreateMediaBuyRequest,

0 commit comments

Comments
 (0)