Skip to content

Commit cda07f9

Browse files
bokelleyclaude
andauthored
feat(examples): v3 reference seller — runnable Tier 2 + v3 wiring (#357) (#373)
* feat(examples): v3 reference seller — runnable Tier 2 + v3 wiring (#357) Multi-file directory under ``examples/v3_reference_seller/`` that wires every Tier 2 / v3-supporting component into one runnable adopter pattern. Spec 3.0-compliant on the wire, 3.1-ready in architecture and storage. * ``src/models.py`` — SQLAlchemy 2.0 models (Tenant, BuyerAgent, Account, MediaBuy) with 3.1-ready columns. * ``src/tenant_router.py`` — SQL-backed SubdomainTenantRouter with in-process cache. * ``src/buyer_registry.py`` — tenant-scoped BuyerAgentRegistry. * ``src/audit.py`` — DbAuditSink writing audit_events. * ``src/platform.py`` — sales-non-guaranteed impl (the bulk of what an adopter customizes). * ``src/app.py`` — main entrypoint wiring serve(transport="both") + a ``build_app`` helper composing SubdomainTenantMiddleware on the parent Starlette app. * ``seed.py`` + ``docker-compose.yml`` — dev fixtures + Postgres. * ``README.md`` — clone-and-modify guide. * ``tests/test_smoke.py`` — 7 import + Protocol-conformance tests. End-to-end verified locally against postgres:16. The compose uses ``POSTGRES_HOST_AUTH_METHOD=trust`` for loopback dev — no literal password in version control; production sellers source DB credentials from a secrets backend. Closes #357 (MVP). Follow-ups: HTTP-Sig verifier middleware in context_factory, admin CRUD API, Alembic migrations, swap to PgTaskRegistry / PgWebhookDeliverySupervisor for production. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(v3-reference-seller): expert-review fix-pack + serve(asgi_middleware=) The 4-expert review of #357 surfaced multiple convergent blockers in the v3 reference seller MVP: * `app.py` `build_app()` imported `_build_mcp_and_a2a_app` from the wrong module → ImportError at adopter call time * `main()` never wired `SubdomainTenantMiddleware` → multi-tenant claim was false; `current_tenant()` would return None * `platform.create_media_buy` extracted `total_budget` / `currency` / `start_time` / `end_time` against a wrong-shape API surface (TotalBudget object, StartTiming union) — every persisted media buy stored corrupted data or NULL * `media_buy_id = f"mb_{uuid[:16]}"` collides at scale → IntegrityError Bundled fix: * Framework: add `asgi_middleware=Sequence[(cls, kwargs)]` kwarg to `adcp.server.serve()` (and forward through `adcp.decisioning.serve()`) so adopters layer Starlette-style ASGI middleware without dropping to `_build_mcp_and_a2a_app`. * Example: `main()` now passes `asgi_middleware=[(SubdomainTenant Middleware, {"router": router})]`. Dead `build_app()` helper removed. * `platform.py`: project `req.total_budget.amount/.currency` and unwrap `StartTiming.root` (`'asap'` → now()); `media_buy_id` is a full UUID (the (tenant_id, idempotency_key) unique constraint already guards replay). * `models.py` + `buyer_registry.py` + `seed.py`: `billing_capabilities`, `default_terms`, `allowed_brands` switched from JSON-encoded String columns to native JSON columns (matches the rest of the schema). * `audit.py`: docstring corrected — `record()` is best-effort but awaited inline (not fire-and-forget). * `tenant_router.py`: cache renamed from "LRU-ish" to bounded FIFO to match actual eviction behavior. * `README.md`: drop inaccurate claims (HTTP-Sig wired, project_account_for_response wired inline). Add dev-only callout for docker-compose trust auth + seed bearer token. Test: `tests/test_serve_asgi_middleware.py` covers the new kwarg's no-op + outermost-first composition contract. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(v3-reference-seller): pricing_options conformance + docstring polish Protocol-expert review surfaced one BLOCKER on commit 8d6d783: ``pricing_options`` in the get_products stub didn't conform to the spec's ``CpmPricingOption`` shape — used ``type``/``rate`` keys instead of the discriminator ``pricing_model`` and required ``fixed_price``, and was missing the required ``pricing_option_id``. Buyer agents validating the response against the discriminated union would reject the seller's catalog. Also addresses two should-fix items: * ``serve(asgi_middleware=)`` docstring now warns adopter middleware to pass through ``lifespan`` and ``websocket`` scopes so the framework's lifespan composition still runs. * README "Alembic migrations" callout clarifies that ``create_all`` does NOT detect column renames or type changes on existing tables — adopters who prototyped against earlier branches need to drop/recreate the dev database after pulling the JSON-column rename. 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 008fa3c commit cda07f9

14 files changed

Lines changed: 1745 additions & 2 deletions

File tree

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
# v3 reference seller
2+
3+
Canonical multi-tenant AdCP seller. **Spec 3.0-compliant on the wire,
4+
3.1-ready in architecture and storage.** Adopters fork this directory
5+
and replace the platform impl with their own business logic.
6+
7+
This directory wires every Tier 2 / v3-supporting component the SDK
8+
ships into one runnable binary:
9+
10+
| Component | Module | Source |
11+
|---|---|---|
12+
| Tier 2 commercial-identity gate | `src/buyer_registry.py` | `adcp.decisioning.BuyerAgentRegistry` |
13+
| Subdomain tenant routing | `src/tenant_router.py` + `src/app.py` | `adcp.server.SubdomainTenantMiddleware` |
14+
| Account v3 storage (bank-details column) | `src/models.py` | `Account.billing_entity` JSON column |
15+
| Audit trail | `src/audit.py` | `adcp.audit_sink.AuditSink` |
16+
| MCP + A2A on one binary | `src/app.py` | `serve(transport="both", asgi_middleware=...)` |
17+
| Durable HITL tasks (optional) | swap to `PgTaskRegistry` | `adcp.decisioning.pg.PgTaskRegistry` |
18+
| Durable webhook delivery (optional) | swap to `PgWebhookDeliverySupervisor` | `adcp.webhook_supervisor_pg` |
19+
| HTTP-Sig verifier → AuthInfo (TODO) | adopter middleware | `adcp.decisioning.AuthInfo.from_verified_signer` |
20+
| Account v3 projection on read (TODO) | adopter wires in `sync_accounts` | `adcp.types.project_account_for_response` |
21+
22+
## Run it
23+
24+
```bash
25+
# 1. Start Postgres
26+
cd examples/v3_reference_seller
27+
docker compose up -d postgres
28+
29+
# 2. Seed dev fixtures
30+
DATABASE_URL=postgresql+asyncpg://postgres@localhost/adcp \
31+
python -m seed
32+
33+
# 3. Boot the seller
34+
DATABASE_URL=postgresql+asyncpg://postgres@localhost/adcp \
35+
python -m src.app
36+
```
37+
38+
The server binds `0.0.0.0:3001` and serves both transports.
39+
40+
> ⚠️ **Local-dev only.** `docker-compose.yml` uses
41+
> `POSTGRES_HOST_AUTH_METHOD=trust` and exposes 5432 on
42+
> `0.0.0.0`. Do not run this compose file on a host reachable from
43+
> an untrusted network. `seed.py` plants a literal dev bearer
44+
> token (`dev-bearer-token-acme-1`) — do not run `seed.py` against
45+
> a production `DATABASE_URL`. Production deployments point
46+
> `DATABASE_URL` at managed Postgres with scram-sha-256 + a real
47+
> password, and seed via the admin API (not this script).
48+
49+
## What's wired
50+
51+
### Schema (`src/models.py`)
52+
53+
Four tables — the spine of a multi-tenant v3 seller:
54+
55+
- `tenants` — one row per `<subdomain>.example.com`.
56+
`SubdomainTenantMiddleware` reads the request `Host` header and
57+
finds the matching row.
58+
- `buyer_agents` — Tier 2 commercial-identity rows. The framework's
59+
dispatch gate reads this BEFORE the platform method runs and
60+
rejects suspended (transient) and blocked (terminal) agents with
61+
structured errors.
62+
- `accounts` — buyer-side accounts under recognized agents. Carries
63+
the spec 3.1-ready `billing_entity` (write-only bank details on
64+
responses) and `reporting_bucket` (offline reporting target). The
65+
reference seller does not implement `sync_accounts`, so the
66+
bank-details projection is a column-level architectural seam, not
67+
an enforced runtime guard — adopters who add `sync_accounts`
68+
MUST project through `adcp.types.project_account_for_response`
69+
before returning the row.
70+
- `media_buys` — terminal artifact of `create_media_buy`,
71+
idempotency-keyed for replay safety.
72+
73+
### Tenant routing (`src/tenant_router.py`)
74+
75+
`SqlSubdomainTenantRouter` implements the framework's
76+
`SubdomainTenantRouter` Protocol. The middleware sets the
77+
`current_tenant()` contextvar; downstream stores
78+
(`buyer_registry.py`, `platform.py`) read it without explicit
79+
plumbing.
80+
81+
### Commercial-identity gate (`src/buyer_registry.py`)
82+
83+
`TenantScopedBuyerAgentRegistry` extends the framework's
84+
`PgBuyerAgentRegistry` pattern with tenant scoping — the same
85+
`agent_url` can have different commercial postures across tenants.
86+
Implements both `resolve_by_agent_url` (signed traffic) and
87+
`resolve_by_credential` (bearer / OAuth).
88+
89+
### Audit trail (`src/audit.py`)
90+
91+
`DbAuditSink` writes one `audit_events` row per skill dispatch.
92+
Failures are swallowed by the framework's audit middleware (the
93+
sink is fire-and-forget by Protocol contract). Adopters with Slack
94+
alerting compose with `adcp.audit_sink.SlackAlertSink` via
95+
`CompositeAuditSink`.
96+
97+
### Platform (`src/platform.py`)
98+
99+
`V3ReferenceSeller` implements `sales-non-guaranteed` — the five
100+
required Sales methods (`get_products`, `create_media_buy`,
101+
`update_media_buy`, `sync_creatives`, `get_media_buy_delivery`).
102+
Every method body reads `ctx.buyer_agent` (the resolved Tier 2
103+
record) and `ctx.account` (the resolved account); both are
104+
populated by the framework's dispatch gate before the method runs.
105+
106+
This file is the bulk of what an adopter customizes. Everything
107+
else is boilerplate the seller wires once.
108+
109+
## Auth modes
110+
111+
The seller supports both v3 signed-request and pre-trust beta
112+
bearer auth simultaneously — the `BuyerAgentRegistry` dispatches
113+
on credential kind. Adopter middleware constructs `AuthInfo` two
114+
ways:
115+
116+
```python
117+
# Signed (v3) — produces typed HttpSigCredential
118+
auth = AuthInfo.from_verified_signer(
119+
signer, # from adcp.signing.verify_request_signature
120+
max_verified_age_s=300.0, # reject stale signers
121+
)
122+
123+
# Bearer (pre-trust beta) — typed ApiKeyCredential
124+
auth = AuthInfo(
125+
kind="bearer",
126+
credential=ApiKeyCredential(kind="api_key", key_id=token_id),
127+
)
128+
```
129+
130+
Both put the typed credential into `ctx.metadata['adcp.auth_info']`
131+
where the framework picks it up.
132+
133+
## What's NOT wired (yet)
134+
135+
These ship as separable follow-ups — the framework's components
136+
exist; the reference seller wires the simpler defaults:
137+
138+
- **HTTP-Sig verifier middleware** — adopters add
139+
`verify_request_signature` in their `context_factory` once
140+
AAO publishes the brand.json registry. The Tier 1 SDK primitives
141+
ship in `adcp.signing`; this seller uses bearer auth in the seed.
142+
- **Brand authorization (Tier 3)** — gated on ADCP spec issue
143+
#3690.
144+
- **Postgres `TaskRegistry` / `WebhookDeliverySupervisor`**
145+
swap `InMemoryTaskRegistry``PgTaskRegistry` and
146+
`InMemoryWebhookDeliverySupervisor``PgWebhookDeliverySupervisor`
147+
in `src/app.py` for production durability. Both classes ship in
148+
the SDK; this seller's `app.py` uses the in-memory variants for
149+
fast iteration.
150+
- **Alembic migrations**`Base.metadata.create_all` runs at boot
151+
(idempotent on table existence — it does NOT detect column
152+
renames or type changes on existing tables). Adopters who
153+
prototyped against earlier branches and pulled new column
154+
changes should drop and recreate the dev database; production
155+
sellers wire Alembic and version their schema changes.
156+
- **Admin CRUD API** — separate Starlette app for tenant / agent
157+
CRUD. Patterns to come; for now use `seed.py` and direct SQL.
158+
159+
## Customization
160+
161+
Adopters typically change:
162+
163+
1. **`src/platform.py`** — the platform method bodies. Replace the
164+
stub product catalog, add your CMS query for `get_products`,
165+
route `create_media_buy` into your real DSP / ad-server, etc.
166+
2. **`src/audit.py`** — extend `details` with adopter-specific
167+
fields (decision flags, fraud scores, A/B variant ids).
168+
3. **Auth wiring in `src/app.py`** — wire your verifier middleware
169+
that constructs `AuthInfo`.
170+
171+
Adopters typically *don't* change:
172+
173+
- Models — the v3 schema is the contract.
174+
- Tenant router logic — the Protocol shape is fixed.
175+
- Audit middleware composition — the framework wires it.
176+
- The unified MCP+A2A binary — `transport="both"` is one knob.
177+
178+
## Spec versioning
179+
180+
This seller is **3.0-compliant on the wire** — every field it sends
181+
matches the AdCP 3.0 schemas. The schema and architecture is
182+
**3.1-ready** (`billing_entity` + `reporting_bucket` columns,
183+
typed `BillingMode`, write-only bank-details projection). Sellers
184+
running this code today serve 3.0 buyers; the same code serves
185+
3.1 buyers when the spec lands.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Dev-only Postgres for the v3 reference seller. Production sellers
2+
# point ``DATABASE_URL`` at their managed Postgres (RDS, Cloud SQL,
3+
# Neon, Supabase) and skip this file entirely.
4+
5+
services:
6+
postgres:
7+
image: postgres:16
8+
environment:
9+
POSTGRES_DB: adcp
10+
# ``trust`` is fine for the loopback dev compose; production
11+
# deployments use scram-sha-256 + a real password sourced from
12+
# a secrets backend (Vault, AWS Secrets Manager, sealed-secrets).
13+
# No POSTGRES_PASSWORD here on purpose — the ``trust`` auth
14+
# method makes it unused, and shipping a literal in version
15+
# control flags secret-scanners.
16+
POSTGRES_HOST_AUTH_METHOD: trust
17+
ports:
18+
- "5432:5432"
19+
healthcheck:
20+
test: ["CMD-SHELL", "pg_isready -U postgres -d adcp"]
21+
interval: 5s
22+
timeout: 3s
23+
retries: 10
24+
volumes:
25+
- pg_data:/var/lib/postgresql/data
26+
27+
volumes:
28+
pg_data:
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"""Dev fixtures — seed two tenants + buyer agents for local
2+
end-to-end testing.
3+
4+
::
5+
6+
docker compose up -d postgres
7+
DATABASE_URL=postgresql+asyncpg://postgres@localhost/adcp \\
8+
python -m examples.v3_reference_seller.seed
9+
10+
After seeding, hit:
11+
12+
* ``http://acme.localhost:3001/.well-known/agent.json`` (A2A)
13+
* ``http://acme.localhost:3001/mcp`` (MCP)
14+
15+
with a buyer agent's signed-request or bearer credential matching
16+
the seeded ``api_key_id``.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import asyncio
22+
import os
23+
24+
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
25+
from src.models import Account, Base, BuyerAgent, Tenant
26+
27+
28+
async def main() -> None:
29+
db_url = os.environ.get(
30+
"DATABASE_URL",
31+
"postgresql+asyncpg://postgres@localhost/adcp",
32+
)
33+
engine = create_async_engine(db_url)
34+
async with engine.begin() as conn:
35+
await conn.run_sync(Base.metadata.create_all)
36+
sm = async_sessionmaker(engine, expire_on_commit=False)
37+
38+
# Insert in FK-dependency order with explicit flushes so the
39+
# accounts → buyer_agents → tenants chain commits correctly.
40+
async with sm() as session:
41+
async with session.begin():
42+
session.add_all(
43+
[
44+
Tenant(id="t_acme", host="acme.localhost", display_name="Acme Publishing"),
45+
Tenant(id="t_beta", host="beta.localhost", display_name="Beta Network"),
46+
]
47+
)
48+
await session.flush()
49+
session.add_all(
50+
[
51+
BuyerAgent(
52+
id="ba_acme_signed",
53+
tenant_id="t_acme",
54+
agent_url="https://signed-buyer.example/",
55+
display_name="Signed Buyer",
56+
status="active",
57+
billing_capabilities=["operator", "agent"],
58+
api_key_id=None,
59+
),
60+
BuyerAgent(
61+
id="ba_acme_bearer",
62+
tenant_id="t_acme",
63+
agent_url="https://bearer-buyer.example/",
64+
display_name="Bearer Buyer",
65+
status="active",
66+
billing_capabilities=["operator"],
67+
api_key_id="dev-bearer-token-acme-1",
68+
),
69+
BuyerAgent(
70+
id="ba_beta_suspended",
71+
tenant_id="t_beta",
72+
agent_url="https://suspended.example/",
73+
display_name="Suspended Buyer",
74+
status="suspended",
75+
billing_capabilities=["operator"],
76+
),
77+
]
78+
)
79+
await session.flush()
80+
session.add_all(
81+
[
82+
Account(
83+
id="a_acme_1",
84+
tenant_id="t_acme",
85+
buyer_agent_id="ba_acme_signed",
86+
account_id="signed-buyer-main",
87+
name="Signed Buyer — Main",
88+
status="active",
89+
billing="operator",
90+
),
91+
Account(
92+
id="a_acme_2",
93+
tenant_id="t_acme",
94+
buyer_agent_id="ba_acme_bearer",
95+
account_id="bearer-buyer-main",
96+
name="Bearer Buyer — Main",
97+
status="active",
98+
billing="operator",
99+
),
100+
]
101+
)
102+
103+
print("Seeded: 2 tenants, 3 buyer agents, 2 accounts.")
104+
print("Hit: http://acme.localhost:3001/.well-known/agent.json")
105+
print("Hit: http://acme.localhost:3001/mcp")
106+
107+
108+
if __name__ == "__main__":
109+
asyncio.run(main())
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""v3 reference seller — Spec 3.0-compliant on the wire, 3.1-ready in arch."""

0 commit comments

Comments
 (0)