Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions MIGRATION_v5_to_v6.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,3 +302,24 @@ Round-trip tests in `tests/test_canonical_formats_roundtrip.py` pin
the projection layer against these fixtures so an upstream-contract
drift (e.g., a dropped `canonical:` annotation, a renamed slot)
surfaces immediately in CI.

### 6.1.0-beta.1

- **`SalesPlatform` Protocol gains `sync_catalogs`.** The method is
required at boot only when claiming `sales-catalog-driven`
(`validate_platform` enforces this). However, because `SalesPlatform`
is `@runtime_checkable`, any code doing
`isinstance(my_platform, SalesPlatform)` will now return `False` on
platforms that don't implement `sync_catalogs` — even those claiming
`sales-non-guaranteed` or other sales-* specialisms.

**Migration:** Add a stub `sync_catalogs` to any platform that uses
`SalesPlatform` for a structural `isinstance` check:

```python
def sync_catalogs(self, req, ctx):
return {"catalogs": []}
```

Platforms actually claiming `sales-catalog-driven` must implement the
full contract (see `examples/hello_seller_catalog.py`).
104 changes: 104 additions & 0 deletions examples/hello_seller_catalog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""Hello-seller-catalog — minimal sales-catalog-driven adopter.

The smallest possible ``sales-catalog-driven`` seller. The specialism
adds ``sync_catalogs`` on top of the standard sales surface, which lets
buyers discover existing catalog state and push catalog updates before
``sync_creatives`` / catalog-referenced media buys.

Run::

uv run python examples/hello_seller_catalog.py
"""

from __future__ import annotations

from typing import Any

from adcp import AdcpError
from adcp.decisioning import (
DecisioningCapabilities,
DecisioningPlatform,
RequestContext,
SingletonAccounts,
serve,
)


class HelloCatalogSeller(DecisioningPlatform):
"""Minimal ``sales-catalog-driven`` adopter.

``sync_catalogs`` is required when claiming this specialism —
``validate_platform`` hard-fails at boot if the method is absent.

Discovery mode: ``req.catalogs is None`` means the buyer wants
existing catalog state without modification. Always check before
applying mutations.

Return a list of catalog-result rows (ergonomic form) or a fully-shaped
``SyncCatalogsSuccessResponse``. The framework wraps the list form to
``{catalogs: [...]}`` on the wire.
"""

capabilities = DecisioningCapabilities(
specialisms=["sales-catalog-driven"],
supported_billing=("agent",),
)
accounts = SingletonAccounts(account_id="hello-catalog")

def get_products(self, req: Any, ctx: RequestContext[Any]) -> dict[str, Any]:
return {"products": []}

def create_media_buy(self, req: Any, ctx: RequestContext[Any]) -> dict[str, Any]:
return {"media_buy_id": "mb_1", "status": "active"}

def update_media_buy(
self, media_buy_id: str, patch: Any, ctx: RequestContext[Any]
) -> dict[str, Any]:
return {"media_buy_id": media_buy_id, "status": "active"}

def sync_creatives(self, req: Any, ctx: RequestContext[Any]) -> dict[str, Any]:
return {"creatives": []}

def get_media_buy_delivery(self, req: Any, ctx: RequestContext[Any]) -> dict[str, Any]:
return {"media_buy_deliveries": []}

def sync_catalogs(self, req: Any, ctx: RequestContext[Any]) -> list[dict[str, Any]]:
"""Sync product catalogs with the platform.

Spec-required guard: ``delete_missing=True`` with ``catalogs=None``
is undefined — reject it rather than silently deleting buyer-managed
catalogs.

Discovery mode (``req.catalogs is None``): return existing catalogs
without any mutation.
"""
if getattr(req, "delete_missing", False) and getattr(req, "catalogs", None) is None:
raise AdcpError("INVALID_REQUEST", field="catalogs")

if getattr(req, "catalogs", None) is None:
# Discovery mode — return existing catalog state, no mutations.
return []

# Push mode — upsert the supplied catalogs.
return [
{
"catalog_id": getattr(c, "catalog_id", str(i)),
"action": "created",
"item_count": 0,
}
for i, c in enumerate(req.catalogs or [])
]


def main() -> None:
"""Boot the seller on http://localhost:3001/mcp.

``auto_emit_completion_webhooks=False`` opts out so this example
boots without a ``webhook_sender``. In production, wire
``webhook_sender=`` for buyer notification.
"""
serve(HelloCatalogSeller(), auto_emit_completion_webhooks=False)


if __name__ == "__main__":
main()
67 changes: 66 additions & 1 deletion src/adcp/decisioning/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,8 @@
SyncAccountsResponse,
SyncAudiencesRequest,
SyncAudiencesSuccessResponse,
SyncCatalogsRequest,
SyncCatalogsSuccessResponse,
SyncCreativesRequest,
SyncCreativesSuccessResponse,
SyncPlansRequest,
Expand Down Expand Up @@ -284,6 +286,11 @@
"sync_audiences",
}
)
_CATALOG_ADVERTISED_TOOLS: frozenset[str] = frozenset(
{
"sync_catalogs",
}
)
_SPONSORED_INTELLIGENCE_ADVERTISED_TOOLS: frozenset[str] = frozenset(
{
"si_get_offering",
Expand Down Expand Up @@ -369,6 +376,8 @@
# AudiencePlatform adopter-internal helper (not wire-served, but
# listed here for symmetry should a future shim wire it)
"poll_audience_statuses",
# Required for sales-catalog-driven; absent on all other sales-* platforms
"sync_catalogs",
}
)

Expand All @@ -394,7 +403,9 @@
"sales-guaranteed": _SALES_ADVERTISED_TOOLS | _ACCOUNT_ADVERTISED_TOOLS,
"sales-broadcast-tv": _SALES_ADVERTISED_TOOLS | _ACCOUNT_ADVERTISED_TOOLS,
"sales-social": _SALES_ADVERTISED_TOOLS | _ACCOUNT_ADVERTISED_TOOLS,
"sales-catalog-driven": _SALES_ADVERTISED_TOOLS | _ACCOUNT_ADVERTISED_TOOLS,
"sales-catalog-driven": (
_SALES_ADVERTISED_TOOLS | _ACCOUNT_ADVERTISED_TOOLS | _CATALOG_ADVERTISED_TOOLS
),
"sales-proposal-mode": _SALES_ADVERTISED_TOOLS | _ACCOUNT_ADVERTISED_TOOLS,
# Creative — Builder + AdServer. Builder claims expose
# build_creative + optional preview_creative; AdServer adds
Expand Down Expand Up @@ -794,6 +805,23 @@ def _project_sync_audiences(result: Any) -> Any:
return result


def _project_sync_catalogs(result: Any) -> Any:
"""Project the adopter's ``sync_catalogs`` return into the wire envelope shape.

Adopters may return a list of catalog-result rows (ergonomic form) or a
fully-shaped :class:`SyncCatalogsSuccessResponse`. The wire envelope per
``schemas/cache/media-buy/sync-catalogs-response.json`` is
``{catalogs: [rows]}``. This helper wraps the list case.
"""
if isinstance(result, list):
return {
"catalogs": [
r.model_dump(mode="json") if hasattr(r, "model_dump") else r for r in result
]
}
return result


def _project_sync_accounts(result: Any) -> Any:
"""Project the adopter's ``upsert`` return into the
``sync_accounts`` wire envelope.
Expand Down Expand Up @@ -1014,6 +1042,7 @@ class PlatformHandler(ADCPHandler[ToolContext]):
| set(_CREATIVE_ADVERTISED_TOOLS)
| set(_SIGNALS_ADVERTISED_TOOLS)
| set(_AUDIENCE_ADVERTISED_TOOLS)
| set(_CATALOG_ADVERTISED_TOOLS)
| set(_GOVERNANCE_ADVERTISED_TOOLS)
| set(_BRAND_RIGHTS_ADVERTISED_TOOLS)
| set(_CONTENT_STANDARDS_ADVERTISED_TOOLS)
Expand Down Expand Up @@ -2376,6 +2405,42 @@ async def sync_audiences( # type: ignore[override]
self._maybe_auto_emit_sync_completion("sync_audiences", params, projected)
return cast("SyncAudiencesSuccessResponse", projected)

async def sync_catalogs( # type: ignore[override]
self,
params: SyncCatalogsRequest,
context: ToolContext | None = None,
) -> SyncCatalogsSuccessResponse:
"""Sync product catalogs with the platform.

The platform method receives the full :class:`SyncCatalogsRequest`
so adopters can inspect ``req.catalogs``, ``req.delete_missing``,
``req.dry_run``, and ``req.validation_mode``. Discovery mode
(``req.catalogs is None``) returns existing synced catalogs without
modification — the platform method must handle ``req.catalogs is None``
as a read-only path.

Two return arms per the per-specialism Protocol: a list of
:class:`SyncCatalogResult` rows (ergonomic form) or a fully-shaped
:class:`SyncCatalogsSuccessResponse`. The shim projects the list arm
to the wire envelope ``{catalogs: [...]}`` so adopters can return
the ergonomic form.
"""
self._require_platform_method("sync_catalogs")
tool_ctx = context or ToolContext()
account = await self._resolve_account(getattr(params, "account", None), tool_ctx)
ctx = self._build_ctx(tool_ctx, account)
result = await _invoke_platform_method(
self._platform,
"sync_catalogs",
params,
ctx,
executor=self._executor,
registry=self._registry,
)
projected = _project_sync_catalogs(result)
self._maybe_auto_emit_sync_completion("sync_catalogs", params, projected)
return cast("SyncCatalogsSuccessResponse", projected)

# ----- CampaignGovernancePlatform -----

async def check_governance( # type: ignore[override]
Expand Down
43 changes: 41 additions & 2 deletions src/adcp/decisioning/specialisms/sales.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,12 @@
* :meth:`provide_performance_feedback`
* :meth:`list_creative_formats`
* :meth:`list_creatives`
* :meth:`sync_catalogs` — required when claiming
``sales-catalog-driven``

Required only when claiming ``sales-catalog-driven``:

* :meth:`sync_catalogs` — catalog sync and discovery. ``validate_platform``
hard-fails at server boot if a ``sales-catalog-driven`` platform doesn't
implement this.

The framework's :func:`validate_platform` walks ``capabilities.specialisms``
and confirms each specialism's required methods exist on the platform
Expand Down Expand Up @@ -64,6 +68,8 @@
ListCreativesResponse,
ProvidePerformanceFeedbackRequest,
ProvidePerformanceFeedbackResponse,
SyncCatalogsRequest,
SyncCatalogsSuccessResponse,
SyncCreativesRequest,
SyncCreativesSuccessResponse,
UpdateMediaBuyRequest,
Expand Down Expand Up @@ -269,3 +275,36 @@ def list_creatives(
Required when claiming any ``sales-*`` specialism in v6.0 rc.1+.
"""
...

# ---- Required when claiming ``sales-catalog-driven`` ----

def sync_catalogs(
self,
req: SyncCatalogsRequest,
ctx: RequestContext[TMeta],
) -> MaybeAsync[SyncCatalogsSuccessResponse]:
"""Sync product catalogs with the platform.

**Required** when claiming ``sales-catalog-driven``.
``validate_platform`` hard-fails at server boot when this method is
absent on a ``sales-catalog-driven`` platform.

Discovery mode: when ``req.catalogs is None``, return the account's
existing synced catalogs without modification (read-only path per
AdCP spec). Check ``req.catalogs`` before applying any mutations::

def sync_catalogs(self, req, ctx):
if req.catalogs is None:
return self._get_existing_catalogs(ctx.account_id)
return self._upsert_catalogs(req.catalogs, ctx)

**Important:** ``req.delete_missing=True`` with ``req.catalogs=None``
is spec-undefined — reject it with
``AdcpError("INVALID_REQUEST", field="catalogs")`` rather than
silently deleting buyer-managed catalogs.

Return a list of :class:`~adcp.types.SyncCatalogResult` rows
(ergonomic form) or a fully-shaped
:class:`~adcp.types.SyncCatalogsSuccessResponse`.
"""
...
60 changes: 60 additions & 0 deletions tests/test_decisioning_advertised_per_specialism.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,29 @@ def build_creative(self, req, ctx):
return {"creative_manifest": {"creative_id": "cr_1"}}


class _CatalogDrivenPlatform(DecisioningPlatform):
capabilities = DecisioningCapabilities(specialisms=["sales-catalog-driven"])
accounts = SingletonAccounts(account_id="catalog-driven")

def get_products(self, req, ctx):
return {"products": []}

def create_media_buy(self, req, ctx):
return {"media_buy_id": "x", "status": "active"}

def update_media_buy(self, media_buy_id, patch, ctx):
return {"media_buy_id": media_buy_id, "status": "active"}

def sync_creatives(self, req, ctx):
return {"creatives": []}

def get_media_buy_delivery(self, req, ctx):
return {"media_buy_deliveries": []}

def sync_catalogs(self, req, ctx):
return {"catalogs": []}


def test_sales_only_does_not_advertise_creative_or_signals_tools(executor) -> None:
"""Regression: sales-only adopter saw acquire_rights, build_creative,
check_governance, etc. in tools/list. After the per-specialism
Expand Down Expand Up @@ -333,3 +356,40 @@ def test_class_level_inspection_preserves_full_universe() -> None:
assert "get_products" in tools
assert "build_creative" in tools
assert "acquire_rights" in tools


def test_catalog_driven_advertises_sync_catalogs(executor) -> None:
"""``sales-catalog-driven`` must expose ``sync_catalogs`` via
``tools/list``. This was the missing wire-up reported in issue #786:
the tool existed and types were defined, but no specialism mapping
surfaced it to buyers."""
handler = PlatformHandler(
_CatalogDrivenPlatform(),
executor=executor,
registry=InMemoryTaskRegistry(),
)
tools = {tool["name"] for tool in get_tools_for_handler(handler)}

assert "sync_catalogs" in tools, (
"sales-catalog-driven platform must advertise sync_catalogs; "
"check SPECIALISM_TO_ADVERTISED_TOOLS['sales-catalog-driven']"
)
# Sales tools also present.
assert "get_products" in tools
assert "create_media_buy" in tools


def test_non_catalog_sales_does_not_advertise_sync_catalogs(executor) -> None:
"""``sync_catalogs`` is specific to ``sales-catalog-driven`` — no
other sales-* specialism should surface it."""
handler = PlatformHandler(
_SalesOnlyPlatform(),
executor=executor,
registry=InMemoryTaskRegistry(),
)
tools = {tool["name"] for tool in get_tools_for_handler(handler)}

assert "sync_catalogs" not in tools, (
"sales-non-guaranteed must not advertise sync_catalogs; "
"sync_catalogs is only for sales-catalog-driven"
)
Loading
Loading