Skip to content

Commit 786e6a2

Browse files
bokelleyclaude
andauthored
feat(decisioning): PlatformRouter + multi-platform proof (#477) (#490)
* feat(decisioning): PlatformRouter + multi-platform example — N tenants per process via Protocol introspection (#477) PlatformRouter is a drop-in DecisioningPlatform that fans calls out across N child platforms keyed by ctx.account.metadata['tenant_id']. The router's delegating methods are synthesized at construction by walking every known specialism Protocol — new specialism Protocols added to the SDK appear on the router automatically, no adopter code change required. The accompanying examples/multi_platform_seller/ ships two pure-Python mock platforms (sales-guaranteed, sales-non-guaranteed) behind one boot, fronted by SubdomainTenantMiddleware so requests route by Host header (tenant-a.localhost / tenant-b.localhost) AND by wire account_ref prefix (tenant-a:demo) so the same boot serves both subdomain-routed buyers and the storyboard runner. A new CI job runs the media_buy_seller storyboard against each tenant subdomain. This is the migration target for salesagent-shaped adopters with ADAPTER_REGISTRY: dict[str, Type[AdServerAdapter]] — the migration doc itself lands in a parallel PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(decisioning): PR #490 review fixes — extension-contract tests, CI signal, example cleanups Address review should-fixes on PlatformRouter + multi-platform example: - Add extension-contract test pinning ``_KNOWN_SPECIALISM_PROTOCOLS`` against ``specialisms.__all__``. Fails the moment a new Platform Protocol is added without updating the router's known set. - Add denylist-drift test pinning ``_ACCOUNT_STORE_METHODS`` against the actual AccountStore Protocol classes. - Drop ``|| true`` from CI storyboard-run steps. The job-level ``continue-on-error: true`` is the explicit non-blocking knob; ``|| true`` was swallowing the runner exit code before ``continue-on-error`` saw it, masking real storyboard failures. - Fix docstring typo in mock_guaranteed.create_media_buy: ``INVENTORY_UNAVAILABLE`` (not a spec code) → ``PRODUCT_UNAVAILABLE`` (matches what the code actually raises). - Replace real ad-server brand names in README "before" examples with neutral placeholders (``adserver_a`` / ``adserver_b``). - Drop the subdomain double-registration in the example app. ``InMemorySubdomainTenantRouter`` strips the port suffix at both construction and lookup via ``_normalize_host``, so registering ``host:port`` and ``host`` produced duplicate keys. Register the bare host once and link the comment to the normalizer. - Move the ``_ASSERT`` mypy assertion in account_store.py to a ``TYPE_CHECKING`` block so it has zero runtime cost and never exposes a synthetic ``"_assertion"`` tenant via the registry. - Sort ``_all_specialism_methods()`` before iterating in ``PlatformRouter.__init__`` for deterministic synthesis order. - Convert README's "companion PR" reference to a relative link to the doc's eventual location. 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 06aaf51 commit 786e6a2

10 files changed

Lines changed: 2466 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -646,3 +646,104 @@ jobs:
646646
name: v3-storyboard-result-${{ github.run_attempt }}
647647
path: examples/v3_reference_seller/v3-storyboard-result.json
648648
if-no-files-found: warn
649+
650+
storyboard-multi-platform-seller:
651+
name: AdCP storyboard runner — examples/multi_platform_seller (PlatformRouter)
652+
runs-on: ubuntu-latest
653+
# Multi-tenant proof: one process, two tenants, one router. Each
654+
# tenant's storyboard runs against its own subdomain
655+
# (tenant-a.localhost / tenant-b.localhost). Non-blocking until
656+
# the storyboard reaches passing — same posture as the
657+
# examples/seller_agent.py job above.
658+
continue-on-error: true
659+
660+
steps:
661+
- uses: actions/checkout@v4
662+
663+
- name: Set up Python 3.12
664+
uses: actions/setup-python@v5
665+
with:
666+
python-version: "3.12"
667+
668+
- name: Set up Node 22
669+
uses: actions/setup-node@v4
670+
with:
671+
node-version: "22"
672+
673+
- name: Cache ~/.npm
674+
uses: actions/cache@v4
675+
with:
676+
path: ~/.npm
677+
key: ${{ runner.os }}-npm-adcp-sdk
678+
restore-keys: |
679+
${{ runner.os }}-npm-
680+
681+
- name: Pre-install @adcp/sdk
682+
run: |
683+
npm install -g @adcp/sdk@latest
684+
adcp --version
685+
686+
- name: Install dependencies
687+
run: |
688+
python -m pip install --upgrade pip
689+
pip install -e ".[dev]"
690+
691+
- name: Map tenant subdomains to localhost
692+
run: |
693+
# The storyboard runner connects to ``tenant-x.localhost`` —
694+
# /etc/hosts gives those names a 127.0.0.1 mapping so the
695+
# subdomain middleware on the seller process resolves the
696+
# right tenant from the Host header.
697+
echo "127.0.0.1 tenant-a.localhost tenant-b.localhost" \
698+
| sudo tee -a /etc/hosts
699+
700+
- name: Boot multi-platform seller
701+
run: |
702+
ADCP_PORT=3001 python -m examples.multi_platform_seller.src.app &
703+
SELLER_PID=$!
704+
echo "SELLER_PID=$SELLER_PID" >> "$GITHUB_ENV"
705+
for i in $(seq 1 60); do
706+
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 1 \
707+
http://127.0.0.1:3001/mcp 2>/dev/null) || HTTP_CODE="000"
708+
if [ "$HTTP_CODE" != "000" ]; then
709+
echo "Seller ready (HTTP ${HTTP_CODE}, pid ${SELLER_PID})"
710+
break
711+
fi
712+
if ! kill -0 "$SELLER_PID" 2>/dev/null; then
713+
echo "Seller process died during startup"
714+
exit 1
715+
fi
716+
if [ "$i" -eq 60 ]; then
717+
echo "Seller failed to start within 30s"
718+
kill "$SELLER_PID" 2>/dev/null || true
719+
exit 1
720+
fi
721+
sleep 0.5
722+
done
723+
724+
- name: Run storyboard — tenant-a (sales-guaranteed)
725+
timeout-minutes: 5
726+
run: |
727+
adcp storyboard run \
728+
http://tenant-a.localhost:3001/mcp media_buy_seller \
729+
--json --allow-http \
730+
> tenant-a-storyboard.json
731+
cat tenant-a-storyboard.json | head -50
732+
733+
- name: Run storyboard — tenant-b (sales-non-guaranteed)
734+
timeout-minutes: 5
735+
run: |
736+
adcp storyboard run \
737+
http://tenant-b.localhost:3001/mcp media_buy_seller \
738+
--json --allow-http \
739+
> tenant-b-storyboard.json
740+
cat tenant-b-storyboard.json | head -50
741+
742+
- if: always()
743+
uses: actions/upload-artifact@v4
744+
with:
745+
name: multi-platform-storyboards-${{ github.run_attempt }}
746+
path: |
747+
tenant-a-storyboard.json
748+
tenant-b-storyboard.json
749+
if-no-files-found: warn
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
# multi_platform_seller — N tenants, N platforms, one process
2+
3+
A worked example of the multi-tenant pattern: one `serve()` call hosts
4+
two tenants, each backed by a different `DecisioningPlatform`
5+
subclass, dispatched by `PlatformRouter` based on the request's
6+
resolved tenant.
7+
8+
## Why this exists
9+
10+
Multi-tenant SaaS sales agents (Prebid `salesagent` and adopters in its
11+
shape) maintain a registry like
12+
13+
```python
14+
ADAPTER_REGISTRY: dict[str, Type[AdServerAdapter]] = {
15+
"adserver_a": AdServerAAdapter,
16+
"adserver_b": AdServerBAdapter,
17+
"mock": MockAdapter,
18+
...
19+
}
20+
```
21+
22+
…and instantiate the right adapter per-request based on the tenant's
23+
configured backend. The `DecisioningPlatform` framework supports the
24+
same shape via `PlatformRouter`: preload one platform per tenant,
25+
let the router pick by `ctx.account.metadata['tenant_id']`. The
26+
adopter swaps a runtime `dict[str, Type[Adapter]]` lookup for a
27+
construction-time `dict[str, DecisioningPlatform]` injection — same
28+
mental model, framework-managed dispatch.
29+
30+
## What it ships
31+
32+
| File | Role |
33+
|---|---|
34+
| `src/mock_guaranteed.py` | `sales-guaranteed` mock — fixed-CPM, capacity-bounded inventory, `pending_creatives → pending_start → active → completed` lifecycle. |
35+
| `src/mock_non_guaranteed.py` | `sales-non-guaranteed` mock — programmatic remnant, always accepts, delivery scales with budget. |
36+
| `src/account_store.py` | `MultiTenantAccountStore` — resolves wire account refs to `Account` instances with `metadata['tenant_id']` populated. Reads either the `Host`-header-set tenant contextvar or the wire `account.account_id` prefix (`tenant-a:...`). |
37+
| `src/app.py` | Boot script. Constructs both platforms, the account store, the `PlatformRouter`, wires `SubdomainTenantMiddleware`, calls `serve()`. |
38+
39+
## Running locally
40+
41+
```bash
42+
# 1. (Optional) Map the tenant subdomains to localhost:
43+
echo "127.0.0.1 tenant-a.localhost tenant-b.localhost" | sudo tee -a /etc/hosts
44+
45+
# 2. Boot the seller:
46+
python -m examples.multi_platform_seller.src.app
47+
48+
# 3. Hit either tenant via subdomain:
49+
curl -X POST \
50+
-H "Content-Type: application/json" \
51+
-H "Accept: application/json, text/event-stream" \
52+
-H "Host: tenant-a.localhost:3001" \
53+
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' \
54+
http://127.0.0.1:3001/mcp
55+
56+
# 4. Or send an explicit account ref (storyboard convention):
57+
curl -X POST \
58+
-H "Content-Type: application/json" \
59+
-H "Accept: application/json, text/event-stream" \
60+
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{
61+
"name":"get_products",
62+
"arguments":{"buying_mode":"brief","brief":"display","account":{"account_id":"tenant-a:demo"}}
63+
}}' \
64+
http://127.0.0.1:3001/mcp
65+
```
66+
67+
## How tenant routing works
68+
69+
```
70+
┌──────────────────────┐ SubdomainTenantMiddleware reads
71+
│ POST /mcp │ ──► the Host header, sets the tenant
72+
│ Host: tenant-a... │ contextvar.
73+
└──────────┬───────────┘
74+
75+
76+
┌──────────────────────┐ MultiTenantAccountStore.resolve()
77+
│ PlatformRouter │ ──► reads the contextvar (or the wire
78+
│ (DecisioningPlatform│ ref's tenant prefix) and stamps
79+
│ subclass) │ metadata['tenant_id'] onto the
80+
└──────────┬───────────┘ resolved Account.
81+
82+
83+
┌──────────────────────┐ Synthesized delegation for every
84+
│ router.get_products │ ──► specialism method looks up the
85+
│ router.create_… │ child by tenant_id and forwards
86+
└──────────┬───────────┘ the call verbatim.
87+
88+
89+
┌──────────────────────────────┐
90+
│ MockGuaranteedPlatform OR │
91+
│ MockNonGuaranteedPlatform │
92+
└──────────────────────────────┘
93+
```
94+
95+
## Capabilities — union, not intersection
96+
97+
The router's `capabilities.specialisms` is the **union** of every
98+
child platform's specialisms. `tools/list` advertises every tool any
99+
child serves; calls to a tool the resolved tenant doesn't implement
100+
raise a structured `UNSUPPORTED_FEATURE` error per spec.
101+
102+
Intersection-based capabilities would silently hide tools that some
103+
tenants DO support, breaking the "one URL → many tenants" model.
104+
105+
## Adding a third tenant
106+
107+
1. Write a new `DecisioningPlatform` subclass for the new tenant's
108+
backend (or reuse one of the mocks).
109+
2. Add `"tenant-c": NewPlatform()` to the `platforms=` mapping in
110+
`app.py`.
111+
3. Add the new tenant to `MultiTenantAccountStore(tenants=...)` and to
112+
the `SubdomainTenantMiddleware` router.
113+
4. If the new tenant adds a specialism (e.g. `audience-sync`), extend
114+
the router's `capabilities.specialisms` accordingly. The router's
115+
synthesized delegation already covers every framework-known
116+
specialism method — you don't have to update the router itself.
117+
118+
## Migration target — `ADAPTER_REGISTRY` adopters
119+
120+
A separate [`MIGRATION_FROM_ADAPTER_REGISTRY.md`](./MIGRATION_FROM_ADAPTER_REGISTRY.md)
121+
walks through the salesagent translation step-by-step. The short version:
122+
123+
```python
124+
# Before — runtime adapter instantiation per tenant
125+
ADAPTER_REGISTRY: dict[str, Type[AdServerAdapter]] = {
126+
"adserver_a": AdServerAAdapter,
127+
"adserver_b": AdServerBAdapter,
128+
}
129+
adapter = ADAPTER_REGISTRY[tenant.backend](tenant.config, principal)
130+
result = adapter.create_media_buy(request)
131+
132+
# After — boot-time platform construction, framework-managed dispatch
133+
router = PlatformRouter(
134+
accounts=tenant_routing_account_store,
135+
platforms={
136+
tenant_id: build_platform_for(tenant)
137+
for tenant_id, tenant in load_tenants().items()
138+
},
139+
capabilities=DecisioningCapabilities(
140+
specialisms=union_of_tenant_specialisms(),
141+
...,
142+
),
143+
)
144+
serve(router, asgi_middleware=[(SubdomainTenantMiddleware, {...})])
145+
```
146+
147+
## What this example deliberately leaves out
148+
149+
- **Real upstream HTTP.** Both mocks set `upstream_url = None` and
150+
serve in-process. Production adopters set `upstream_url` on each
151+
child platform and let `DecisioningPlatform.upstream_for(ctx)`
152+
thread the per-request client.
153+
- **Multi-protocol fan-out.** Each request resolves to exactly one
154+
tenant. Fan-out (one buyer call → multiple sellers) is a different
155+
pattern and out of scope.
156+
- **Cross-tenant state.** Each tenant is an island — its own
157+
inventory, its own buys, its own auth. The router never aggregates
158+
across tenants.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""Multi-platform seller example — N tenants, N platforms, one process.
2+
3+
See the README for the wiring story; this package exposes:
4+
5+
* :class:`MockGuaranteedPlatform` — fixed-allocation, capacity-bounded
6+
inventory (the canonical guaranteed-buy shape).
7+
* :class:`MockNonGuaranteedPlatform` — programmatic remnant, always
8+
accepts, delivery scales with budget.
9+
* :class:`MultiTenantAccountStore` — resolves wire account refs to
10+
Account[TenantMeta] with ``metadata['tenant_id']`` populated.
11+
* :func:`build_router` — assembles the :class:`PlatformRouter` from the
12+
per-tenant platforms.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
from .account_store import MultiTenantAccountStore
18+
from .mock_guaranteed import MockGuaranteedPlatform
19+
from .mock_non_guaranteed import MockNonGuaranteedPlatform
20+
21+
__all__ = [
22+
"MockGuaranteedPlatform",
23+
"MockNonGuaranteedPlatform",
24+
"MultiTenantAccountStore",
25+
]

0 commit comments

Comments
 (0)