From 20258e317b38eab26aecdfac3ecae2309e5b4742 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 21:37:42 +0000 Subject: [PATCH 1/2] feat(handlers): expose sync_catalogs through sales-catalog-driven specialism Wires the sync_catalogs tool (available in types since 5.6.0) into the DecisioningPlatform advertisement and dispatch path for sales-catalog-driven platforms. Previously the tool was unreachable: SPECIALISM_TO_ADVERTISED_TOOLS had no mapping for it and PlatformHandler had no shim. Closes #786 https://claude.ai/code/session_01RHG8jmrXLEyLjwaLTZHSi5 --- src/adcp/decisioning/handler.py | 67 ++++- src/adcp/decisioning/specialisms/sales.py | 43 ++- ...t_decisioning_advertised_per_specialism.py | 60 +++++ tests/test_decisioning_dispatch.py | 34 +++ tests/test_decisioning_handler_shims.py | 250 ++++++++++++++++++ tests/test_decisioning_specialisms.py | 8 + 6 files changed, 459 insertions(+), 3 deletions(-) diff --git a/src/adcp/decisioning/handler.py b/src/adcp/decisioning/handler.py index 0dc431e59..ac482c443 100644 --- a/src/adcp/decisioning/handler.py +++ b/src/adcp/decisioning/handler.py @@ -165,6 +165,8 @@ SyncAccountsResponse, SyncAudiencesRequest, SyncAudiencesSuccessResponse, + SyncCatalogsRequest, + SyncCatalogsSuccessResponse, SyncCreativesRequest, SyncCreativesSuccessResponse, SyncPlansRequest, @@ -270,6 +272,11 @@ "sync_audiences", } ) +_CATALOG_ADVERTISED_TOOLS: frozenset[str] = frozenset( + { + "sync_catalogs", + } +) _GOVERNANCE_ADVERTISED_TOOLS: frozenset[str] = frozenset( { "check_governance", @@ -338,6 +345,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", } ) @@ -363,7 +372,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 @@ -653,6 +664,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. @@ -851,6 +879,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) @@ -2105,6 +2134,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] diff --git a/src/adcp/decisioning/specialisms/sales.py b/src/adcp/decisioning/specialisms/sales.py index 24a1a0395..65ee85ed9 100644 --- a/src/adcp/decisioning/specialisms/sales.py +++ b/src/adcp/decisioning/specialisms/sales.py @@ -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 @@ -64,6 +68,8 @@ ListCreativesResponse, ProvidePerformanceFeedbackRequest, ProvidePerformanceFeedbackResponse, + SyncCatalogsRequest, + SyncCatalogsSuccessResponse, SyncCreativesRequest, SyncCreativesSuccessResponse, UpdateMediaBuyRequest, @@ -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`. + """ + ... diff --git a/tests/test_decisioning_advertised_per_specialism.py b/tests/test_decisioning_advertised_per_specialism.py index 2d62355c6..1b211a052 100644 --- a/tests/test_decisioning_advertised_per_specialism.py +++ b/tests/test_decisioning_advertised_per_specialism.py @@ -109,6 +109,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 @@ -303,3 +326,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" + ) diff --git a/tests/test_decisioning_dispatch.py b/tests/test_decisioning_dispatch.py index 24023701c..558419877 100644 --- a/tests/test_decisioning_dispatch.py +++ b/tests/test_decisioning_dispatch.py @@ -127,6 +127,40 @@ def update_media_buy(self, media_buy_id, patch, ctx): assert "get_media_buy_delivery" in missing_methods +def test_validate_platform_raises_on_catalog_driven_without_sync_catalogs() -> None: + """Platform claims sales-catalog-driven but doesn't implement + ``sync_catalogs`` — hard-fails at server boot (D12 boot-fail rule). + Docstring on SalesPlatform.sync_catalogs promises this; this test + pins the behavior so a future refactor can't silently break it.""" + + class _CatalogDrivenNoMethod(DecisioningPlatform): + capabilities = DecisioningCapabilities(specialisms=["sales-catalog-driven"]) + accounts = SingletonAccounts(account_id="hello") + + def get_products(self, req, ctx): + return {"products": []} + + def create_media_buy(self, req, ctx): + return {"media_buy_id": "mb_1"} + + 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": []} + + # Deliberately no sync_catalogs. + + with pytest.raises(AdcpError) as exc_info: + validate_platform(_CatalogDrivenNoMethod()) + assert exc_info.value.code == "INVALID_REQUEST" + missing_methods = {m["method"] for m in exc_info.value.details["missing"]} + assert "sync_catalogs" in missing_methods + + def test_validate_platform_warns_on_novel_specialism() -> None: """Truly novel specialism (no close spelling match to any known slug) emits UserWarning, NOT a raise. Forward-compat with v6.x+ diff --git a/tests/test_decisioning_handler_shims.py b/tests/test_decisioning_handler_shims.py index 883381856..ade726c48 100644 --- a/tests/test_decisioning_handler_shims.py +++ b/tests/test_decisioning_handler_shims.py @@ -40,6 +40,7 @@ PlatformHandler, _project_build_creative, _project_sync_audiences, + _project_sync_catalogs, ) from adcp.decisioning.webhook_emit import _BACKGROUND_WEBHOOK_TASKS from adcp.server.base import ToolContext @@ -83,6 +84,8 @@ def test_advertised_tools_covers_every_specialism_wire_tool() -> None: "activate_signal", # Audience "sync_audiences", + # Catalog (sales-catalog-driven only) + "sync_catalogs", # Governance "check_governance", "sync_plans", @@ -127,6 +130,7 @@ def test_advertised_tools_covers_every_specialism_wire_tool() -> None: "get_signals", "activate_signal", "sync_audiences", + "sync_catalogs", "check_governance", "sync_plans", "report_plan_outcome", @@ -601,6 +605,252 @@ def test_project_sync_audiences_passthrough_dict_rows() -> None: assert projected == {"audiences": [{"audience_id": "a1"}]} +# ---- sync_catalogs shim tests ---- + + +def _catalog_driven_platform_class(): + """Return a minimal sales-catalog-driven DecisioningPlatform class.""" + + class _CatalogAgent(DecisioningPlatform): + capabilities = DecisioningCapabilities(specialisms=["sales-catalog-driven"]) + accounts = SingletonAccounts(account_id="hello") + + 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": []} + + return _CatalogAgent + + +@pytest.mark.asyncio +async def test_sync_catalogs_shim_passes_full_request_to_platform(executor) -> None: + """The ``sync_catalogs`` shim passes the full ``SyncCatalogsRequest`` + to the platform method (no arg projection). Adopters receive the full + request so they can inspect ``req.catalogs``, ``req.delete_missing``, + ``req.dry_run``, and ``req.validation_mode``.""" + received_req = [] + + class _CatalogAgent(DecisioningPlatform): + capabilities = DecisioningCapabilities(specialisms=["sales-catalog-driven"]) + accounts = SingletonAccounts(account_id="hello") + + 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): + received_req.append(req) + return {"catalogs": []} + + handler = PlatformHandler( + _CatalogAgent(), + executor=executor, + registry=InMemoryTaskRegistry(), + ) + from adcp.types import SyncCatalogsRequest + + fake_catalogs = [{"type": "product", "catalog_id": "feed-1"}] + req = SyncCatalogsRequest.model_construct(catalogs=fake_catalogs) + await handler.sync_catalogs(req, ToolContext()) + assert len(received_req) == 1 + assert received_req[0] is req + + +@pytest.mark.asyncio +async def test_sync_catalogs_discovery_mode_passes_none_catalogs(executor) -> None: + """Discovery mode (``req.catalogs is None``) passes the request + through intact — the platform receives ``req`` with ``catalogs=None`` + and can distinguish discovery from an explicit empty push.""" + received_catalogs_value = [] + + class _CatalogAgent(DecisioningPlatform): + capabilities = DecisioningCapabilities(specialisms=["sales-catalog-driven"]) + accounts = SingletonAccounts(account_id="hello") + + 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): + received_catalogs_value.append(req.catalogs) + return {"catalogs": []} + + handler = PlatformHandler( + _CatalogAgent(), + executor=executor, + registry=InMemoryTaskRegistry(), + ) + from adcp.types import SyncCatalogsRequest + + req = SyncCatalogsRequest.model_construct(catalogs=None) + await handler.sync_catalogs(req, ToolContext()) + assert received_catalogs_value == [None] + + +@pytest.mark.asyncio +async def test_sync_catalogs_list_return_projected_to_envelope(executor) -> None: + """When the platform returns a list of catalog results (ergonomic arm), + the shim wraps it in ``{catalogs: [...]}``.""" + + class _CatalogAgent(DecisioningPlatform): + capabilities = DecisioningCapabilities(specialisms=["sales-catalog-driven"]) + accounts = SingletonAccounts(account_id="hello") + + 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 [{"catalog_id": "feed-1", "action": "created", "item_count": 100}] + + handler = PlatformHandler( + _CatalogAgent(), + executor=executor, + registry=InMemoryTaskRegistry(), + ) + from adcp.types import SyncCatalogsRequest + + req = SyncCatalogsRequest.model_construct( + catalogs=[{"type": "product", "catalog_id": "feed-1"}] + ) + result = await handler.sync_catalogs(req, ToolContext()) + assert result == { + "catalogs": [{"catalog_id": "feed-1", "action": "created", "item_count": 100}] + } + + +# ---- _project_sync_catalogs arms ---- + + +def test_project_sync_catalogs_wraps_pydantic_row_list() -> None: + """A list of Pydantic-like catalog-result rows wraps into ``{catalogs: [...]}``.""" + + class _Row: + def __init__(self, cid: str) -> None: + self.cid = cid + + def model_dump(self, mode: str = "json") -> dict: + return {"catalog_id": self.cid, "action": "created"} + + projected = _project_sync_catalogs([_Row("c1"), _Row("c2")]) + assert projected == { + "catalogs": [ + {"catalog_id": "c1", "action": "created"}, + {"catalog_id": "c2", "action": "created"}, + ] + } + + +def test_project_sync_catalogs_wraps_plain_dict_row_list() -> None: + """List of plain dicts (no model_dump) — the row passthrough + inside the comprehension is exercised.""" + projected = _project_sync_catalogs([{"catalog_id": "c1", "action": "updated"}]) + assert projected == {"catalogs": [{"catalog_id": "c1", "action": "updated"}]} + + +def test_project_sync_catalogs_passthrough_envelope_dict() -> None: + """Already-shaped envelope is unchanged.""" + envelope = {"catalogs": [{"catalog_id": "c1"}]} + assert _project_sync_catalogs(envelope) is envelope + + +def test_project_sync_catalogs_passthrough_non_list() -> None: + """Non-list, non-Pydantic shape is returned unchanged so the wire validator + surfaces a precise mis-shape error.""" + sentinel = "unexpected_string" + assert _project_sync_catalogs(sentinel) == sentinel + + +# ---- sync_catalogs UNSUPPORTED_FEATURE gate ---- + + +@pytest.mark.asyncio +async def test_sync_catalogs_unsupported_when_platform_lacks_method(executor) -> None: + """A sales-non-guaranteed platform that doesn't implement ``sync_catalogs`` + surfaces ``UNSUPPORTED_FEATURE`` rather than ``INTERNAL_ERROR`` from the + AttributeError wrapper in ``_invoke_platform_method``.""" + + class _NoSyncCatalogs(DecisioningPlatform): + capabilities = DecisioningCapabilities(specialisms=["sales-non-guaranteed"]) + accounts = SingletonAccounts(account_id="hello") + + def get_products(self, req, ctx): + return {"products": []} + + def create_media_buy(self, req, ctx): + return {"media_buy_id": "mb_1"} + + 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": []} + + # Deliberately no sync_catalogs. + + handler = PlatformHandler( + _NoSyncCatalogs(), + executor=executor, + registry=InMemoryTaskRegistry(), + ) + from adcp.types import SyncCatalogsRequest + + with pytest.raises(AdcpError) as exc_info: + await handler.sync_catalogs(SyncCatalogsRequest.model_construct(), ToolContext()) + assert exc_info.value.code == "UNSUPPORTED_FEATURE" + assert "sync_catalogs" in str(exc_info.value) + + # ---- build_creative gate when platform doesn't implement ---- diff --git a/tests/test_decisioning_specialisms.py b/tests/test_decisioning_specialisms.py index 094bb6c6b..e5208ba6c 100644 --- a/tests/test_decisioning_specialisms.py +++ b/tests/test_decisioning_specialisms.py @@ -311,6 +311,11 @@ def get_media_buy_delivery(self, req, ctx): # against a minimal-but-complete impl rather than checking # ``_is_protocol`` (a private CPython typing internal — brittle # against typing-module changes). + # ``sync_catalogs`` is required on the Protocol body (gated to + # ``sales-catalog-driven`` at runtime by validate_platform, but + # the @runtime_checkable isinstance path requires all declared + # methods — a known and documented structural consequence of + # adding it to the Protocol rather than a separate mixin). class _SalesShim: def get_products(self, req, ctx): return {"products": []} @@ -339,6 +344,9 @@ def list_creative_formats(self, req, ctx): def list_creatives(self, req, ctx): return {} + def sync_catalogs(self, req, ctx): + return {} + assert isinstance(_SalesShim(), SalesPlatform) From 293412276ba600ef59efbc77fc1548fccf62468a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 21:41:31 +0000 Subject: [PATCH 2/2] refactor(handlers): remove sync_catalogs from _OPTIONAL_PLATFORM_METHODS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sync_catalogs is required for sales-catalog-driven (enforced at boot by validate_platform) rather than optional. Listing it in _OPTIONAL_PLATFORM_METHODS was semantically misleading and could confuse a future reviewer or pass adding it to a future validation gate that suppresses the boot-fail. The _require_platform_method("sync_catalogs") guard in the shim correctly surfaces UNSUPPORTED_FEATURE for non-catalog-driven callers via hasattr — it does not depend on _OPTIONAL_PLATFORM_METHODS. https://claude.ai/code/session_01RHG8jmrXLEyLjwaLTZHSi5 --- src/adcp/decisioning/handler.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/adcp/decisioning/handler.py b/src/adcp/decisioning/handler.py index ac482c443..667822194 100644 --- a/src/adcp/decisioning/handler.py +++ b/src/adcp/decisioning/handler.py @@ -345,8 +345,6 @@ # 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", } )