Skip to content

Commit d50c64e

Browse files
bokelleyclaude
andcommitted
fix(decisioning): per-tenant ProposalManager binding via PlatformRouter
v1 shipped single-ProposalManager-per-serve(), wired through a kwarg on serve()/PlatformHandler. Reviewer feedback (Brian, architect): ProposalManager has to be per-tenant. Multi-tenant deployments (salesagent, agentic-adapters social) need different proposal logic per tenant — a GAM tenant has different products from a Kevel tenant; a Meta tenant has different proposal assembly from a TikTok tenant. Single-tenant binding doesn't fit. Changes: - PlatformRouter accepts ``proposal_managers={tenant_id: ProposalManager}``. Validates keys are a subset of platforms keys at construction (orphan tenants raise ValueError). The router overrides its synthesized ``get_products`` delegation with an explicit method that does per-tenant manager lookup, refine-mode selection (capability + method-presence gated), and per-tenant fall-through to ``platforms[tenant_id].get_products`` when no manager is wired — back-compat per tenant. - ``serve(proposal_manager=)`` and ``create_adcp_server_from_platform(proposal_manager=)`` kwargs removed. Single-tenant adopters wire a one-entry router: ``PlatformRouter(platforms={"default": ...}, proposal_managers={"default": ...})``. Same pattern as v3 reference seller adopted in PR #488. - PlatformHandler's ``proposal_manager=`` field and ``_select_proposal_method`` helper removed. The router's ``get_products`` does its own dispatch and the handler delegates uniformly via ``_invoke_platform_method``. - ``examples/hello_proposal_manager.py`` rewritten to demonstrate the per-tenant binding: tenant_acme has a wired MockProposalManager; tenant_globex falls through to its platform's get_products. - Tests rewritten to cover per-tenant routing: orphan-key validation, per-tenant isolation, per-tenant fall-through, sync+async manager dispatch, refine routing across all four conditions. 18 tests total, all green; full regression suite (3720 tests) green; no existing example modified (v3 reference seller, hello_seller, hello_mock_seller, multi_platform_seller — back-compat preserved since none ever used the removed ``proposal_manager=`` kwarg). - docs/proposals/product-architecture.md § "Open questions" updated: the tenant binding model question is now resolved (v1 ships per-tenant via PlatformRouter). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4400a70 commit d50c64e

8 files changed

Lines changed: 559 additions & 215 deletions

File tree

docs/proposals/product-architecture.md

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -828,13 +828,18 @@ work.
828828

829829
## Open questions
830830

831-
1. **Tenant binding model.** Path B above proposes
832-
`decisioning_platforms: dict[recipe_kind, DecisioningPlatform]`.
833-
Does this fit cleanly into the existing `PlatformRouter`
834-
(multi-platform-per-process work in #477), or does it need a new
835-
binding type? The two are orthogonal — `PlatformRouter` routes
836-
by tenant; proposal-side binding routes by recipe_kind within a
837-
tenant — but they may share a conceptual seam worth unifying.
831+
1. **Tenant binding model.** *Resolved.* The `ProposalManager` v1
832+
ships per-tenant binding via `PlatformRouter`:
833+
`PlatformRouter(proposal_managers={tenant_id: ProposalManager,
834+
...})`. Multi-tenant deployments (salesagent, agentic-adapters
835+
social) need different proposal logic per tenant — a GAM tenant
836+
has different products from a Kevel tenant; a Meta tenant has
837+
different proposal assembly from a TikTok tenant. Single-tenant
838+
adopters use a one-entry router. Tenants without a wired
839+
ProposalManager fall through to their `DecisioningPlatform`'s
840+
`get_products` — backward-compatible per tenant. The orthogonal
841+
axis (per-`recipe_kind` `DecisioningPlatform` binding for Path B
842+
multi-decisioning within a tenant) remains future work.
838843

839844
2. **Default `recipe_kind` for legacy adopters.** Adopters who
840845
haven't yet migrated to typed recipes pass opaque

examples/hello_proposal_manager.py

Lines changed: 147 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,27 @@
11
"""Hello-proposal-manager — the v1 two-platform composition demo.
22
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.
3+
Shows the per-tenant ProposalManager binding via :class:`PlatformRouter`.
4+
Multi-tenant deployments need different proposal logic per tenant — a
5+
GAM tenant's products differ from a Kevel tenant's; a Meta tenant's
6+
proposal assembly differs from a TikTok tenant's. The router binds
7+
``proposal_managers={tenant_id: ProposalManager}`` per tenant; tenants
8+
without an entry fall through to the tenant's
9+
:meth:`DecisioningPlatform.get_products` (back-compat per tenant).
10+
11+
Single-tenant adopters use a one-entry router with
12+
``platforms={"default": ...}`` and
13+
``proposal_managers={"default": ...}`` — same code path, no branching.
14+
15+
This demo wires two tenants:
16+
17+
* ``tenant_acme`` — has a :class:`MockProposalManager` wired. Forwards
18+
``get_products`` to a running mock-server.
19+
* ``tenant_globex`` — no proposal_manager. Falls through to the
20+
tenant's :meth:`HelloDecisioningPlatform.get_products` (which here
21+
returns a static stub catalogue).
22+
23+
Both tenants share the same :class:`HelloDecisioningPlatform` shape
24+
for ``create_media_buy`` / ``update_media_buy`` / etc.
2025
2126
**Prerequisite:** start the mock-server before running this example::
2227
@@ -26,13 +31,8 @@
2631
2732
uv run python examples/hello_proposal_manager.py
2833
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-
3434
See ``docs/proposals/product-architecture.md`` for the full design
35-
context — § "The two-platform composition" + § "Shape 3 — Mock-backed".
35+
context — § "The two-platform composition" + § "Tenant binding model".
3636
"""
3737

3838
from __future__ import annotations
@@ -44,11 +44,12 @@
4444
DecisioningCapabilities,
4545
DecisioningPlatform,
4646
MockProposalManager,
47+
PlatformRouter,
4748
RequestContext,
4849
SalesPlatform,
49-
SingletonAccounts,
5050
serve,
5151
)
52+
from adcp.decisioning.accounts import Account, AuthInfo
5253
from adcp.decisioning.capabilities import (
5354
Account as CapabilitiesAccount,
5455
)
@@ -64,8 +65,12 @@ class HelloDecisioningPlatform(DecisioningPlatform, SalesPlatform):
6465
6566
Handles ``create_media_buy`` / ``update_media_buy`` /
6667
``sync_creatives`` / ``get_media_buy_delivery`` with stub responses.
67-
Does NOT implement ``get_products`` — the wired
68-
:class:`MockProposalManager` handles that surface.
68+
69+
Also implements ``get_products`` so tenants WITHOUT a wired
70+
ProposalManager (here: ``tenant_globex``) have a working product
71+
catalog. Tenants WITH a wired ProposalManager (here:
72+
``tenant_acme``) bypass this — the router routes to the manager
73+
instead.
6974
7075
In production, this is where the adopter's adapter code lives —
7176
translating wire ``CreateMediaBuyRequest`` payloads into upstream
@@ -85,19 +90,56 @@ class HelloDecisioningPlatform(DecisioningPlatform, SalesPlatform):
8590
account=CapabilitiesAccount(supported_billing=["operator"]),
8691
media_buy=MediaBuy(supported_pricing_models=["cpm"]),
8792
)
88-
accounts = SingletonAccounts(account_id="hello-proposal-manager-acct")
93+
# ``accounts`` is required on the Protocol but the router's
94+
# dispatch path does NOT consult per-platform AccountStores —
95+
# the router's own ``accounts`` does the resolution. Stub here.
96+
accounts = None # type: ignore[assignment]
97+
98+
def get_products(self, req: Any, ctx: RequestContext[Any]) -> dict[str, Any]:
99+
"""Static stub catalogue for tenants without a ProposalManager."""
100+
del req, ctx
101+
return {
102+
"products": [
103+
{
104+
"product_id": "globex-static-catalog-product",
105+
"name": "Globex catalog stub",
106+
"description": "Static catalog product served by the platform.",
107+
"delivery_type": "non_guaranteed",
108+
"publisher_properties": [
109+
{"publisher_domain": "globex.example", "selection_type": "all"},
110+
],
111+
"format_ids": [
112+
{
113+
"agent_url": "https://creative.adcontextprotocol.org/",
114+
"id": "display_300x250",
115+
},
116+
],
117+
"pricing_options": [
118+
{
119+
"pricing_option_id": "po-cpm-default",
120+
"pricing_model": "cpm",
121+
"floor_price": 5.0,
122+
"currency": "USD",
123+
},
124+
],
125+
"reporting_capabilities": {
126+
"available_metrics": ["impressions"],
127+
"available_reporting_frequencies": ["daily"],
128+
"date_range_support": "date_range",
129+
"supports_webhooks": False,
130+
"expected_delay_minutes": 60,
131+
"timezone": "UTC",
132+
},
133+
"delivery_measurement": {"provider": "internal"},
134+
},
135+
],
136+
}
89137

90138
def create_media_buy(
91139
self,
92140
req: Any,
93141
ctx: RequestContext[Any],
94142
) -> 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-
"""
101143
del ctx
102144
idem_key = getattr(req, "idempotency_key", "unknown")
103145
return {
@@ -150,30 +192,87 @@ def get_media_buy_delivery(
150192
}
151193

152194

195+
class _DemoMultiTenantAccounts:
196+
"""Demo AccountStore that maps the wire ``account_id`` to a tenant.
197+
198+
Real adopters back this with a database lookup or read
199+
:func:`adcp.server.tenant_router.current_tenant` (set by
200+
:class:`SubdomainTenantMiddleware`). Kept intentionally simple here
201+
so the example focuses on the router wiring.
202+
"""
203+
204+
resolution = "explicit"
205+
206+
def resolve(
207+
self,
208+
ref: dict[str, Any] | None = None,
209+
auth_info: AuthInfo | None = None,
210+
) -> Account[Any]:
211+
ref = ref or {}
212+
account_id = str(ref.get("account_id", "tenant_acme:default"))
213+
# Convention: ``"<tenant_id>:<rest>"`` — extract the tenant.
214+
tenant_id = account_id.split(":", 1)[0]
215+
return Account(
216+
id=account_id,
217+
name=account_id,
218+
status="active",
219+
metadata={"tenant_id": tenant_id},
220+
auth_info=None,
221+
)
222+
223+
153224
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.
225+
# Mock-server URL — tenant_acme's MockProposalManager forwards to
226+
# this URL. Override via env for CI / local dev.
158227
mock_url = os.environ.get(
159228
"ADCP_MOCK_PROPOSAL_URL",
160229
"http://localhost:4500",
161230
)
162231

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",
232+
# Per-tenant ProposalManager mapping. tenant_acme gets a mock-
233+
# backed ProposalManager; tenant_globex falls through to its
234+
# platform's own ``get_products`` — both shapes coexist behind
235+
# one router.
236+
proposal_managers = {
237+
"tenant_acme": MockProposalManager(
238+
mock_upstream_url=mock_url,
239+
sales_specialism="sales-non-guaranteed",
240+
),
241+
# tenant_globex: deliberately omitted — fall-through to
242+
# platform.get_products demonstrates per-tenant back-compat.
243+
}
244+
245+
router = PlatformRouter(
246+
accounts=_DemoMultiTenantAccounts(),
247+
platforms={
248+
"tenant_acme": HelloDecisioningPlatform(),
249+
"tenant_globex": HelloDecisioningPlatform(),
250+
},
251+
proposal_managers=proposal_managers,
252+
capabilities=DecisioningCapabilities(
253+
specialisms=["sales-non-guaranteed"],
254+
adcp=Adcp(
255+
major_versions=[3],
256+
idempotency=IdempotencySupported(
257+
supported=True,
258+
replay_ttl_seconds=86400,
259+
),
260+
),
261+
account=CapabilitiesAccount(supported_billing=["operator"]),
262+
media_buy=MediaBuy(supported_pricing_models=["cpm"]),
263+
),
169264
)
170265

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.
266+
# Single-tenant adopters use the same shape:
267+
#
268+
# PlatformRouter(
269+
# accounts=...,
270+
# platforms={"default": MyPlatform()},
271+
# proposal_managers={"default": MyProposalManager(...)},
272+
# capabilities=...,
273+
# )
174274
serve(
175-
HelloDecisioningPlatform(),
176-
proposal_manager=proposal_manager,
275+
router,
177276
name="hello-proposal-manager",
178277
port=3001,
179278
auto_emit_completion_webhooks=False,

0 commit comments

Comments
 (0)