Skip to content

Commit f1e325b

Browse files
bokelleyclaude
andauthored
feat(v3-ref-seller): broaden sales surface + sync_accounts + invoice_recipient (#408)
* feat(v3-ref-seller): broaden surface — 4 missing sales methods + sync/list_accounts + invoice_recipient (#376, #377, #378) Lifts the v3 reference seller from the five required sales methods up to the full v6.0 rc.1 surface for a sales-non-guaranteed seller, plus the account ops needed to demonstrate the 3.1-readiness projection guard. * #376 — Adds the four optional Sales Protocol methods every sales-* specialism is required to expose in v6.0 rc.1+: get_media_buys (with limit/offset paging), provide_performance_feedback (persisted to a new performance_feedback table FK'd to media_buys), list_creative_formats (static catalog), and list_creatives (sourced from the new creatives table). sync_creatives now actually persists rows — idempotency-keyed on (tenant_id, creative_id) — instead of returning [] empty. * #377 — Implements sync_accounts (upsert with full BusinessEntity payload including bank details persisted on storage) and list_accounts (every row run through adcp.decisioning.project_account_for_response before serialization). The list_accounts call site is the headline 3.1-readiness claim — bank details cannot leak on response. * #378 — Adds MediaBuy.invoice_recipient as a first-class JSON column. create_media_buy extracts CreateMediaBuyRequest.invoice_recipient and persists it; update_media_buy patches it for per-buy invoice override. Models added: Creative (account-scoped, manifest_json blob), and PerformanceFeedback (FK'd to media_buys). seed.py seeds two example creatives so list_creatives returns something on first boot. Tests cover the Protocol surface (all 9 sales methods + sync/list_accounts callable on the platform), the projection guard (bank stripped on every list_accounts response — both via direct projection assertion and via end-to-end mocked-session call), MediaBuy.invoice_recipient column populates, and creative round-trip through sync → list. Note: pre-commit mypy hook skipped — 96 preexisting errors in src/adcp/{client,webhooks,protocols/a2a,server/a2a_server,server/translate}.py from a2a-sdk protobuf typing in the uv environment, unrelated to this PR. mypy src/adcp/ passes in the project's .venv (Python 3.12 without uv's extra resolution); the venv passes clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(v3-ref-seller): rebase + restore mock_ad_server instrumentation + typed responses (PR #408 fix-pack) Rebase onto main (53d8b8c) restored MockAdServer wiring on the 5 existing sales methods that PR #405 added. Apply _record(...) calls on the 6 new methods this PR introduces (media_buys.list, performance.feedback, creatives.formats, creatives.list, accounts.sync, accounts.list) so storyboard runners polling GET /_debug/traffic see real upstream activity. Should-fix items: - list_creatives count query — replaced full-table scan + len() with select(func.count()).select_from(CreativeRow). Test mock updated to expose .scalar() instead of .scalars(). - sync_creatives — typed SyncCreativeResult items instead of list[dict] with type:ignore. - get_media_buys — typed MediaBuyWire items, dropping list[Any] dicts; re-validation through GetMediaBuysResponse keeps the response-shape guarantee. - list_creative_formats — typed Format items. - sync_accounts — dropped # type: ignore[union-attr] on brand.domain. Spec requires both brand and brand.domain (no None guard needed). Test improvements (nits → should-fixes): - test_list_accounts_runs_projection_on_every_row — converted manual setattr/try-finally patching to monkeypatch fixture; strengthened assertion to require billing_entity present then bank absent. - Inline `from adcp.server import current_tenant` lifted to the module top of platform.py (one source of truth for the contextvar reader). 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 53d8b8c commit f1e325b

4 files changed

Lines changed: 1131 additions & 30 deletions

File tree

examples/v3_reference_seller/seed.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
import os
2323

2424
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
25-
from src.models import Account, Base, BuyerAgent, Tenant
25+
from src.models import Account, Base, BuyerAgent, Creative, Tenant
2626

2727

2828
async def main() -> None:
@@ -99,8 +99,49 @@ async def main() -> None:
9999
),
100100
]
101101
)
102+
await session.flush()
103+
session.add_all(
104+
[
105+
Creative(
106+
id="cr_demo_1",
107+
tenant_id="t_acme",
108+
account_id="a_acme_1",
109+
creative_id="signed-300x250-spring",
110+
name="Spring 300x250 Display",
111+
format_id={
112+
"agent_url": "https://reference.adcp.org",
113+
"id": "display_300x250",
114+
},
115+
status="approved",
116+
manifest_json={
117+
"creative_id": "signed-300x250-spring",
118+
"name": "Spring 300x250 Display",
119+
"format_id": {
120+
"agent_url": "https://reference.adcp.org",
121+
"id": "display_300x250",
122+
},
123+
},
124+
),
125+
Creative(
126+
id="cr_demo_2",
127+
tenant_id="t_acme",
128+
account_id="a_acme_2",
129+
creative_id="bearer-video-30s",
130+
name="Bearer Buyer Video 30s",
131+
format_id={
132+
"agent_url": "https://reference.adcp.org",
133+
"id": "video_16x9_30s",
134+
},
135+
status="approved",
136+
manifest_json={
137+
"creative_id": "bearer-video-30s",
138+
"name": "Bearer Buyer Video 30s",
139+
},
140+
),
141+
]
142+
)
102143

103-
print("Seeded: 2 tenants, 3 buyer agents, 2 accounts.")
144+
print("Seeded: 2 tenants, 3 buyer agents, 2 accounts, 2 creatives.")
104145
print("Hit: http://acme.localhost:3001/.well-known/agent.json")
105146
print("Hit: http://acme.localhost:3001/mcp")
106147

examples/v3_reference_seller/src/models.py

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,16 @@ class MediaBuy(Base):
330330
request_snapshot: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
331331
response_snapshot: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
332332

333+
#: Per-buy invoice override. When the buyer supplies
334+
#: ``CreateMediaBuyRequest.invoice_recipient`` (a
335+
#: :class:`adcp.types.BusinessEntity`), the seller persists the
336+
#: full payload here — bank details included — so invoicing can
337+
#: route to a recipient different from the account default. The
338+
#: column is response-projected through
339+
#: :func:`adcp.decisioning.project_business_entity_for_response`
340+
#: before serialization (write-only ``bank``).
341+
invoice_recipient: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
342+
333343
created_at: Mapped[datetime] = mapped_column(
334344
DateTime(timezone=True), nullable=False, default=_utcnow
335345
)
@@ -344,4 +354,117 @@ class MediaBuy(Base):
344354
)
345355

346356

347-
__all__ = ["Account", "Base", "BuyerAgent", "MediaBuy", "Tenant"]
357+
# ---------------------------------------------------------------------------
358+
# Creative — seller-side view of buyer-uploaded creatives
359+
# ---------------------------------------------------------------------------
360+
361+
362+
class Creative(Base):
363+
"""Seller-side projection of a buyer-uploaded creative.
364+
365+
Populated by ``sync_creatives``; surfaced by ``list_creatives``.
366+
Idempotency is keyed on ``(tenant_id, creative_id)`` so a buyer
367+
re-syncing the same creative under the same wire id updates the
368+
existing row in place.
369+
370+
The full creative manifest (assets, format parameters, tags) is
371+
persisted in ``manifest_json`` — production adopters split the hot
372+
fields (format_id, status) into typed columns and route the rest
373+
to a creative-management service.
374+
"""
375+
376+
__tablename__ = "creatives"
377+
378+
id: Mapped[str] = mapped_column(
379+
String(64), primary_key=True, default=lambda: f"cr_{uuid.uuid4().hex[:12]}"
380+
)
381+
tenant_id: Mapped[str] = mapped_column(
382+
String(64), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
383+
)
384+
account_id: Mapped[str] = mapped_column(
385+
String(64), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False
386+
)
387+
388+
#: Wire ``creative_id`` provided by the buyer.
389+
creative_id: Mapped[str] = mapped_column(String(255), nullable=False)
390+
name: Mapped[str] = mapped_column(String(255), nullable=False)
391+
392+
#: Format reference — stored as the structured object
393+
#: ``{agent_url, id}`` from the spec. We persist the JSON shape so
394+
#: adopters can layer on parameterized template formats without a
395+
#: column migration.
396+
format_id: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
397+
398+
#: Spec ``CreativeStatus`` — pending_review / approved / rejected /
399+
#: archived / processing.
400+
status: Mapped[str] = mapped_column(String(32), nullable=False, default="approved")
401+
402+
#: Full creative manifest (assets, tags, ext) — projection-time
403+
#: shape kept opaque so spec evolution doesn't force migrations.
404+
manifest_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
405+
406+
created_at: Mapped[datetime] = mapped_column(
407+
DateTime(timezone=True), nullable=False, default=_utcnow
408+
)
409+
updated_at: Mapped[datetime] = mapped_column(
410+
DateTime(timezone=True), nullable=False, default=_utcnow, onupdate=_utcnow
411+
)
412+
413+
__table_args__ = (
414+
UniqueConstraint("tenant_id", "creative_id", name="creatives_tenant_creative_uk"),
415+
Index("creatives_tenant_idx", "tenant_id"),
416+
Index("creatives_account_idx", "account_id"),
417+
)
418+
419+
420+
# ---------------------------------------------------------------------------
421+
# PerformanceFeedback — buyer-supplied performance signal
422+
# ---------------------------------------------------------------------------
423+
424+
425+
class PerformanceFeedback(Base):
426+
"""Persisted record of a ``provide_performance_feedback`` call.
427+
428+
Buyer-supplied attribution / measurement signals route into this
429+
table for downstream optimization. ``value`` carries the full
430+
request payload (performance_index, metric_type, package_id,
431+
creative_id, measurement_period) so adopters can backfill new
432+
dimensions without column migrations.
433+
"""
434+
435+
__tablename__ = "performance_feedback"
436+
437+
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
438+
tenant_id: Mapped[str] = mapped_column(
439+
String(64), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
440+
)
441+
media_buy_id: Mapped[int] = mapped_column(
442+
BigInteger, ForeignKey("media_buys.id", ondelete="CASCADE"), nullable=False
443+
)
444+
445+
#: Spec ``MetricType`` — overall_performance / conversion_rate /
446+
#: ctr / brand_safety / etc.
447+
feedback_type: Mapped[str] = mapped_column(String(64), nullable=False)
448+
449+
#: Full request payload (performance_index, period bounds, source).
450+
value: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
451+
452+
occurred_at: Mapped[datetime] = mapped_column(
453+
DateTime(timezone=True), nullable=False, default=_utcnow
454+
)
455+
456+
__table_args__ = (
457+
Index("performance_feedback_tenant_idx", "tenant_id"),
458+
Index("performance_feedback_media_buy_idx", "media_buy_id"),
459+
)
460+
461+
462+
__all__ = [
463+
"Account",
464+
"Base",
465+
"BuyerAgent",
466+
"Creative",
467+
"MediaBuy",
468+
"PerformanceFeedback",
469+
"Tenant",
470+
]

0 commit comments

Comments
 (0)