From 3161f1815afd1c7b3b2dcda1266fb517b1a20530 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 3 May 2026 00:44:19 +0000 Subject: [PATCH 1/3] feat(examples): add 4 spec-required sales-* methods to v3 reference seller Closes #376 Implements the four optional-but-required-in-v6.0-rc.1 methods for the sales-non-guaranteed specialism in examples/v3_reference_seller: - get_media_buys: queries existing MediaBuy table (tenant+account scoped) - provide_performance_feedback: persists to new PerformanceFeedback ORM table - list_creative_formats: static stub (empty catalog; extend for production) - list_creatives: stub (defers sync_creatives persistence wiring) Adds PerformanceFeedback SQLAlchemy model with idempotency-key unique constraint (perf_feedback_idem_uk), mirroring MediaBuy replay-safety pattern. Smoke tests updated to cover all nine methods and the new table. https://claude.ai/code/session_01R5JrFxdiswxFhwvEr3SWAd --- examples/v3_reference_seller/src/models.py | 60 +++++++- examples/v3_reference_seller/src/platform.py | 139 +++++++++++++++++- .../v3_reference_seller/tests/test_smoke.py | 69 ++++++++- 3 files changed, 262 insertions(+), 6 deletions(-) diff --git a/examples/v3_reference_seller/src/models.py b/examples/v3_reference_seller/src/models.py index 8854bfad8..038667b50 100644 --- a/examples/v3_reference_seller/src/models.py +++ b/examples/v3_reference_seller/src/models.py @@ -4,7 +4,7 @@ and storage**. Adopters fork this file and extend the columns with their own seller-side audit / contract / billing fields. -Four tables make up the spine: +Five tables make up the spine: * :class:`Tenant` — multi-tenant root. The :class:`adcp.server.SubdomainTenantMiddleware` resolves @@ -20,6 +20,9 @@ delivery target). * :class:`MediaBuy` — terminal artifact of ``create_media_buy``. Idempotency-keyed for replay safety. +* :class:`PerformanceFeedback` — buyer-supplied performance signals + for a media buy. Idempotency-keyed; persisted by + ``provide_performance_feedback``. Admin API and protocol-side audit log live in separate tables (:mod:`audit` ships :class:`AuditEvent`). @@ -344,4 +347,57 @@ class MediaBuy(Base): ) -__all__ = ["Account", "Base", "BuyerAgent", "MediaBuy", "Tenant"] +# --------------------------------------------------------------------------- +# PerformanceFeedback — buyer-supplied performance signals +# --------------------------------------------------------------------------- + + +class PerformanceFeedback(Base): + """Buyer-supplied performance signals for a media buy. + + Persisted by ``provide_performance_feedback``. Idempotency-keyed + per tenant — mirrors the :class:`MediaBuy` replay-safety pattern. + Adopters extend with campaign-level aggregation columns or FK to + their internal attribution tables. + """ + + __tablename__ = "performance_feedback" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + + tenant_id: Mapped[str] = mapped_column( + String(64), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False + ) + account_id: Mapped[str] = mapped_column( + String(64), ForeignKey("accounts.id", ondelete="RESTRICT"), nullable=False + ) + + #: Wire ``media_buy_id`` the feedback is attached to. + media_buy_id: Mapped[str] = mapped_column(String(64), nullable=False) + + #: Buyer's idempotency key — prevents double-counting the same + #: feedback event on retry. + idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False) + + performance_index: Mapped[float | None] = mapped_column(Float, nullable=True) + measurement_period: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) + metric_type: Mapped[str | None] = mapped_column(String(64), nullable=True) + package_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + creative_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + feedback_source: Mapped[str | None] = mapped_column(String(64), nullable=True) + + received_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=_utcnow + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=_utcnow + ) + + __table_args__ = ( + UniqueConstraint("tenant_id", "idempotency_key", name="perf_feedback_idem_uk"), + Index("perf_feedback_tenant_idx", "tenant_id"), + Index("perf_feedback_media_buy_idx", "media_buy_id"), + ) + + +__all__ = ["Account", "Base", "BuyerAgent", "MediaBuy", "PerformanceFeedback", "Tenant"] diff --git a/examples/v3_reference_seller/src/platform.py b/examples/v3_reference_seller/src/platform.py index ed4c31501..1302f7db3 100644 --- a/examples/v3_reference_seller/src/platform.py +++ b/examples/v3_reference_seller/src/platform.py @@ -1,12 +1,16 @@ """DecisioningPlatform impl for the v3 reference seller. -Sales-non-guaranteed specialism with the five required Sales methods: +Sales-non-guaranteed specialism with all nine Sales methods: * :meth:`get_products` — read inventory catalog * :meth:`create_media_buy` — terminal artifact insert; idempotency-keyed * :meth:`update_media_buy` — patch (status / pause / spend cap) * :meth:`sync_creatives` — accept creative manifests * :meth:`get_media_buy_delivery` — read delivery actuals +* :meth:`get_media_buys` — list media buys for the dispatching principal +* :meth:`provide_performance_feedback` — accept buyer-side perf signals +* :meth:`list_creative_formats` — return supported format catalog (stub) +* :meth:`list_creatives` — list buyer-uploaded creatives (stub) All five run against the SQLAlchemy models in :mod:`models`. The platform reads the resolved :class:`adcp.decisioning.BuyerAgent` @@ -43,9 +47,17 @@ CreateMediaBuySuccessResponse, GetMediaBuyDeliveryRequest, GetMediaBuyDeliveryResponse, + GetMediaBuysRequest, + GetMediaBuysResponse, GetProductsRequest, GetProductsResponse, + ListCreativeFormatsRequest, + ListCreativeFormatsResponse, + ListCreativesRequest, + ListCreativesResponse, Product, + ProvidePerformanceFeedbackRequest, + ProvidePerformanceFeedbackSuccessResponse, SyncCreativesRequest, SyncCreativesSuccessResponse, UpdateMediaBuyRequest, @@ -59,6 +71,7 @@ from .models import Account as AccountRow from .models import MediaBuy as MediaBuyRow +from .models import PerformanceFeedback as PerformanceFeedbackRow logger = logging.getLogger(__name__) @@ -311,6 +324,130 @@ async def get_media_buy_delivery( del req, ctx return GetMediaBuyDeliveryResponse(media_buys=[]) + # ----- get_media_buys -------------------------------------------------- + + async def get_media_buys( + self, req: GetMediaBuysRequest, ctx: RequestContext + ) -> GetMediaBuysResponse: + """List media buys for the dispatching principal. + + Queries the existing :class:`MediaBuyRow` table filtered by + tenant + account, with optional ``media_buy_ids`` and + ``status_filter`` narrowing. Rows missing the wire-required + ``currency`` or ``total_budget`` columns (created when the + buyer omitted budget in ``create_media_buy``) are silently + excluded — production adopters should enforce budget at + creation time to avoid silent exclusion here. + """ + if ctx.account is None: + raise AdcpError( + "INTERNAL_ERROR", + message="Dispatch should have populated account.", + recovery="terminal", + ) + async with self._sessionmaker() as session: + stmt = select(MediaBuyRow).where( + MediaBuyRow.tenant_id == ctx.account.metadata["tenant_id"], + MediaBuyRow.account_id == ctx.account.id, + ) + if req.media_buy_ids: + stmt = stmt.where(MediaBuyRow.media_buy_id.in_(req.media_buy_ids)) + if req.status_filter: + stmt = stmt.where(MediaBuyRow.status.in_(req.status_filter)) + result = await session.execute(stmt) + rows = result.scalars().all() + + wire_buys = [ + { + "media_buy_id": row.media_buy_id, + "status": row.status, + "currency": row.currency, + "total_budget": row.total_budget, + "packages": [], + "start_time": row.start_time, + "end_time": row.end_time, + "created_at": row.created_at, + "updated_at": row.updated_at, + } + for row in rows + if row.total_budget is not None and row.currency is not None + ] + return GetMediaBuysResponse(media_buys=wire_buys) + + # ----- provide_performance_feedback ------------------------------------ + + async def provide_performance_feedback( + self, req: ProvidePerformanceFeedbackRequest, ctx: RequestContext + ) -> ProvidePerformanceFeedbackSuccessResponse: + """Persist buyer-side performance signals for a media buy. + + Idempotency-keyed: a retry with the same ``idempotency_key`` + under the same tenant raises an ``IntegrityError`` at the DB + level (``perf_feedback_idem_uk``), which the framework's + idempotency middleware catches and replays the cached response. + """ + if ctx.account is None: + raise AdcpError( + "INTERNAL_ERROR", + message="Dispatch should have populated account.", + recovery="terminal", + ) + row = PerformanceFeedbackRow( + tenant_id=ctx.account.metadata["tenant_id"], + account_id=ctx.account.id, + media_buy_id=req.media_buy_id, + idempotency_key=req.idempotency_key, + performance_index=req.performance_index, + measurement_period=req.measurement_period.model_dump(mode="json"), + metric_type=req.metric_type.value if req.metric_type else None, + package_id=req.package_id, + creative_id=req.creative_id, + feedback_source=req.feedback_source.value if req.feedback_source else None, + ) + async with self._sessionmaker() as session, session.begin(): + session.add(row) + logger.info( + "Persisted performance feedback for media_buy=%s account=%s", + req.media_buy_id, + ctx.account.id, + ) + return ProvidePerformanceFeedbackSuccessResponse(success=True) + + # ----- list_creative_formats ------------------------------------------- + + async def list_creative_formats( + self, req: ListCreativeFormatsRequest, ctx: RequestContext + ) -> ListCreativeFormatsResponse: + """Return the seller's supported creative formats. + + Stub: returns an empty catalog. Production adopters replace + this with a query against a ``CreativeFormat`` table or a + fetch from their creative management platform. When formats + are tenant-configurable, add a ``creative_formats`` table and + filter by ``ctx.account.metadata['tenant_id']``. + """ + del req, ctx + return ListCreativeFormatsResponse(formats=[]) + + # ----- list_creatives -------------------------------------------------- + + async def list_creatives( + self, req: ListCreativesRequest, ctx: RequestContext + ) -> ListCreativesResponse: + """List buyer-uploaded creatives for the dispatching principal. + + Stub: returns an empty list. Full persistence requires wiring + ``sync_creatives`` to a ``Creative`` ORM table and querying it + here. That end-to-end wiring is deferred — this stub satisfies + the v6.0 rc.1 boot-validation requirement. + """ + del req, ctx + return ListCreativesResponse( + query_summary={"total_matching": 0, "returned": 0}, + pagination={"has_more": False}, + creatives=[], + ) + def _project_start_time(value: Any) -> datetime: """Project :class:`StartTiming` (root: ``'asap'`` | :class:`AwareDatetime`) diff --git a/examples/v3_reference_seller/tests/test_smoke.py b/examples/v3_reference_seller/tests/test_smoke.py index 2cd59fa43..026bab77d 100644 --- a/examples/v3_reference_seller/tests/test_smoke.py +++ b/examples/v3_reference_seller/tests/test_smoke.py @@ -19,12 +19,18 @@ def test_models_import_and_declare_tables() -> None: - from src.models import Account, Base, BuyerAgent, MediaBuy, Tenant + from src.models import Account, Base, BuyerAgent, MediaBuy, PerformanceFeedback, Tenant table_names = {t.name for t in Base.metadata.tables.values()} - assert {"tenants", "buyer_agents", "accounts", "media_buys"} <= table_names + assert { + "tenants", + "buyer_agents", + "accounts", + "media_buys", + "performance_feedback", + } <= table_names # Sanity: every model is in the metadata. - for cls in (Tenant, BuyerAgent, Account, MediaBuy): + for cls in (Tenant, BuyerAgent, Account, MediaBuy, PerformanceFeedback): assert cls.__tablename__ in table_names @@ -111,3 +117,60 @@ async def test_buyer_registry_returns_none_without_tenant() -> None: cred = ApiKeyCredential(kind="api_key", key_id="any") assert await registry.resolve_by_agent_url("https://x/") is None assert await registry.resolve_by_credential(cred) is None + + +def test_platform_has_all_nine_sales_methods() -> None: + """V3ReferenceSeller exposes all nine SalesPlatform methods.""" + from src.platform import V3ReferenceSeller + + required = { + "get_products", + "create_media_buy", + "update_media_buy", + "sync_creatives", + "get_media_buy_delivery", + "get_media_buys", + "provide_performance_feedback", + "list_creative_formats", + "list_creatives", + } + missing = required - set(dir(V3ReferenceSeller)) + assert not missing, f"Missing methods: {missing}" + + +@pytest.mark.asyncio +async def test_list_creative_formats_returns_valid_response() -> None: + """list_creative_formats returns a spec-valid empty catalog.""" + from src.platform import V3ReferenceSeller + + from adcp.types import ListCreativeFormatsRequest, ListCreativeFormatsResponse + + platform = V3ReferenceSeller(sessionmaker=lambda: None) # type: ignore[arg-type] + req = ListCreativeFormatsRequest() + resp = await platform.list_creative_formats(req, ctx=None) # type: ignore[arg-type] + assert isinstance(resp, ListCreativeFormatsResponse) + assert resp.formats == [] + + +@pytest.mark.asyncio +async def test_list_creatives_returns_valid_response() -> None: + """list_creatives returns a spec-valid empty result.""" + from src.platform import V3ReferenceSeller + + from adcp.types import ListCreativesRequest, ListCreativesResponse + + platform = V3ReferenceSeller(sessionmaker=lambda: None) # type: ignore[arg-type] + req = ListCreativesRequest() + resp = await platform.list_creatives(req, ctx=None) # type: ignore[arg-type] + assert isinstance(resp, ListCreativesResponse) + assert resp.creatives == [] + assert resp.query_summary.total_matching == 0 + + +def test_performance_feedback_table_has_idempotency_constraint() -> None: + """PerformanceFeedback table declares the idempotency unique constraint.""" + from src.models import Base + + table = Base.metadata.tables["performance_feedback"] + constraint_names = {c.name for c in table.constraints} + assert "perf_feedback_idem_uk" in constraint_names From 8aae1fdfd3488e9f3bd6d77ace844c400464b7b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 3 May 2026 00:48:01 +0000 Subject: [PATCH 2/3] fix(examples): log warning when get_media_buys skips budget-less rows Pre-PR review (dx-expert) flagged that silently dropping media buys missing total_budget/currency made debugging impossible for adopters. Add logger.warning with the media_buy_id so the skip is visible in logs. https://claude.ai/code/session_01R5JrFxdiswxFhwvEr3SWAd --- examples/v3_reference_seller/src/platform.py | 38 ++++++++++++-------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/examples/v3_reference_seller/src/platform.py b/examples/v3_reference_seller/src/platform.py index 1302f7db3..22b56ddde 100644 --- a/examples/v3_reference_seller/src/platform.py +++ b/examples/v3_reference_seller/src/platform.py @@ -357,21 +357,29 @@ async def get_media_buys( result = await session.execute(stmt) rows = result.scalars().all() - wire_buys = [ - { - "media_buy_id": row.media_buy_id, - "status": row.status, - "currency": row.currency, - "total_budget": row.total_budget, - "packages": [], - "start_time": row.start_time, - "end_time": row.end_time, - "created_at": row.created_at, - "updated_at": row.updated_at, - } - for row in rows - if row.total_budget is not None and row.currency is not None - ] + wire_buys = [] + for row in rows: + if row.total_budget is None or row.currency is None: + logger.warning( + "Skipping media_buy_id=%s from get_media_buys: " + "missing total_budget or currency. Enforce budget at " + "create_media_buy time to avoid silent exclusion.", + row.media_buy_id, + ) + continue + wire_buys.append( + { + "media_buy_id": row.media_buy_id, + "status": row.status, + "currency": row.currency, + "total_budget": row.total_budget, + "packages": [], + "start_time": row.start_time, + "end_time": row.end_time, + "created_at": row.created_at, + "updated_at": row.updated_at, + } + ) return GetMediaBuysResponse(media_buys=wire_buys) # ----- provide_performance_feedback ------------------------------------ From 0075ae21cc44311def9984704ac79f341646476d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 3 May 2026 00:49:27 +0000 Subject: [PATCH 3/3] docs(examples): fix stale model count in platform.py docstring PerformanceFeedback is now the sixth model used by the platform; update the "All five" reference to "All six". https://claude.ai/code/session_01R5JrFxdiswxFhwvEr3SWAd --- examples/v3_reference_seller/src/platform.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/v3_reference_seller/src/platform.py b/examples/v3_reference_seller/src/platform.py index 22b56ddde..7ddf73967 100644 --- a/examples/v3_reference_seller/src/platform.py +++ b/examples/v3_reference_seller/src/platform.py @@ -12,7 +12,7 @@ * :meth:`list_creative_formats` — return supported format catalog (stub) * :meth:`list_creatives` — list buyer-uploaded creatives (stub) -All five run against the SQLAlchemy models in :mod:`models`. The +All six run against the SQLAlchemy models in :mod:`models`. The platform reads the resolved :class:`adcp.decisioning.BuyerAgent` from :attr:`RequestContext.buyer_agent` (set by the framework's dispatch gate) and the :class:`adcp.decisioning.Account` from