Module adcp.decisioning.handler
PlatformHandler — wire-shape shims that route to a DecisioningPlatform.
This module is the codegen target — scripts/generate_decisioning_handler.py
will (in a follow-up PR) emit this file by walking the per-specialism
Protocols. For v6.0 alpha foundation, the file is hand-written; the
codegen drift test ships in Stage 4.
Each shim:
- Accepts the typed Pydantic request + framework :class:
ToolContext. - Resolves the account via
platform.accounts.resolve. - Builds the typed :class:
RequestContextvia :func:_build_request_context(D2 + D9 + D15). - Calls :func:
_invoke_platform_methodto invoke the platform method, which projectsTaskHandoffand wraps non-AdcpErrorexceptions to the wire envelope. - Returns whatever the platform method returned — typed Pydantic
response, plain dict matching the wire shape, or the
Submittedenvelope dict from a TaskHandoff projection. Thecast()on each shim is a static-typing hint for callers; it is NOT a runtime validation pass. The framework's transport layer (serve()) handles wire serialization for both Pydantic and dict returns. Adopters relying on Pydantic round-trip validation can opt in viaresponse_validatormiddleware.
The class-level advertised_tools: ClassVar[set[str]] declaration is
auto-registered with the framework's tool-discovery seam via
:meth:adcp.server.base.ADCPHandler.__init_subclass__ (PR #318). Adopters
get a focused tools/list filter without manual registration.
Classes
class PlatformHandler (platform: DecisioningPlatform,
*,
executor: ThreadPoolExecutor,
registry: TaskRegistry,
state_reader: StateReader | None = None,
resource_resolver: ResourceResolver | None = None,
webhook_sender: WebhookSender | None = None,
webhook_supervisor: WebhookDeliverySupervisor | None = None,
auto_emit_completion_webhooks: bool = True,
buyer_agent_registry: BuyerAgentRegistry | None = None,
brand_authorization_gate: BrandAuthorizationGate | None = None,
config_store: ProductConfigStore | None = None,
property_list_fetcher: PropertyListFetcher | None = None,
media_buy_store: MediaBuyStore | None = None,
advertise_all: bool = False)-
Expand source code
class PlatformHandler(ADCPHandler[ToolContext]): """ADCPHandler subclass that routes wire requests to a :class:`DecisioningPlatform` via :func:`_invoke_platform_method`. Constructed by :func:`adcp.decisioning.serve.create_adcp_server_from_platform` — adopters never instantiate directly. The handler holds: * ``platform`` — the adopter's :class:`DecisioningPlatform` subclass instance. Method dispatches read/call this. * ``executor`` — the framework-allocated thread-pool for sync platform methods (D5). * ``registry`` — the :class:`TaskRegistry` for handoff lifecycle. * Optional ``state_reader`` / ``resource_resolver`` — Stage-3+ wiring for v6.1 backing-store impls; defaults to the v6.0 stubs. Per-method shims follow the same template: 1. Extract ``account_ref`` from the typed request (when the tool carries ``account`` on the wire). 2. Resolve via ``platform.accounts.resolve(ref, auth_info=...)``. 3. Build :class:`RequestContext` via :func:`_build_request_context`. 4. Invoke the platform method via :func:`_invoke_platform_method`. Adopters who don't override a given platform method get the framework's ``not_supported`` baseline (per ADCPHandler) on those tools — and the override-detection filter drops the tool from ``tools/list`` unless they pass ``advertise_all=True``. """ #: Class-level union of every tool the shim CAN serve. Used by the #: framework's ``__init_subclass__`` registration so the class shows #: up in :data:`adcp.server.mcp_tools._HANDLER_TOOLS`. The actual #: per-instance advertisement is computed by #: :meth:`advertised_tools_for_instance` from the platform's claimed #: specialisms — without that intersection, a sales-only adopter #: would advertise all 40+ shims (Emma cross-cutting P1). advertised_tools: ClassVar[set[str]] = ( set(_SALES_ADVERTISED_TOOLS) | set(_ACCOUNT_ADVERTISED_TOOLS) | 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) | set(_PROPERTY_LISTS_ADVERTISED_TOOLS) | set(_COLLECTION_LISTS_ADVERTISED_TOOLS) ) _agent_type = "decisioning platform" def advertised_tools_for_instance(self) -> frozenset[str]: """Tools this handler advertises GIVEN ITS PLATFORM'S CLAIMED SPECIALISMS. Without this hook, ``get_tools_for_handler`` walks the class's MRO + ``_is_method_overridden`` filter — both keyed on ``PlatformHandler``, which defines all 40+ shims as concrete methods. Result: a sales-only adopter advertises ``acquire_rights``, ``build_creative``, every signals/audience tool, etc. Buyers see a giant menu of tools that 501 on call; Emma sales/creative/signals backend tests all flagged this as P1 ("advertising 42 of 42 tools"). Per-instance advertisement intersects the universe of shim coverage with what the platform's claimed specialisms are responsible for via :data:`SPECIALISM_TO_ADVERTISED_TOOLS`. Specialisms not in that map (``signed-requests``, ``governance-aware-seller``) are meta-claims and contribute no tools — they compose with a non-meta claim that does. :returns: The intersection of ``advertised_tools`` (universe) with the per-specialism-allowed set. Empty when no recognized specialisms are claimed (e.g., adopter still piloting a novel slug not in the spec enum); transport layer should fall back to the class-level set in that case so the handler isn't accidentally muted. """ claimed = self._platform.capabilities.specialisms serving: set[str] = set() for entry in claimed: # ``specialisms`` is ``list[Specialism | str]`` — spec-known # entries are coerced to enum by ``__post_init__``; novel / # pre-spec slugs pass through as strings. slug = entry.value if hasattr(entry, "value") else entry tools = SPECIALISM_TO_ADVERTISED_TOOLS.get(slug) if tools is not None: serving |= set(tools) # Drop sync_accounts / list_accounts when the platform's # AccountStore doesn't expose the corresponding optional # Protocol method. ``sales-*`` claims union both tools in by # default (account roster is required by spec) but adopters who # haven't wired :class:`AccountStoreUpsertRequest`, # :class:`AccountStoreUpsert`, or :class:`AccountStoreList` # would otherwise advertise tools that always answer # OPERATION_NOT_SUPPORTED. # # Log once per (handler, dropped-tool) when the claim asked for # a tool the store can't serve — actionable signal for adopters # whose storyboard scenarios stay ``skipped (missing_tool)`` # without the polyfill. accounts = getattr(self._platform, "accounts", None) can_sync_accounts = accounts is not None and ( callable(getattr(accounts, "upsert_request", None)) or callable(getattr(accounts, "upsert", None)) ) if "sync_accounts" in serving and not can_sync_accounts: serving.discard("sync_accounts") self._log_account_tool_dropped("sync_accounts", "upsert") if "list_accounts" in serving and ( accounts is None or not callable(getattr(accounts, "list", None)) ): serving.discard("list_accounts") self._log_account_tool_dropped("list_accounts", "list") return frozenset(serving) def _log_account_tool_dropped(self, tool_name: str, method_name: str) -> None: """Log once per dropped account tool, then suppress. Adopter signal: a ``sales-*`` specialism implies an account roster (``sync_accounts`` / ``list_accounts``) but the platform's :class:`AccountStore` doesn't expose the optional Protocol method. Without this log, a downstream storyboard scenario stuck on ``skipped (missing_tool)`` has no breadcrumb pointing at the missing optional Protocol. Dedupe state lives on the handler instance, so deployments wiring a per-tenant handler via :class:`LazyPlatformRouter` emit one log per (tenant, dropped tool) — intentional, since different tenants can be wired with different stores and adopters need the per-tenant signal. """ seen: set[str] = self.__dict__.setdefault("_account_tool_drop_logged", set()) if tool_name in seen: return seen.add(tool_name) logger.info( "PlatformHandler dropped %r from advertised_tools — " "platform.accounts does not implement %r. " "Implement the optional AccountStore%s Protocol method " "(see adcp.decisioning.accounts) to surface this tool on the wire.", tool_name, method_name, "Upsert" if method_name == "upsert" else "List", ) def get_advertised_tools(self, *, advertise_all: bool | None = None) -> frozenset[str]: """Names ``tools/list`` will return when this handler is served. The class-level :attr:`advertised_tools` set is the *universe* of tools the handler base supports across all specialisms (~50 entries on :class:`PlatformHandler`). What buyers actually see on the wire is narrower: 1. Per-instance specialism filter — :meth:`advertised_tools_for_instance` intersects the universe with the platform's claimed specialisms (a sales-only adopter drops audience/governance tools). 2. Override-detection filter — tools whose handler method is still the SDK's ``not_supported`` default are dropped (``advertise_all=False``, the spec-aligned default). This method runs the same pipeline :func:`adcp.server.serve` runs at boot, so adopters can inspect the effective set without standing up a network port. The default ``advertise_all`` value is whatever was configured on :func:`adcp.decisioning.create_adcp_server_from_platform` (``False`` when not set). :param advertise_all: Override the configured value for this call. ``True`` returns the per-specialism set without the override filter; ``False`` applies the full filter. :returns: Frozen set of tool names. """ from adcp.server.mcp_tools import get_tools_for_handler effective = self._advertise_all if advertise_all is None else advertise_all return frozenset( tool["name"] for tool in get_tools_for_handler(self, advertise_all=effective) ) def __init__( self, platform: DecisioningPlatform, *, executor: ThreadPoolExecutor, registry: TaskRegistry, state_reader: StateReader | None = None, resource_resolver: ResourceResolver | None = None, webhook_sender: WebhookSender | None = None, webhook_supervisor: WebhookDeliverySupervisor | None = None, auto_emit_completion_webhooks: bool = True, buyer_agent_registry: BuyerAgentRegistry | None = None, brand_authorization_gate: BrandAuthorizationGate | None = None, config_store: ProductConfigStore | None = None, property_list_fetcher: PropertyListFetcher | None = None, media_buy_store: MediaBuyStore | None = None, advertise_all: bool = False, ) -> None: super().__init__() self._platform = platform self._executor = executor self._registry = registry self._state_reader = state_reader self._resource_resolver = resource_resolver self._webhook_sender = webhook_sender self._webhook_supervisor = webhook_supervisor self._auto_emit_completion_webhooks = auto_emit_completion_webhooks self._buyer_agent_registry = buyer_agent_registry self._brand_authorization_gate = brand_authorization_gate self._config_store = config_store self._property_list_fetcher = property_list_fetcher self._media_buy_store = media_buy_store self._advertise_all = advertise_all # Cache whether the platform's create_media_buy accepts 'configs' # so we only pay the inspect.signature cost at construction time. self._create_media_buy_accepts_configs = _method_accepts_configs( platform, "create_media_buy" ) if config_store is None and self._create_media_buy_accepts_configs: warnings.warn( "create_media_buy declares a 'configs' parameter but no " "ProductConfigStore was wired — the framework will inject " "configs={} (empty dict) on every call. Wire a store via " "config_store= in create_adcp_server_from_platform to enable " "automatic implementation_config lookup.", UserWarning, stacklevel=2, ) # ----- account resolution helper ----- async def _prime_auth_context(self, ctx: ToolContext) -> None: """Resolve the registry buyer-agent (if a registry is wired) and stash it on ``ctx.metadata`` so the :class:`AccountStore` dispatch path's :class:`ResolveContext` can read it without invoking :meth:`AccountStore.resolve`. Used by tools that operate above the per-account scope — ``sync_accounts`` and ``list_accounts``, which the spec uses to bootstrap or enumerate the roster. ``explicit``-mode stores raise ``ACCOUNT_NOT_FOUND`` on a no-ref resolve, which would deadlock the bootstrap path: every account is unknown until ``sync_accounts`` creates it, but ``sync_accounts`` requires an already-resolved account. The suspended / blocked / unrecognized buyer-agent gate still runs (via :func:`_resolve_buyer_agent`) so commercial-identity rejection happens before the adopter's store ever sees the request — same surface as :meth:`_resolve_account`. """ auth_info = self._extract_auth_info(ctx) if self._buyer_agent_registry is not None: buyer_agent = await _resolve_buyer_agent( self._buyer_agent_registry, auth_info, ) ctx.metadata[_BUYER_AGENT_METADATA_KEY] = buyer_agent async def _resolve_account( self, ref: object | None, ctx: ToolContext, ) -> Account[Any]: """Resolve a wire :class:`AccountReference` to a typed :class:`Account` via the platform's :class:`AccountStore`. Pulls auth info from ``ctx.metadata['auth_info']`` when the operator's ``context_factory`` populates it; otherwise None. Adopter ``AccountStore`` impls handle missing-auth cases per their own resolution mode (``'derived'`` tolerates None; ``'implicit'`` raises ``AUTH_INVALID``; ``'explicit'`` resolves by ref). ``AccountStore.resolve`` takes a dict — convert the typed Pydantic ``AccountReference`` via ``model_dump()`` so adopter store impls see a normalized shape. When a :class:`adcp.decisioning.BuyerAgentRegistry` is wired, this method ALSO resolves the commercial buyer-agent identity BEFORE calling ``AccountStore.resolve`` and stashes the result on ``ctx.metadata['adcp.buyer_agent']`` for :meth:`_build_ctx` to read into the typed :class:`RequestContext`. Suspended / blocked / unrecognized agents are rejected here with the dedicated per-agent codes per AdCP 3.1: suspended → ``AGENT_SUSPENDED``, blocked → ``AGENT_BLOCKED`` (both ``recovery="terminal"``, no ``details`` payload); unrecognized → ``PERMISSION_DENIED`` with no ``details.scope`` so the wire shape does not enumerate which ``agent_url``s are onboarded with this seller. This prevents the registry miss from leaking into AccountStore as ``ACCOUNT_NOT_FOUND``. """ await self._prime_auth_context(ctx) auth_info = self._extract_auth_info(ctx) # Handle both Pydantic AccountReference (typical wire path) and # raw dict (test fixtures using model_construct, custom dispatch # paths). Adopter stores implementing custom shapes are # responsible for whatever they accept. ref_dict: dict[str, Any] | None if ref is None: ref_dict = None elif hasattr(ref, "model_dump"): ref_dict = ref.model_dump() elif isinstance(ref, dict): ref_dict = ref else: ref_dict = cast("dict[str, Any]", ref) result = self._platform.accounts.resolve(ref_dict, auth_info=auth_info) if asyncio.iscoroutine(result): resolved = cast("Account[Any]", await result) else: resolved = cast("Account[Any]", result) # Phase 1 sandbox-authority — track explicit mode values for the # comply controller's env-fallback fail-closed guard. Implicit # default-live (resolver didn't populate mode) is intentionally # NOT recorded so pre-migration adopters keep working with # ADCP_SANDBOX=1. from adcp.decisioning.observed_modes import record_resolved_account_mode record_resolved_account_mode(resolved) # Tier 3 brand-authorization gate (issue #350 stage 5). Opt-in # via ``serve(brand_authz_resolver=..., brand_identity_resolver=...)``. # Runs AFTER ``accounts.resolve`` so the gate has the resolved # :class:`Account` available — adopters source the brand identity # from whatever signal their account model carries (the wire # :class:`AccountReference` natural-key shape, ``account.metadata``, # an internal mapping, etc.). The gate is a no-op when no buyer # agent has been resolved (Tier 2 not wired) — see # :func:`_enforce_brand_authorization` for the rationale. if self._brand_authorization_gate is not None: buyer_agent_for_gate = cast( "BuyerAgent | None", ctx.metadata.get(_BUYER_AGENT_METADATA_KEY) if ctx.metadata else None, ) await _enforce_brand_authorization( self._brand_authorization_gate, resolved, buyer_agent_for_gate, ) return resolved @staticmethod def _extract_auth_info(ctx: ToolContext) -> AuthInfo | None: """Pull AuthInfo from ToolContext.metadata when present. The framework's existing auth integrations (BearerTokenAuthMiddleware, custom context_factory) populate ``ctx.metadata`` with principal/scope info. Adopter conventions vary; this helper checks for an ``adcp.auth_info`` key — Stage 3 ``serve()`` wiring sets this from the canonical principal. Returns None when no auth key is present (dev / ``'derived'`` fixtures). """ raw = ctx.metadata.get(_AUTH_INFO_METADATA_KEY) if ctx.metadata else None if isinstance(raw, AuthInfo): return raw if isinstance(raw, dict): # Translate the legacy dict-shape into typed AuthInfo via # the framework-internal classmethod that pre-synthesizes # the bearer credential without firing the # DeprecationWarning. The warning's actionable target is # adopter code constructing AuthInfo directly — pointing # it at this framework shim every request would be noise # the adopter can't fix by changing their code. return AuthInfo._from_legacy_dict(raw) return None def _maybe_auto_emit_sync_completion( self, method_name: str, params: Any, result: Any, ) -> None: """Fire the F12 sync-completion webhook if applicable. Skips TaskHandoff projections — on the async (handoff) arm the terminal completion / failure webhook is delivered from the background completion path in :func:`adcp.decisioning.dispatch._project_handoff` (:func:`adcp.decisioning.webhook_emit.emit_terminal_completion_webhook`), fired exactly once after ``registry.complete`` / ``registry.fail`` when the buyer registered ``push_notification_config``. This gate fires on the sync-success arm only; skipping the submitted projection here is what keeps the two paths from double-delivering. Mirrors the JS-side ``routeIfHandoff`` logic at ``src/lib/server/decisioning/runtime/from-platform.ts``. TaskHandoff projection returns the exact 2-key dict ``{"task_id": ..., "status": "submitted"}`` from ``_project_handoff``; we match the full key set rather than the loose ``status == "submitted"`` predicate so an adopter who legitimately returns a sync ``{"status": "submitted", ...}`` (e.g., synchronous queue acceptance with extra metadata) still gets the auto-emit. """ if ( isinstance(result, dict) and set(result.keys()) == {"task_id", "status"} and result.get("status") == "submitted" ): # TaskHandoff projection — the background completion path in # _project_handoff owns the terminal webhook for this task # (delivered once when push config is present); skipping here # prevents a double-delivery. return maybe_emit_sync_completion( sender=self._webhook_sender, supervisor=self._webhook_supervisor, enabled=self._auto_emit_completion_webhooks, method_name=method_name, params=params, result=result, ) def _handoff_webhook_kwargs(self) -> dict[str, Any]: """Webhook delivery kwargs threaded into :func:`_invoke_platform_method` for the async (handoff) completion path. The supervisor takes precedence over the bare sender (retry + circuit breaker), matching the resolution order :func:`maybe_emit_sync_completion` uses on the sync arm. When a request hands off AND the buyer registered ``push_notification_config``, the background completion path delivers the terminal completion / failure webhook to that target. The sync arm's auto-emit gate is wired separately via :meth:`_maybe_auto_emit_sync_completion`; both honor the same ``auto_emit_completion_webhooks`` flag so an adopter emitting manually never gets a framework double-delivery on either arm. """ return { "webhook_target": self._webhook_supervisor or self._webhook_sender, "webhook_auto_emit": self._auto_emit_completion_webhooks, } def _build_ctx( self, tool_ctx: ToolContext, account: Account[Any], ) -> Any: """Wrap :func:`_build_request_context` with the handler's wired StateReader / ResourceResolver overrides AND the platform's AccountStore (for D9 round-3 composite cache scope-key derivation). Reads the resolved :class:`BuyerAgent` from ``tool_ctx.metadata['adcp.buyer_agent']`` (stashed by :meth:`_resolve_account` when a registry is wired) and passes it through to the typed :class:`RequestContext`. Uses ``pop`` so the value is consumed once — protects against the pathological case where a misconfigured ``context_factory`` returns the same ``ToolContext`` across requests, which would otherwise leak the prior request's resolved buyer-agent into the next dispatch. """ auth_info = self._extract_auth_info(tool_ctx) # Pop adcp.auth_info after extraction so the AuthInfo object doesn't # survive into RequestContext.metadata, where it would be opaque to # downstream serializers. Mirrors adcp.buyer_agent on the next line. if tool_ctx.metadata: tool_ctx.metadata.pop(_AUTH_INFO_METADATA_KEY, None) buyer_agent = ( tool_ctx.metadata.pop(_BUYER_AGENT_METADATA_KEY, None) if tool_ctx.metadata else None ) return _build_request_context( tool_ctx, account, auth_info, store=self._platform.accounts, state_reader=self._state_reader, resource_resolver=self._resource_resolver, buyer_agent=buyer_agent, ) # ----- Protocol discovery ----- async def get_adcp_capabilities( self, params: GetAdcpCapabilitiesRequest | dict[str, Any] | None = None, context: ToolContext | None = None, ) -> dict[str, Any]: """Project the platform's :class:`DecisioningCapabilities` into a spec-conformant ``get_adcp_capabilities`` response. The projection mirrors the wire spec block-by-block. Each top-level capability block (``account``, ``media_buy``, ``signals``, ``governance``, ``sponsored_intelligence``, ``brand``, ``creative``, ``request_signing``, ``webhook_signing``, ``identity``, ``compliance_testing``) is emitted via ``model_dump(mode="json", exclude_none=True)`` when the adopter has declared a value. Auto-derives: * ``adcp.idempotency`` from :attr:`DecisioningCapabilities.adcp` (when set) or defaults to ``{"supported": False}`` so the response stays spec-valid. * ``supported_protocols`` from :attr:`DecisioningCapabilities.supported_protocols` (override) or, when None, the union of :data:`SPECIALISM_TO_PROTOCOLS` over the platform's claimed specialisms. * Wire-level ``specialisms`` field from spec-known entries in :attr:`DecisioningCapabilities.specialisms` (novel / typo strings are filtered — only spec-defined slugs reach the wire). * ``media_buy.execution.targeting.geo_postal_areas`` from one typed declaration into the caller's negotiated postal shape: native country-keyed systems for explicit AdCP 3.1+ callers, deprecated fused booleans for unversioned / 3.0 callers. Legacy-field projection (deprecation warnings emitted): * ``account.supported_billing`` falls back to :attr:`DecisioningCapabilities.supported_billing` when ``account`` isn't set. ``DeprecationWarning``. * ``media_buy.supported_pricing_models`` falls back to :attr:`DecisioningCapabilities.pricing_models` when ``media_buy`` isn't set. ``DeprecationWarning``. * ``channels`` is no longer projected — the spec's ``portfolio.primary_channels`` requires ``portfolio.publisher_domains`` alongside, which the flat ``channels`` field can't supply. Adopters who set ``channels`` get a ``DeprecationWarning`` pointing at ``media_buy.portfolio``. Adopters who need request-scoped capability blocks (for example tenant-specific publisher domains or webhook-signing support) override :meth:`DecisioningPlatform.get_adcp_capabilities_for_request`. That hook returns a typed :class:`DecisioningCapabilities` override before the automatic postal projection runs; this method remains responsible for the canonical wire projection. """ from adcp.decisioning.types import AdcpError caps = self._platform.capabilities try: scoped_caps = self._platform.get_adcp_capabilities_for_request(params, context) if inspect.isawaitable(scoped_caps): scoped_caps = await scoped_caps except AdcpError: raise except Exception as exc: logger.exception( "Unhandled exception in platform.get_adcp_capabilities_for_request — " "wrapping to INTERNAL_ERROR" ) raise AdcpError( "INTERNAL_ERROR", message=( "Unhandled exception in platform.get_adcp_capabilities_for_request: " f"{type(exc).__name__}: {exc}" ), recovery="terminal", details={ "caused_by": { "type": type(exc).__name__, "message": str(exc), } }, ) from exc has_scoped_caps = scoped_caps is not None if scoped_caps is not None: caps = scoped_caps if not isinstance(caps, DecisioningCapabilities): raise AdcpError( "INVALID_REQUEST", message=( "DecisioningPlatform.get_adcp_capabilities_for_request() " "must return DecisioningCapabilities or None; got " f"{type(caps).__name__}" ), recovery="terminal", ) if has_scoped_caps: _validate_scoped_capabilities_static_contract( static_caps=self._platform.capabilities, scoped_caps=caps, ) from adcp.decisioning.validate_idempotency import ( validate_idempotency_wiring_for_capabilities, ) from adcp.decisioning.webhook_emit import validate_webhook_signing_for_capabilities validate_idempotency_wiring_for_capabilities(self._platform, caps) validate_webhook_signing_for_capabilities( capabilities=caps, sender=self._webhook_sender, supervisor=self._webhook_supervisor, ) # ----- supported_protocols: explicit override > derive from specialisms ----- supported_protocols: list[str] if caps.supported_protocols is not None: supported_protocols = [ p.value if hasattr(p, "value") else str(p) for p in caps.supported_protocols ] else: protocols: set[str] = set() for entry in caps.specialisms: slug = entry.value if hasattr(entry, "value") else entry protocols.update(SPECIALISM_TO_PROTOCOLS.get(slug, frozenset())) # ``supported_protocols`` is the storyboard commitment per # spec. When no declared specialism resolves to a protocol # (e.g. only meta specialisms like ``governance-aware-seller`` # claimed, or no specialisms at all), emit an empty list # rather than silently default to ``["media_buy"]`` — claiming # a protocol the adopter never declared forces them to fail # the baseline storyboard for that protocol. The boot-time # validator # (:func:`adcp.decisioning.validate_capabilities.validate_capabilities_response_shape`) # catches the empty list as an explicit configuration error, # giving adopters a clean failure pointing at the declaration # site rather than a wire-shaped lie. Adopters who claim a # protocol without an enumerated specialism set # ``supported_protocols`` explicitly. supported_protocols = sorted(protocols) # ----- adcp block: structured > default ----- # Route structured overrides through the response helper so its # supported_versions defaults stay aligned with major_versions. # A custom Adcp(major_versions=[2, 3]) without exact # supported_versions must not inherit the SDK's 3.x exact list. from adcp.server.responses import capabilities_response adcp = caps.adcp.model_dump(mode="json", exclude_none=True) if caps.adcp is not None else {} response = capabilities_response( supported_protocols, major_versions=adcp.get("major_versions"), supported_versions=adcp.get("supported_versions"), build_version=adcp.get("build_version"), idempotency=adcp.get("idempotency") or {"supported": False}, ) # ----- structured capability blocks (model_dump for each) ----- # Each block emits only when the adopter has declared a value. # ``exclude_none=True`` keeps the wire shape minimal (every # nested optional collapses cleanly). if caps.account is not None: response["account"] = caps.account.model_dump(mode="json", exclude_none=True) if caps.media_buy is not None: media_buy = caps.media_buy.model_dump(mode="json", exclude_none=True) execution_model = getattr(caps.media_buy, "execution", None) targeting_model = getattr(execution_model, "targeting", None) geo_postal_areas = getattr(targeting_model, "geo_postal_areas", None) if geo_postal_areas is not None: projected_geo_postal_areas = project_geo_postal_areas( geo_postal_areas, context.resolved_adcp_version if context is not None else None, ) execution = media_buy.get("execution") if isinstance(execution, dict): targeting = execution.get("targeting") if isinstance(targeting, dict): if projected_geo_postal_areas: targeting["geo_postal_areas"] = projected_geo_postal_areas else: targeting.pop("geo_postal_areas", None) if not targeting: execution.pop("targeting", None) if not execution: media_buy.pop("execution", None) response["media_buy"] = media_buy if caps.signals is not None: response["signals"] = caps.signals.model_dump(mode="json", exclude_none=True) if caps.governance is not None: response["governance"] = caps.governance.model_dump(mode="json", exclude_none=True) if caps.sponsored_intelligence is not None: response["sponsored_intelligence"] = caps.sponsored_intelligence.model_dump( mode="json", exclude_none=True ) if caps.brand is not None: response["brand"] = caps.brand.model_dump(mode="json", exclude_none=True) if caps.creative is not None: response["creative"] = caps.creative.model_dump(mode="json", exclude_none=True) if caps.measurement is not None: response["measurement"] = caps.measurement.model_dump(mode="json", exclude_none=True) if caps.request_signing is not None: response["request_signing"] = caps.request_signing.model_dump( mode="json", exclude_none=True ) if caps.webhook_signing is not None: response["webhook_signing"] = caps.webhook_signing.model_dump( mode="json", exclude_none=True ) if caps.identity is not None: response["identity"] = caps.identity.model_dump(mode="json", exclude_none=True) if caps.compliance_testing is not None: response["compliance_testing"] = caps.compliance_testing.model_dump( mode="json", exclude_none=True ) if caps.experimental_features: response["experimental_features"] = [ feature.model_dump(mode="json") if hasattr(feature, "model_dump") else feature for feature in caps.experimental_features ] # ----- wire ``specialisms`` field: spec-known entries only ----- # Only spec-defined enum members reach the wire — novel / typo # strings stay diagnostic-only at the dispatch layer and don't # leak into the capabilities response. wire_specialisms = [entry.value for entry in caps.specialisms if hasattr(entry, "value")] if wire_specialisms: response["specialisms"] = wire_specialisms # ----- legacy flat-field projection ----- # Deprecation warnings for legacy fields fire at construction in # ``DecisioningCapabilities.__post_init__`` — they point at the # adopter's declaration site, not at the dispatcher. Here we only # project the legacy values when the structured equivalent isn't # set. ``channels`` doesn't project to anything because the spec's # ``portfolio.primary_channels`` requires ``publisher_domains`` # alongside, which the flat field can't supply. if caps.supported_billing and caps.account is None: response["account"] = {"supported_billing": list(caps.supported_billing)} if caps.pricing_models and caps.media_buy is None and "media_buy" in supported_protocols: # Spec requires uniqueItems on supported_pricing_models; # dedupe via dict.fromkeys to preserve declaration order. response["media_buy"] = { "supported_pricing_models": list(dict.fromkeys(caps.pricing_models)), } if has_scoped_caps: from adcp.decisioning.validate_capabilities import _validate_response_dict _validate_response_dict(response) return response # ----- Sales tools ----- async def get_products( # type: ignore[override] self, params: GetProductsRequest, context: ToolContext | None = None, ) -> GetProductsResponse: """Invoke the platform's ``get_products`` method and apply fields projection. When the platform is a :class:`PlatformRouter` with per-tenant ``proposal_managers`` wired, the router's own ``get_products`` method handles the proposal-side dispatch (per-tenant :class:`ProposalManager` lookup, refine-mode selection, fall-through to the tenant's :class:`DecisioningPlatform`). The handler delegates uniformly via :func:`_invoke_platform_method`; the routing decision lives on the router. When ``params.fields`` is set the framework drops unrequested product fields after the platform method returns, always retaining the eight schema-required fields. When ``params.fields`` is ``None`` the response passes through unchanged. """ tool_ctx = context or ToolContext() # Pre-adapter: validate buying_mode against the wire spec's # mutual-exclusion rules (refine+brief, wholesale+brief, refine # without refine[]). assert_buying_mode_consistent(params) # Guard (a): wholesale + push_notification_config is rejected # before the platform method runs — wholesale discovery is a # synchronous rate-card read with no async lifecycle. The # platform method is never invoked on this path. assert_discovery_push_consistent(params, mode_field="buying_mode") account = await self._resolve_account(params.account, tool_ctx) # Guard (c) pre-dispatch: a buyer-supplied push_notification_config # makes the request async up front. If the account is unresolved # (sentinel/empty id) the eventual task_id would be unreachable — # reject before invoking the platform method. _has_push = getattr(params, "push_notification_config", None) is not None if _has_push: assert_account_resolved_for_async(None, account_id=account.id, has_push=True) ctx = self._build_ctx(tool_ctx, account) # Refine flow: when buying_mode='refine' the framework dispatches # to refine_get_products() (when present) and projects the result # into the wire response — adopters return a RefineResult and # framework constructs position-matched refinement_applied[]. buying_mode_attr = getattr(params, "buying_mode", None) mode = ( ( buying_mode_attr.value if hasattr(buying_mode_attr, "value") else str(buying_mode_attr) ) if buying_mode_attr is not None else None ) if mode == "refine": from adcp.decisioning.types import AdcpError # v1.5 finalize interception (D2 + D7). When the request # carries a refine[i].action='finalize' AND the resolved # tenant has a finalize-capable manager + wired store, the # framework intercepts before refine_products and runs the # finalize lifecycle: hydrate draft → call finalize_proposal → # commit → project wire response. No-op when finalize isn't # in scope; falls through to the v1 refine path. finalize_response = await maybe_intercept_finalize( self._platform, params, ctx, executor=self._executor, registry=self._registry, ) if finalize_response is not None: return cast("GetProductsResponse", finalize_response) await maybe_validate_refine_proposal_refs(self._platform, params, ctx) if not has_refine_support(self._platform): raise AdcpError( "INVALID_REQUEST", message=( "buying_mode='refine' is not supported by this " "seller. The platform does not implement " "refine_get_products(). Buyers should retry with " "buying_mode='brief' or 'wholesale'." ), field="buying_mode", ) refine_result = await _invoke_platform_method( self._platform, "refine_get_products", params, ctx, executor=self._executor, registry=self._registry, ) # Two refine return shapes coexist: # - Direct platform.refine_get_products returns a RefineResult # (typed object with per_refine_outcome) — framework projects # to wire response. # - PlatformRouter.refine_get_products forwards to the per-tenant # ProposalManager.refine_products which returns a wire-shaped # GetProductsResponse directly. Skip projection in that case. if isinstance(refine_result, RefineResult): projected: GetProductsResponse = project_refine_response( refine_result, params.refine or [] ) await maybe_persist_draft_after_get_products(self._platform, projected, ctx) return projected await maybe_persist_draft_after_get_products(self._platform, refine_result, ctx) return cast("GetProductsResponse", refine_result) # Resolve time_budget to a seconds deadline. _resolve_account and # _build_ctx are intentionally outside this try/except so their # AdcpErrors propagate unmodified; only the platform call is deadline- # wrapped. deadline = resolve_time_budget(params.time_budget) # Terminal side-effect (persist draft proposals) threaded as an # on_complete hook so it fires on COMPLETION in BOTH the sync and # handoff paths — never at submit. On the handoff path the bg task # awaits the adopter's coroutine, then fires this hook with the # terminal GetProductsResponse before registry.complete; on the # sync path it fires inline with the adapter's return. This mirrors # create_media_buy's consumption-finalize hook (handler.py # create_media_buy). The hook persists the raw adapter result; # buyer-presentation projections (property-list, pagination, # fields) shape only the wire response, not the stored draft. captured_platform = self._platform captured_ctx = ctx async def _persist_draft_hook(get_products_result: Any) -> None: await maybe_persist_draft_after_get_products( captured_platform, get_products_result, captured_ctx ) # Guard (b) pre-launch: a wholesale call that hands off is rejected # the instant dispatch detects the TaskHandoff — before any registry # row, persist-draft, background coroutine, or completion webhook. # Belt-and-braces post-dispatch check stays below. pre_handoff_reject = ( (lambda: reject_wholesale_handoff_before_launch("buying_mode")) if mode == "wholesale" else None ) coro = _invoke_platform_method( self._platform, "get_products", params, ctx, executor=self._executor, registry=self._registry, on_complete=_persist_draft_hook, pre_handoff_reject=pre_handoff_reject, **self._handoff_webhook_kwargs(), ) try: result = await ( asyncio.wait_for(coro, timeout=deadline) if deadline is not None else coro ) except asyncio.TimeoutError: # Deadline expired. The platform coroutine is cancelled; for # sync adopters the underlying thread runs to completion but the # asyncio side has moved on (thread-pool slot leak documented in # adcp.decisioning.time_budget module header). tb = params.time_budget interval = tb.interval if tb is not None else 0 unit_raw = tb.unit if tb is not None else None unit = ( (unit_raw.value if hasattr(unit_raw, "value") else str(unit_raw)) if unit_raw is not None else "unknown" ) logger.warning( "[adcp.decisioning] get_products timed out after %ds " "(time_budget=%d %s); returning incomplete response. " "To avoid timeout cancellations, optimise get_products " "latency or reduce the platform's search scope.", deadline, interval, unit, ) return GetProductsResponse.model_validate( project_incomplete_response(interval=interval, unit=unit) ) # Post-dispatch discovery guards (run on the raw result before any # success-shape projection). ``result`` is either a typed # GetProductsResponse (sync) or the framework's submitted # projection dict (handoff). Both reject with # INVALID_REQUEST / correctable. # (d) hand-rolled submitted: adopter built {'status':'submitted'} # by hand instead of ctx.handoff_to_task — no registry row. # (b) wholesale + handoff: wholesale MUST be synchronous. The # pre-launch arm (pre_handoff_reject above) already rejected # it before any task/draft/webhook side effect; this is the # belt-and-braces post-dispatch check. # Guard (c) — async + unresolved account — is NOT re-checked here. # The push arm is rejected pre-dispatch above (correctable # field='account'); the no-push handoff arm is owned by # compose_caller_identity, which fails closed inside _build_ctx # (terminal INVALID_REQUEST) BEFORE the platform method runs, so no # task row is ever minted against an unresolved account. A # post-dispatch (c) re-check would be unreachable: reaching this # line means _build_ctx succeeded, i.e. the account resolved. reject_hand_rolled_submitted(result) reject_wholesale_handoff(result, mode=mode, mode_field="buying_mode") # Handoff path: the submitted envelope is returned verbatim, the # same as create_media_buy. Success-shape projections (property # list, pagination, fields, draft persist) apply only to the sync # success arm. When the buyer registered push_notification_config, # the terminal completion / failure webhook is delivered from the # background completion path in _project_handoff exactly once; with # no push config the buyer polls tasks/get. Either way nothing fires # here at submit time. if isinstance(result, dict) and result.get("status") == "submitted": return cast("GetProductsResponse", result) response = cast("GetProductsResponse", result) # Post-adapter: capability-gated property-list filter. response = cast( "GetProductsResponse", await maybe_apply_property_list_filter( params=params, response=response, fetcher=self._property_list_fetcher, capability_enabled=property_list_capability_enabled(self._platform), ), ) if self._platform.capabilities.auto_paginate and params.pagination is not None: response = cast( "GetProductsResponse", apply_framework_pagination( response, params.pagination, _query_hash(params), ), ) if params.fields: response = _project_product_fields(response, params.fields) # Draft proposals are persisted by the _persist_draft_hook # on_complete seam (threaded above) so the same side-effect fires # on both the sync and handoff completion paths. The hook ran # inline on the sync path before this point. return response async def create_media_buy( # type: ignore[override] self, params: CreateMediaBuyRequest, context: ToolContext | None = None, ) -> CreateMediaBuyResponse: from adcp.decisioning.types import AdcpError tool_ctx = context or ToolContext() account = await self._resolve_account(params.account, tool_ctx) ctx = self._build_ctx(tool_ctx, account) configs: dict[str, Any] = {} if self._config_store is not None: # proposal_id flows have packages=None — skip lookup, inject {} if params.packages: product_ids = list(dict.fromkeys(p.product_id for p in params.packages)) try: configs = await self._config_store.lookup_implementation_configs( product_ids, ctx ) except AdcpError: raise except Exception as exc: logger.exception( "[adcp.decisioning] ProductConfigStore.lookup_implementation_configs " "raised for create_media_buy — translating to SERVICE_UNAVAILABLE" ) raise AdcpError( "SERVICE_UNAVAILABLE", message=( "implementation_config lookup failed for " f"{len(product_ids)} product(s). Retry the request; " "if the problem persists contact the seller." ), recovery="transient", details={"caused_by": {"type": type(exc).__name__}}, ) from exc # v1.5: when params.proposal_id is set AND a tenant store is # wired, hydrate ctx.recipes from the committed proposal + # validate expiry / capability overlap before the adapter runs. # Returns the ProposalRecord (state=CONSUMING) so we can # finalize_consumption on success or release_consumption on # failure. The two-phase commit prevents the inventory # double-spend race that a check-then-act sequence would expose. proposal_record = await maybe_hydrate_recipes_for_create_media_buy( self._platform, params, ctx ) extra: dict[str, Any] | None = ( {"configs": configs} if self._create_media_buy_accepts_configs else None ) # Build the consumption-lifecycle hooks. Used inline on the # sync return path AND forwarded into _project_handoff on the # TaskHandoff path — same closure, two firing points. Single # source of truth for "what to do when create_media_buy lands" # regardless of whether it lands now or after HITL approval. on_complete: Callable[[Any], Awaitable[None]] | None = None on_failure: Callable[[BaseException], Awaitable[None]] | None = None if proposal_record is not None: captured_record = proposal_record captured_ctx = ctx captured_platform = self._platform async def _finalize_consumption_hook(create_result: Any) -> None: # Idempotent on re-call with the same media_buy_id # (idempotency_key replays land here too). For the # handoff path the create_result is the typed # CreateMediaBuySuccess from the bg task, NOT the # Submitted envelope — _extract_media_buy_id reads .id # off either shape. media_buy_id = _extract_media_buy_id(create_result) if media_buy_id is not None: await mark_proposal_consumed( captured_platform, captured_record, media_buy_id=media_buy_id, ctx=captured_ctx, ) async def _release_reservation_hook(_exc: BaseException) -> None: # Adapter raised (sync) OR handoff fn raised (HITL) OR # finalize_consumption raised: release the reservation # so the buyer can retry without PROPOSAL_NOT_COMMITTED # blocking them. await release_proposal_reservation(captured_platform, captured_record, captured_ctx) on_complete = _finalize_consumption_hook on_failure = _release_reservation_hook # MediaBuyStore persist hook — fires on the same on_complete seam # so both sync and HITL completions persist the per-package # ``targeting_overlay`` for later echo on ``get_media_buys``. # Chains with the proposal hook (if any) so both side effects run. if self._media_buy_store is not None: prior_on_complete = on_complete captured_store = self._media_buy_store captured_account_id = account.id async def _persist_overlay_hook(create_result: Any) -> None: if prior_on_complete is not None: await prior_on_complete(create_result) await captured_store.persist_from_create( captured_account_id, _to_store_dict(params), _to_store_dict(create_result), ) on_complete = _persist_overlay_hook result = await _invoke_platform_method( self._platform, "create_media_buy", params, ctx, executor=self._executor, registry=self._registry, extra_kwargs=extra, on_complete=on_complete, on_failure=on_failure, **self._handoff_webhook_kwargs(), ) self._maybe_auto_emit_sync_completion("create_media_buy", params, result) return cast("CreateMediaBuyResponse", result) async def update_media_buy( # type: ignore[override] self, params: UpdateMediaBuyRequest, context: ToolContext | None = None, ) -> UpdateMediaBuySuccessResponse: """Wire shape carries ``media_buy_id`` + the patch fields at the same level on ``UpdateMediaBuyRequest``. The platform method signature is ``update_media_buy(media_buy_id, patch, ctx)`` — cleaner adopter ergonomics. Arg-projection per D1. """ tool_ctx = context or ToolContext() account = await self._resolve_account(params.account, tool_ctx) ctx = self._build_ctx(tool_ctx, account) # v1.5: hydrate ctx.recipes from the consumed proposal via the # ProposalStore reverse-index. Re-validates capability overlap # against any packages on the patch (Resolutions §5). await maybe_hydrate_recipes_for_media_buy_id( self._platform, params.media_buy_id, ctx, packages=list(getattr(params, "packages", None) or []), ) # MediaBuyStore merge hook — fires on the on_complete seam so the # patch is recorded for both sync and HITL completions. Spec: # ``targeting_overlay`` is echoed from the most recent # ``create_media_buy`` OR ``update_media_buy`` per the # ``get-media-buys-response`` schema. on_complete: Callable[[Any], Awaitable[None]] | None = None if self._media_buy_store is not None: captured_store = self._media_buy_store captured_account_id = account.id captured_media_buy_id = params.media_buy_id async def _merge_overlay_hook(_update_result: Any) -> None: await captured_store.merge_from_update( captured_account_id, captured_media_buy_id, _to_store_dict(params), ) on_complete = _merge_overlay_hook result = await _invoke_platform_method( self._platform, "update_media_buy", params, ctx, executor=self._executor, registry=self._registry, arg_projector={"media_buy_id": params.media_buy_id, "patch": params}, on_complete=on_complete, **self._handoff_webhook_kwargs(), ) self._maybe_auto_emit_sync_completion("update_media_buy", params, result) return cast("UpdateMediaBuySuccessResponse", result) async def sync_creatives( # type: ignore[override] self, params: SyncCreativesRequest, context: ToolContext | None = None, ) -> SyncCreativesSuccessResponse: tool_ctx = context or ToolContext() account = await self._resolve_account(params.account, tool_ctx) ctx = self._build_ctx(tool_ctx, account) result = await _invoke_platform_method( self._platform, "sync_creatives", params, ctx, executor=self._executor, registry=self._registry, **self._handoff_webhook_kwargs(), ) self._maybe_auto_emit_sync_completion("sync_creatives", params, result) return cast("SyncCreativesSuccessResponse", result) async def get_media_buy_delivery( # type: ignore[override] self, params: GetMediaBuyDeliveryRequest, context: ToolContext | None = None, ) -> GetMediaBuyDeliveryResponse: tool_ctx = context or ToolContext() account = await self._resolve_account(params.account, tool_ctx) ctx = self._build_ctx(tool_ctx, account) # v1.5: hydrate ctx.recipes for the consumed proposal — adapter # reads ``ctx.recipes[product_id]`` for per-product delivery # logic. Hydrates from the first media_buy_id on the request; # multi-buy responses re-hydrate per call. media_buy_ids = list(getattr(params, "media_buy_ids", None) or []) first_id = str(media_buy_ids[0]) if media_buy_ids else "" if first_id: await maybe_hydrate_recipes_for_media_buy_id(self._platform, first_id, ctx) return cast( "GetMediaBuyDeliveryResponse", await _invoke_platform_method( self._platform, "get_media_buy_delivery", params, ctx, executor=self._executor, registry=self._registry, ), ) # ----- Optional sales tools (gated by capabilities + override) ----- async def get_media_buys( # type: ignore[override] self, params: GetMediaBuysRequest, context: ToolContext | None = None, ) -> GetMediaBuysResponse: tool_ctx = context or ToolContext() account = await self._resolve_account(params.account, tool_ctx) ctx = self._build_ctx(tool_ctx, account) result = await _invoke_platform_method( self._platform, "get_media_buys", params, ctx, executor=self._executor, registry=self._registry, ) # MediaBuyStore backfill — fill in ``packages[].targeting_overlay`` # from the persisted store for sellers claiming # ``property-lists`` / ``collection-lists``. Packages the # platform already echoed are left untouched (the in-memory # reference impl checks ``pkg.get("targeting_overlay") is not None`` # before injecting). Normalize to dict before the store sees it # so the adopter contract is consistent regardless of whether the # platform returned a typed Pydantic response or a dict. if self._media_buy_store is not None: result = await self._media_buy_store.backfill(account.id, _to_store_dict(result)) return cast("GetMediaBuysResponse", result) async def provide_performance_feedback( # type: ignore[override] self, params: ProvidePerformanceFeedbackRequest, context: ToolContext | None = None, ) -> ProvidePerformanceFeedbackResponse: """Wire request has no ``account`` field — resolve via auth only. Adopters in ``explicit`` resolution mode get an ``ACCOUNT_NOT_FOUND`` from their AccountStore unless they wire a derived/singleton path or extend ``AccountStore.resolve`` to handle the no-ref case (see python-port-v2 RFC TODO(rc.1)).""" tool_ctx = context or ToolContext() account = await self._resolve_account(None, tool_ctx) ctx = self._build_ctx(tool_ctx, account) return cast( "ProvidePerformanceFeedbackResponse", await _invoke_platform_method( self._platform, "provide_performance_feedback", params, ctx, executor=self._executor, registry=self._registry, ), ) async def list_creative_formats( # type: ignore[override] self, params: ListCreativeFormatsRequest, context: ToolContext | None = None, ) -> ListCreativeFormatsResponse: """Wire request has no ``account`` field. See :meth:`provide_performance_feedback` for the no-ref account resolution caveat.""" tool_ctx = context or ToolContext() account = await self._resolve_account(None, tool_ctx) ctx = self._build_ctx(tool_ctx, account) return cast( "ListCreativeFormatsResponse", await _invoke_platform_method( self._platform, "list_creative_formats", params, ctx, executor=self._executor, registry=self._registry, ), ) async def list_creatives( # type: ignore[override] self, params: ListCreativesRequest, context: ToolContext | None = None, ) -> ListCreativesResponse: tool_ctx = context or ToolContext() account = await self._resolve_account(params.account, tool_ctx) ctx = self._build_ctx(tool_ctx, account) return cast( "ListCreativesResponse", await _invoke_platform_method( self._platform, "list_creatives", params, ctx, executor=self._executor, registry=self._registry, ), ) # ----- Account roster (AccountStoreUpsert / AccountStoreList) ----- def _make_resolve_context(self, tool_ctx: ToolContext, tool_name: str) -> ResolveContext: """Build a :class:`ResolveContext` for the account-store dispatch path. Carries the caller's verified :class:`AuthInfo` and resolved :class:`BuyerAgent` (when a registry is wired) so adopter ``upsert`` / ``list`` impls can implement principal-keyed gates (e.g. spec ``BILLING_NOT_PERMITTED_FOR_AGENT``) off the same canonical context :meth:`AccountStore.resolve` already reads. """ auth_info = self._extract_auth_info(tool_ctx) buyer_agent = ( tool_ctx.metadata.get(_BUYER_AGENT_METADATA_KEY) if tool_ctx.metadata else None ) return ResolveContext( auth_info=auth_info, tool_name=tool_name, agent=buyer_agent, ) async def sync_accounts( # type: ignore[override] self, params: SyncAccountsRequest, context: ToolContext | None = None, ) -> SyncAccountsResponse | NotImplementedResponse: """Route ``sync_accounts`` through the account-store upsert surface. ``sync_accounts`` lives on the AccountStore Protocol surface, not on per-specialism platform methods — :data:`adcp.decisioning.platform_router._ACCOUNT_STORE_METHODS` already excludes it from per-tenant delegation. Surface ``OPERATION_NOT_SUPPORTED`` (via :meth:`_not_supported`) when the store doesn't expose either the optional full-request ``upsert_request`` hook or the legacy :class:`AccountStoreUpsert` Protocol — distinct from ``AttributeError`` (which is what an unguarded ``getattr().()`` would produce). ``ResolveContext`` carries the caller's :class:`AuthInfo` and resolved :class:`BuyerAgent` so adopter impls implementing principal-keyed gates (e.g. spec ``BILLING_NOT_PERMITTED_FOR_AGENT``) read the principal off the canonical context — same threading :meth:`AccountStore.resolve` already uses. """ upsert_request = getattr(self._platform.accounts, "upsert_request", None) upsert = getattr(self._platform.accounts, "upsert", None) if not callable(upsert_request) and not callable(upsert): return self._not_supported("sync_accounts") tool_ctx = context or ToolContext() # Prime auth-context only — DON'T call ``_resolve_account``. # ``sync_accounts`` is the bootstrap tool buyers use to # populate an explicit-mode store; routing through the store's # no-ref ``resolve()`` would deadlock the bootstrap path # (``ACCOUNT_NOT_FOUND`` until the account exists, but the # account exists only after this call succeeds). await self._prime_auth_context(tool_ctx) resolve_ctx = self._make_resolve_context(tool_ctx, "sync_accounts") if callable(upsert_request): result = _call_with_optional_ctx(upsert_request, params, ctx=resolve_ctx) else: assert callable(upsert) refs = list(getattr(params, "accounts", []) or []) result = _call_with_optional_ctx(upsert, refs, ctx=resolve_ctx) if inspect.isawaitable(result): result = await result return cast("SyncAccountsResponse", _project_sync_accounts(result)) async def list_accounts( # type: ignore[override] self, params: ListAccountsRequest, context: ToolContext | None = None, ) -> ListAccountsResponse | NotImplementedResponse: """Route ``list_accounts`` through :meth:`AccountStore.list`. See :meth:`sync_accounts` for the OPERATION_NOT_SUPPORTED gate and ``ResolveContext`` threading rationale. """ listing = getattr(self._platform.accounts, "list", None) if not callable(listing): return self._not_supported("list_accounts") tool_ctx = context or ToolContext() # Prime auth-context only — see :meth:`sync_accounts` for the # explicit-mode-bootstrap rationale. ``list_accounts`` is also # used to enumerate accounts in stores keyed solely by auth # principal; a no-ref ``resolve()`` is the wrong shape there. await self._prime_auth_context(tool_ctx) resolve_ctx = self._make_resolve_context(tool_ctx, "list_accounts") filter_dict = _build_list_accounts_filter(params) result = _call_with_optional_ctx(listing, filter_dict, ctx=resolve_ctx) if inspect.isawaitable(result): result = await result return cast("ListAccountsResponse", _project_list_accounts(result)) # ----- Optional-method gate ----- def _require_platform_method(self, method_name: str) -> None: """Raise ``UNSUPPORTED_FEATURE`` if the adopter's platform doesn't implement ``method_name``. Used by shims for OPTIONAL Protocol methods (per ``_OPTIONAL_PLATFORM_METHODS``). Required methods are caught at server boot by ``validate_platform``; optional methods can legitimately be absent and need a runtime gate. Without this, a buyer calling an optional method on a platform that doesn't implement it would see ``INTERNAL_ERROR`` from the AttributeError wrapper in ``_invoke_platform_method`` — adopter contract violation, not buyer-fixable. """ if not hasattr(self._platform, method_name): from adcp.decisioning.types import AdcpError raise AdcpError( "UNSUPPORTED_FEATURE", message=( f"This platform doesn't implement {method_name!r}. " "The method is optional on the per-specialism Protocol; " "the adopter chose not to wire it." ), recovery="terminal", ) # ----- CreativeBuilderPlatform / CreativeAdServerPlatform ----- async def build_creative( # type: ignore[override] self, params: BuildCreativeRequest, context: ToolContext | None = None, ) -> BuildCreativeResponse: """Build / retrieve a creative. Three discriminated return arms per the per-specialism Protocol: a single :class:`CreativeManifest`, a list of manifests (multi-format), or a fully-shaped :class:`BuildCreativeSuccessResponse`. The shim projects bare manifests / lists to the wire envelope shape so adopters can return the ergonomic form (per the JS-side ``projectBuildCreativeReturn`` parity). Wire envelope per ``schemas/cache/media-buy/build-creative-response.json``: ``{creative_manifest: ...}`` (single) or ``{creative_manifests: [...]}`` (multi). """ self._require_platform_method("build_creative") 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, "build_creative", params, ctx, executor=self._executor, registry=self._registry, ) return cast("BuildCreativeResponse", _project_build_creative(result)) async def preview_creative( # type: ignore[override] self, params: PreviewCreativeRequest, context: ToolContext | None = None, ) -> PreviewCreativeResponse: """Optional on :class:`CreativeBuilderPlatform`; required on :class:`CreativeAdServerPlatform`. Surface ``UNSUPPORTED_FEATURE`` when the adopter's platform doesn't implement it (Builder adopters who don't render preview).""" self._require_platform_method("preview_creative") tool_ctx = context or ToolContext() account = await self._resolve_account(getattr(params, "account", None), tool_ctx) ctx = self._build_ctx(tool_ctx, account) return cast( "PreviewCreativeResponse", await _invoke_platform_method( self._platform, "preview_creative", params, ctx, executor=self._executor, registry=self._registry, ), ) async def get_creative_delivery( # type: ignore[override] self, params: GetCreativeDeliveryRequest, context: ToolContext | None = None, ) -> GetCreativeDeliveryResponse: """Required on :class:`CreativeAdServerPlatform` — per-creative delivery actuals (impressions, spend, pacing).""" 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, "get_creative_delivery", params, ctx, executor=self._executor, registry=self._registry, **self._handoff_webhook_kwargs(), ) self._maybe_auto_emit_sync_completion("get_creative_delivery", params, result) return cast("GetCreativeDeliveryResponse", result) async def validate_input( # type: ignore[override] self, params: ValidateInputRequest, context: ToolContext | None = None, ) -> ValidateInputResponse: """Optional creative preflight validation for beta 3 inputs.""" self._require_platform_method("validate_input") tool_ctx = context or ToolContext() account = await self._resolve_account(getattr(params, "account", None), tool_ctx) ctx = self._build_ctx(tool_ctx, account) return cast( "ValidateInputResponse", await _invoke_platform_method( self._platform, "validate_input", params, ctx, executor=self._executor, registry=self._registry, ), ) # ----- SignalsPlatform ----- async def get_signals( # type: ignore[override] self, params: GetSignalsRequest, context: ToolContext | None = None, ) -> GetSignalsResponse: """Catalog discovery for signal-marketplace / signal-owned. Synchronous by default; ``discovery_mode='brief'`` MAY hand off to a background task (submitted / working arms — no input_required). ``discovery_mode='wholesale'`` MUST stay synchronous. """ tool_ctx = context or ToolContext() # Guard (a): wholesale + push_notification_config is rejected # before the platform method runs. Wholesale signal discovery is a # synchronous catalog read with no async lifecycle. assert_discovery_push_consistent(params, mode_field="discovery_mode") account = await self._resolve_account(getattr(params, "account", None), tool_ctx) # Guard (c) pre-dispatch: push_notification_config makes the request # async up front; reject against an unresolved account before # _build_ctx (whose compose_caller_identity would otherwise raise a # less-specific terminal error on the sentinel id) and before the # platform method runs. _has_push = getattr(params, "push_notification_config", None) is not None if _has_push: assert_account_resolved_for_async(None, account_id=account.id, has_push=True) ctx = self._build_ctx(tool_ctx, account) mode = getattr(params, "discovery_mode", None) if mode is None: mode_str = None else: mode_str = mode.value if hasattr(mode, "value") else str(mode) # Guard (b) pre-launch: a wholesale call that hands off is rejected # before any registry row / background coroutine / completion # webhook. Belt-and-braces post-dispatch check stays below. pre_handoff_reject = ( (lambda: reject_wholesale_handoff_before_launch("discovery_mode")) if mode_str == "wholesale" else None ) result = await _invoke_platform_method( self._platform, "get_signals", params, ctx, executor=self._executor, registry=self._registry, pre_handoff_reject=pre_handoff_reject, **self._handoff_webhook_kwargs(), ) # Post-dispatch discovery guards (mirror get_products): # (d) hand-rolled submitted, (b) wholesale + handoff. Both # INVALID_REQUEST / correctable. Guard (b) here is the # belt-and-braces check; the pre-launch arm above already # rejected a wholesale handoff before any side effect. # Guard (c) — async + unresolved account — is NOT re-checked here: # the push arm is rejected pre-dispatch above (correctable # field='account'), and the no-push handoff arm is owned by # compose_caller_identity, which fails closed terminally inside # _build_ctx before the platform method runs. Reaching this line # means the account resolved, so a post-dispatch (c) check is # unreachable. reject_hand_rolled_submitted(result) reject_wholesale_handoff(result, mode=mode_str, mode_field="discovery_mode") # On the handoff arm the terminal completion / failure webhook is # delivered from _project_handoff's background path when the buyer # registered push_notification_config; the sync auto-emit gate below # skips the {task_id, status} submitted projection so there is no # double-delivery. self._maybe_auto_emit_sync_completion("get_signals", params, result) return cast("GetSignalsResponse", result) async def activate_signal( # type: ignore[override] self, params: ActivateSignalRequest, context: ToolContext | None = None, ) -> ActivateSignalSuccessResponse: """Provision a signal onto destination platforms.""" self._require_platform_method("activate_signal") 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, "activate_signal", params, ctx, executor=self._executor, registry=self._registry, **self._handoff_webhook_kwargs(), ) self._maybe_auto_emit_sync_completion("activate_signal", params, result) return cast("ActivateSignalSuccessResponse", result) # ----- AudiencePlatform ----- async def sync_audiences( # type: ignore[override] self, params: SyncAudiencesRequest, context: ToolContext | None = None, ) -> SyncAudiencesSuccessResponse: """Push audiences to the platform. Wire shape carries ``audiences[]`` on the request; the platform method signature is ``sync_audiences(audiences, ctx)`` — adopter ergonomic per the JS reference. Arg-projection extracts the list. Two return arms per the per-specialism Protocol: a list of audience-result rows (the JS-side ergonomic) or a fully-shaped :class:`SyncAudiencesSuccessResponse`. The shim projects the list arm to the wire envelope ``{audiences: [...]}`` so adopters can return the ergonomic form. """ 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_audiences", params, ctx, executor=self._executor, registry=self._registry, arg_projector={"audiences": getattr(params, "audiences", []) or []}, **self._handoff_webhook_kwargs(), ) projected = _project_sync_audiences(result) 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, **self._handoff_webhook_kwargs(), ) 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] self, params: CheckGovernanceRequest, context: ToolContext | None = None, ) -> CheckGovernanceResponse: """Runtime governance decision (approved / denied / conditions). Wire request has no ``account`` field per ``schemas/cache/governance/check-governance-request.json`` (``additionalProperties: false`` — account is forbidden); resolve via auth only. See :meth:`provide_performance_feedback` for the no-ref account resolution caveat. """ tool_ctx = context or ToolContext() account = await self._resolve_account(None, tool_ctx) ctx = self._build_ctx(tool_ctx, account) return cast( "CheckGovernanceResponse", await _invoke_platform_method( self._platform, "check_governance", params, ctx, executor=self._executor, registry=self._registry, ), ) async def sync_plans( # type: ignore[override] self, params: SyncPlansRequest, context: ToolContext | None = None, ) -> SyncPlansResponse: """Plan CRUD with delta upsert into governance agent. Wire request has no ``account`` field per ``schemas/cache/governance/sync-plans-request.json`` (``additionalProperties: false``); resolve via auth only. """ tool_ctx = context or ToolContext() account = await self._resolve_account(None, tool_ctx) ctx = self._build_ctx(tool_ctx, account) return cast( "SyncPlansResponse", await _invoke_platform_method( self._platform, "sync_plans", params, ctx, executor=self._executor, registry=self._registry, ), ) async def report_plan_outcome( # type: ignore[override] self, params: ReportPlanOutcomeRequest, context: ToolContext | None = None, ) -> ReportPlanOutcomeResponse: """Outcome reporting from sellers (delivery actuals). Wire request has no ``account`` field per ``schemas/cache/governance/report-plan-outcome-request.json`` (``additionalProperties: false``); resolve via auth only. """ tool_ctx = context or ToolContext() account = await self._resolve_account(None, tool_ctx) ctx = self._build_ctx(tool_ctx, account) return cast( "ReportPlanOutcomeResponse", await _invoke_platform_method( self._platform, "report_plan_outcome", params, ctx, executor=self._executor, registry=self._registry, ), ) async def get_plan_audit_logs( # type: ignore[override] self, params: GetPlanAuditLogsRequest, context: ToolContext | None = None, ) -> GetPlanAuditLogsResponse: """Audit log read for governance decisions + outcomes. Wire request has no ``account`` field per ``schemas/cache/governance/get-plan-audit-logs-request.json`` (``additionalProperties: false``); resolve via auth only. """ tool_ctx = context or ToolContext() account = await self._resolve_account(None, tool_ctx) ctx = self._build_ctx(tool_ctx, account) return cast( "GetPlanAuditLogsResponse", await _invoke_platform_method( self._platform, "get_plan_audit_logs", params, ctx, executor=self._executor, registry=self._registry, ), ) # ----- BrandRightsPlatform ----- async def get_brand_identity( # type: ignore[override] self, params: GetBrandIdentityRequest, context: ToolContext | None = None, ) -> GetBrandIdentitySuccessResponse: """Read brand identity record (catalog + identity record). Wire request has no ``account`` field per ``schemas/cache/brand-rights/get-brand-identity-request.json`` (``additionalProperties: false``); resolve via auth only. See :meth:`provide_performance_feedback` for the no-ref account resolution caveat. """ tool_ctx = context or ToolContext() account = await self._resolve_account(None, tool_ctx) ctx = self._build_ctx(tool_ctx, account) result = await _invoke_platform_method( self._platform, "get_brand_identity", params, ctx, executor=self._executor, registry=self._registry, **self._handoff_webhook_kwargs(), ) self._maybe_auto_emit_sync_completion("get_brand_identity", params, result) return cast("GetBrandIdentitySuccessResponse", result) async def get_rights( # type: ignore[override] self, params: GetRightsRequest, context: ToolContext | None = None, ) -> GetRightsSuccessResponse: """List rights matching a brand + use query. Wire request has no ``account`` field per ``schemas/cache/brand-rights/get-rights-request.json`` (``additionalProperties: false``); resolve via auth only. """ tool_ctx = context or ToolContext() account = await self._resolve_account(None, tool_ctx) ctx = self._build_ctx(tool_ctx, account) result = await _invoke_platform_method( self._platform, "get_rights", params, ctx, executor=self._executor, registry=self._registry, **self._handoff_webhook_kwargs(), ) self._maybe_auto_emit_sync_completion("get_rights", params, result) return cast("GetRightsSuccessResponse", result) async def acquire_rights( # type: ignore[override] self, params: AcquireRightsRequest, context: ToolContext | None = None, ) -> AcquireRightsResponse: """Acquire rights — 4-arm discriminated success union (acquired / pending / rejected / error). Rejection-as-data per the Protocol; the ``error`` arm covers rights-system failures the buyer can retry against (vs. ``AdcpError`` for adopter infrastructure failures). Wire request has no ``account`` field per ``schemas/cache/brand-rights/acquire-rights-request.json`` (``additionalProperties: false``); resolve via auth only. """ tool_ctx = context or ToolContext() account = await self._resolve_account(None, tool_ctx) ctx = self._build_ctx(tool_ctx, account) result = await _invoke_platform_method( self._platform, "acquire_rights", params, ctx, executor=self._executor, registry=self._registry, **self._handoff_webhook_kwargs(), ) self._maybe_auto_emit_sync_completion("acquire_rights", params, result) return cast("AcquireRightsResponse", result) async def update_rights( # type: ignore[override] self, params: UpdateRightsRequest, context: ToolContext | None = None, ) -> UpdateRightsResponse: """Mutate an existing rights acquisition (extend term, change usage scope, revoke). Wire request has no ``account`` field per ``schemas/cache/brand-rights/update-rights-request.json`` (``additionalProperties: false``); resolve via auth only. Not currently in :data:`SPEC_WEBHOOK_TASK_TYPES` — buyers registering ``push_notification_config.url`` won't get an auto-emit; rely on ``publishStatusChange`` for long-running update state. Bump the spec enum and the ``SPEC_WEBHOOK_TASK_TYPES`` constant in lockstep when this joins the closed enum. """ tool_ctx = context or ToolContext() account = await self._resolve_account(None, tool_ctx) ctx = self._build_ctx(tool_ctx, account) return cast( "UpdateRightsResponse", await _invoke_platform_method( self._platform, "update_rights", params, ctx, executor=self._executor, registry=self._registry, ), ) async def verify_brand_claim( # type: ignore[override] self, params: VerifyBrandClaimRequest, context: ToolContext | None = None, ) -> VerifyBrandClaimResponse: """Optional brand claim verification for beta 3 brand agents.""" self._require_platform_method("verify_brand_claim") tool_ctx = context or ToolContext() account = await self._resolve_account(None, tool_ctx) ctx = self._build_ctx(tool_ctx, account) return cast( "VerifyBrandClaimResponse", await _invoke_platform_method( self._platform, "verify_brand_claim", params, ctx, executor=self._executor, registry=self._registry, ), ) async def verify_brand_claims( # type: ignore[override] self, params: VerifyBrandClaimsRequest, context: ToolContext | None = None, ) -> VerifyBrandClaimsResponseBulk: """Optional bulk brand claim verification for beta 3 brand agents.""" self._require_platform_method("verify_brand_claims") tool_ctx = context or ToolContext() account = await self._resolve_account(None, tool_ctx) ctx = self._build_ctx(tool_ctx, account) return cast( "VerifyBrandClaimsResponseBulk", await _invoke_platform_method( self._platform, "verify_brand_claims", params, ctx, executor=self._executor, registry=self._registry, ), ) # ----- ContentStandardsPlatform ----- async def list_content_standards( # type: ignore[override] self, params: ListContentStandardsRequest, context: ToolContext | None = None, ) -> ListContentStandardsResponse: """Discover content standards published by this agent. Wire request has no ``account`` field per ``schemas/cache/content-standards/list-content-standards-request.json``; resolve via auth only. """ tool_ctx = context or ToolContext() account = await self._resolve_account(None, tool_ctx) ctx = self._build_ctx(tool_ctx, account) return cast( "ListContentStandardsResponse", await _invoke_platform_method( self._platform, "list_content_standards", params, ctx, executor=self._executor, registry=self._registry, ), ) async def get_content_standards( # type: ignore[override] self, params: GetContentStandardsRequest, context: ToolContext | None = None, ) -> GetContentStandardsResponse: """Wire request has no ``account`` field per ``schemas/cache/content-standards/get-content-standards-request.json``; resolve via auth only.""" tool_ctx = context or ToolContext() account = await self._resolve_account(None, tool_ctx) ctx = self._build_ctx(tool_ctx, account) return cast( "GetContentStandardsResponse", await _invoke_platform_method( self._platform, "get_content_standards", params, ctx, executor=self._executor, registry=self._registry, ), ) async def create_content_standards( # type: ignore[override] self, params: CreateContentStandardsRequest, context: ToolContext | None = None, ) -> CreateContentStandardsResponse: """Wire request has no ``account`` field; resolve via auth only.""" tool_ctx = context or ToolContext() account = await self._resolve_account(None, tool_ctx) ctx = self._build_ctx(tool_ctx, account) return cast( "CreateContentStandardsResponse", await _invoke_platform_method( self._platform, "create_content_standards", params, ctx, executor=self._executor, registry=self._registry, ), ) async def update_content_standards( # type: ignore[override] self, params: UpdateContentStandardsRequest, context: ToolContext | None = None, ) -> UpdateContentStandardsResponse: """Wire request has no ``account`` field; resolve via auth only.""" tool_ctx = context or ToolContext() account = await self._resolve_account(None, tool_ctx) ctx = self._build_ctx(tool_ctx, account) return cast( "UpdateContentStandardsResponse", await _invoke_platform_method( self._platform, "update_content_standards", params, ctx, executor=self._executor, registry=self._registry, ), ) async def calibrate_content( # type: ignore[override] self, params: CalibrateContentRequest, context: ToolContext | None = None, ) -> CalibrateContentResponse: """Calibrate content against published standards. Wire request has no ``account`` field per ``schemas/cache/content-standards/calibrate-content-request.json``; resolve via auth only. """ tool_ctx = context or ToolContext() account = await self._resolve_account(None, tool_ctx) ctx = self._build_ctx(tool_ctx, account) return cast( "CalibrateContentResponse", await _invoke_platform_method( self._platform, "calibrate_content", params, ctx, executor=self._executor, registry=self._registry, ), ) async def validate_content_delivery( # type: ignore[override] self, params: ValidateContentDeliveryRequest, context: ToolContext | None = None, ) -> ValidateContentDeliveryResponse: """Post-flight conformance check. Wire request has no ``account`` field per ``schemas/cache/content-standards/validate-content-delivery-request.json``; resolve via auth only. """ tool_ctx = context or ToolContext() account = await self._resolve_account(None, tool_ctx) ctx = self._build_ctx(tool_ctx, account) return cast( "ValidateContentDeliveryResponse", await _invoke_platform_method( self._platform, "validate_content_delivery", params, ctx, executor=self._executor, registry=self._registry, ), ) async def get_media_buy_artifacts( # type: ignore[override] self, params: GetMediaBuyArtifactsRequest, context: ToolContext | None = None, ) -> GetMediaBuyArtifactsResponse: """Optional analyzer read — adopters without artifact archival surface ``UNSUPPORTED_FEATURE``.""" self._require_platform_method("get_media_buy_artifacts") tool_ctx = context or ToolContext() account = await self._resolve_account(getattr(params, "account", None), tool_ctx) ctx = self._build_ctx(tool_ctx, account) return cast( "GetMediaBuyArtifactsResponse", await _invoke_platform_method( self._platform, "get_media_buy_artifacts", params, ctx, executor=self._executor, registry=self._registry, ), ) async def get_creative_features( # type: ignore[override] self, params: GetCreativeFeaturesRequest, context: ToolContext | None = None, ) -> GetCreativeFeaturesResponse: """Optional analyzer read — adopters without analyzer pipelines surface ``UNSUPPORTED_FEATURE``.""" self._require_platform_method("get_creative_features") tool_ctx = context or ToolContext() account = await self._resolve_account(getattr(params, "account", None), tool_ctx) ctx = self._build_ctx(tool_ctx, account) return cast( "GetCreativeFeaturesResponse", await _invoke_platform_method( self._platform, "get_creative_features", params, ctx, executor=self._executor, registry=self._registry, ), ) # ----- PropertyListsPlatform ----- async def create_property_list( # type: ignore[override] self, params: CreatePropertyListRequest, context: ToolContext | None = None, ) -> CreatePropertyListResponse: 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, "create_property_list", params, ctx, executor=self._executor, registry=self._registry, **self._handoff_webhook_kwargs(), ) self._maybe_auto_emit_sync_completion("create_property_list", params, result) return cast("CreatePropertyListResponse", result) async def update_property_list( # type: ignore[override] self, params: UpdatePropertyListRequest, context: ToolContext | None = None, ) -> UpdatePropertyListResponse: 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, "update_property_list", params, ctx, executor=self._executor, registry=self._registry, **self._handoff_webhook_kwargs(), ) self._maybe_auto_emit_sync_completion("update_property_list", params, result) return cast("UpdatePropertyListResponse", result) async def get_property_list( # type: ignore[override] self, params: GetPropertyListRequest, context: ToolContext | None = None, ) -> GetPropertyListResponse: 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, "get_property_list", params, ctx, executor=self._executor, registry=self._registry, **self._handoff_webhook_kwargs(), ) self._maybe_auto_emit_sync_completion("get_property_list", params, result) return cast("GetPropertyListResponse", result) async def list_property_lists( # type: ignore[override] self, params: ListPropertyListsRequest, context: ToolContext | None = None, ) -> ListPropertyListsResponse: 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, "list_property_lists", params, ctx, executor=self._executor, registry=self._registry, **self._handoff_webhook_kwargs(), ) self._maybe_auto_emit_sync_completion("list_property_lists", params, result) return cast("ListPropertyListsResponse", result) async def delete_property_list( # type: ignore[override] self, params: DeletePropertyListRequest, context: ToolContext | None = None, ) -> DeletePropertyListResponse: """Security-critical: revokes the per-seller fetch_token and signals cache invalidation. Compromise-driven revocation MUST also trigger this path.""" 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, "delete_property_list", params, ctx, executor=self._executor, registry=self._registry, **self._handoff_webhook_kwargs(), ) self._maybe_auto_emit_sync_completion("delete_property_list", params, result) return cast("DeletePropertyListResponse", result) # ----- CollectionListsPlatform ----- async def create_collection_list( # type: ignore[override] self, params: CreateCollectionListRequest, context: ToolContext | None = None, ) -> CreateCollectionListResponse: tool_ctx = context or ToolContext() account = await self._resolve_account(getattr(params, "account", None), tool_ctx) ctx = self._build_ctx(tool_ctx, account) return cast( "CreateCollectionListResponse", await _invoke_platform_method( self._platform, "create_collection_list", params, ctx, executor=self._executor, registry=self._registry, ), ) async def update_collection_list( # type: ignore[override] self, params: UpdateCollectionListRequest, context: ToolContext | None = None, ) -> UpdateCollectionListResponse: tool_ctx = context or ToolContext() account = await self._resolve_account(getattr(params, "account", None), tool_ctx) ctx = self._build_ctx(tool_ctx, account) return cast( "UpdateCollectionListResponse", await _invoke_platform_method( self._platform, "update_collection_list", params, ctx, executor=self._executor, registry=self._registry, ), ) async def get_collection_list( # type: ignore[override] self, params: GetCollectionListRequest, context: ToolContext | None = None, ) -> GetCollectionListResponse: tool_ctx = context or ToolContext() account = await self._resolve_account(getattr(params, "account", None), tool_ctx) ctx = self._build_ctx(tool_ctx, account) return cast( "GetCollectionListResponse", await _invoke_platform_method( self._platform, "get_collection_list", params, ctx, executor=self._executor, registry=self._registry, ), ) async def list_collection_lists( # type: ignore[override] self, params: ListCollectionListsRequest, context: ToolContext | None = None, ) -> ListCollectionListsResponse: tool_ctx = context or ToolContext() account = await self._resolve_account(getattr(params, "account", None), tool_ctx) ctx = self._build_ctx(tool_ctx, account) return cast( "ListCollectionListsResponse", await _invoke_platform_method( self._platform, "list_collection_lists", params, ctx, executor=self._executor, registry=self._registry, ), ) async def delete_collection_list( # type: ignore[override] self, params: DeleteCollectionListRequest, context: ToolContext | None = None, ) -> DeleteCollectionListResponse: """Security-critical: revokes the fetch_token. See :meth:`delete_property_list` for the same security contract.""" tool_ctx = context or ToolContext() account = await self._resolve_account(getattr(params, "account", None), tool_ctx) ctx = self._build_ctx(tool_ctx, account) return cast( "DeleteCollectionListResponse", await _invoke_platform_method( self._platform, "delete_collection_list", params, ctx, executor=self._executor, registry=self._registry, ), )ADCPHandler subclass that routes wire requests to a :class:
DecisioningPlatformvia :func:_invoke_platform_method.Constructed by :func:
adcp.decisioning.serve.create_adcp_server_from_platform— adopters never instantiate directly. The handler holds:platform— the adopter's :class:DecisioningPlatformsubclass instance. Method dispatches read/call this.executor— the framework-allocated thread-pool for sync platform methods (D5).registry— the :class:TaskRegistryfor handoff lifecycle.- Optional
state_reader/resource_resolver— Stage-3+ wiring for v6.1 backing-store impls; defaults to the v6.0 stubs.
Per-method shims follow the same template:
- Extract
account_reffrom the typed request (when the tool carriesaccounton the wire). - Resolve via
platform.accounts.resolve(ref, auth_info=...). - Build :class:
RequestContextvia :func:_build_request_context. - Invoke the platform method via :func:
_invoke_platform_method.
Adopters who don't override a given platform method get the framework's
not_supportedbaseline (per ADCPHandler) on those tools — and the override-detection filter drops the tool fromtools/listunless they passadvertise_all=True.Ancestors
- ADCPHandler
- abc.ABC
- typing.Generic
Class variables
var advertised_tools : ClassVar[set[str]]-
Class-level union of every tool the shim CAN serve. Used by the framework's
__init_subclass__registration so the class shows up in :data:adcp.server.mcp_tools._HANDLER_TOOLS. The actual per-instance advertisement is computed by :meth:advertised_tools_for_instancefrom the platform's claimed specialisms — without that intersection, a sales-only adopter would advertise all 40+ shims (Emma cross-cutting P1).
Methods
async def acquire_rights(self, params: AcquireRightsRequest, context: ToolContext | None = None) ‑> adcp.types.generated_poc.brand.acquire_rights_response.AcquireRightsResponse1 | adcp.types.generated_poc.brand.acquire_rights_response.AcquireRightsResponse2 | adcp.types.generated_poc.brand.acquire_rights_response.AcquireRightsResponse3 | adcp.types.generated_poc.brand.acquire_rights_response.AcquireRightsResponse4-
Expand source code
async def acquire_rights( # type: ignore[override] self, params: AcquireRightsRequest, context: ToolContext | None = None, ) -> AcquireRightsResponse: """Acquire rights — 4-arm discriminated success union (acquired / pending / rejected / error). Rejection-as-data per the Protocol; the ``error`` arm covers rights-system failures the buyer can retry against (vs. ``AdcpError`` for adopter infrastructure failures). Wire request has no ``account`` field per ``schemas/cache/brand-rights/acquire-rights-request.json`` (``additionalProperties: false``); resolve via auth only. """ tool_ctx = context or ToolContext() account = await self._resolve_account(None, tool_ctx) ctx = self._build_ctx(tool_ctx, account) result = await _invoke_platform_method( self._platform, "acquire_rights", params, ctx, executor=self._executor, registry=self._registry, **self._handoff_webhook_kwargs(), ) self._maybe_auto_emit_sync_completion("acquire_rights", params, result) return cast("AcquireRightsResponse", result)Acquire rights — 4-arm discriminated success union (acquired / pending / rejected / error). Rejection-as-data per the Protocol; the
errorarm covers rights-system failures the buyer can retry against (vs.AdcpErrorfor adopter infrastructure failures).Wire request has no
accountfield perschemas/cache/brand-rights/acquire-rights-request.json(additionalProperties: false); resolve via auth only. async def activate_signal(self, params: ActivateSignalRequest, context: ToolContext | None = None) ‑> adcp.types.generated_poc.signals.activate_signal_response.ActivateSignalResponse1-
Expand source code
async def activate_signal( # type: ignore[override] self, params: ActivateSignalRequest, context: ToolContext | None = None, ) -> ActivateSignalSuccessResponse: """Provision a signal onto destination platforms.""" self._require_platform_method("activate_signal") 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, "activate_signal", params, ctx, executor=self._executor, registry=self._registry, **self._handoff_webhook_kwargs(), ) self._maybe_auto_emit_sync_completion("activate_signal", params, result) return cast("ActivateSignalSuccessResponse", result)Provision a signal onto destination platforms.
def advertised_tools_for_instance(self) ‑> frozenset[str]-
Expand source code
def advertised_tools_for_instance(self) -> frozenset[str]: """Tools this handler advertises GIVEN ITS PLATFORM'S CLAIMED SPECIALISMS. Without this hook, ``get_tools_for_handler`` walks the class's MRO + ``_is_method_overridden`` filter — both keyed on ``PlatformHandler``, which defines all 40+ shims as concrete methods. Result: a sales-only adopter advertises ``acquire_rights``, ``build_creative``, every signals/audience tool, etc. Buyers see a giant menu of tools that 501 on call; Emma sales/creative/signals backend tests all flagged this as P1 ("advertising 42 of 42 tools"). Per-instance advertisement intersects the universe of shim coverage with what the platform's claimed specialisms are responsible for via :data:`SPECIALISM_TO_ADVERTISED_TOOLS`. Specialisms not in that map (``signed-requests``, ``governance-aware-seller``) are meta-claims and contribute no tools — they compose with a non-meta claim that does. :returns: The intersection of ``advertised_tools`` (universe) with the per-specialism-allowed set. Empty when no recognized specialisms are claimed (e.g., adopter still piloting a novel slug not in the spec enum); transport layer should fall back to the class-level set in that case so the handler isn't accidentally muted. """ claimed = self._platform.capabilities.specialisms serving: set[str] = set() for entry in claimed: # ``specialisms`` is ``list[Specialism | str]`` — spec-known # entries are coerced to enum by ``__post_init__``; novel / # pre-spec slugs pass through as strings. slug = entry.value if hasattr(entry, "value") else entry tools = SPECIALISM_TO_ADVERTISED_TOOLS.get(slug) if tools is not None: serving |= set(tools) # Drop sync_accounts / list_accounts when the platform's # AccountStore doesn't expose the corresponding optional # Protocol method. ``sales-*`` claims union both tools in by # default (account roster is required by spec) but adopters who # haven't wired :class:`AccountStoreUpsertRequest`, # :class:`AccountStoreUpsert`, or :class:`AccountStoreList` # would otherwise advertise tools that always answer # OPERATION_NOT_SUPPORTED. # # Log once per (handler, dropped-tool) when the claim asked for # a tool the store can't serve — actionable signal for adopters # whose storyboard scenarios stay ``skipped (missing_tool)`` # without the polyfill. accounts = getattr(self._platform, "accounts", None) can_sync_accounts = accounts is not None and ( callable(getattr(accounts, "upsert_request", None)) or callable(getattr(accounts, "upsert", None)) ) if "sync_accounts" in serving and not can_sync_accounts: serving.discard("sync_accounts") self._log_account_tool_dropped("sync_accounts", "upsert") if "list_accounts" in serving and ( accounts is None or not callable(getattr(accounts, "list", None)) ): serving.discard("list_accounts") self._log_account_tool_dropped("list_accounts", "list") return frozenset(serving)Tools this handler advertises GIVEN ITS PLATFORM'S CLAIMED SPECIALISMS.
Without this hook,
get_tools_for_handlerwalks the class's MRO +_is_method_overriddenfilter — both keyed onPlatformHandler, which defines all 40+ shims as concrete methods. Result: a sales-only adopter advertisesacquire_rights,build_creative, every signals/audience tool, etc. Buyers see a giant menu of tools that 501 on call; Emma sales/creative/signals backend tests all flagged this as P1 ("advertising 42 of 42 tools").Per-instance advertisement intersects the universe of shim coverage with what the platform's claimed specialisms are responsible for via :data:
SPECIALISM_TO_ADVERTISED_TOOLS. Specialisms not in that map (signed-requests,governance-aware-seller) are meta-claims and contribute no tools — they compose with a non-meta claim that does.:returns: The intersection of
advertised_tools(universe) with the per-specialism-allowed set. Empty when no recognized specialisms are claimed (e.g., adopter still piloting a novel slug not in the spec enum); transport layer should fall back to the class-level set in that case so the handler isn't accidentally muted. async def build_creative(self, params: BuildCreativeRequest, context: ToolContext | None = None) ‑> adcp.types.generated_poc.media_buy.build_creative_response.BuildCreativeResponse1 | adcp.types.generated_poc.media_buy.build_creative_response.BuildCreativeResponse2 | adcp.types.generated_poc.media_buy.build_creative_response.BuildCreativeResponse3 | adcp.types.generated_poc.media_buy.build_creative_response.BuildCreativeResponse4 | adcp.types.generated_poc.media_buy.build_creative_response.BuildCreativeResponse5 | adcp.types.generated_poc.media_buy.build_creative_response.BuildCreativeResponse6-
Expand source code
async def build_creative( # type: ignore[override] self, params: BuildCreativeRequest, context: ToolContext | None = None, ) -> BuildCreativeResponse: """Build / retrieve a creative. Three discriminated return arms per the per-specialism Protocol: a single :class:`CreativeManifest`, a list of manifests (multi-format), or a fully-shaped :class:`BuildCreativeSuccessResponse`. The shim projects bare manifests / lists to the wire envelope shape so adopters can return the ergonomic form (per the JS-side ``projectBuildCreativeReturn`` parity). Wire envelope per ``schemas/cache/media-buy/build-creative-response.json``: ``{creative_manifest: ...}`` (single) or ``{creative_manifests: [...]}`` (multi). """ self._require_platform_method("build_creative") 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, "build_creative", params, ctx, executor=self._executor, registry=self._registry, ) return cast("BuildCreativeResponse", _project_build_creative(result))Build / retrieve a creative.
Three discriminated return arms per the per-specialism Protocol: a single :class:
CreativeManifest, a list of manifests (multi-format), or a fully-shaped :class:BuildCreativeSuccessResponse. The shim projects bare manifests / lists to the wire envelope shape so adopters can return the ergonomic form (per the JS-sideprojectBuildCreativeReturnparity).Wire envelope per
schemas/cache/media-buy/build-creative-response.json:{creative_manifest: ...}(single) or{creative_manifests: [...]}(multi). async def calibrate_content(self, params: CalibrateContentRequest, context: ToolContext | None = None) ‑> adcp.types.generated_poc.content_standards.calibrate_content_response.CalibrateContentResponse1 | adcp.types.generated_poc.content_standards.calibrate_content_response.CalibrateContentResponse2-
Expand source code
async def calibrate_content( # type: ignore[override] self, params: CalibrateContentRequest, context: ToolContext | None = None, ) -> CalibrateContentResponse: """Calibrate content against published standards. Wire request has no ``account`` field per ``schemas/cache/content-standards/calibrate-content-request.json``; resolve via auth only. """ tool_ctx = context or ToolContext() account = await self._resolve_account(None, tool_ctx) ctx = self._build_ctx(tool_ctx, account) return cast( "CalibrateContentResponse", await _invoke_platform_method( self._platform, "calibrate_content", params, ctx, executor=self._executor, registry=self._registry, ), )Calibrate content against published standards.
Wire request has no
accountfield perschemas/cache/content-standards/calibrate-content-request.json; resolve via auth only. async def check_governance(self, params: CheckGovernanceRequest, context: ToolContext | None = None) ‑> adcp.types.generated_poc.governance.check_governance_response.CheckGovernanceResponse-
Expand source code
async def check_governance( # type: ignore[override] self, params: CheckGovernanceRequest, context: ToolContext | None = None, ) -> CheckGovernanceResponse: """Runtime governance decision (approved / denied / conditions). Wire request has no ``account`` field per ``schemas/cache/governance/check-governance-request.json`` (``additionalProperties: false`` — account is forbidden); resolve via auth only. See :meth:`provide_performance_feedback` for the no-ref account resolution caveat. """ tool_ctx = context or ToolContext() account = await self._resolve_account(None, tool_ctx) ctx = self._build_ctx(tool_ctx, account) return cast( "CheckGovernanceResponse", await _invoke_platform_method( self._platform, "check_governance", params, ctx, executor=self._executor, registry=self._registry, ), )Runtime governance decision (approved / denied / conditions).
Wire request has no
accountfield perschemas/cache/governance/check-governance-request.json(additionalProperties: false— account is forbidden); resolve via auth only. See :meth:provide_performance_feedbackfor the no-ref account resolution caveat. async def create_content_standards(self, params: CreateContentStandardsRequest, context: ToolContext | None = None) ‑> adcp.types.generated_poc.content_standards.create_content_standards_response.CreateContentStandardsResponse-
Expand source code
async def create_content_standards( # type: ignore[override] self, params: CreateContentStandardsRequest, context: ToolContext | None = None, ) -> CreateContentStandardsResponse: """Wire request has no ``account`` field; resolve via auth only.""" tool_ctx = context or ToolContext() account = await self._resolve_account(None, tool_ctx) ctx = self._build_ctx(tool_ctx, account) return cast( "CreateContentStandardsResponse", await _invoke_platform_method( self._platform, "create_content_standards", params, ctx, executor=self._executor, registry=self._registry, ), )Wire request has no
accountfield; resolve via auth only. async def delete_collection_list(self, params: DeleteCollectionListRequest, context: ToolContext | None = None) ‑> adcp.types.generated_poc.collection.delete_collection_list_response.DeleteCollectionListResponse-
Expand source code
async def delete_collection_list( # type: ignore[override] self, params: DeleteCollectionListRequest, context: ToolContext | None = None, ) -> DeleteCollectionListResponse: """Security-critical: revokes the fetch_token. See :meth:`delete_property_list` for the same security contract.""" tool_ctx = context or ToolContext() account = await self._resolve_account(getattr(params, "account", None), tool_ctx) ctx = self._build_ctx(tool_ctx, account) return cast( "DeleteCollectionListResponse", await _invoke_platform_method( self._platform, "delete_collection_list", params, ctx, executor=self._executor, registry=self._registry, ), )Security-critical: revokes the fetch_token. See :meth:
delete_property_listfor the same security contract. async def delete_property_list(self, params: DeletePropertyListRequest, context: ToolContext | None = None) ‑> adcp.types.generated_poc.property.delete_property_list_response.DeletePropertyListResponse-
Expand source code
async def delete_property_list( # type: ignore[override] self, params: DeletePropertyListRequest, context: ToolContext | None = None, ) -> DeletePropertyListResponse: """Security-critical: revokes the per-seller fetch_token and signals cache invalidation. Compromise-driven revocation MUST also trigger this path.""" 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, "delete_property_list", params, ctx, executor=self._executor, registry=self._registry, **self._handoff_webhook_kwargs(), ) self._maybe_auto_emit_sync_completion("delete_property_list", params, result) return cast("DeletePropertyListResponse", result)Security-critical: revokes the per-seller fetch_token and signals cache invalidation. Compromise-driven revocation MUST also trigger this path.
async def get_adcp_capabilities(self,
params: GetAdcpCapabilitiesRequest | dict[str, Any] | None = None,
context: ToolContext | None = None) ‑> dict[str, typing.Any]-
Expand source code
async def get_adcp_capabilities( self, params: GetAdcpCapabilitiesRequest | dict[str, Any] | None = None, context: ToolContext | None = None, ) -> dict[str, Any]: """Project the platform's :class:`DecisioningCapabilities` into a spec-conformant ``get_adcp_capabilities`` response. The projection mirrors the wire spec block-by-block. Each top-level capability block (``account``, ``media_buy``, ``signals``, ``governance``, ``sponsored_intelligence``, ``brand``, ``creative``, ``request_signing``, ``webhook_signing``, ``identity``, ``compliance_testing``) is emitted via ``model_dump(mode="json", exclude_none=True)`` when the adopter has declared a value. Auto-derives: * ``adcp.idempotency`` from :attr:`DecisioningCapabilities.adcp` (when set) or defaults to ``{"supported": False}`` so the response stays spec-valid. * ``supported_protocols`` from :attr:`DecisioningCapabilities.supported_protocols` (override) or, when None, the union of :data:`SPECIALISM_TO_PROTOCOLS` over the platform's claimed specialisms. * Wire-level ``specialisms`` field from spec-known entries in :attr:`DecisioningCapabilities.specialisms` (novel / typo strings are filtered — only spec-defined slugs reach the wire). * ``media_buy.execution.targeting.geo_postal_areas`` from one typed declaration into the caller's negotiated postal shape: native country-keyed systems for explicit AdCP 3.1+ callers, deprecated fused booleans for unversioned / 3.0 callers. Legacy-field projection (deprecation warnings emitted): * ``account.supported_billing`` falls back to :attr:`DecisioningCapabilities.supported_billing` when ``account`` isn't set. ``DeprecationWarning``. * ``media_buy.supported_pricing_models`` falls back to :attr:`DecisioningCapabilities.pricing_models` when ``media_buy`` isn't set. ``DeprecationWarning``. * ``channels`` is no longer projected — the spec's ``portfolio.primary_channels`` requires ``portfolio.publisher_domains`` alongside, which the flat ``channels`` field can't supply. Adopters who set ``channels`` get a ``DeprecationWarning`` pointing at ``media_buy.portfolio``. Adopters who need request-scoped capability blocks (for example tenant-specific publisher domains or webhook-signing support) override :meth:`DecisioningPlatform.get_adcp_capabilities_for_request`. That hook returns a typed :class:`DecisioningCapabilities` override before the automatic postal projection runs; this method remains responsible for the canonical wire projection. """ from adcp.decisioning.types import AdcpError caps = self._platform.capabilities try: scoped_caps = self._platform.get_adcp_capabilities_for_request(params, context) if inspect.isawaitable(scoped_caps): scoped_caps = await scoped_caps except AdcpError: raise except Exception as exc: logger.exception( "Unhandled exception in platform.get_adcp_capabilities_for_request — " "wrapping to INTERNAL_ERROR" ) raise AdcpError( "INTERNAL_ERROR", message=( "Unhandled exception in platform.get_adcp_capabilities_for_request: " f"{type(exc).__name__}: {exc}" ), recovery="terminal", details={ "caused_by": { "type": type(exc).__name__, "message": str(exc), } }, ) from exc has_scoped_caps = scoped_caps is not None if scoped_caps is not None: caps = scoped_caps if not isinstance(caps, DecisioningCapabilities): raise AdcpError( "INVALID_REQUEST", message=( "DecisioningPlatform.get_adcp_capabilities_for_request() " "must return DecisioningCapabilities or None; got " f"{type(caps).__name__}" ), recovery="terminal", ) if has_scoped_caps: _validate_scoped_capabilities_static_contract( static_caps=self._platform.capabilities, scoped_caps=caps, ) from adcp.decisioning.validate_idempotency import ( validate_idempotency_wiring_for_capabilities, ) from adcp.decisioning.webhook_emit import validate_webhook_signing_for_capabilities validate_idempotency_wiring_for_capabilities(self._platform, caps) validate_webhook_signing_for_capabilities( capabilities=caps, sender=self._webhook_sender, supervisor=self._webhook_supervisor, ) # ----- supported_protocols: explicit override > derive from specialisms ----- supported_protocols: list[str] if caps.supported_protocols is not None: supported_protocols = [ p.value if hasattr(p, "value") else str(p) for p in caps.supported_protocols ] else: protocols: set[str] = set() for entry in caps.specialisms: slug = entry.value if hasattr(entry, "value") else entry protocols.update(SPECIALISM_TO_PROTOCOLS.get(slug, frozenset())) # ``supported_protocols`` is the storyboard commitment per # spec. When no declared specialism resolves to a protocol # (e.g. only meta specialisms like ``governance-aware-seller`` # claimed, or no specialisms at all), emit an empty list # rather than silently default to ``["media_buy"]`` — claiming # a protocol the adopter never declared forces them to fail # the baseline storyboard for that protocol. The boot-time # validator # (:func:`adcp.decisioning.validate_capabilities.validate_capabilities_response_shape`) # catches the empty list as an explicit configuration error, # giving adopters a clean failure pointing at the declaration # site rather than a wire-shaped lie. Adopters who claim a # protocol without an enumerated specialism set # ``supported_protocols`` explicitly. supported_protocols = sorted(protocols) # ----- adcp block: structured > default ----- # Route structured overrides through the response helper so its # supported_versions defaults stay aligned with major_versions. # A custom Adcp(major_versions=[2, 3]) without exact # supported_versions must not inherit the SDK's 3.x exact list. from adcp.server.responses import capabilities_response adcp = caps.adcp.model_dump(mode="json", exclude_none=True) if caps.adcp is not None else {} response = capabilities_response( supported_protocols, major_versions=adcp.get("major_versions"), supported_versions=adcp.get("supported_versions"), build_version=adcp.get("build_version"), idempotency=adcp.get("idempotency") or {"supported": False}, ) # ----- structured capability blocks (model_dump for each) ----- # Each block emits only when the adopter has declared a value. # ``exclude_none=True`` keeps the wire shape minimal (every # nested optional collapses cleanly). if caps.account is not None: response["account"] = caps.account.model_dump(mode="json", exclude_none=True) if caps.media_buy is not None: media_buy = caps.media_buy.model_dump(mode="json", exclude_none=True) execution_model = getattr(caps.media_buy, "execution", None) targeting_model = getattr(execution_model, "targeting", None) geo_postal_areas = getattr(targeting_model, "geo_postal_areas", None) if geo_postal_areas is not None: projected_geo_postal_areas = project_geo_postal_areas( geo_postal_areas, context.resolved_adcp_version if context is not None else None, ) execution = media_buy.get("execution") if isinstance(execution, dict): targeting = execution.get("targeting") if isinstance(targeting, dict): if projected_geo_postal_areas: targeting["geo_postal_areas"] = projected_geo_postal_areas else: targeting.pop("geo_postal_areas", None) if not targeting: execution.pop("targeting", None) if not execution: media_buy.pop("execution", None) response["media_buy"] = media_buy if caps.signals is not None: response["signals"] = caps.signals.model_dump(mode="json", exclude_none=True) if caps.governance is not None: response["governance"] = caps.governance.model_dump(mode="json", exclude_none=True) if caps.sponsored_intelligence is not None: response["sponsored_intelligence"] = caps.sponsored_intelligence.model_dump( mode="json", exclude_none=True ) if caps.brand is not None: response["brand"] = caps.brand.model_dump(mode="json", exclude_none=True) if caps.creative is not None: response["creative"] = caps.creative.model_dump(mode="json", exclude_none=True) if caps.measurement is not None: response["measurement"] = caps.measurement.model_dump(mode="json", exclude_none=True) if caps.request_signing is not None: response["request_signing"] = caps.request_signing.model_dump( mode="json", exclude_none=True ) if caps.webhook_signing is not None: response["webhook_signing"] = caps.webhook_signing.model_dump( mode="json", exclude_none=True ) if caps.identity is not None: response["identity"] = caps.identity.model_dump(mode="json", exclude_none=True) if caps.compliance_testing is not None: response["compliance_testing"] = caps.compliance_testing.model_dump( mode="json", exclude_none=True ) if caps.experimental_features: response["experimental_features"] = [ feature.model_dump(mode="json") if hasattr(feature, "model_dump") else feature for feature in caps.experimental_features ] # ----- wire ``specialisms`` field: spec-known entries only ----- # Only spec-defined enum members reach the wire — novel / typo # strings stay diagnostic-only at the dispatch layer and don't # leak into the capabilities response. wire_specialisms = [entry.value for entry in caps.specialisms if hasattr(entry, "value")] if wire_specialisms: response["specialisms"] = wire_specialisms # ----- legacy flat-field projection ----- # Deprecation warnings for legacy fields fire at construction in # ``DecisioningCapabilities.__post_init__`` — they point at the # adopter's declaration site, not at the dispatcher. Here we only # project the legacy values when the structured equivalent isn't # set. ``channels`` doesn't project to anything because the spec's # ``portfolio.primary_channels`` requires ``publisher_domains`` # alongside, which the flat field can't supply. if caps.supported_billing and caps.account is None: response["account"] = {"supported_billing": list(caps.supported_billing)} if caps.pricing_models and caps.media_buy is None and "media_buy" in supported_protocols: # Spec requires uniqueItems on supported_pricing_models; # dedupe via dict.fromkeys to preserve declaration order. response["media_buy"] = { "supported_pricing_models": list(dict.fromkeys(caps.pricing_models)), } if has_scoped_caps: from adcp.decisioning.validate_capabilities import _validate_response_dict _validate_response_dict(response) return responseProject the platform's :class:
DecisioningCapabilitiesinto a spec-conformantget_adcp_capabilitiesresponse.The projection mirrors the wire spec block-by-block. Each top-level capability block (
account,media_buy,signals,governance,sponsored_intelligence,brand,creative,request_signing,webhook_signing,identity,compliance_testing) is emitted viamodel_dump(mode="json", exclude_none=True)when the adopter has declared a value.Auto-derives:
adcp.idempotencyfrom :attr:DecisioningCapabilities.adcp(when set) or defaults to{"supported": False}so the response stays spec-valid.supported_protocolsfrom :attr:DecisioningCapabilities.supported_protocols(override) or, when None, the union of :data:SPECIALISM_TO_PROTOCOLSover the platform's claimed specialisms.- Wire-level
specialismsfield from spec-known entries in :attr:DecisioningCapabilities.specialisms(novel / typo strings are filtered — only spec-defined slugs reach the wire). media_buy.execution.targeting.geo_postal_areasfrom one typed declaration into the caller's negotiated postal shape: native country-keyed systems for explicit AdCP 3.1+ callers, deprecated fused booleans for unversioned / 3.0 callers.
Legacy-field projection (deprecation warnings emitted):
account.supported_billingfalls back to :attr:DecisioningCapabilities.supported_billingwhenaccountisn't set.DeprecationWarning.media_buy.supported_pricing_modelsfalls back to :attr:DecisioningCapabilities.pricing_modelswhenmedia_buyisn't set.DeprecationWarning.channelsis no longer projected — the spec'sportfolio.primary_channelsrequiresportfolio.publisher_domainsalongside, which the flatchannelsfield can't supply. Adopters who setchannelsget aDeprecationWarningpointing atmedia_buy.portfolio.
Adopters who need request-scoped capability blocks (for example tenant-specific publisher domains or webhook-signing support) override :meth:
DecisioningPlatform.get_adcp_capabilities_for_request. That hook returns a typed :class:DecisioningCapabilitiesoverride before the automatic postal projection runs; this method remains responsible for the canonical wire projection. def get_advertised_tools(self, *, advertise_all: bool | None = None) ‑> frozenset[str]-
Expand source code
def get_advertised_tools(self, *, advertise_all: bool | None = None) -> frozenset[str]: """Names ``tools/list`` will return when this handler is served. The class-level :attr:`advertised_tools` set is the *universe* of tools the handler base supports across all specialisms (~50 entries on :class:`PlatformHandler`). What buyers actually see on the wire is narrower: 1. Per-instance specialism filter — :meth:`advertised_tools_for_instance` intersects the universe with the platform's claimed specialisms (a sales-only adopter drops audience/governance tools). 2. Override-detection filter — tools whose handler method is still the SDK's ``not_supported`` default are dropped (``advertise_all=False``, the spec-aligned default). This method runs the same pipeline :func:`adcp.server.serve` runs at boot, so adopters can inspect the effective set without standing up a network port. The default ``advertise_all`` value is whatever was configured on :func:`adcp.decisioning.create_adcp_server_from_platform` (``False`` when not set). :param advertise_all: Override the configured value for this call. ``True`` returns the per-specialism set without the override filter; ``False`` applies the full filter. :returns: Frozen set of tool names. """ from adcp.server.mcp_tools import get_tools_for_handler effective = self._advertise_all if advertise_all is None else advertise_all return frozenset( tool["name"] for tool in get_tools_for_handler(self, advertise_all=effective) )Names
tools/listwill return when this handler is served.The class-level :attr:
advertised_toolsset is the universe of tools the handler base supports across all specialisms (~50 entries on :class:PlatformHandler). What buyers actually see on the wire is narrower:- Per-instance specialism filter — :meth:
advertised_tools_for_instanceintersects the universe with the platform's claimed specialisms (a sales-only adopter drops audience/governance tools). - Override-detection filter — tools whose handler method is
still the SDK's
not_supporteddefault are dropped (advertise_all=False, the spec-aligned default).
This method runs the same pipeline :func:
serve()runs at boot, so adopters can inspect the effective set without standing up a network port. The defaultadvertise_allvalue is whatever was configured on :func:create_adcp_server_from_platform()(Falsewhen not set).:param advertise_all: Override the configured value for this call.
Truereturns the per-specialism set without the override filter;Falseapplies the full filter. :returns: Frozen set of tool names. - Per-instance specialism filter — :meth:
async def get_brand_identity(self, params: GetBrandIdentityRequest, context: ToolContext | None = None) ‑> adcp.types.generated_poc.brand.get_brand_identity_response.GetBrandIdentityResponse1-
Expand source code
async def get_brand_identity( # type: ignore[override] self, params: GetBrandIdentityRequest, context: ToolContext | None = None, ) -> GetBrandIdentitySuccessResponse: """Read brand identity record (catalog + identity record). Wire request has no ``account`` field per ``schemas/cache/brand-rights/get-brand-identity-request.json`` (``additionalProperties: false``); resolve via auth only. See :meth:`provide_performance_feedback` for the no-ref account resolution caveat. """ tool_ctx = context or ToolContext() account = await self._resolve_account(None, tool_ctx) ctx = self._build_ctx(tool_ctx, account) result = await _invoke_platform_method( self._platform, "get_brand_identity", params, ctx, executor=self._executor, registry=self._registry, **self._handoff_webhook_kwargs(), ) self._maybe_auto_emit_sync_completion("get_brand_identity", params, result) return cast("GetBrandIdentitySuccessResponse", result)Read brand identity record (catalog + identity record).
Wire request has no
accountfield perschemas/cache/brand-rights/get-brand-identity-request.json(additionalProperties: false); resolve via auth only. See :meth:provide_performance_feedbackfor the no-ref account resolution caveat. async def get_content_standards(self, params: GetContentStandardsRequest, context: ToolContext | None = None) ‑> adcp.types.generated_poc.content_standards.get_content_standards_response.GetContentStandardsResponse1 | adcp.types.generated_poc.content_standards.get_content_standards_response.GetContentStandardsResponse2-
Expand source code
async def get_content_standards( # type: ignore[override] self, params: GetContentStandardsRequest, context: ToolContext | None = None, ) -> GetContentStandardsResponse: """Wire request has no ``account`` field per ``schemas/cache/content-standards/get-content-standards-request.json``; resolve via auth only.""" tool_ctx = context or ToolContext() account = await self._resolve_account(None, tool_ctx) ctx = self._build_ctx(tool_ctx, account) return cast( "GetContentStandardsResponse", await _invoke_platform_method( self._platform, "get_content_standards", params, ctx, executor=self._executor, registry=self._registry, ), )Wire request has no
accountfield perschemas/cache/content-standards/get-content-standards-request.json; resolve via auth only. async def get_creative_delivery(self, params: GetCreativeDeliveryRequest, context: ToolContext | None = None) ‑> adcp.types.generated_poc.creative.get_creative_delivery_response.GetCreativeDeliveryResponse-
Expand source code
async def get_creative_delivery( # type: ignore[override] self, params: GetCreativeDeliveryRequest, context: ToolContext | None = None, ) -> GetCreativeDeliveryResponse: """Required on :class:`CreativeAdServerPlatform` — per-creative delivery actuals (impressions, spend, pacing).""" 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, "get_creative_delivery", params, ctx, executor=self._executor, registry=self._registry, **self._handoff_webhook_kwargs(), ) self._maybe_auto_emit_sync_completion("get_creative_delivery", params, result) return cast("GetCreativeDeliveryResponse", result)Required on :class:
CreativeAdServerPlatform— per-creative delivery actuals (impressions, spend, pacing). async def get_creative_features(self, params: GetCreativeFeaturesRequest, context: ToolContext | None = None) ‑> adcp.types.generated_poc.creative.get_creative_features_response.GetCreativeFeaturesResponse1 | adcp.types.generated_poc.creative.get_creative_features_response.GetCreativeFeaturesResponse2-
Expand source code
async def get_creative_features( # type: ignore[override] self, params: GetCreativeFeaturesRequest, context: ToolContext | None = None, ) -> GetCreativeFeaturesResponse: """Optional analyzer read — adopters without analyzer pipelines surface ``UNSUPPORTED_FEATURE``.""" self._require_platform_method("get_creative_features") tool_ctx = context or ToolContext() account = await self._resolve_account(getattr(params, "account", None), tool_ctx) ctx = self._build_ctx(tool_ctx, account) return cast( "GetCreativeFeaturesResponse", await _invoke_platform_method( self._platform, "get_creative_features", params, ctx, executor=self._executor, registry=self._registry, ), )Optional analyzer read — adopters without analyzer pipelines surface
UNSUPPORTED_FEATURE. async def get_media_buy_artifacts(self, params: GetMediaBuyArtifactsRequest, context: ToolContext | None = None) ‑> adcp.types.generated_poc.content_standards.get_media_buy_artifacts_response.GetMediaBuyArtifactsResponse1 | adcp.types.generated_poc.content_standards.get_media_buy_artifacts_response.GetMediaBuyArtifactsResponse2-
Expand source code
async def get_media_buy_artifacts( # type: ignore[override] self, params: GetMediaBuyArtifactsRequest, context: ToolContext | None = None, ) -> GetMediaBuyArtifactsResponse: """Optional analyzer read — adopters without artifact archival surface ``UNSUPPORTED_FEATURE``.""" self._require_platform_method("get_media_buy_artifacts") tool_ctx = context or ToolContext() account = await self._resolve_account(getattr(params, "account", None), tool_ctx) ctx = self._build_ctx(tool_ctx, account) return cast( "GetMediaBuyArtifactsResponse", await _invoke_platform_method( self._platform, "get_media_buy_artifacts", params, ctx, executor=self._executor, registry=self._registry, ), )Optional analyzer read — adopters without artifact archival surface
UNSUPPORTED_FEATURE. async def get_plan_audit_logs(self, params: GetPlanAuditLogsRequest, context: ToolContext | None = None) ‑> adcp.types.generated_poc.governance.get_plan_audit_logs_response.GetPlanAuditLogsResponse-
Expand source code
async def get_plan_audit_logs( # type: ignore[override] self, params: GetPlanAuditLogsRequest, context: ToolContext | None = None, ) -> GetPlanAuditLogsResponse: """Audit log read for governance decisions + outcomes. Wire request has no ``account`` field per ``schemas/cache/governance/get-plan-audit-logs-request.json`` (``additionalProperties: false``); resolve via auth only. """ tool_ctx = context or ToolContext() account = await self._resolve_account(None, tool_ctx) ctx = self._build_ctx(tool_ctx, account) return cast( "GetPlanAuditLogsResponse", await _invoke_platform_method( self._platform, "get_plan_audit_logs", params, ctx, executor=self._executor, registry=self._registry, ), )Audit log read for governance decisions + outcomes.
Wire request has no
accountfield perschemas/cache/governance/get-plan-audit-logs-request.json(additionalProperties: false); resolve via auth only. async def get_products(self, params: GetProductsRequest, context: ToolContext | None = None) ‑> adcp.types.generated_poc.media_buy.get_products_response.GetProductsResponse-
Expand source code
async def get_products( # type: ignore[override] self, params: GetProductsRequest, context: ToolContext | None = None, ) -> GetProductsResponse: """Invoke the platform's ``get_products`` method and apply fields projection. When the platform is a :class:`PlatformRouter` with per-tenant ``proposal_managers`` wired, the router's own ``get_products`` method handles the proposal-side dispatch (per-tenant :class:`ProposalManager` lookup, refine-mode selection, fall-through to the tenant's :class:`DecisioningPlatform`). The handler delegates uniformly via :func:`_invoke_platform_method`; the routing decision lives on the router. When ``params.fields`` is set the framework drops unrequested product fields after the platform method returns, always retaining the eight schema-required fields. When ``params.fields`` is ``None`` the response passes through unchanged. """ tool_ctx = context or ToolContext() # Pre-adapter: validate buying_mode against the wire spec's # mutual-exclusion rules (refine+brief, wholesale+brief, refine # without refine[]). assert_buying_mode_consistent(params) # Guard (a): wholesale + push_notification_config is rejected # before the platform method runs — wholesale discovery is a # synchronous rate-card read with no async lifecycle. The # platform method is never invoked on this path. assert_discovery_push_consistent(params, mode_field="buying_mode") account = await self._resolve_account(params.account, tool_ctx) # Guard (c) pre-dispatch: a buyer-supplied push_notification_config # makes the request async up front. If the account is unresolved # (sentinel/empty id) the eventual task_id would be unreachable — # reject before invoking the platform method. _has_push = getattr(params, "push_notification_config", None) is not None if _has_push: assert_account_resolved_for_async(None, account_id=account.id, has_push=True) ctx = self._build_ctx(tool_ctx, account) # Refine flow: when buying_mode='refine' the framework dispatches # to refine_get_products() (when present) and projects the result # into the wire response — adopters return a RefineResult and # framework constructs position-matched refinement_applied[]. buying_mode_attr = getattr(params, "buying_mode", None) mode = ( ( buying_mode_attr.value if hasattr(buying_mode_attr, "value") else str(buying_mode_attr) ) if buying_mode_attr is not None else None ) if mode == "refine": from adcp.decisioning.types import AdcpError # v1.5 finalize interception (D2 + D7). When the request # carries a refine[i].action='finalize' AND the resolved # tenant has a finalize-capable manager + wired store, the # framework intercepts before refine_products and runs the # finalize lifecycle: hydrate draft → call finalize_proposal → # commit → project wire response. No-op when finalize isn't # in scope; falls through to the v1 refine path. finalize_response = await maybe_intercept_finalize( self._platform, params, ctx, executor=self._executor, registry=self._registry, ) if finalize_response is not None: return cast("GetProductsResponse", finalize_response) await maybe_validate_refine_proposal_refs(self._platform, params, ctx) if not has_refine_support(self._platform): raise AdcpError( "INVALID_REQUEST", message=( "buying_mode='refine' is not supported by this " "seller. The platform does not implement " "refine_get_products(). Buyers should retry with " "buying_mode='brief' or 'wholesale'." ), field="buying_mode", ) refine_result = await _invoke_platform_method( self._platform, "refine_get_products", params, ctx, executor=self._executor, registry=self._registry, ) # Two refine return shapes coexist: # - Direct platform.refine_get_products returns a RefineResult # (typed object with per_refine_outcome) — framework projects # to wire response. # - PlatformRouter.refine_get_products forwards to the per-tenant # ProposalManager.refine_products which returns a wire-shaped # GetProductsResponse directly. Skip projection in that case. if isinstance(refine_result, RefineResult): projected: GetProductsResponse = project_refine_response( refine_result, params.refine or [] ) await maybe_persist_draft_after_get_products(self._platform, projected, ctx) return projected await maybe_persist_draft_after_get_products(self._platform, refine_result, ctx) return cast("GetProductsResponse", refine_result) # Resolve time_budget to a seconds deadline. _resolve_account and # _build_ctx are intentionally outside this try/except so their # AdcpErrors propagate unmodified; only the platform call is deadline- # wrapped. deadline = resolve_time_budget(params.time_budget) # Terminal side-effect (persist draft proposals) threaded as an # on_complete hook so it fires on COMPLETION in BOTH the sync and # handoff paths — never at submit. On the handoff path the bg task # awaits the adopter's coroutine, then fires this hook with the # terminal GetProductsResponse before registry.complete; on the # sync path it fires inline with the adapter's return. This mirrors # create_media_buy's consumption-finalize hook (handler.py # create_media_buy). The hook persists the raw adapter result; # buyer-presentation projections (property-list, pagination, # fields) shape only the wire response, not the stored draft. captured_platform = self._platform captured_ctx = ctx async def _persist_draft_hook(get_products_result: Any) -> None: await maybe_persist_draft_after_get_products( captured_platform, get_products_result, captured_ctx ) # Guard (b) pre-launch: a wholesale call that hands off is rejected # the instant dispatch detects the TaskHandoff — before any registry # row, persist-draft, background coroutine, or completion webhook. # Belt-and-braces post-dispatch check stays below. pre_handoff_reject = ( (lambda: reject_wholesale_handoff_before_launch("buying_mode")) if mode == "wholesale" else None ) coro = _invoke_platform_method( self._platform, "get_products", params, ctx, executor=self._executor, registry=self._registry, on_complete=_persist_draft_hook, pre_handoff_reject=pre_handoff_reject, **self._handoff_webhook_kwargs(), ) try: result = await ( asyncio.wait_for(coro, timeout=deadline) if deadline is not None else coro ) except asyncio.TimeoutError: # Deadline expired. The platform coroutine is cancelled; for # sync adopters the underlying thread runs to completion but the # asyncio side has moved on (thread-pool slot leak documented in # adcp.decisioning.time_budget module header). tb = params.time_budget interval = tb.interval if tb is not None else 0 unit_raw = tb.unit if tb is not None else None unit = ( (unit_raw.value if hasattr(unit_raw, "value") else str(unit_raw)) if unit_raw is not None else "unknown" ) logger.warning( "[adcp.decisioning] get_products timed out after %ds " "(time_budget=%d %s); returning incomplete response. " "To avoid timeout cancellations, optimise get_products " "latency or reduce the platform's search scope.", deadline, interval, unit, ) return GetProductsResponse.model_validate( project_incomplete_response(interval=interval, unit=unit) ) # Post-dispatch discovery guards (run on the raw result before any # success-shape projection). ``result`` is either a typed # GetProductsResponse (sync) or the framework's submitted # projection dict (handoff). Both reject with # INVALID_REQUEST / correctable. # (d) hand-rolled submitted: adopter built {'status':'submitted'} # by hand instead of ctx.handoff_to_task — no registry row. # (b) wholesale + handoff: wholesale MUST be synchronous. The # pre-launch arm (pre_handoff_reject above) already rejected # it before any task/draft/webhook side effect; this is the # belt-and-braces post-dispatch check. # Guard (c) — async + unresolved account — is NOT re-checked here. # The push arm is rejected pre-dispatch above (correctable # field='account'); the no-push handoff arm is owned by # compose_caller_identity, which fails closed inside _build_ctx # (terminal INVALID_REQUEST) BEFORE the platform method runs, so no # task row is ever minted against an unresolved account. A # post-dispatch (c) re-check would be unreachable: reaching this # line means _build_ctx succeeded, i.e. the account resolved. reject_hand_rolled_submitted(result) reject_wholesale_handoff(result, mode=mode, mode_field="buying_mode") # Handoff path: the submitted envelope is returned verbatim, the # same as create_media_buy. Success-shape projections (property # list, pagination, fields, draft persist) apply only to the sync # success arm. When the buyer registered push_notification_config, # the terminal completion / failure webhook is delivered from the # background completion path in _project_handoff exactly once; with # no push config the buyer polls tasks/get. Either way nothing fires # here at submit time. if isinstance(result, dict) and result.get("status") == "submitted": return cast("GetProductsResponse", result) response = cast("GetProductsResponse", result) # Post-adapter: capability-gated property-list filter. response = cast( "GetProductsResponse", await maybe_apply_property_list_filter( params=params, response=response, fetcher=self._property_list_fetcher, capability_enabled=property_list_capability_enabled(self._platform), ), ) if self._platform.capabilities.auto_paginate and params.pagination is not None: response = cast( "GetProductsResponse", apply_framework_pagination( response, params.pagination, _query_hash(params), ), ) if params.fields: response = _project_product_fields(response, params.fields) # Draft proposals are persisted by the _persist_draft_hook # on_complete seam (threaded above) so the same side-effect fires # on both the sync and handoff completion paths. The hook ran # inline on the sync path before this point. return responseInvoke the platform's
get_productsmethod and apply fields projection.When the platform is a :class:
PlatformRouterwith per-tenantproposal_managerswired, the router's ownget_productsmethod handles the proposal-side dispatch (per-tenant :class:ProposalManagerlookup, refine-mode selection, fall-through to the tenant's :class:DecisioningPlatform). The handler delegates uniformly via :func:_invoke_platform_method; the routing decision lives on the router.When
params.fieldsis set the framework drops unrequested product fields after the platform method returns, always retaining the eight schema-required fields. Whenparams.fieldsisNonethe response passes through unchanged. async def get_rights(self, params: GetRightsRequest, context: ToolContext | None = None) ‑> adcp.types.generated_poc.brand.get_rights_response.GetRightsResponse1-
Expand source code
async def get_rights( # type: ignore[override] self, params: GetRightsRequest, context: ToolContext | None = None, ) -> GetRightsSuccessResponse: """List rights matching a brand + use query. Wire request has no ``account`` field per ``schemas/cache/brand-rights/get-rights-request.json`` (``additionalProperties: false``); resolve via auth only. """ tool_ctx = context or ToolContext() account = await self._resolve_account(None, tool_ctx) ctx = self._build_ctx(tool_ctx, account) result = await _invoke_platform_method( self._platform, "get_rights", params, ctx, executor=self._executor, registry=self._registry, **self._handoff_webhook_kwargs(), ) self._maybe_auto_emit_sync_completion("get_rights", params, result) return cast("GetRightsSuccessResponse", result)List rights matching a brand + use query.
Wire request has no
accountfield perschemas/cache/brand-rights/get-rights-request.json(additionalProperties: false); resolve via auth only. async def get_signals(self, params: GetSignalsRequest, context: ToolContext | None = None) ‑> adcp.types.generated_poc.signals.get_signals_response.GetSignalsResponse-
Expand source code
async def get_signals( # type: ignore[override] self, params: GetSignalsRequest, context: ToolContext | None = None, ) -> GetSignalsResponse: """Catalog discovery for signal-marketplace / signal-owned. Synchronous by default; ``discovery_mode='brief'`` MAY hand off to a background task (submitted / working arms — no input_required). ``discovery_mode='wholesale'`` MUST stay synchronous. """ tool_ctx = context or ToolContext() # Guard (a): wholesale + push_notification_config is rejected # before the platform method runs. Wholesale signal discovery is a # synchronous catalog read with no async lifecycle. assert_discovery_push_consistent(params, mode_field="discovery_mode") account = await self._resolve_account(getattr(params, "account", None), tool_ctx) # Guard (c) pre-dispatch: push_notification_config makes the request # async up front; reject against an unresolved account before # _build_ctx (whose compose_caller_identity would otherwise raise a # less-specific terminal error on the sentinel id) and before the # platform method runs. _has_push = getattr(params, "push_notification_config", None) is not None if _has_push: assert_account_resolved_for_async(None, account_id=account.id, has_push=True) ctx = self._build_ctx(tool_ctx, account) mode = getattr(params, "discovery_mode", None) if mode is None: mode_str = None else: mode_str = mode.value if hasattr(mode, "value") else str(mode) # Guard (b) pre-launch: a wholesale call that hands off is rejected # before any registry row / background coroutine / completion # webhook. Belt-and-braces post-dispatch check stays below. pre_handoff_reject = ( (lambda: reject_wholesale_handoff_before_launch("discovery_mode")) if mode_str == "wholesale" else None ) result = await _invoke_platform_method( self._platform, "get_signals", params, ctx, executor=self._executor, registry=self._registry, pre_handoff_reject=pre_handoff_reject, **self._handoff_webhook_kwargs(), ) # Post-dispatch discovery guards (mirror get_products): # (d) hand-rolled submitted, (b) wholesale + handoff. Both # INVALID_REQUEST / correctable. Guard (b) here is the # belt-and-braces check; the pre-launch arm above already # rejected a wholesale handoff before any side effect. # Guard (c) — async + unresolved account — is NOT re-checked here: # the push arm is rejected pre-dispatch above (correctable # field='account'), and the no-push handoff arm is owned by # compose_caller_identity, which fails closed terminally inside # _build_ctx before the platform method runs. Reaching this line # means the account resolved, so a post-dispatch (c) check is # unreachable. reject_hand_rolled_submitted(result) reject_wholesale_handoff(result, mode=mode_str, mode_field="discovery_mode") # On the handoff arm the terminal completion / failure webhook is # delivered from _project_handoff's background path when the buyer # registered push_notification_config; the sync auto-emit gate below # skips the {task_id, status} submitted projection so there is no # double-delivery. self._maybe_auto_emit_sync_completion("get_signals", params, result) return cast("GetSignalsResponse", result)Catalog discovery for signal-marketplace / signal-owned.
Synchronous by default;
discovery_mode='brief'MAY hand off to a background task (submitted / working arms — no input_required).discovery_mode='wholesale'MUST stay synchronous. async def list_accounts(self, params: ListAccountsRequest, context: ToolContext | None = None) ‑> adcp.types.generated_poc.account.list_accounts_response.ListAccountsResponse | NotImplementedResponse-
Expand source code
async def list_accounts( # type: ignore[override] self, params: ListAccountsRequest, context: ToolContext | None = None, ) -> ListAccountsResponse | NotImplementedResponse: """Route ``list_accounts`` through :meth:`AccountStore.list`. See :meth:`sync_accounts` for the OPERATION_NOT_SUPPORTED gate and ``ResolveContext`` threading rationale. """ listing = getattr(self._platform.accounts, "list", None) if not callable(listing): return self._not_supported("list_accounts") tool_ctx = context or ToolContext() # Prime auth-context only — see :meth:`sync_accounts` for the # explicit-mode-bootstrap rationale. ``list_accounts`` is also # used to enumerate accounts in stores keyed solely by auth # principal; a no-ref ``resolve()`` is the wrong shape there. await self._prime_auth_context(tool_ctx) resolve_ctx = self._make_resolve_context(tool_ctx, "list_accounts") filter_dict = _build_list_accounts_filter(params) result = _call_with_optional_ctx(listing, filter_dict, ctx=resolve_ctx) if inspect.isawaitable(result): result = await result return cast("ListAccountsResponse", _project_list_accounts(result))Route
list_accountsthrough :meth:AccountStore.list.See :meth:
sync_accountsfor the OPERATION_NOT_SUPPORTED gate andResolveContextthreading rationale. async def list_content_standards(self, params: ListContentStandardsRequest, context: ToolContext | None = None) ‑> adcp.types.generated_poc.content_standards.list_content_standards_response.ListContentStandardsResponse-
Expand source code
async def list_content_standards( # type: ignore[override] self, params: ListContentStandardsRequest, context: ToolContext | None = None, ) -> ListContentStandardsResponse: """Discover content standards published by this agent. Wire request has no ``account`` field per ``schemas/cache/content-standards/list-content-standards-request.json``; resolve via auth only. """ tool_ctx = context or ToolContext() account = await self._resolve_account(None, tool_ctx) ctx = self._build_ctx(tool_ctx, account) return cast( "ListContentStandardsResponse", await _invoke_platform_method( self._platform, "list_content_standards", params, ctx, executor=self._executor, registry=self._registry, ), )Discover content standards published by this agent.
Wire request has no
accountfield perschemas/cache/content-standards/list-content-standards-request.json; resolve via auth only. async def list_creative_formats(self, params: ListCreativeFormatsRequest, context: ToolContext | None = None) ‑> adcp.types.generated_poc.media_buy.list_creative_formats_response.ListCreativeFormatsResponse-
Expand source code
async def list_creative_formats( # type: ignore[override] self, params: ListCreativeFormatsRequest, context: ToolContext | None = None, ) -> ListCreativeFormatsResponse: """Wire request has no ``account`` field. See :meth:`provide_performance_feedback` for the no-ref account resolution caveat.""" tool_ctx = context or ToolContext() account = await self._resolve_account(None, tool_ctx) ctx = self._build_ctx(tool_ctx, account) return cast( "ListCreativeFormatsResponse", await _invoke_platform_method( self._platform, "list_creative_formats", params, ctx, executor=self._executor, registry=self._registry, ), )Wire request has no
accountfield. See :meth:provide_performance_feedbackfor the no-ref account resolution caveat. async def preview_creative(self, params: PreviewCreativeRequest, context: ToolContext | None = None) ‑> adcp.types.generated_poc.creative.preview_creative_response.PreviewCreativeResponse1 | adcp.types.generated_poc.creative.preview_creative_response.PreviewCreativeResponse2 | adcp.types.generated_poc.creative.preview_creative_response.PreviewCreativeResponse3-
Expand source code
async def preview_creative( # type: ignore[override] self, params: PreviewCreativeRequest, context: ToolContext | None = None, ) -> PreviewCreativeResponse: """Optional on :class:`CreativeBuilderPlatform`; required on :class:`CreativeAdServerPlatform`. Surface ``UNSUPPORTED_FEATURE`` when the adopter's platform doesn't implement it (Builder adopters who don't render preview).""" self._require_platform_method("preview_creative") tool_ctx = context or ToolContext() account = await self._resolve_account(getattr(params, "account", None), tool_ctx) ctx = self._build_ctx(tool_ctx, account) return cast( "PreviewCreativeResponse", await _invoke_platform_method( self._platform, "preview_creative", params, ctx, executor=self._executor, registry=self._registry, ), )Optional on :class:
CreativeBuilderPlatform; required on :class:CreativeAdServerPlatform. SurfaceUNSUPPORTED_FEATUREwhen the adopter's platform doesn't implement it (Builder adopters who don't render preview). async def provide_performance_feedback(self,
params: ProvidePerformanceFeedbackRequest,
context: ToolContext | None = None) ‑> adcp.types.generated_poc.media_buy.provide_performance_feedback_response.ProvidePerformanceFeedbackResponse1 | adcp.types.generated_poc.media_buy.provide_performance_feedback_response.ProvidePerformanceFeedbackResponse2-
Expand source code
async def provide_performance_feedback( # type: ignore[override] self, params: ProvidePerformanceFeedbackRequest, context: ToolContext | None = None, ) -> ProvidePerformanceFeedbackResponse: """Wire request has no ``account`` field — resolve via auth only. Adopters in ``explicit`` resolution mode get an ``ACCOUNT_NOT_FOUND`` from their AccountStore unless they wire a derived/singleton path or extend ``AccountStore.resolve`` to handle the no-ref case (see python-port-v2 RFC TODO(rc.1)).""" tool_ctx = context or ToolContext() account = await self._resolve_account(None, tool_ctx) ctx = self._build_ctx(tool_ctx, account) return cast( "ProvidePerformanceFeedbackResponse", await _invoke_platform_method( self._platform, "provide_performance_feedback", params, ctx, executor=self._executor, registry=self._registry, ), )Wire request has no
accountfield — resolve via auth only. Adopters inexplicitresolution mode get anACCOUNT_NOT_FOUNDfrom their AccountStore unless they wire a derived/singleton path or extendAccountStore.resolveto handle the no-ref case (see python-port-v2 RFC TODO(rc.1)). async def report_plan_outcome(self, params: ReportPlanOutcomeRequest, context: ToolContext | None = None) ‑> adcp.types.generated_poc.governance.report_plan_outcome_response.ReportPlanOutcomeResponse-
Expand source code
async def report_plan_outcome( # type: ignore[override] self, params: ReportPlanOutcomeRequest, context: ToolContext | None = None, ) -> ReportPlanOutcomeResponse: """Outcome reporting from sellers (delivery actuals). Wire request has no ``account`` field per ``schemas/cache/governance/report-plan-outcome-request.json`` (``additionalProperties: false``); resolve via auth only. """ tool_ctx = context or ToolContext() account = await self._resolve_account(None, tool_ctx) ctx = self._build_ctx(tool_ctx, account) return cast( "ReportPlanOutcomeResponse", await _invoke_platform_method( self._platform, "report_plan_outcome", params, ctx, executor=self._executor, registry=self._registry, ), )Outcome reporting from sellers (delivery actuals).
Wire request has no
accountfield perschemas/cache/governance/report-plan-outcome-request.json(additionalProperties: false); resolve via auth only. async def sync_accounts(self, params: SyncAccountsRequest, context: ToolContext | None = None) ‑> adcp.types.generated_poc.account.sync_accounts_response.SyncAccountsResponse1 | adcp.types.generated_poc.account.sync_accounts_response.SyncAccountsResponse2 | NotImplementedResponse-
Expand source code
async def sync_accounts( # type: ignore[override] self, params: SyncAccountsRequest, context: ToolContext | None = None, ) -> SyncAccountsResponse | NotImplementedResponse: """Route ``sync_accounts`` through the account-store upsert surface. ``sync_accounts`` lives on the AccountStore Protocol surface, not on per-specialism platform methods — :data:`adcp.decisioning.platform_router._ACCOUNT_STORE_METHODS` already excludes it from per-tenant delegation. Surface ``OPERATION_NOT_SUPPORTED`` (via :meth:`_not_supported`) when the store doesn't expose either the optional full-request ``upsert_request`` hook or the legacy :class:`AccountStoreUpsert` Protocol — distinct from ``AttributeError`` (which is what an unguarded ``getattr().()`` would produce). ``ResolveContext`` carries the caller's :class:`AuthInfo` and resolved :class:`BuyerAgent` so adopter impls implementing principal-keyed gates (e.g. spec ``BILLING_NOT_PERMITTED_FOR_AGENT``) read the principal off the canonical context — same threading :meth:`AccountStore.resolve` already uses. """ upsert_request = getattr(self._platform.accounts, "upsert_request", None) upsert = getattr(self._platform.accounts, "upsert", None) if not callable(upsert_request) and not callable(upsert): return self._not_supported("sync_accounts") tool_ctx = context or ToolContext() # Prime auth-context only — DON'T call ``_resolve_account``. # ``sync_accounts`` is the bootstrap tool buyers use to # populate an explicit-mode store; routing through the store's # no-ref ``resolve()`` would deadlock the bootstrap path # (``ACCOUNT_NOT_FOUND`` until the account exists, but the # account exists only after this call succeeds). await self._prime_auth_context(tool_ctx) resolve_ctx = self._make_resolve_context(tool_ctx, "sync_accounts") if callable(upsert_request): result = _call_with_optional_ctx(upsert_request, params, ctx=resolve_ctx) else: assert callable(upsert) refs = list(getattr(params, "accounts", []) or []) result = _call_with_optional_ctx(upsert, refs, ctx=resolve_ctx) if inspect.isawaitable(result): result = await result return cast("SyncAccountsResponse", _project_sync_accounts(result))Route
sync_accountsthrough the account-store upsert surface.sync_accountslives on the AccountStore Protocol surface, not on per-specialism platform methods — :data:adcp.decisioning.platform_router._ACCOUNT_STORE_METHODSalready excludes it from per-tenant delegation. SurfaceOPERATION_NOT_SUPPORTED(via :meth:_not_supported) when the store doesn't expose either the optional full-requestupsert_requesthook or the legacy :class:AccountStoreUpsertProtocol — distinct fromAttributeError(which is what an unguardedgetattr().()would produce).ResolveContextcarries the caller's :class:AuthInfoand resolved :class:BuyerAgentso adopter impls implementing principal-keyed gates (e.g. specBILLING_NOT_PERMITTED_FOR_AGENT) read the principal off the canonical context — same threading :meth:AccountStore.resolvealready uses. async def sync_audiences(self, params: SyncAudiencesRequest, context: ToolContext | None = None) ‑> adcp.types.generated_poc.media_buy.sync_audiences_response.SyncAudiencesResponse1-
Expand source code
async def sync_audiences( # type: ignore[override] self, params: SyncAudiencesRequest, context: ToolContext | None = None, ) -> SyncAudiencesSuccessResponse: """Push audiences to the platform. Wire shape carries ``audiences[]`` on the request; the platform method signature is ``sync_audiences(audiences, ctx)`` — adopter ergonomic per the JS reference. Arg-projection extracts the list. Two return arms per the per-specialism Protocol: a list of audience-result rows (the JS-side ergonomic) or a fully-shaped :class:`SyncAudiencesSuccessResponse`. The shim projects the list arm to the wire envelope ``{audiences: [...]}`` so adopters can return the ergonomic form. """ 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_audiences", params, ctx, executor=self._executor, registry=self._registry, arg_projector={"audiences": getattr(params, "audiences", []) or []}, **self._handoff_webhook_kwargs(), ) projected = _project_sync_audiences(result) self._maybe_auto_emit_sync_completion("sync_audiences", params, projected) return cast("SyncAudiencesSuccessResponse", projected)Push audiences to the platform.
Wire shape carries
audiences[]on the request; the platform method signature issync_audiences(audiences, ctx)— adopter ergonomic per the JS reference. Arg-projection extracts the list.Two return arms per the per-specialism Protocol: a list of audience-result rows (the JS-side ergonomic) or a fully-shaped :class:
SyncAudiencesSuccessResponse. The shim projects the list arm to the wire envelope{audiences: [...]}so adopters can return the ergonomic form. async def sync_catalogs(self, params: SyncCatalogsRequest, context: ToolContext | None = None) ‑> adcp.types.generated_poc.media_buy.sync_catalogs_response.SyncCatalogsResponse1-
Expand source code
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, **self._handoff_webhook_kwargs(), ) projected = _project_sync_catalogs(result) self._maybe_auto_emit_sync_completion("sync_catalogs", params, projected) return cast("SyncCatalogsSuccessResponse", projected)Sync product catalogs with the platform.
The platform method receives the full :class:
SyncCatalogsRequestso adopters can inspectreq.catalogs,req.delete_missing,req.dry_run, andreq.validation_mode. Discovery mode (req.catalogs is None) returns existing synced catalogs without modification — the platform method must handlereq.catalogs is Noneas a read-only path.Two return arms per the per-specialism Protocol: a list of :class:
SyncCatalogResultrows (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. async def sync_plans(self, params: SyncPlansRequest, context: ToolContext | None = None) ‑> adcp.types.generated_poc.governance.sync_plans_response.SyncPlansResponse-
Expand source code
async def sync_plans( # type: ignore[override] self, params: SyncPlansRequest, context: ToolContext | None = None, ) -> SyncPlansResponse: """Plan CRUD with delta upsert into governance agent. Wire request has no ``account`` field per ``schemas/cache/governance/sync-plans-request.json`` (``additionalProperties: false``); resolve via auth only. """ tool_ctx = context or ToolContext() account = await self._resolve_account(None, tool_ctx) ctx = self._build_ctx(tool_ctx, account) return cast( "SyncPlansResponse", await _invoke_platform_method( self._platform, "sync_plans", params, ctx, executor=self._executor, registry=self._registry, ), )Plan CRUD with delta upsert into governance agent.
Wire request has no
accountfield perschemas/cache/governance/sync-plans-request.json(additionalProperties: false); resolve via auth only. async def update_content_standards(self, params: UpdateContentStandardsRequest, context: ToolContext | None = None) ‑> adcp.types.generated_poc.content_standards.update_content_standards_response.UpdateContentStandardsResponse-
Expand source code
async def update_content_standards( # type: ignore[override] self, params: UpdateContentStandardsRequest, context: ToolContext | None = None, ) -> UpdateContentStandardsResponse: """Wire request has no ``account`` field; resolve via auth only.""" tool_ctx = context or ToolContext() account = await self._resolve_account(None, tool_ctx) ctx = self._build_ctx(tool_ctx, account) return cast( "UpdateContentStandardsResponse", await _invoke_platform_method( self._platform, "update_content_standards", params, ctx, executor=self._executor, registry=self._registry, ), )Wire request has no
accountfield; resolve via auth only. async def update_media_buy(self, params: UpdateMediaBuyRequest, context: ToolContext | None = None) ‑> adcp.types.generated_poc.media_buy.update_media_buy_response.UpdateMediaBuyResponse1-
Expand source code
async def update_media_buy( # type: ignore[override] self, params: UpdateMediaBuyRequest, context: ToolContext | None = None, ) -> UpdateMediaBuySuccessResponse: """Wire shape carries ``media_buy_id`` + the patch fields at the same level on ``UpdateMediaBuyRequest``. The platform method signature is ``update_media_buy(media_buy_id, patch, ctx)`` — cleaner adopter ergonomics. Arg-projection per D1. """ tool_ctx = context or ToolContext() account = await self._resolve_account(params.account, tool_ctx) ctx = self._build_ctx(tool_ctx, account) # v1.5: hydrate ctx.recipes from the consumed proposal via the # ProposalStore reverse-index. Re-validates capability overlap # against any packages on the patch (Resolutions §5). await maybe_hydrate_recipes_for_media_buy_id( self._platform, params.media_buy_id, ctx, packages=list(getattr(params, "packages", None) or []), ) # MediaBuyStore merge hook — fires on the on_complete seam so the # patch is recorded for both sync and HITL completions. Spec: # ``targeting_overlay`` is echoed from the most recent # ``create_media_buy`` OR ``update_media_buy`` per the # ``get-media-buys-response`` schema. on_complete: Callable[[Any], Awaitable[None]] | None = None if self._media_buy_store is not None: captured_store = self._media_buy_store captured_account_id = account.id captured_media_buy_id = params.media_buy_id async def _merge_overlay_hook(_update_result: Any) -> None: await captured_store.merge_from_update( captured_account_id, captured_media_buy_id, _to_store_dict(params), ) on_complete = _merge_overlay_hook result = await _invoke_platform_method( self._platform, "update_media_buy", params, ctx, executor=self._executor, registry=self._registry, arg_projector={"media_buy_id": params.media_buy_id, "patch": params}, on_complete=on_complete, **self._handoff_webhook_kwargs(), ) self._maybe_auto_emit_sync_completion("update_media_buy", params, result) return cast("UpdateMediaBuySuccessResponse", result)Wire shape carries
media_buy_id+ the patch fields at the same level onUpdateMediaBuyRequest. The platform method signature isupdate_media_buy(media_buy_id, patch, ctx)— cleaner adopter ergonomics. Arg-projection per D1. async def update_rights(self, params: UpdateRightsRequest, context: ToolContext | None = None) ‑> adcp.types.generated_poc.brand.update_rights_response.UpdateRightsResponse1 | adcp.types.generated_poc.brand.update_rights_response.UpdateRightsResponse2-
Expand source code
async def update_rights( # type: ignore[override] self, params: UpdateRightsRequest, context: ToolContext | None = None, ) -> UpdateRightsResponse: """Mutate an existing rights acquisition (extend term, change usage scope, revoke). Wire request has no ``account`` field per ``schemas/cache/brand-rights/update-rights-request.json`` (``additionalProperties: false``); resolve via auth only. Not currently in :data:`SPEC_WEBHOOK_TASK_TYPES` — buyers registering ``push_notification_config.url`` won't get an auto-emit; rely on ``publishStatusChange`` for long-running update state. Bump the spec enum and the ``SPEC_WEBHOOK_TASK_TYPES`` constant in lockstep when this joins the closed enum. """ tool_ctx = context or ToolContext() account = await self._resolve_account(None, tool_ctx) ctx = self._build_ctx(tool_ctx, account) return cast( "UpdateRightsResponse", await _invoke_platform_method( self._platform, "update_rights", params, ctx, executor=self._executor, registry=self._registry, ), )Mutate an existing rights acquisition (extend term, change usage scope, revoke).
Wire request has no
accountfield perschemas/cache/brand-rights/update-rights-request.json(additionalProperties: false); resolve via auth only.Not currently in :data:
SPEC_WEBHOOK_TASK_TYPES— buyers registeringpush_notification_config.urlwon't get an auto-emit; rely onpublishStatusChangefor long-running update state. Bump the spec enum and theSPEC_WEBHOOK_TASK_TYPESconstant in lockstep when this joins the closed enum. async def validate_content_delivery(self,
params: ValidateContentDeliveryRequest,
context: ToolContext | None = None) ‑> adcp.types.generated_poc.content_standards.validate_content_delivery_response.ValidateContentDeliveryResponse1 | adcp.types.generated_poc.content_standards.validate_content_delivery_response.ValidateContentDeliveryResponse2-
Expand source code
async def validate_content_delivery( # type: ignore[override] self, params: ValidateContentDeliveryRequest, context: ToolContext | None = None, ) -> ValidateContentDeliveryResponse: """Post-flight conformance check. Wire request has no ``account`` field per ``schemas/cache/content-standards/validate-content-delivery-request.json``; resolve via auth only. """ tool_ctx = context or ToolContext() account = await self._resolve_account(None, tool_ctx) ctx = self._build_ctx(tool_ctx, account) return cast( "ValidateContentDeliveryResponse", await _invoke_platform_method( self._platform, "validate_content_delivery", params, ctx, executor=self._executor, registry=self._registry, ), )Post-flight conformance check.
Wire request has no
accountfield perschemas/cache/content-standards/validate-content-delivery-request.json; resolve via auth only. async def validate_input(self, params: ValidateInputRequest, context: ToolContext | None = None) ‑> adcp.types.generated_poc.creative.validate_input_response.ValidateInputResponse-
Expand source code
async def validate_input( # type: ignore[override] self, params: ValidateInputRequest, context: ToolContext | None = None, ) -> ValidateInputResponse: """Optional creative preflight validation for beta 3 inputs.""" self._require_platform_method("validate_input") tool_ctx = context or ToolContext() account = await self._resolve_account(getattr(params, "account", None), tool_ctx) ctx = self._build_ctx(tool_ctx, account) return cast( "ValidateInputResponse", await _invoke_platform_method( self._platform, "validate_input", params, ctx, executor=self._executor, registry=self._registry, ), )Optional creative preflight validation for beta 3 inputs.
async def verify_brand_claim(self, params: VerifyBrandClaimRequest, context: ToolContext | None = None) ‑> adcp.types.generated_poc.brand.verify_brand_claim_response.VerifyBrandClaimSuccessResponse | adcp.types.generated_poc.brand.verify_brand_claim_response.VerifyBrandClaimErrorResponse-
Expand source code
async def verify_brand_claim( # type: ignore[override] self, params: VerifyBrandClaimRequest, context: ToolContext | None = None, ) -> VerifyBrandClaimResponse: """Optional brand claim verification for beta 3 brand agents.""" self._require_platform_method("verify_brand_claim") tool_ctx = context or ToolContext() account = await self._resolve_account(None, tool_ctx) ctx = self._build_ctx(tool_ctx, account) return cast( "VerifyBrandClaimResponse", await _invoke_platform_method( self._platform, "verify_brand_claim", params, ctx, executor=self._executor, registry=self._registry, ), )Optional brand claim verification for beta 3 brand agents.
async def verify_brand_claims(self, params: VerifyBrandClaimsRequest, context: ToolContext | None = None) ‑> adcp.types.generated_poc.brand.verify_brand_claims_response.VerifyBrandClaimsResponseBulk-
Expand source code
async def verify_brand_claims( # type: ignore[override] self, params: VerifyBrandClaimsRequest, context: ToolContext | None = None, ) -> VerifyBrandClaimsResponseBulk: """Optional bulk brand claim verification for beta 3 brand agents.""" self._require_platform_method("verify_brand_claims") tool_ctx = context or ToolContext() account = await self._resolve_account(None, tool_ctx) ctx = self._build_ctx(tool_ctx, account) return cast( "VerifyBrandClaimsResponseBulk", await _invoke_platform_method( self._platform, "verify_brand_claims", params, ctx, executor=self._executor, registry=self._registry, ), )Optional bulk brand claim verification for beta 3 brand agents.
Inherited members
ADCPHandler:comply_test_controllercontext_matchcreate_collection_listcreate_media_buycreate_property_listget_account_financialsget_collection_listget_media_buy_deliveryget_media_buysget_property_listget_task_statusidentity_matchlist_collection_listslist_creativeslist_property_listslist_taskslist_transformerslog_eventreport_usagesi_get_offeringsi_initiate_sessionsi_send_messagesi_terminate_sessionsync_creativessync_event_sourcessync_governanceupdate_collection_listupdate_property_list