Module adcp.decisioning

Decisioning Platform v6.0 — Protocol-driven adopter framework.

The successor to ADCPHandler for adopters who want a hybrid sync/handoff return shape and per-specialism Protocol classes instead of inheriting + overriding methods on a base ABC. Lives inside the existing adcp package so adopters reuse the foundation primitives in adcp.signing / adcp._idempotency / adcp.server rather than spinning up parallel implementations.

Quickstart::

from adcp.decisioning import (
    DecisioningPlatform,
    DecisioningCapabilities,
    SingletonAccounts,
    SalesPlatform,
    create_adcp_server_from_platform,
    serve,
)
from adcp.types import (
    GetProductsRequest, GetProductsResponse,
    CreateMediaBuyRequest, CreateMediaBuySuccess,
)


class HelloSeller(DecisioningPlatform):
    capabilities = DecisioningCapabilities(
        specialisms=["sales-non-guaranteed"],
        channels=["display"],
        pricing_models=["cpm"],
    )
    accounts = SingletonAccounts(account_id="hello")

    def get_products(self, req: GetProductsRequest, ctx) -> GetProductsResponse:
        return GetProductsResponse(products=[...])

    def create_media_buy(
        self, req: CreateMediaBuyRequest, ctx,
    ) -> CreateMediaBuySuccess:
        return CreateMediaBuySuccess(media_buy_id=f"mb_{req.idempotency_key}", ...)


serve(create_adcp_server_from_platform(
    platform=HelloSeller(), name="hello-seller", version="0.0.1",
))

See examples/hello_seller.py for the runnable version.

Sub-modules

adcp.decisioning.account_mode

Account-mode primitives for sandbox-authority enforcement of comply_test_controller and other test-only surfaces …

adcp.decisioning.account_projection

Wire-emit projections for AdCP v3 :class:Account payloads …

adcp.decisioning.accounts

Account resolution: AccountStore Protocol + three reference impls …

adcp.decisioning.brand_authz_gate

Tier 3 brand-authorization dispatch gate …

adcp.decisioning.capabilities

Capability sub-models for declaring :class:DecisioningCapabilities

adcp.decisioning.compose

Method-level composition for :class:DecisioningPlatform adopters …

adcp.decisioning.context

Request context for DecisioningPlatform method dispatch …

adcp.decisioning.derive_packages

Framework-level package derivation from proposal allocations …

adcp.decisioning.discovery_guards

Rejection guards for async (handoff) discovery on get_products / get_signals …

adcp.decisioning.dispatch

Dispatch layer for the v6.0 DecisioningPlatform framework …

adcp.decisioning.errors

Typed exception subclasses for the AdCP error code vocabulary …

adcp.decisioning.handler

PlatformHandler — wire-shape shims that route to a DecisioningPlatform …

adcp.decisioning.helpers

Small mechanical helpers for adopter platform method bodies …

adcp.decisioning.implementation_config

ProductConfigStore — pluggable implementation_config lookup for create_media_buy …

adcp.decisioning.media_buy_store

create_media_buy_store() — opt-in framework wiring that gates targeting_overlay echo on the seller's declared specialisms …

adcp.decisioning.mock_ad_server

Anti-façade traffic counters for adopter platforms …

adcp.decisioning.oauth_passthrough

OAuth pass-through AccountStore factory ("Shape B") …

adcp.decisioning.observed_modes

Process-scoped tracker of explicit Account.mode values returned from :meth:AccountStore.resolve() during framework-side comply-controller dispatch …

adcp.decisioning.pagination

Framework-managed cursor pagination for list responses …

adcp.decisioning.pg

PostgreSQL-backed implementations for the decisioning module …

adcp.decisioning.platform

DecisioningPlatform base class + capabilities declaration …

adcp.decisioning.platform_router

Tenant-keyed multi-platform dispatcher …

adcp.decisioning.property_list

Property-list resolver and product intersection helper for get_products …

adcp.decisioning.proposal_dispatch

Dispatch-side wiring for the v1.5 proposal lifecycle …

adcp.decisioning.proposal_lifecycle

Proposal-lifecycle framework helpers — the v1.5 intercept seam …

adcp.decisioning.proposal_manager

ProposalManager — the second platform shape …

adcp.decisioning.proposal_store

ProposalStore — per-tenant proposal lifecycle persistence …

adcp.decisioning.recipe

Recipe — discriminated-union base for typed product implementation_config …

adcp.decisioning.refine

Refine flow scaffold for buying_mode='refine' on get_products

adcp.decisioning.registry

Buyer-agent registry — commercial identity layer for v3 sellers …

adcp.decisioning.registry_cache

In-process wrappers around :class:~adcp.decisioning.BuyerAgentRegistry

adcp.decisioning.resolve

Async framework-mediated resource resolver for :class:RequestContext

adcp.decisioning.roster_store

Roster-backed :class:AccountStore factory for resolution='explicit' publisher-curated platforms …

adcp.decisioning.specialisms

Per-specialism Protocol classes …

adcp.decisioning.state

Sync workflow-state reader for :class:RequestContext

adcp.decisioning.state_machines

Canonical state-machine helpers for AdCP lifecycle objects …

adcp.decisioning.task_registry

Task registry for the DecisioningPlatform handoff path …

adcp.decisioning.tenant_store

create_tenant_store() — opinionated multi-tenant :class:AccountStore builder with a baked-in per-entry tenant-isolation gate …

adcp.decisioning.time_budget

time_budget deadline wrapper for get_products …

adcp.decisioning.translation

Bidirectional, type-safe key translation between AdCP and upstream values …

adcp.decisioning.types

Core types for the DecisioningPlatform layer …

adcp.decisioning.update_media_buy

Helpers for interpreting adcp.decisioning.update_media_buy patch requests.

adcp.decisioning.upstream

HTTP client primitives for AdCP translator adapters …

adcp.decisioning.validate_capabilities

Boot-time validation of the projected get_adcp_capabilities response …

adcp.decisioning.validate_idempotency

Boot-time validator: declared idempotency capability vs. wired decorator …

adcp.decisioning.webhook_emit

Auto-emit completion webhook on sync-success arm of mutating tools …

Functions

def assert_buying_mode_consistent(req: GetProductsRequest) ‑> None
Expand source code
def assert_buying_mode_consistent(req: GetProductsRequest) -> None:
    """Validate ``buying_mode`` against the wire spec's mutual-exclusion rules.

    Per the wire description on ``GetProductsRequest``:

    * ``buying_mode='wholesale'`` — ``brief`` MUST NOT be provided.
    * ``buying_mode='refine'`` — ``brief`` MUST NOT be provided; ``refine[]``
      drives iteration.
    * ``buying_mode='brief'`` — ``brief`` is required (handled by Pydantic
      validation upstream).

    Raises :class:`AdcpError(INVALID_REQUEST)` with the offending field on
    violation.  Called at the top of the ``get_products`` shim before any
    platform dispatch.
    """
    from adcp.decisioning.types import AdcpError

    mode = _coerce_enum_value(getattr(req, "buying_mode", None))
    brief = getattr(req, "brief", None)
    refine = getattr(req, "refine", None)

    if mode == "wholesale" and brief:
        raise AdcpError(
            "INVALID_REQUEST",
            message=(
                "buying_mode='wholesale' must not be combined with brief. "
                "Wholesale callers request raw inventory and apply their "
                "own audiences; the brief is only meaningful in 'brief' mode."
            ),
            field="brief",
        )

    if mode == "refine":
        if brief:
            raise AdcpError(
                "INVALID_REQUEST",
                message=(
                    "buying_mode='refine' must not be combined with brief. "
                    "The refine[] array drives iteration on a previous "
                    "get_products response."
                ),
                field="brief",
            )
        if not refine:
            raise AdcpError(
                "INVALID_REQUEST",
                message=(
                    "buying_mode='refine' requires a non-empty refine[] "
                    "array — the buyer must declare what to iterate on."
                ),
                field="refine",
            )

Validate buying_mode against the wire spec's mutual-exclusion rules.

Per the wire description on GetProductsRequest:

  • buying_mode='wholesale'brief MUST NOT be provided.
  • buying_mode='refine'brief MUST NOT be provided; adcp.decisioning.refine[] drives iteration.
  • buying_mode='brief'brief is required (handled by Pydantic validation upstream).

Raises :class:AdcpError(INVALID_REQUEST) with the offending field on violation. Called at the top of the get_products shim before any platform dispatch.

def assert_creative_transition(from_state: str, to_state: str, *, creative_id: str | None = None) ‑> None
Expand source code
def assert_creative_transition(
    from_state: str,
    to_state: str,
    *,
    creative_id: str | None = None,
) -> None:
    """Raise ``INVALID_STATE`` on illegal creative-asset transitions.

    Use inside ``sync_creatives`` / creative-approval handlers to refuse
    non-monotonic state changes (e.g., ``archived`` → ``approved``).

    :param from_state: Current creative status.
    :param to_state: Requested next status.
    :param creative_id: Optional id, surfaced in ``error.details`` for
        buyer-side debugging.
    :raises AdcpError: with ``code='INVALID_STATE'`` and
        ``recovery='correctable'`` per the spec when the transition is
        not in :data:`CREATIVE_ASSET_TRANSITIONS`.
    """
    _assert_transition(
        from_state,
        to_state,
        graph=CREATIVE_ASSET_TRANSITIONS,
        resource_kind="creative",
        resource_id=creative_id,
        id_field="creative_id",
    )

Raise INVALID_STATE on illegal creative-asset transitions.

Use inside sync_creatives / creative-approval handlers to refuse non-monotonic state changes (e.g., archivedapproved).

:param from_state: Current creative status. :param to_state: Requested next status. :param creative_id: Optional id, surfaced in error.details for buyer-side debugging. :raises AdcpError: with code='INVALID_STATE' and recovery='correctable' per the spec when the transition is not in :data:CREATIVE_ASSET_TRANSITIONS.

def assert_media_buy_transition(from_state: str, to_state: str, *, media_buy_id: str | None = None) ‑> None
Expand source code
def assert_media_buy_transition(
    from_state: str,
    to_state: str,
    *,
    media_buy_id: str | None = None,
) -> None:
    """Raise ``INVALID_STATE`` on illegal media-buy transitions.

    Use inside ``update_media_buy`` / ``cancel_media_buy`` /
    lifecycle-changing handlers to refuse non-monotonic state changes
    (e.g., ``active`` → ``pending_creatives``).

    :param from_state: Current media-buy status.
    :param to_state: Requested next status.
    :param media_buy_id: Optional id, surfaced in ``error.details`` for
        buyer-side debugging.
    :raises AdcpError: with ``code='INVALID_STATE'`` and
        ``recovery='correctable'`` per the spec when the transition is
        not in :data:`MEDIA_BUY_TRANSITIONS`.
    """
    _assert_transition(
        from_state,
        to_state,
        graph=MEDIA_BUY_TRANSITIONS,
        resource_kind="media buy",
        resource_id=media_buy_id,
        id_field="media_buy_id",
    )

Raise INVALID_STATE on illegal media-buy transitions.

Use inside adcp.decisioning.update_media_buy / cancel_media_buy / lifecycle-changing handlers to refuse non-monotonic state changes (e.g., activepending_creatives).

:param from_state: Current media-buy status. :param to_state: Requested next status. :param media_buy_id: Optional id, surfaced in error.details for buyer-side debugging. :raises AdcpError: with code='INVALID_STATE' and recovery='correctable' per the spec when the transition is not in :data:MEDIA_BUY_TRANSITIONS.

def assert_sandbox_account(account: Any, *, tool: str | None = None, message: str | None = None) ‑> None
Expand source code
def assert_sandbox_account(
    account: Any,
    *,
    tool: str | None = None,
    message: str | None = None,
) -> None:
    """Raise ``AdcpError('PERMISSION_DENIED')`` if the account is not in
    a non-production mode. Use to gate dispatch of test-only surfaces.

    Fail-closed semantics:

    - ``account is None`` (no resolved account): raises.
    - ``mode == 'live'`` or unspecified + no ``sandbox is True``: raises.
    - ``mode in {'sandbox', 'mock'}`` (or legacy ``sandbox is True``):
      no-op, dispatch proceeds.

    The ``details`` payload carries ``{scope: 'sandbox-gate', tool?}``
    so dashboards can distinguish gate-rejections from other permission
    denials.

    **Resolver discipline.** The strength of this gate depends entirely
    on how the adopter's :meth:`AccountStore.resolve` constructs its
    return value. Resolvers MUST NOT spread untrusted input (request
    body, headers, ``ctx_metadata``, query params) into the resolved
    account — doing so lets a buyer self-promote to ``mode='sandbox'``
    and unlock test-only surfaces on a live principal. Source ``mode``
    (and ``sandbox``) from a trusted store keyed by the authenticated
    principal; never from request data.

    **opts.message must be a static string literal.** The message is
    echoed on the wire inside the error envelope. Interpolating
    user-controlled values into it creates a reflection sink (PII
    leakage, log injection, downstream HTML rendering). Pick from a
    fixed set of messages keyed by ``tool`` if you need variants.
    """
    if is_sandbox_or_mock_account(account):
        return
    details: dict[str, Any] = {
        "scope": "sandbox-gate",
        "reason": "sandbox-or-mock-required",
    }
    if tool is not None:
        details["tool"] = tool
    raise AdcpError(
        "PERMISSION_DENIED",
        message=message
        or (
            "Test-only surface requires a sandbox or mock account; "
            "resolved account is in live mode."
        ),
        recovery="terminal",
        details=details,
    )

Raise AdcpError('PERMISSION_DENIED') if the account is not in a non-production mode. Use to gate dispatch of test-only surfaces.

Fail-closed semantics:

  • account is None (no resolved account): raises.
  • mode == 'live' or unspecified + no sandbox is True: raises.
  • mode in {'sandbox', 'mock'} (or legacy sandbox is True): no-op, dispatch proceeds.

The details payload carries {scope: 'sandbox-gate', tool?} so dashboards can distinguish gate-rejections from other permission denials.

Resolver discipline. The strength of this gate depends entirely on how the adopter's :meth:AccountStore.resolve() constructs its return value. Resolvers MUST NOT spread untrusted input (request body, headers, ctx_metadata, query params) into the resolved account — doing so lets a buyer self-promote to mode='sandbox' and unlock test-only surfaces on a live principal. Source mode (and sandbox) from a trusted store keyed by the authenticated principal; never from request data.

opts.message must be a static string literal. The message is echoed on the wire inside the error envelope. Interpolating user-controlled values into it creates a reflection sink (PII leakage, log injection, downstream HTML rendering). Pick from a fixed set of messages keyed by tool if you need variants.

def bearer_only_registry(resolve_by_credential: _CredentialResolver) ‑> BuyerAgentRegistry
Expand source code
def bearer_only_registry(
    resolve_by_credential: _CredentialResolver,
) -> BuyerAgentRegistry:
    """Pre-trust beta: accept bearer / API-key / OAuth traffic only.

    Adopter supplies an async function that maps an
    :class:`ApiKeyCredential` or :class:`OAuthCredential` to a
    :class:`BuyerAgent` (or ``None`` to reject). Signed traffic gets
    ``PERMISSION_DENIED`` — adopt :func:`mixed_registry` once signed
    onboarding is wired.
    """
    return _BearerOnlyRegistry(_resolve_by_credential=resolve_by_credential)

Pre-trust beta: accept bearer / API-key / OAuth traffic only.

Adopter supplies an async function that maps an :class:ApiKeyCredential or :class:OAuthCredential to a :class:BuyerAgent (or None to reject). Signed traffic gets PERMISSION_DENIED — adopt :func:mixed_registry() once signed onboarding is wired.

def build_refinement_applied(refines: list[Any],
outcomes: list[RefinementOutcome]) ‑> list[typing.Any]
Expand source code
def build_refinement_applied(
    refines: list[Any],
    outcomes: list[RefinementOutcome],
) -> list[Any]:
    """Position-match the request's ``refine[]`` with the adopter's outcomes.

    Each ``refines[i]`` entry has a discriminated ``scope`` (``'request'``,
    ``'product'``, or ``'proposal'``).  This function emits a parallel
    ``refinement_applied[i]`` carrying the same scope, the matching ID
    field (``product_id`` / ``proposal_id``), and the adopter's
    ``status`` + ``notes``.

    :param refines: ``request.refine`` (length N).
    :param outcomes: Adopter's per-entry outcomes (must also be length N).
    :returns: Wire-shape ``RefinementApplied`` (RootModel) instances (one per entry).
    :raises ValueError: ``len(outcomes) != len(refines)``.  Developer-facing,
        not buyer-facing — adopter-side bug.
    """
    if len(outcomes) != len(refines):
        raise ValueError(
            f"refine_get_products returned {len(outcomes)} outcomes for "
            f"{len(refines)} refine entries — counts must match. The "
            "framework constructs refinement_applied[] by zipping these "
            "lists; mismatched lengths break the buyer's position-matched "
            "echo contract."
        )

    from adcp.types import (
        RefinementApplied,
        RefinementApplied1,
        RefinementApplied2,
        RefinementApplied3,
    )

    # The wire enum on RefinementApplied{1,2,3}.status is the discriminated
    # ``Status`` enum (``applied``/``partial``/``unable``).  Pydantic accepts
    # the matching string at runtime; the model_validate path coerces.
    status_field = {"applied": "applied", "partial": "partial", "unable": "unable"}

    out: list[Any] = []
    for entry, outcome in zip(refines, outcomes, strict=True):
        # Refine is a RootModel discriminated on `scope`; unwrap to the
        # variant.
        inner = getattr(entry, "root", entry)
        scope = getattr(inner, "scope", None)
        status_str = status_field[outcome.status]

        if scope == "request":
            applied: Any = RefinementApplied1.model_validate(
                {"scope": "request", "status": status_str, "notes": outcome.notes}
            )
        elif scope == "product":
            applied = RefinementApplied2.model_validate(
                {
                    "scope": "product",
                    "product_id": str(getattr(inner, "product_id")),
                    "status": status_str,
                    "notes": outcome.notes,
                }
            )
        elif scope == "proposal":
            applied = RefinementApplied3.model_validate(
                {
                    "scope": "proposal",
                    "proposal_id": str(getattr(inner, "proposal_id")),
                    "status": status_str,
                    "notes": outcome.notes,
                }
            )
        else:
            raise ValueError(
                f"Unknown refine scope {scope!r}; expected " "'request' | 'product' | 'proposal'."
            )
        out.append(RefinementApplied(root=applied))
    return out

Position-match the request's adcp.decisioning.refine[] with the adopter's outcomes.

Each refines[i] entry has a discriminated scope ('request', 'product', or 'proposal'). This function emits a parallel refinement_applied[i] carrying the same scope, the matching ID field (product_id / proposal_id), and the adopter's status + notes.

:param refines: request.refine (length N). :param outcomes: Adopter's per-entry outcomes (must also be length N). :returns: Wire-shape RefinementApplied (RootModel) instances (one per entry). :raises ValueError: len(outcomes) != len(refines). Developer-facing, not buyer-facing — adopter-side bug.

def compose_method(inner: Callable[[Req, RequestContext[Any]], Awaitable[Res]],
*,
before: Callable[[Req, RequestContext[Any]], Awaitable[ShortCircuit[Res] | None]] | None = None,
after: Callable[[Res, Req, RequestContext[Any]], Awaitable[Res]] | None = None) ‑> Callable[[~Req, RequestContext[typing.Any]], Awaitable[~Res]]
Expand source code
def compose_method(
    inner: Callable[[Req, RequestContext[Any]], Awaitable[Res]],
    *,
    before: (
        Callable[
            [Req, RequestContext[Any]],
            Awaitable[ShortCircuit[Res] | None],
        ]
        | None
    ) = None,
    after: (
        Callable[
            [Res, Req, RequestContext[Any]],
            Awaitable[Res],
        ]
        | None
    ) = None,
) -> Callable[[Req, RequestContext[Any]], Awaitable[Res]]:
    """Wrap a platform method with typed ``before`` / ``after`` hooks.

    Type-preserving: the returned callable has the same
    ``async (params, ctx) -> Res`` signature as ``inner`` so it slots
    into a typed :class:`DecisioningPlatform` shape without casts.

    Validates ``inner`` is callable eagerly at wrap time so adopters
    who reference an optional method that wasn't implemented on the
    underlying platform get a clear :class:`TypeError` at module load
    rather than at first traffic.

    Example::

        from adcp.decisioning.compose import compose_method, ShortCircuit

        async def before_hook(req, ctx):
            if req.optimization == "price":
                return ShortCircuit(value=cached_price_opt)
            return None

        async def after_hook(result, req, ctx):
            return result.model_copy(
                update={
                    "ext": {
                        **(result.ext or {}),
                        "carbon_grams_per_impression": await score(result),
                    }
                }
            )

        wrapped = compose_method(
            base_platform.get_media_buy_delivery,
            before=before_hook,
            after=after_hook,
        )

    :param inner: The platform method to wrap. Must be callable;
        non-callables raise :class:`TypeError` immediately.
    :param before: Optional pre-call hook. Returning
        :class:`ShortCircuit` skips the inner method and feeds the
        wrapped value through ``after``. Returning ``None`` falls
        through. Returning a bare non-:class:`ShortCircuit` non-``None``
        value raises :class:`TypeError`.
    :param after: Optional post-call hook. Runs whether the result
        came from ``inner`` or from a ``before`` short-circuit, and
        BEFORE response-schema validation. Decorations must satisfy
        the wire schema; vendor-specific data goes under ``ext``.
    :returns: A wrapper with the same signature as ``inner``.
    :raises TypeError: when ``inner`` is not callable.
    """
    if not callable(inner):
        raise TypeError(
            f"compose_method: 'inner' must be callable, got "
            f"{type(inner).__name__}. Did you reference an optional "
            f"method that wasn't implemented on the platform?"
        )

    async def wrapper(req: Req, ctx: RequestContext[Any]) -> Res:
        result: Res
        if before is not None:
            early = await before(req, ctx)
            if early is None:
                result = await inner(req, ctx)
            elif isinstance(early, ShortCircuit):
                result = early.value
            else:
                raise TypeError(
                    f"compose_method: before hook returned "
                    f"{type(early).__name__}; expected None or "
                    f"ShortCircuit. Wrap the value: "
                    f"`return ShortCircuit(value=...)`."
                )
        else:
            result = await inner(req, ctx)
        if after is not None:
            result = await after(result, req, ctx)
        return result

    return wrapper

Wrap a platform method with typed before / after hooks.

Type-preserving: the returned callable has the same async (params, ctx) -> Res signature as inner so it slots into a typed :class:DecisioningPlatform shape without casts.

Validates inner is callable eagerly at wrap time so adopters who reference an optional method that wasn't implemented on the underlying platform get a clear :class:TypeError at module load rather than at first traffic.

Example::

from adcp.decisioning.compose import compose_method, ShortCircuit

async def before_hook(req, ctx):
    if req.optimization == "price":
        return ShortCircuit(value=cached_price_opt)
    return None

async def after_hook(result, req, ctx):
    return result.model_copy(
        update={
            "ext": {
                **(result.ext or {}),
                "carbon_grams_per_impression": await score(result),
            }
        }
    )

wrapped = compose_method(
    base_platform.get_media_buy_delivery,
    before=before_hook,
    after=after_hook,
)

:param inner: The platform method to wrap. Must be callable; non-callables raise :class:TypeError immediately. :param before: Optional pre-call hook. Returning :class:ShortCircuit skips the inner method and feeds the wrapped value through after. Returning None falls through. Returning a bare non-:class:ShortCircuit non-None value raises :class:TypeError. :param after: Optional post-call hook. Runs whether the result came from inner or from a before short-circuit, and BEFORE response-schema validation. Decorations must satisfy the wire schema; vendor-specific data goes under ext. :returns: A wrapper with the same signature as inner. :raises TypeError: when inner is not callable.

def create_adcp_server_from_platform(platform: DecisioningPlatform,
*,
executor: ThreadPoolExecutor | None = None,
thread_pool_size: int | None = None,
registry: TaskRegistry | None = None,
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_authz_resolver: BrandAuthorizationResolver | None = None,
brand_identity_resolver: BrandIdentityResolver | None = None,
config_store: ProductConfigStore | None = None,
property_list_fetcher: PropertyListFetcher | None = None,
media_buy_store: MediaBuyStore | None = None,
advertise_all: bool = False,
validate_at_init: bool = True) ‑> tuple[PlatformHandler, ThreadPoolExecutor, TaskRegistry]
Expand source code
def create_adcp_server_from_platform(
    platform: DecisioningPlatform,
    *,
    executor: ThreadPoolExecutor | None = None,
    thread_pool_size: int | None = None,
    registry: TaskRegistry | None = None,
    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_authz_resolver: BrandAuthorizationResolver | None = None,
    brand_identity_resolver: BrandIdentityResolver | None = None,
    config_store: ProductConfigStore | None = None,
    property_list_fetcher: PropertyListFetcher | None = None,
    media_buy_store: MediaBuyStore | None = None,
    advertise_all: bool = False,
    validate_at_init: bool = True,
) -> tuple[PlatformHandler, ThreadPoolExecutor, TaskRegistry]:
    """Build the :class:`PlatformHandler` + supporting wiring from a
    :class:`DecisioningPlatform`.

    Returns a 3-tuple ``(handler, executor, registry)``. The handler
    wraps the platform; the executor is wired into dispatch for sync
    platform methods; the registry handles
    :class:`adcp.decisioning.TaskHandoff` lifecycle.

    Adopters who need full control over the MCP server wiring use this
    seam — compose the returned handler with their own
    :func:`adcp.server.create_mcp_server` call. Most adopters use
    :func:`serve` instead.

    Validates the platform at server boot via
    :func:`validate_platform` — fails fast on missing specialism
    methods, missing ``accounts``, governance opt-in violations
    (D15 round-4), and unknown specialisms (UserWarning per round-3
    D14).

    :param platform: The adopter's :class:`DecisioningPlatform`
        subclass instance.
    :param executor: Bring-your-own :class:`ThreadPoolExecutor` —
        for operators with audit-instrumented thread pools or
        wrappers around stdlib's executor. Mutually exclusive with
        ``thread_pool_size``. Operator owns lifecycle (caller's
        ``shutdown(wait=True)`` responsibility).
    :param thread_pool_size: Size the default framework-allocated
        executor. Mutually exclusive with ``executor``. Default is
        :func:`_default_thread_pool_size`.
    :param registry: Bring-your-own :class:`TaskRegistry` — typically
        a v6.1 durable backing store. Default is
        :class:`InMemoryTaskRegistry`, which the production-mode
        gate refuses unless
        ``ADCP_DECISIONING_ALLOW_INMEMORY_TASKS=1`` is set.
    :param state_reader: Custom :class:`StateReader` impl
        (D15 — workflow-state reads). Default is the v6.0 stub
        (empty returns + one-time UserWarning per method).
    :param resource_resolver: Custom :class:`ResourceResolver` impl
        (D15 — async framework-mediated fetches). Default is the
        v6.0 stub (raises ``NotImplementedError`` with a pointer to
        v6.1).
    :param webhook_sender: Bring-your-own
        :class:`adcp.webhook_sender.WebhookSender` for completion webhook
        delivery — both the sync-success auto-emit and the terminal
        completion / failure notification on the async (handoff) path of
        any spec-eligible verb when the buyer registered
        ``push_notification_config``. Default ``None``. The
        sender is the *transport* — one HTTP-Signatures POST per call,
        no retry, no breaker. Production sellers typically wrap the
        sender in a :class:`~adcp.webhook_supervisor.WebhookDeliverySupervisor`
        and pass that via ``webhook_supervisor=`` instead.
    :param webhook_supervisor: Bring-your-own
        :class:`~adcp.webhook_supervisor.WebhookDeliverySupervisor` for
        reliable delivery (retry, circuit breaker, attempt audit). When
        passed, the F12 auto-emit path routes through it instead of
        ``webhook_sender``. The reference
        :class:`~adcp.webhook_supervisor.InMemoryWebhookDeliverySupervisor`
        wraps a sender; adopters with infra-side retry (Celery, Kafka,
        durable outbox) implement the Protocol against their queue.
        Mutually optional with ``webhook_sender``; passing both is
        valid (supervisor wins for auto-emit, sender remains available
        for direct calls inside platform methods).
    :param buyer_agent_registry: BYO
        :class:`adcp.decisioning.BuyerAgentRegistry` — the v3 commercial
        identity layer. When wired, the framework calls the registry
        BEFORE :meth:`AccountStore.resolve` to gate every request on
        the seller's commercial allowlist. Suspended / blocked /
        unrecognized agents are rejected with structured errors:
        suspended → ``AGENT_SUSPENDED``, blocked → ``AGENT_BLOCKED``
        (both ``recovery="terminal"``, no ``details`` payload — the
        code itself is the discriminator per AdCP 3.1); unrecognized
        → ``PERMISSION_DENIED`` with no ``details.scope`` so the wire
        shape does not enumerate which ``agent_url``s are onboarded
        with this seller. The resolved
        :class:`adcp.decisioning.BuyerAgent` is threaded onto
        :attr:`RequestContext.buyer_agent` so platform methods can
        read commercial context (billing capabilities, default terms,
        adopter ext) without a second registry call. Default ``None``
        — pre-trust beta adopters running existing key-based auth
        without commercial gating omit this and the dispatch path
        falls through to ``AccountStore.resolve`` unchanged.
    :param auto_emit_completion_webhooks: F12 feature gate. When
        ``True`` (default), the framework auto-fires a completion
        webhook on the sync-success arm of mutating tools whenever the
        request supplied ``push_notification_config.url`` AND the tool
        is in :data:`adcp.decisioning.webhook_emit.SPEC_WEBHOOK_TASK_TYPES`.
        Buyers passing the URL expect notification regardless of
        whether the seller routed sync vs HITL. Set ``False`` for
        adopters who emit webhooks manually inside their handlers
        (avoid duplicate delivery; idempotency-key dedup at the
        receiver would handle it but explicit suppression matches the
        v5 manual-emit posture for adopters mid-migration).
    :param media_buy_store: Opt-in :class:`adcp.decisioning.MediaBuyStore`
        wrapper that gates ``targeting_overlay`` echo on the seller's
        declared specialisms. Typically built via
        :func:`adcp.decisioning.create_media_buy_store` with the seller's
        ``capabilities`` so the persistence layer only fires for sellers
        claiming ``property-lists`` or ``collection-lists``. When wired,
        the framework calls ``persist_from_create`` on successful
        ``create_media_buy`` (via the same on-complete hook the proposal
        flow uses, so HITL completions also persist), calls
        ``merge_from_update`` on successful ``update_media_buy``, and
        calls ``backfill`` before returning from ``get_media_buys``.
        Default ``None`` — sellers who don't claim the relevant
        specialisms or who echo ``targeting_overlay`` themselves omit
        this and pay no overhead.
    :param advertise_all: Mirror of the same flag on :func:`serve` —
        controls how :meth:`PlatformHandler.get_advertised_tools` and
        the eventual ``tools/list`` response filter the handler's tool
        universe. ``False`` (default, spec-aligned) drops tools whose
        method is still the SDK's ``not_supported`` shim; ``True``
        advertises every tool the platform's claimed specialisms cover
        regardless of override status. Stored on the returned handler
        so adopters can call ``handler.get_advertised_tools()`` to
        inspect the effective set without standing up a server.
    :param validate_at_init: When ``True`` (default), the framework
        runs :func:`validate_capabilities_response_shape` during
        construction — fail-fast boot validation for the projected
        capabilities response. The sync validator drives the async
        handler via :func:`asyncio.run`, so the call **fails** with
        ``RuntimeError: asyncio.run() cannot be called from a running
        event loop`` when the constructor is invoked from inside an
        async context (test fixtures, Starlette ``lifespan``,
        in-process A2A test clients). Pass ``False`` in those
        contexts and run the async validator yourself::

            handler, executor, registry = create_adcp_server_from_platform(
                platform, validate_at_init=False,
            )
            await validate_capabilities_response_shape_async(handler)

        The other boot validators (:func:`validate_platform`,
        :func:`validate_webhook_signing_for_capabilities`,
        :func:`validate_idempotency_wiring`) are synchronous-pure and
        always run; this flag only gates the capabilities-response
        check. See #700.

    To wire a :class:`ProposalManager` (v1 two-platform composition),
    pass it on a :class:`PlatformRouter` via
    ``proposal_managers={tenant_id: ProposalManager}``. The router is
    the per-tenant binding point — single-tenant adopters use a
    one-entry router (``platforms={"default": ...}``,
    ``proposal_managers={"default": ...}``). See
    ``docs/proposals/product-architecture.md`` § "Tenant binding model".

    :raises ValueError: when ``executor`` and ``thread_pool_size`` are
        both supplied (D5 mutually-exclusive validation).
    :raises AdcpError: from :func:`validate_platform` when the
        platform fails server-boot validation, OR when the production
        gate refuses :class:`InMemoryTaskRegistry`.
    """
    # D5: executor / thread_pool_size mutually exclusive.
    if executor is not None and thread_pool_size is not None:
        raise ValueError(
            "Pass either executor= or thread_pool_size=, not both. "
            "thread_pool_size sizes the default executor; executor= is "
            "for operators wiring an audit-instrumented or otherwise "
            "vetted threadpool."
        )

    # Allocate executor.
    if executor is None:
        size = thread_pool_size if thread_pool_size is not None else _default_thread_pool_size()
        executor = ThreadPoolExecutor(
            max_workers=size,
            thread_name_prefix="adcp-decisioning-",
        )

    # Allocate registry, with production-mode gate (Emma #8).
    # Gate reads the registry's is_durable class-level marker rather
    # than `isinstance(registry, InMemoryTaskRegistry)`. Two reasons:
    #   1. Adopters subclassing InMemoryTaskRegistry for instrumentation
    #      inherit `is_durable=False` and correctly trip the gate.
    #   2. Adopters duck-typing a custom in-memory store would bypass
    #      the isinstance check; the marker is opt-in for durability,
    #      defaulting safe.
    if registry is None:
        registry = InMemoryTaskRegistry()
    # Round-5 Emma P1: an adopter duck-typing TaskRegistry without the
    # is_durable marker would treat the missing attribute as False and
    # silently trip the production gate — operator sees "non-durable
    # registry refused" with no clear cause. Distinguish "marker
    # absent" from "marker present and False" so the diagnostic
    # points at the real problem.
    has_marker = hasattr(type(registry), "is_durable") or hasattr(registry, "is_durable")
    is_durable = bool(getattr(registry, "is_durable", False))
    if not has_marker:
        raise AdcpError(
            "INVALID_REQUEST",
            message=(
                f"TaskRegistry impl {type(registry).__name__!r} is missing "
                "the ``is_durable: ClassVar[bool]`` marker. The framework's "
                "production-mode gate requires every registry to declare "
                "durability explicitly — set ``is_durable = True`` (durable "
                "backing store like Postgres/Redis) or ``is_durable = False`` "
                "(in-memory / lossy). Without the marker, the gate would "
                "silent-deny the registry with a confusing 'non-durable' "
                "error."
            ),
            recovery="terminal",
            details={
                "registry": type(registry).__name__,
            },
        )
    if not is_durable and _is_production_env():
        opt_in = os.environ.get("ADCP_DECISIONING_ALLOW_INMEMORY_TASKS", "").strip()
        if opt_in != "1":
            raise AdcpError(
                "INVALID_REQUEST",
                message=(
                    f"Non-durable TaskRegistry ({type(registry).__name__}) "
                    "refuses to start in production (ADCP_ENV is 'prod' "
                    "or 'production'). HITL flows depend on the registry "
                    "— silent in-memory fallback would lose tasks across "
                    "process restarts. Either wire a durable "
                    "TaskRegistry impl (set is_durable=True on the class; "
                    "v6.1 ships PgTaskRegistry) OR set "
                    "ADCP_DECISIONING_ALLOW_INMEMORY_TASKS=1 to "
                    "explicitly opt into in-memory tasks (e.g., for "
                    "single-process pilots)."
                ),
                recovery="terminal",
                details={
                    "registry": type(registry).__name__,
                    "is_durable": is_durable,
                    "ADCP_ENV": os.environ.get("ADCP_ENV", ""),
                },
            )

    # Validate the platform AFTER executor + registry exist so any
    # validation diagnostic includes the wiring context. Failure here
    # propagates to the caller.
    validate_platform(platform)

    # Tier 3 brand-authorization gate (issue #350 stage 5). The pair is
    # bundled here so the dispatch path sees an atomic configuration:
    # both wired or neither. Passing one without the other is almost
    # always a misconfiguration (a resolver without an extractor never
    # has a brand to check; an extractor without a resolver never has
    # anything to do) — fail closed at boot with a specific diagnostic
    # rather than a silent never-fires gate at request time.
    if (brand_authz_resolver is None) != (brand_identity_resolver is None):
        raise ValueError(
            "brand_authz_resolver and brand_identity_resolver must be wired "
            "together. Pass both (to enable Tier 3 brand-authorization gating) "
            "or neither (to skip the gate). A resolver without an extractor "
            "has no brand identity to check; an extractor without a resolver "
            "has nothing to do."
        )
    brand_authorization_gate: BrandAuthorizationGate | None
    if brand_authz_resolver is not None and brand_identity_resolver is not None:
        from adcp.decisioning.brand_authz_gate import BrandAuthorizationGate

        brand_authorization_gate = BrandAuthorizationGate(
            resolver=brand_authz_resolver,
            extract_identity=brand_identity_resolver,
        )
    else:
        brand_authorization_gate = None

    handler = PlatformHandler(
        platform,
        executor=executor,
        registry=registry,
        state_reader=state_reader,
        resource_resolver=resource_resolver,
        webhook_sender=webhook_sender,
        webhook_supervisor=webhook_supervisor,
        auto_emit_completion_webhooks=auto_emit_completion_webhooks,
        buyer_agent_registry=buyer_agent_registry,
        brand_authorization_gate=brand_authorization_gate,
        config_store=config_store,
        property_list_fetcher=property_list_fetcher,
        media_buy_store=media_buy_store,
        advertise_all=advertise_all,
    )

    # Boot-time fail-fast: property_list_filtering declared but no fetcher wired.
    from adcp.decisioning.property_list import (
        property_list_capability_enabled,
        validate_property_list_config,
    )

    validate_property_list_config(
        capability_enabled=property_list_capability_enabled(platform),
        fetcher=property_list_fetcher,
    )

    # F12 boot-time fail-fast (Emma sales-direct P0 root cause): if
    # the platform's claimed specialisms expose any spec-eligible
    # webhook task type (create_media_buy, activate_signal, etc.) AND
    # auto-emit is on AND no webhook_sender is wired, every buyer
    # ``push_notification_config.url`` would silently drop. Catch at
    # boot so adopters discover the misconfig before shipping. Same
    # posture as validate_platform's governance opt-in gate.
    #
    # Uses the per-instance advertised set (NOT the class-level
    # universe). A platform that doesn't claim any
    # webhook-eligible-tool-bearing specialism (test fixtures,
    # discovery-only agents) doesn't trigger the gate.
    from adcp.decisioning.webhook_emit import (
        validate_webhook_sender_for_platform,
        validate_webhook_signing_for_capabilities,
    )

    validate_webhook_sender_for_platform(
        advertised_tools=handler.advertised_tools_for_instance(),
        sender=webhook_sender,
        supervisor=webhook_supervisor,
        auto_emit=auto_emit_completion_webhooks,
    )

    # Issue #384: a platform advertising webhook_signing.supported=True
    # must wire a JWK-signing sender. The check is independent of the
    # auto-emit gate above — manually-emitted webhooks signed by the
    # platform handler also need to honor the capability advertisement.
    validate_webhook_signing_for_capabilities(
        capabilities=platform.capabilities,
        sender=webhook_sender,
        supervisor=webhook_supervisor,
    )

    # DX #422: boot-time fail-fast on a non-conformant capabilities
    # projection. Same posture as validate_platform / F12 — the
    # operator sees one structured AdcpError before the server starts
    # taking traffic, instead of buyers discovering a malformed
    # capabilities envelope on first contact.
    #
    # The sync validator drives the async handler via ``asyncio.run``,
    # which raises ``RuntimeError`` when called from inside an already-
    # running event loop. ``validate_at_init=False`` opts out so async
    # callers (test fixtures, ``lifespan`` handlers, in-process A2A
    # clients) can run the async sibling themselves — see #700.
    if validate_at_init:
        from adcp.decisioning.validate_capabilities import (
            validate_capabilities_response_shape,
        )

        validate_capabilities_response_shape(handler)

    # Boot-time fail-fast: idempotency advertised but no @wrap applied.
    # Buyers reading IdempotencySupported(supported=True) on the
    # capabilities envelope assume retries dedupe; without the
    # decorator, every retry re-executes side effects.
    from adcp.decisioning.validate_idempotency import (
        validate_idempotency_wiring,
    )

    validate_idempotency_wiring(platform)

    return handler, executor, registry

Build the :class:PlatformHandler + supporting wiring from a :class:DecisioningPlatform.

Returns a 3-tuple (adcp.decisioning.handler, executor, adcp.decisioning.registry). The handler wraps the platform; the executor is wired into dispatch for sync platform methods; the registry handles :class:TaskHandoff lifecycle.

Adopters who need full control over the MCP server wiring use this seam — compose the returned handler with their own :func:create_mcp_server() call. Most adopters use :func:serve() instead.

Validates the platform at server boot via :func:validate_platform() — fails fast on missing specialism methods, missing adcp.decisioning.accounts, governance opt-in violations (D15 round-4), and unknown specialisms (UserWarning per round-3 D14).

:param platform: The adopter's :class:DecisioningPlatform subclass instance. :param executor: Bring-your-own :class:ThreadPoolExecutor — for operators with audit-instrumented thread pools or wrappers around stdlib's executor. Mutually exclusive with thread_pool_size. Operator owns lifecycle (caller's shutdown(wait=True) responsibility). :param thread_pool_size: Size the default framework-allocated executor. Mutually exclusive with executor. Default is :func:_default_thread_pool_size. :param registry: Bring-your-own :class:TaskRegistry — typically a v6.1 durable backing store. Default is :class:InMemoryTaskRegistry, which the production-mode gate refuses unless ADCP_DECISIONING_ALLOW_INMEMORY_TASKS=1 is set. :param state_reader: Custom :class:StateReader impl (D15 — workflow-state reads). Default is the v6.0 stub (empty returns + one-time UserWarning per method). :param resource_resolver: Custom :class:ResourceResolver impl (D15 — async framework-mediated fetches). Default is the v6.0 stub (raises NotImplementedError with a pointer to v6.1). :param webhook_sender: Bring-your-own :class:WebhookSender for completion webhook delivery — both the sync-success auto-emit and the terminal completion / failure notification on the async (handoff) path of any spec-eligible verb when the buyer registered push_notification_config. Default None. The sender is the transport — one HTTP-Signatures POST per call, no retry, no breaker. Production sellers typically wrap the sender in a :class:~adcp.webhook_supervisor.WebhookDeliverySupervisor and pass that via webhook_supervisor= instead. :param webhook_supervisor: Bring-your-own :class:~adcp.webhook_supervisor.WebhookDeliverySupervisor for reliable delivery (retry, circuit breaker, attempt audit). When passed, the F12 auto-emit path routes through it instead of webhook_sender. The reference :class:~adcp.webhook_supervisor.InMemoryWebhookDeliverySupervisor wraps a sender; adopters with infra-side retry (Celery, Kafka, durable outbox) implement the Protocol against their queue. Mutually optional with webhook_sender; passing both is valid (supervisor wins for auto-emit, sender remains available for direct calls inside platform methods). :param buyer_agent_registry: BYO :class:BuyerAgentRegistry — the v3 commercial identity layer. When wired, the framework calls the registry BEFORE :meth:AccountStore.resolve() to gate every request on the seller's commercial allowlist. Suspended / blocked / unrecognized agents are rejected with structured errors: suspended → AGENT_SUSPENDED, blocked → AGENT_BLOCKED (both recovery="terminal", no details payload — the code itself is the discriminator per AdCP 3.1); unrecognized → PERMISSION_DENIED with no details.scope so the wire shape does not enumerate which agent_urls are onboarded with this seller. The resolved :class:BuyerAgent is threaded onto :attr:RequestContext.buyer_agent so platform methods can read commercial context (billing capabilities, default terms, adopter ext) without a second registry call. Default None — pre-trust beta adopters running existing key-based auth without commercial gating omit this and the dispatch path falls through to AccountStore.resolve() unchanged. :param auto_emit_completion_webhooks: F12 feature gate. When True (default), the framework auto-fires a completion webhook on the sync-success arm of mutating tools whenever the request supplied push_notification_config.url AND the tool is in :data:SPEC_WEBHOOK_TASK_TYPES. Buyers passing the URL expect notification regardless of whether the seller routed sync vs HITL. Set False for adopters who emit webhooks manually inside their handlers (avoid duplicate delivery; idempotency-key dedup at the receiver would handle it but explicit suppression matches the v5 manual-emit posture for adopters mid-migration). :param media_buy_store: Opt-in :class:MediaBuyStore wrapper that gates targeting_overlay echo on the seller's declared specialisms. Typically built via :func:create_media_buy_store() with the seller's adcp.decisioning.capabilities so the persistence layer only fires for sellers claiming property-lists or collection-lists. When wired, the framework calls persist_from_create on successful create_media_buy (via the same on-complete hook the proposal flow uses, so HITL completions also persist), calls merge_from_update on successful adcp.decisioning.update_media_buy, and calls backfill before returning from get_media_buys. Default None — sellers who don't claim the relevant specialisms or who echo targeting_overlay themselves omit this and pay no overhead. :param advertise_all: Mirror of the same flag on :func:serve() — controls how :meth:PlatformHandler.get_advertised_tools and the eventual tools/list response filter the handler's tool universe. False (default, spec-aligned) drops tools whose method is still the SDK's not_supported shim; True advertises every tool the platform's claimed specialisms cover regardless of override status. Stored on the returned handler so adopters can call handler.get_advertised_tools() to inspect the effective set without standing up a server. :param validate_at_init: When True (default), the framework runs :func:validate_capabilities_response_shape() during construction — fail-fast boot validation for the projected capabilities response. The sync validator drives the async handler via :func:asyncio.run, so the call fails with RuntimeError: asyncio.run() cannot be called from a running event loop when the constructor is invoked from inside an async context (test fixtures, Starlette lifespan, in-process A2A test clients). Pass False in those contexts and run the async validator yourself::

    handler, executor, registry = create_adcp_server_from_platform(
        platform, validate_at_init=False,
    )
    await validate_capabilities_response_shape_async(handler)

The other boot validators (:func:<code><a title="adcp.decisioning.validate_platform" href="#adcp.decisioning.validate_platform">validate\_platform()</a></code>,
:func:<code>validate\_webhook\_signing\_for\_capabilities</code>,
:func:<code>validate\_idempotency\_wiring</code>) are synchronous-pure and
always run; this flag only gates the capabilities-response
check. See #700.

To wire a :class:ProposalManager (v1 two-platform composition), pass it on a :class:PlatformRouter via proposal_managers={tenant_id: ProposalManager}. The router is the per-tenant binding point — single-tenant adopters use a one-entry router (platforms={"default": ...}, proposal_managers={"default": ...}). See docs/proposals/product-architecture.md § "Tenant binding model".

:raises ValueError: when executor and thread_pool_size are both supplied (D5 mutually-exclusive validation). :raises AdcpError: from :func:validate_platform() when the platform fails server-boot validation, OR when the production gate refuses :class:InMemoryTaskRegistry.

def create_dev_proposal_store(*,
draft_ttl: timedelta = datetime.timedelta(days=1),
committed_grace: timedelta = datetime.timedelta(days=7)) ‑> InMemoryProposalStore
Expand source code
def create_dev_proposal_store(
    *,
    draft_ttl: timedelta = _DEFAULT_DRAFT_TTL,
    committed_grace: timedelta = _DEFAULT_COMMITTED_GRACE,
) -> InMemoryProposalStore:
    """Build an :class:`InMemoryProposalStore` with a dev-mode warning.

    Adopters bringing up a storyboard locally use this factory so the
    wiring reads as a deliberate dev-mode choice. Production
    deployments wire a durable backing — the warning surfaces at every
    construction site so silent-prod-on-in-memory is one log search
    away from being caught.

    See ``docs/proposals/proposal-manager-v15-design.md`` § D1.
    """
    warnings.warn(
        "create_dev_proposal_store() returns an in-memory store; "
        "do NOT use in production deployments. Multi-worker (gunicorn / "
        "uvicorn workers / k8s replicas) deployments lose every "
        "in-flight proposal at the first worker that didn't see "
        "put_draft. Wire a durable ProposalStore (Postgres / Redis / "
        "SQLAlchemy) for production.",
        UserWarning,
        stacklevel=2,
    )
    return InMemoryProposalStore(
        draft_ttl=draft_ttl,
        committed_grace=committed_grace,
    )

Build an :class:InMemoryProposalStore with a dev-mode warning.

Adopters bringing up a storyboard locally use this factory so the wiring reads as a deliberate dev-mode choice. Production deployments wire a durable backing — the warning surfaces at every construction site so silent-prod-on-in-memory is one log search away from being caught.

See docs/proposals/proposal-manager-v15-design.md § D1.

def create_media_buy_store(adopter_store: MediaBuyStore,
*,
capabilities: DecisioningCapabilities) ‑> MediaBuyStore
Expand source code
def create_media_buy_store(
    adopter_store: MediaBuyStore,
    *,
    capabilities: DecisioningCapabilities,
) -> MediaBuyStore:
    """Wrap an adopter :class:`MediaBuyStore` with specialism-aware
    ``targeting_overlay`` echo gating.

    :param adopter_store: The persistence + echo implementation. Must
        satisfy the :class:`MediaBuyStore` Protocol — three methods
        (``persist_from_create``, ``merge_from_update``, ``backfill``)
        sync or async.
    :param capabilities: The seller's :class:`DecisioningCapabilities`.
        Read once at construction to decide whether the wrapper
        delegates or no-ops; not re-read per request, so adopters who
        mutate ``capabilities.specialisms`` after building the store
        won't see the change reflected. Build-time decision matches the
        boot-time validation pattern used elsewhere
        (``validate_platform``).

    :returns: A :class:`MediaBuyStore` wrapper. When ``capabilities.specialisms``
        intersects ``{property-lists, collection-lists}``, every method
        delegates to ``adopter_store``. Otherwise every method is a
        no-op pass-through and the adopter store is never invoked.

    The returned object is always distinct from ``adopter_store`` — even
    on the no-op path — so adopters can reason about identity at the
    assignment site (``platform.media_buy_store = ...``) without
    aliasing the underlying persistence layer.
    """
    # ``capabilities.specialisms`` is ``list[Specialism | str]`` per #479
    # — spec-known slugs are coerced to ``Specialism`` enum members at
    # construction; novel/typo slugs pass through as strings. Extract the
    # slug uniformly before set intersection.
    declared_slugs = {
        entry.value if hasattr(entry, "value") else entry for entry in capabilities.specialisms
    }
    claimed = declared_slugs & _OVERLAY_ECHO_SPECIALISMS
    if claimed:
        return _ActiveMediaBuyStore(adopter_store)
    return _NoopMediaBuyStore(adopter_store)

Wrap an adopter :class:MediaBuyStore with specialism-aware targeting_overlay echo gating.

:param adopter_store: The persistence + echo implementation. Must satisfy the :class:MediaBuyStore Protocol — three methods (persist_from_create, merge_from_update, backfill) sync or async. :param capabilities: The seller's :class:DecisioningCapabilities. Read once at construction to decide whether the wrapper delegates or no-ops; not re-read per request, so adopters who mutate capabilities.specialisms after building the store won't see the change reflected. Build-time decision matches the boot-time validation pattern used elsewhere (validate_platform()).

:returns: A :class:MediaBuyStore wrapper. When capabilities.specialisms intersects {property-lists, collection-lists}, every method delegates to adopter_store. Otherwise every method is a no-op pass-through and the adopter store is never invoked.

The returned object is always distinct from adopter_store — even on the no-op path — so adopters can reason about identity at the assignment site (platform.media_buy_store = ...) without aliasing the underlying persistence layer.

def create_oauth_passthrough_resolver(*,
http_client: UpstreamHttpClient,
list_endpoint: str,
to_account: Callable[[Any, ResolveContext | None], Account[Any] | Awaitable[Account[Any]]],
id_field: str = 'id',
extract_rows: Callable[[Any], list[Any] | None] | None = None,
get_auth_context: Callable[[ResolveContext | None], AuthContext | None] | None = None) ‑> adcp.decisioning.oauth_passthrough._OAuthPassthroughAccountStore
Expand source code
def create_oauth_passthrough_resolver(
    *,
    http_client: UpstreamHttpClient,
    list_endpoint: str,
    to_account: Callable[
        [Any, ResolveContext | None],
        Account[Any] | Awaitable[Account[Any]],
    ],
    id_field: str = "id",
    extract_rows: Callable[[Any], list[Any] | None] | None = None,
    get_auth_context: Callable[[ResolveContext | None], AuthContext | None] | None = None,
) -> _OAuthPassthroughAccountStore:
    """Create an :class:`AccountStore` backed by an upstream
    OAuth-protected listing endpoint.

    The returned object satisfies the :class:`AccountStore` Protocol
    (``resolution = 'explicit'``, ``resolve(ref, auth_info=None)``).
    Adopters wire it directly into :class:`DecisioningPlatform`::

        class SnapSeller(DecisioningPlatform):
            accounts = create_oauth_passthrough_resolver(...)

    Shape B adapters typically don't manage account lifecycle on the
    seller side, so the returned store implements only ``resolve`` —
    not the optional :meth:`AccountStoreUpsert.upsert` /
    :meth:`AccountStoreList.list` surfaces. Add those by wrapping the
    returned store in a class that delegates ``resolve`` and adds the
    upsert/list methods.

    :param http_client: Pre-configured upstream HTTP client (typically
        from :func:`create_upstream_http_client`). Should be configured
        with :class:`DynamicBearer` so the per-request auth context
        flows through to bearer selection.
    :param list_endpoint: Path on the upstream API that returns the
        buyer's accounts. Common shapes: ``/v1/adaccounts``,
        ``/me/adaccounts``, ``/customers``.
    :param to_account: Map an upstream row to a framework
        :class:`Account`. Receives the row and a synthesised
        :class:`ResolveContext` (carrying the caller's
        ``auth_info``). Sync or async — the framework awaits the
        result either way.

        **Treat any embedded credential in ``Account.metadata`` as a
        secret.** The framework strips ``metadata`` from the wire
        response, but adopter code that throws an error containing
        ``json.dumps(account)`` or logs ``ctx.account`` at info level
        WILL leak it. Either don't embed the bearer (re-derive from
        ``ctx.auth_info`` on each downstream method), or audit your
        error projections.
    :param id_field: Field on each upstream row that matches
        ``AccountReference.account_id``. Defaults to ``"id"``. A typo
        here silently always returns ``None`` — verify against the
        upstream's documented response shape.
    :param extract_rows: Optional callback receiving the raw parsed
        upstream body and returning the row list. Defaults to: try the
        body if it's a list, else ``body["data"]`` if it's a dict with
        that key. Provide a custom callback for deeper-nested shapes
        (e.g. ``{"data": {"list": [...]}}``).
    :param get_auth_context: Extract the auth context to forward to the
        upstream's :meth:`DynamicBearer.get_token` resolver. The return
        value flows through as the per-call ``auth_context`` on
        :meth:`UpstreamHttpClient.get`. Defaults to forwarding
        ``ctx.auth_info`` verbatim — works when the http client's token
        resolver reads from :class:`AuthInfo` directly.

    Behavior:

    * The returned store only handles the ``{account_id}``
      discriminated-union arm of :class:`AccountReference`. Other arms
      (``{brand, operator}``) and ``None`` ref return ``None`` without
      calling upstream. Adopters needing natural-key fallback compose
      their own resolver around this one.
    * Upstream errors propagate verbatim — ``http_client`` already
      projects non-2xx to spec-conformant :class:`AdcpError` codes
      (``AUTH_REQUIRED``, ``SERVICE_UNAVAILABLE``, etc.). Adopters
      compose error mapping over the result if they want a different
      shape.
    * 404 from the upstream listing endpoint surfaces as ``None`` (the
      http client's ``treat_404_as_none`` default), which the store
      treats as "no rows found".
    * **Pagination is not handled.** A single GET fetches the full
      list; paginated upstreams drop accounts beyond page one. See the
      module docstring for adopter workarounds.

    Example::

        from adcp.decisioning import (
            DynamicBearer,
            create_oauth_passthrough_resolver,
            create_upstream_http_client,
        )

        async def get_token(ctx):
            # ctx is the AuthInfo forwarded by default get_auth_context.
            return ctx.credential.token

        upstream = create_upstream_http_client(
            "https://upstream.example.com",
            auth=DynamicBearer(get_token=get_token),
        )

        class UpstreamSeller(DecisioningPlatform):
            accounts = create_oauth_passthrough_resolver(
                http_client=upstream,
                list_endpoint="/v1/me/adaccounts",
                to_account=lambda row, ctx: Account(
                    id=row["id"],
                    name=row["name"],
                    status="active",
                    metadata={"upstream_id": row["id"]},
                ),
            )
    """
    return _OAuthPassthroughAccountStore(
        http_client=http_client,
        list_endpoint=list_endpoint,
        to_account=to_account,
        id_field=id_field,
        extract_rows=(extract_rows if extract_rows is not None else _default_extract_rows),
        get_auth_context=(
            get_auth_context if get_auth_context is not None else _default_auth_context
        ),
    )

Create an :class:AccountStore backed by an upstream OAuth-protected listing endpoint.

The returned object satisfies the :class:AccountStore Protocol (resolution = 'explicit', resolve(ref, auth_info=None)). Adopters wire it directly into :class:DecisioningPlatform::

class SnapSeller(DecisioningPlatform):
    accounts = create_oauth_passthrough_resolver(...)

Shape B adapters typically don't manage account lifecycle on the seller side, so the returned store implements only adcp.decisioning.resolve — not the optional :meth:AccountStoreUpsert.upsert() / :meth:AccountStoreList.list() surfaces. Add those by wrapping the returned store in a class that delegates adcp.decisioning.resolve and adds the upsert/list methods.

:param http_client: Pre-configured upstream HTTP client (typically from :func:create_upstream_http_client()). Should be configured with :class:DynamicBearer so the per-request auth context flows through to bearer selection. :param list_endpoint: Path on the upstream API that returns the buyer's accounts. Common shapes: /v1/adaccounts, /me/adaccounts, /customers. :param to_account: Map an upstream row to a framework :class:Account. Receives the row and a synthesised :class:ResolveContext (carrying the caller's auth_info). Sync or async — the framework awaits the result either way.

**Treat any embedded credential in <code><a title="adcp.decisioning.Account.metadata" href="#adcp.decisioning.Account.metadata">Account.metadata</a></code> as a
secret.** The framework strips <code>metadata</code> from the wire
response, but adopter code that throws an error containing
<code>json.dumps(account)</code> or logs <code>ctx.account</code> at info level
WILL leak it. Either don't embed the bearer (re-derive from
<code>ctx.auth\_info</code> on each downstream method), or audit your
error projections.

:param id_field: Field on each upstream row that matches AccountReference.account_id. Defaults to "id". A typo here silently always returns None — verify against the upstream's documented response shape. :param extract_rows: Optional callback receiving the raw parsed upstream body and returning the row list. Defaults to: try the body if it's a list, else body["data"] if it's a dict with that key. Provide a custom callback for deeper-nested shapes (e.g. {"data": {"list": [...]}}). :param get_auth_context: Extract the auth context to forward to the upstream's :meth:DynamicBearer.get_token resolver. The return value flows through as the per-call auth_context on :meth:UpstreamHttpClient.get(). Defaults to forwarding ctx.auth_info verbatim — works when the http client's token resolver reads from :class:AuthInfo directly.

Behavior:

  • The returned store only handles the {account_id} discriminated-union arm of :class:AccountReference. Other arms ({brand, operator}) and None ref return None without calling upstream. Adopters needing natural-key fallback compose their own resolver around this one.
  • Upstream errors propagate verbatim — http_client already projects non-2xx to spec-conformant :class:AdcpError codes (AUTH_REQUIRED, SERVICE_UNAVAILABLE, etc.). Adopters compose error mapping over the result if they want a different shape.
  • 404 from the upstream listing endpoint surfaces as None (the http client's treat_404_as_none default), which the store treats as "no rows found".
  • Pagination is not handled. A single GET fetches the full list; paginated upstreams drop accounts beyond page one. See the module docstring for adopter workarounds.

Example::

from adcp.decisioning import (
    DynamicBearer,
    create_oauth_passthrough_resolver,
    create_upstream_http_client,
)

async def get_token(ctx):
    # ctx is the AuthInfo forwarded by default get_auth_context.
    return ctx.credential.token

upstream = create_upstream_http_client(
    "https://upstream.example.com",
    auth=DynamicBearer(get_token=get_token),
)

class UpstreamSeller(DecisioningPlatform):
    accounts = create_oauth_passthrough_resolver(
        http_client=upstream,
        list_endpoint="/v1/me/adaccounts",
        to_account=lambda row, ctx: Account(
            id=row["id"],
            name=row["name"],
            status="active",
            metadata={"upstream_id": row["id"]},
        ),
    )
def create_roster_account_store(*,
roster: Mapping[str, Account[TMeta]]) ‑> adcp.decisioning.roster_store._RosterAccountStore[~TMeta]
Expand source code
def create_roster_account_store(
    *,
    roster: Mapping[str, Account[TMeta]],
) -> _RosterAccountStore[TMeta]:
    """Build an :class:`AccountStore` backed by a fixed publisher-
    curated roster.

    The returned object conforms to the :class:`AccountStore` Protocol
    plus the optional :class:`AccountStoreList`,
    :class:`AccountStoreUpsert`, and :class:`AccountStoreSyncGovernance`
    Protocols. ``upsert`` and ``sync_governance`` fail closed with
    ``PERMISSION_DENIED`` per entry — adopters who need to support
    write paths use a custom :class:`AccountStore` implementation
    instead.

    :param roster: Mapping from ``account_id`` → :class:`Account`. Each
        value's ``id`` MUST match its key; mismatch raises
        :class:`ValueError` at construction. The mapping is copied into
        an internal immutable view, so subsequent mutation of the
        caller's dict does not affect the store.

    :returns: An :class:`AccountStore` whose:

        * :meth:`resolve` returns the roster entry for an
          ``account_id``-arm ref, ``None`` otherwise.
        * :meth:`list` returns every roster entry.
        * :meth:`upsert` rejects every input entry with
          ``PERMISSION_DENIED``.
        * :meth:`sync_governance` rejects every input entry with
          ``PERMISSION_DENIED``.

    :raises ValueError: When any roster value's ``id`` does not match
        its dict key.
    """
    return _RosterAccountStore(roster)

Build an :class:AccountStore backed by a fixed publisher- curated roster.

The returned object conforms to the :class:AccountStore Protocol plus the optional :class:AccountStoreList, :class:AccountStoreUpsert, and :class:AccountStoreSyncGovernance Protocols. upsert and sync_governance fail closed with PERMISSION_DENIED per entry — adopters who need to support write paths use a custom :class:AccountStore implementation instead.

:param roster: Mapping from account_id → :class:Account. Each value's id MUST match its key; mismatch raises :class:ValueError at construction. The mapping is copied into an internal immutable view, so subsequent mutation of the caller's dict does not affect the store.

:returns: An :class:AccountStore whose:

* :meth:<code><a title="adcp.decisioning.resolve" href="resolve.html">adcp.decisioning.resolve</a></code> returns the roster entry for an
  <code>account\_id</code>-arm ref, <code>None</code> otherwise.
* :meth:<code>list</code> returns every roster entry.
* :meth:<code>upsert</code> rejects every input entry with
  <code>PERMISSION\_DENIED</code>.
* :meth:<code>sync\_governance</code> rejects every input entry with
  <code>PERMISSION\_DENIED</code>.

:raises ValueError: When any roster value's id does not match its dict key.

def create_tenant_store(*,
resolve_by_ref: _ResolveByRef[TMeta],
resolve_from_auth: _ResolveFromAuth,
tenant_id: _TenantIdFn[TMeta],
tenant_to_account: _TenantToAccount[TMeta],
upsert_row: _UpsertRow | None = None,
sync_governance_row: _SyncGovernanceRow | None = None) ‑> _TenantStore[TMeta]
Expand source code
def create_tenant_store(
    *,
    resolve_by_ref: _ResolveByRef[TMeta],
    resolve_from_auth: _ResolveFromAuth,
    tenant_id: _TenantIdFn[TMeta],
    tenant_to_account: _TenantToAccount[TMeta],
    upsert_row: _UpsertRow | None = None,
    sync_governance_row: _SyncGovernanceRow | None = None,
) -> _TenantStore[TMeta]:
    """Build an :class:`AccountStore` whose ``resolve`` / ``upsert`` /
    ``list`` / ``sync_governance`` methods enforce tenant isolation.

    Canonical helper for the multi-tenant pattern described in
    :class:`~adcp.decisioning.AccountStore` — composes tenant scope
    into the returned ``Account.id`` so every downstream store
    (:class:`~adcp.decisioning.ProposalStore`,
    :class:`~adcp.decisioning.TaskRegistry`, framework idempotency
    cache) sees globally-unique identifiers and treats tenancy as
    opaque. Adopters writing a hand-rolled multi-tenant
    :class:`AccountStore` should reach for this factory first.

    :param resolve_by_ref: ``(ref, ctx) -> Account | None``. Resolves a
        wire :class:`AccountReference` to the framework Account it
        points at — independent of who the caller is. Return ``None``
        if the ref is unknown (helper emits ``ACCOUNT_NOT_FOUND`` for
        that row). May be sync or async.
    :param resolve_from_auth: ``(ctx) -> tenant_id | None``. Derives the
        tenant from the auth principal. Return ``None`` if no principal
        is resolvable (no auth, principal not registered) — every entry
        on per-entry tools then fails ``PERMISSION_DENIED``
        (fail-closed).
    :param tenant_id: ``(account) -> str``. Stable identity for tenant-
        equality checks. The helper compares
        ``tenant_id(entry_account) == resolve_from_auth(ctx)`` to
        enforce isolation. A stable string id beats reference equality
        (Postgres-backed stores hand back fresh objects each fetch).
    :param tenant_to_account: ``(tenant_id) -> Account | None``. Project
        a tenant id to its Account. Used by Path-2 ``resolve``
        (no-ref tools) and by ``list``.
    :param upsert_row: Optional ``(ref, ctx) -> SyncAccountsResultRow``
        per-entry storage callback. Cross-tenant entries and unknown-ref
        entries NEVER reach this callback — the helper builds
        ``PERMISSION_DENIED`` / ``ACCOUNT_NOT_FOUND`` rows for those
        before invoking adopter code. Omit for adopters whose platform
        doesn't claim ``sync_accounts``; the helper returns
        ``action='unchanged'`` for authorized rows in that case.
    :param sync_governance_row: Optional ``(entry, ctx) ->
        SyncGovernanceResultRow``. Same gating rules as ``upsert_row``.
        Adopters persist the buyer's governance-agent binding here.

    :returns: An :class:`AccountStore`-shaped object whose gate methods
        are class-level (immutable per instance — ``__slots__`` forbids
        attribute assignment).

    Example::

        from adcp.decisioning import create_tenant_store

        store = create_tenant_store(
            resolve_by_ref=lambda ref, ctx: lookup_account_by_ref(ref),
            resolve_from_auth=lambda ctx: principal_to_tenant.get(
                ctx.auth_info.principal if ctx.auth_info else None
            ),
            tenant_id=lambda account: account.metadata["tenant_id"],
            tenant_to_account=lambda tid: tenants[tid].account,
            upsert_row=lambda ref, ctx: persist_account(ref),
        )
    """
    return _TenantStore(
        resolve_by_ref=resolve_by_ref,
        resolve_from_auth=resolve_from_auth,
        tenant_id=tenant_id,
        tenant_to_account=tenant_to_account,
        upsert_row=upsert_row,
        sync_governance_row=sync_governance_row,
    )

Build an :class:AccountStore whose adcp.decisioning.resolve / upsert / list / sync_governance methods enforce tenant isolation.

Canonical helper for the multi-tenant pattern described in :class:~adcp.decisioning.AccountStore — composes tenant scope into the returned Account.id so every downstream store (:class:~adcp.decisioning.ProposalStore, :class:~adcp.decisioning.TaskRegistry, framework idempotency cache) sees globally-unique identifiers and treats tenancy as opaque. Adopters writing a hand-rolled multi-tenant :class:AccountStore should reach for this factory first.

:param resolve_by_ref: (ref, ctx) -> Account | None. Resolves a wire :class:AccountReference to the framework Account it points at — independent of who the caller is. Return None if the ref is unknown (helper emits ACCOUNT_NOT_FOUND for that row). May be sync or async. :param resolve_from_auth: (ctx) -> tenant_id | None. Derives the tenant from the auth principal. Return None if no principal is resolvable (no auth, principal not registered) — every entry on per-entry tools then fails PERMISSION_DENIED (fail-closed). :param tenant_id: (account) -> str. Stable identity for tenant- equality checks. The helper compares tenant_id(entry_account) == resolve_from_auth(ctx) to enforce isolation. A stable string id beats reference equality (Postgres-backed stores hand back fresh objects each fetch). :param tenant_to_account: (tenant_id) -> Account | None. Project a tenant id to its Account. Used by Path-2 adcp.decisioning.resolve (no-ref tools) and by list. :param upsert_row: Optional (ref, ctx) -> SyncAccountsResultRow per-entry storage callback. Cross-tenant entries and unknown-ref entries NEVER reach this callback — the helper builds PERMISSION_DENIED / ACCOUNT_NOT_FOUND rows for those before invoking adopter code. Omit for adopters whose platform doesn't claim sync_accounts; the helper returns action='unchanged' for authorized rows in that case. :param sync_governance_row: Optional (entry, ctx) -> SyncGovernanceResultRow<code>. Same gating rules as </code>upsert_row. Adopters persist the buyer's governance-agent binding here.

:returns: An :class:AccountStore-shaped object whose gate methods are class-level (immutable per instance — __slots__ forbids attribute assignment).

Example::

from adcp.decisioning import create_tenant_store

store = create_tenant_store(
    resolve_by_ref=lambda ref, ctx: lookup_account_by_ref(ref),
    resolve_from_auth=lambda ctx: principal_to_tenant.get(
        ctx.auth_info.principal if ctx.auth_info else None
    ),
    tenant_id=lambda account: account.metadata["tenant_id"],
    tenant_to_account=lambda tid: tenants[tid].account,
    upsert_row=lambda ref, ctx: persist_account(ref),
)
def create_translation_map(adcp_to_upstream: Mapping[A, U],
*,
default_adcp: A | None = None,
default_upstream: U | None = None) ‑> TranslationMap[~A, ~U]
Expand source code
def create_translation_map(
    adcp_to_upstream: Mapping[A, U],
    *,
    default_adcp: A | None = None,
    default_upstream: U | None = None,
) -> TranslationMap[A, U]:
    """Build a bidirectional translation map from an AdCP→upstream record.

    Keys on the left side are AdCP wire values; values on the right
    are upstream platform values. Collisions in either direction (two
    AdCP keys mapping to the same upstream value) are detected at
    construction time and raise :class:`ValueError` — silent
    overwrite would produce wrong-tenant routing in production.

    :param adcp_to_upstream: ``{adcp_key: upstream_key}`` mapping.
    :param default_adcp: Returned by :meth:`TranslationMap.to_adcp`
        when the upstream key isn't in the map. ``None`` (default)
        raises ``KeyError``.
    :param default_upstream: Returned by
        :meth:`TranslationMap.to_upstream` when the AdCP key isn't in
        the map. ``None`` (default) raises ``KeyError``.

    Example::

        delivery_map = create_translation_map({
            "guaranteed": "GUARANTEED_AT_PRICE",
            "non_guaranteed": "STANDARD",
        })
        delivery_map.to_upstream("guaranteed")  # "GUARANTEED_AT_PRICE"
        delivery_map.to_adcp("STANDARD")        # "non_guaranteed"
    """
    return TranslationMap(
        adcp_to_upstream,
        default_adcp=default_adcp,
        default_upstream=default_upstream,
    )

Build a bidirectional translation map from an AdCP→upstream record.

Keys on the left side are AdCP wire values; values on the right are upstream platform values. Collisions in either direction (two AdCP keys mapping to the same upstream value) are detected at construction time and raise :class:ValueError — silent overwrite would produce wrong-tenant routing in production.

:param adcp_to_upstream: {adcp_key: upstream_key} mapping. :param default_adcp: Returned by :meth:TranslationMap.to_adcp() when the upstream key isn't in the map. None (default) raises KeyError. :param default_upstream: Returned by :meth:TranslationMap.to_upstream() when the AdCP key isn't in the map. None (default) raises KeyError.

Example::

delivery_map = create_translation_map({
    "guaranteed": "GUARANTEED_AT_PRICE",
    "non_guaranteed": "STANDARD",
})
delivery_map.to_upstream("guaranteed")  # "GUARANTEED_AT_PRICE"
delivery_map.to_adcp("STANDARD")        # "non_guaranteed"
def create_upstream_http_client(base_url: str,
*,
auth: UpstreamAuth | None = None,
default_headers: Mapping[str, str] | None = None,
timeout: float = 30.0,
treat_404_as_none: bool = True) ‑> UpstreamHttpClient
Expand source code
def create_upstream_http_client(
    base_url: str,
    *,
    auth: UpstreamAuth | None = None,
    default_headers: Mapping[str, str] | None = None,
    timeout: float = 30.0,
    treat_404_as_none: bool = True,
) -> UpstreamHttpClient:
    """Create a thin typed HTTP client for an upstream platform API.

    Handles auth injection, JSON serialization, 404→None translation,
    and projection of non-2xx responses to spec-conformant
    :class:`AdcpError` codes so adapters focus on domain logic.

    :param base_url: Base URL of the upstream API. Trailing slashes
        are stripped.
    :param auth: Authentication strategy. Defaults to :class:`NoAuth`
        for unauthenticated internal services.
    :param default_headers: Headers included on every request
        (e.g. tenant id, API version). Per-request headers passed to
        individual method calls take precedence.
    :param timeout: Per-request timeout in seconds. Default 30.0.
    :param treat_404_as_none: When ``True`` (default), GET/DELETE 404
        responses return ``None`` rather than raising. Set ``False``
        to surface 404 as ``AdcpError(MEDIA_BUY_NOT_FOUND)``. POST and
        PUT always raise on 404 — they're not lookups.

    Example::

        client = create_upstream_http_client(
            "https://upstream.example.com",
            auth=DynamicBearer(get_token=resolve_tenant_token),
            default_headers={"X-API-Version": "2"},
        )
        order = await client.get(
            f"/v1/orders/{order_id}",
            auth_context={"network_code": ctx.account.ext["network_code"]},
        )
        if order is None:
            raise AdcpError("MEDIA_BUY_NOT_FOUND", message=f"order {order_id}")
    """
    return UpstreamHttpClient(
        base_url=base_url,
        auth=auth or NoAuth(),
        default_headers=default_headers,
        timeout=timeout,
        treat_404_as_none=treat_404_as_none,
    )

Create a thin typed HTTP client for an upstream platform API.

Handles auth injection, JSON serialization, 404→None translation, and projection of non-2xx responses to spec-conformant :class:AdcpError codes so adapters focus on domain logic.

:param base_url: Base URL of the upstream API. Trailing slashes are stripped. :param auth: Authentication strategy. Defaults to :class:NoAuth for unauthenticated internal services. :param default_headers: Headers included on every request (e.g. tenant id, API version). Per-request headers passed to individual method calls take precedence. :param timeout: Per-request timeout in seconds. Default 30.0. :param treat_404_as_none: When True (default), GET/DELETE 404 responses return None rather than raising. Set False to surface 404 as AdcpError(MEDIA_BUY_NOT_FOUND). POST and PUT always raise on 404 — they're not lookups.

Example::

client = create_upstream_http_client(
    "https://upstream.example.com",
    auth=DynamicBearer(get_token=resolve_tenant_token),
    default_headers={"X-API-Version": "2"},
)
order = await client.get(
    f"/v1/orders/{order_id}",
    auth_context={"network_code": ctx.account.ext["network_code"]},
)
if order is None:
    raise AdcpError("MEDIA_BUY_NOT_FOUND", message=f"order {order_id}")
def decompose_update_media_buy(patch: Any, current_media_buy: Any | None = None) ‑> list[UpdateMediaBuyMutation]
Expand source code
def decompose_update_media_buy(
    patch: Any,
    current_media_buy: Any | None = None,
) -> list[UpdateMediaBuyMutation]:
    """Split an ``update_media_buy`` patch into ordered logical mutations.

    ``patch`` may be a generated Pydantic ``UpdateMediaBuyRequest`` or a plain
    mapping. ``current_media_buy`` is optional; when supplied, the helper can
    promote coarse mutations into specific actions such as ``increase_budget``,
    ``decrease_budget``, ``extend_flight``, ``shorten_flight``, and
    ``reallocate_budget``.
    """

    patch_dict = _to_plain_mapping(patch)
    current_dict = _to_plain_mapping(current_media_buy) if current_media_buy is not None else {}
    current_packages = _index_packages(current_dict.get("packages"))

    mutations: list[UpdateMediaBuyMutation] = []

    if patch_dict.get("paused") is True:
        mutations.append(
            _mutation(
                "pause",
                ("paused",),
                before=_current_paused(current_dict),
                after=True,
            )
        )
    elif patch_dict.get("paused") is False:
        mutations.append(
            _mutation(
                "resume",
                ("paused",),
                before=_current_paused(current_dict),
                after=False,
            )
        )

    if patch_dict.get("canceled") is True:
        fields = ["canceled"]
        after: dict[str, Any] = {"canceled": True}
        if "cancellation_reason" in patch_dict:
            fields.append("cancellation_reason")
            after["cancellation_reason"] = patch_dict["cancellation_reason"]
        mutations.append(
            _mutation(
                "cancel",
                tuple(fields),
                before=_current_status(current_dict),
                after=after,
                raw=after,
            )
        )
    elif "cancellation_reason" in patch_dict:
        mutations.append(
            _unknown_mutation(
                ("cancellation_reason",),
                after=patch_dict["cancellation_reason"],
            )
        )

    date_fields = tuple(
        field_name for field_name in ("start_time", "end_time") if field_name in patch_dict
    )
    if date_fields:
        before = {
            field_name: current_dict[field_name]
            for field_name in date_fields
            if field_name in current_dict
        }
        after = {field_name: patch_dict[field_name] for field_name in date_fields}
        action, resolution = _date_action(date_fields, before, after)
        mutations.append(
            _mutation(
                action,
                date_fields,
                before=before or None,
                after=after,
                raw=after,
                resolution=resolution,
            )
        )

    if "new_packages" in patch_dict:
        mutations.append(
            _mutation(
                "add_packages",
                ("new_packages",),
                after=patch_dict["new_packages"],
                raw=patch_dict["new_packages"],
            )
        )

    packages = _as_sequence_of_mappings(patch_dict.get("packages"))
    reallocation = _package_budget_reallocation(packages, current_packages)
    if reallocation is not None:
        mutations.append(reallocation)
        reallocated_package_ids = set(reallocation.after)
    else:
        reallocated_package_ids = set()

    for index, package_patch in enumerate(packages):
        package_id = _package_id(package_patch)
        current_package = current_packages.get(package_id or "")
        mutations.extend(
            _decompose_package_patch(
                package_patch,
                index=index,
                package_id=package_id,
                current_package=current_package,
                skip_budget=package_id in reallocated_package_ids,
            )
        )

    for field_name in _TOP_LEVEL_UNMAPPED_MUTATION_FIELDS:
        if field_name in patch_dict:
            mutations.append(
                _unknown_mutation(
                    (field_name,),
                    after=patch_dict[field_name],
                    raw=patch_dict[field_name],
                )
            )

    for field_name in _unknown_top_level_fields(patch_dict):
        mutations.append(
            _unknown_mutation(
                (field_name,),
                after=patch_dict[field_name],
                raw=patch_dict[field_name],
            )
        )

    return mutations

Split an adcp.decisioning.update_media_buy patch into ordered logical mutations.

patch may be a generated Pydantic UpdateMediaBuyRequest or a plain mapping. current_media_buy is optional; when supplied, the helper can promote coarse mutations into specific actions such as increase_budget, decrease_budget, extend_flight, shorten_flight, and reallocate_budget.

def derive_packages_from_proposal(proposal_payload: Mapping[str, Any],
total_budget: Any,
*,
recipes: Mapping[str, Recipe] | None = None) ‑> list[PackageRequest]
Expand source code
def derive_packages_from_proposal(
    proposal_payload: Mapping[str, Any],
    total_budget: Any,
    *,
    recipes: Mapping[str, Recipe] | None = None,
) -> list[PackageRequest]:
    """Derive a ``list[PackageRequest]`` from a committed proposal's allocations.

    Default even-percentage distribution per ``ProductAllocation``:

    * ``budget = total_budget.amount * allocation.allocation_percentage / 100``
    * ``product_id``, ``pricing_option_id`` copied from the allocation
    * ``start_time`` / ``end_time`` copied when present (per-flight
      scheduling per spec)
    * ``pacing``, ``targeting_overlay``, ``bid_price`` etc. are NOT
      derived — adopters whose proposals carry these per allocation
      should override :meth:`ProposalManager.derive_packages` and emit
      them explicitly.

    **Currency.** Per spec, ``PackageRequest.budget`` is in
    ``total_budget.currency`` (the media-buy-level unit). This helper
    treats ``allocation_percentage`` as a unit-less multiplier; it
    does not inspect or compare any currency carried by the proposal's
    products' pricing_options. Multi-currency adopters should override
    :meth:`ProposalManager.derive_packages` and apply their own FX
    conversion before emitting packages.

    :param proposal_payload: The committed proposal's wire payload
        (typically ``ProposalRecord.proposal_payload``). Must contain
        an ``allocations[]`` array; absent / empty allocations are
        treated as a buyer error and surfaced as ``INVALID_REQUEST``.
    :param total_budget: The ``TotalBudget`` (or dict-shaped equivalent
        with ``amount`` / ``currency`` keys) from the buyer's
        ``CreateMediaBuyRequest``. Must be non-None — the buyer must
        supply ``total_budget`` whenever they omit ``packages``.
    :param recipes: Unused by the built-in derivation. Threaded through
        so the signature matches :meth:`ProposalManager.derive_packages`
        for adopters who delegate to this helper from inside their
        override.

    :raises AdcpError: ``INVALID_REQUEST`` when the proposal payload
        lacks ``allocations[]``, when an allocation is missing required
        fields (``product_id``, ``pricing_option_id``,
        ``allocation_percentage``), or when ``total_budget`` is missing.
    """
    # Local imports — :class:`PackageRequest` lives in adcp.types and we
    # avoid a top-level circular when adcp.types reimports adcp helpers.
    from adcp.types import PackageRequest

    del recipes  # built-in derivation doesn't consult recipes

    if total_budget is None:
        raise AdcpError(
            "INVALID_REQUEST",
            message=(
                "create_media_buy(proposal_id=...) requires total_budget "
                "when packages are omitted; the publisher derives package "
                "budgets by applying the proposal's allocation_percentage "
                "values to total_budget.amount."
            ),
            recovery="correctable",
            field="total_budget",
        )

    budget_amount = _read_attr(total_budget, "amount")
    if budget_amount is None:
        raise AdcpError(
            "INVALID_REQUEST",
            message="total_budget.amount is required for package derivation.",
            recovery="correctable",
            field="total_budget.amount",
        )

    allocations = (
        proposal_payload.get("allocations") if isinstance(proposal_payload, Mapping) else None
    )
    if not allocations or not isinstance(allocations, list):
        raise AdcpError(
            "INVALID_REQUEST",
            message=(
                "Cannot derive packages: the committed proposal carries no "
                "allocations[]. The buyer must supply packages[] explicitly "
                "or the seller must regenerate the proposal with allocations."
            ),
            recovery="terminal",
            field="proposal_id",
        )

    packages: list[PackageRequest] = []
    for idx, allocation in enumerate(allocations):
        product_id = _read_attr(allocation, "product_id")
        if not product_id:
            raise AdcpError(
                "INVALID_REQUEST",
                message=(f"Cannot derive packages: allocations[{idx}] is missing " "product_id."),
                recovery="terminal",
                field=f"proposal.allocations[{idx}].product_id",
            )
        pct = _read_attr(allocation, "allocation_percentage")
        if pct is None:
            raise AdcpError(
                "INVALID_REQUEST",
                message=(
                    f"Cannot derive packages: allocations[{idx}] for product "
                    f"{product_id!r} is missing allocation_percentage."
                ),
                recovery="terminal",
                field=f"proposal.allocations[{idx}].allocation_percentage",
            )
        pricing_option_id = _read_attr(allocation, "pricing_option_id")
        if not pricing_option_id:
            # Seller-side gap, NOT buyer-correctable. ProductAllocation
            # `pricing_option_id` is optional on the wire (it's only a
            # "recommended" pricing option) but PackageRequest requires
            # one. The built-in even-percentage derivation has no way to
            # pick — only the seller knows whether the product is auction-
            # priced, has multiple options, etc. INTERNAL_ERROR signals
            # this to the buyer as a seller bug; the seller's options
            # are (a) populate allocation.pricing_option_id at proposal-
            # assembly time, (b) ensure products under the proposal have
            # exactly one pricing_options[] entry (framework auto-picks),
            # or (c) implement ProposalManager.derive_packages for
            # auction / multi-option semantics.
            logger.error(
                "Cannot derive packages from proposal allocation %d: "
                "product %r is missing pricing_option_id. Adopter must "
                "set allocation.pricing_option_id at proposal-assembly "
                "time, expose a single product.pricing_options entry, "
                "or implement ProposalManager.derive_packages. The "
                "buyer's create_media_buy will fail until this is fixed.",
                idx,
                product_id,
            )
            raise AdcpError(
                "INTERNAL_ERROR",
                message=(
                    f"Seller configuration error: proposal allocation for "
                    f"product {product_id!r} is missing pricing_option_id. "
                    "Contact the seller — this is not a buyer-correctable "
                    "input."
                ),
                recovery="terminal",
            )

        pkg_budget = float(budget_amount) * (float(pct) / 100.0)
        # Spec-defined per-allocation flight scheduling — when the
        # seller's proposal carries allocation.start_time/end_time,
        # propagate them to the derived package. ``ProductAllocation``
        # docs them as "allows publishers to propose per-flight
        # scheduling within a proposal." Dropping them silently would
        # erase seller intent.
        kwargs: dict[str, Any] = {
            "product_id": str(product_id),
            "budget": pkg_budget,
            "pricing_option_id": str(pricing_option_id),
        }
        start_time = _read_attr(allocation, "start_time")
        if start_time is not None:
            kwargs["start_time"] = start_time
        end_time = _read_attr(allocation, "end_time")
        if end_time is not None:
            kwargs["end_time"] = end_time
        packages.append(PackageRequest(**kwargs))
    return packages

Derive a list[PackageRequest] from a committed proposal's allocations.

Default even-percentage distribution per ProductAllocation:

  • budget = total_budget.amount * allocation.allocation_percentage / 100
  • product_id, pricing_option_id copied from the allocation
  • start_time / end_time copied when present (per-flight scheduling per spec)
  • pacing, targeting_overlay, bid_price etc. are NOT derived — adopters whose proposals carry these per allocation should override :meth:ProposalManager.derive_packages and emit them explicitly.

Currency. Per spec, PackageRequest.budget is in total_budget.currency (the media-buy-level unit). This helper treats allocation_percentage as a unit-less multiplier; it does not inspect or compare any currency carried by the proposal's products' pricing_options. Multi-currency adopters should override :meth:ProposalManager.derive_packages and apply their own FX conversion before emitting packages.

:param proposal_payload: The committed proposal's wire payload (typically ProposalRecord.proposal_payload). Must contain an allocations[] array; absent / empty allocations are treated as a buyer error and surfaced as INVALID_REQUEST. :param total_budget: The TotalBudget (or dict-shaped equivalent with amount / currency keys) from the buyer's CreateMediaBuyRequest. Must be non-None — the buyer must supply total_budget whenever they omit packages. :param recipes: Unused by the built-in derivation. Threaded through so the signature matches :meth:ProposalManager.derive_packages for adopters who delegate to this helper from inside their override.

:raises AdcpError: INVALID_REQUEST when the proposal payload lacks allocations[], when an allocation is missing required fields (product_id, pricing_option_id, allocation_percentage), or when total_budget is missing.

def disallowed_update_media_buy_mutations(patch: Any,
allowed_actions: Iterable[Any] | None,
current_media_buy: Any | None = None,
*,
allowed_modes: Iterable[str] | None = ('self_serve', 'conditional_self_serve')) ‑> list[UpdateMediaBuyMutation]
Expand source code
def disallowed_update_media_buy_mutations(
    patch: Any,
    allowed_actions: Iterable[Any] | None,
    current_media_buy: Any | None = None,
    *,
    allowed_modes: Iterable[str] | None = SELF_SERVE_UPDATE_ACTION_MODES,
) -> list[UpdateMediaBuyMutation]:
    """Return action-gated mutations not covered by the supplied allowed actions.

    ``UNKNOWN_UPDATE_ACTION`` mutations stay visible in
    :func:`decompose_update_media_buy`, but are not treated as
    allowed-action failures because the protocol has no action mapping for
    them yet.
    """

    return [
        mutation
        for mutation in decompose_update_media_buy(patch, current_media_buy)
        if mutation.action != UNKNOWN_UPDATE_ACTION
        if not is_update_media_buy_mutation_allowed(
            mutation,
            allowed_actions,
            allowed_modes=allowed_modes,
        )
    ]

Return action-gated mutations not covered by the supplied allowed actions.

UNKNOWN_UPDATE_ACTION mutations stay visible in :func:decompose_update_media_buy(), but are not treated as allowed-action failures because the protocol has no action mapping for them yet.

def filter_products_by_property_list(products: list[Any], allowed_property_ids: set[str]) ‑> list[typing.Any]
Expand source code
def filter_products_by_property_list(
    products: list[Any],
    allowed_property_ids: set[str],
) -> list[Any]:
    """Filter a product list to those matching the buyer's authorized property IDs.

    Respects ``publisher_properties.selection_type``:

    * ``'all'`` — product covers all publisher properties; always included.
    * ``'by_id'`` — intersect the product's ``property_ids`` with
      ``allowed_property_ids``.
    * ``'by_tag'`` — property tags cannot be matched against a property ID list;
      this entry does not contribute to inclusion.

    Respects ``product.property_targeting_allowed``:

    * ``False`` (default, "all or nothing") — the product's full set of
      ``by_id`` property IDs must be a subset of ``allowed_property_ids``.
    * ``True`` (permissive) — any non-empty intersection is sufficient.

    A product is included if ANY of its ``publisher_properties`` entries passes
    the filter.  This models the semantics of a product covering inventory from
    multiple publishers: if ANY publisher's inventory is in the buyer's allowed
    set, the product is relevant.

    :param products: Products from the platform's ``get_products`` response.
    :param allowed_property_ids: Set of property_id strings the buyer is
        authorized to spend on (result of :func:`resolve_property_list`).
    :returns: Filtered product list; original order preserved.
    """
    return [p for p in products if _product_matches(p, allowed_property_ids)]

Filter a product list to those matching the buyer's authorized property IDs.

Respects publisher_properties.selection_type:

  • 'all' — product covers all publisher properties; always included.
  • 'by_id' — intersect the product's property_ids with allowed_property_ids.
  • 'by_tag' — property tags cannot be matched against a property ID list; this entry does not contribute to inclusion.

Respects product.property_targeting_allowed:

  • False (default, "all or nothing") — the product's full set of by_id property IDs must be a subset of allowed_property_ids.
  • True (permissive) — any non-empty intersection is sufficient.

A product is included if ANY of its publisher_properties entries passes the filter. This models the semantics of a product covering inventory from multiple publishers: if ANY publisher's inventory is in the buyer's allowed set, the product is relevant.

:param products: Products from the platform's get_products response. :param allowed_property_ids: Set of property_id strings the buyer is authorized to spend on (result of :func:resolve_property_list()). :returns: Filtered product list; original order preserved.

def get_account_mode(account: Any) ‑> Literal['live', 'sandbox', 'mock']
Expand source code
def get_account_mode(account: Any) -> AccountMode:
    """Read ``mode`` off any account-shaped value, with back-compat for
    the legacy ``sandbox: bool`` field.

    Returns the explicit mode if present; otherwise infers ``'sandbox'``
    from ``sandbox is True``; otherwise ``'live'``.

    Adopters that have not yet migrated to the ``mode`` field continue to
    work — ``account.sandbox is True`` reads as sandbox mode through this
    helper. New code should prefer ``mode`` directly.

    Unknown / unrecognized ``mode`` values fall through to ``'live'`` —
    we never silently admit on a misspelled mode string.
    """
    if account is None:
        return "live"
    mode = _attr(account, "mode")
    if mode == "live" or mode == "sandbox" or mode == "mock":
        return cast(AccountMode, mode)
    # Back-compat: legacy `sandbox: True` flag reads as `sandbox` mode.
    if _attr(account, "sandbox") is True:
        return "sandbox"
    return "live"

Read mode off any account-shaped value, with back-compat for the legacy sandbox: bool field.

Returns the explicit mode if present; otherwise infers 'sandbox' from sandbox is True; otherwise 'live'.

Adopters that have not yet migrated to the mode field continue to work — account.sandbox is True reads as sandbox mode through this helper. New code should prefer mode directly.

Unknown / unrecognized mode values fall through to 'live' — we never silently admit on a misspelled mode string.

def get_mock_upstream_url(account: Account[Any] | None) ‑> str | None
Expand source code
def get_mock_upstream_url(account: Account[Any] | None) -> str | None:
    """Read ``account.metadata['mock_upstream_url']`` safely.

    Adopters populate this in :meth:`AccountStore.resolve` for
    ``mode='mock'`` accounts so the framework's
    :meth:`DecisioningPlatform.upstream_for` knows which mock-server
    fixture URL to point the adapter's :class:`UpstreamHttpClient` at.
    The mock-server is per-specialism (``bin/adcp.js mock-server
    <specialism>``); adopters or CI start it and supply the URL on the
    account.

    Returns ``None`` when:

    - ``account.metadata`` is not a :class:`Mapping`.
    - ``mock_upstream_url`` is absent.
    - ``mock_upstream_url`` is empty / falsy / not a string.

    The framework treats any of these as "no mock URL declared" and
    fails closed at :meth:`DecisioningPlatform.upstream_for` rather
    than silently routing to a live URL.
    """
    if account is None:
        return None
    metadata = getattr(account, "metadata", None)
    if not isinstance(metadata, Mapping):
        return None
    url = metadata.get("mock_upstream_url")
    if not isinstance(url, str) or not url:
        return None
    return url

Read account.metadata['mock_upstream_url'] safely.

Adopters populate this in :meth:AccountStore.resolve() for mode='mock' accounts so the framework's :meth:DecisioningPlatform.upstream_for() knows which mock-server fixture URL to point the adapter's :class:UpstreamHttpClient at. The mock-server is per-specialism (bin/adcp.js mock-server <specialism>); adopters or CI start it and supply the URL on the account.

Returns None when:

  • account.metadata is not a :class:Mapping.
  • mock_upstream_url is absent.
  • mock_upstream_url is empty / falsy / not a string.

The framework treats any of these as "no mock URL declared" and fails closed at :meth:DecisioningPlatform.upstream_for() rather than silently routing to a live URL.

def has_refine_support(platform: Any) ‑> bool
Expand source code
def has_refine_support(platform: Any) -> bool:
    """Return True when refine is reachable through this platform.

    A platform supports refine if it implements ``refine_get_products``
    directly OR — in the case of a ``PlatformRouter`` — if any of its
    wired ProposalManagers declares the refine capability. The router
    itself doesn't expose ``refine_get_products``; refine routes
    through the ProposalManager's ``refine_products`` method on the
    proposal-side surface.
    """
    if callable(getattr(platform, "refine_get_products", None)):
        return True
    proposal_managers = getattr(platform, "_proposal_managers", None)
    if isinstance(proposal_managers, dict):
        for manager in proposal_managers.values():
            caps = getattr(manager, "capabilities", None)
            if getattr(caps, "refine", False) and callable(
                getattr(manager, "refine_products", None)
            ):
                return True
    return False

Return True when refine is reachable through this platform.

A platform supports refine if it implements refine_get_products directly OR — in the case of a PlatformRouter — if any of its wired ProposalManagers declares the refine capability. The router itself doesn't expose refine_get_products; refine routes through the ProposalManager's refine_products method on the proposal-side surface.

def is_sandbox_or_mock_account(account: Any) ‑> bool
Expand source code
def is_sandbox_or_mock_account(account: Any) -> bool:
    """Predicate: is the account in a non-production mode that admits
    test-only surfaces (comply controller, force_*, simulate_*)?

    Returns ``True`` for ``mode in {'sandbox', 'mock'}`` (or legacy
    ``sandbox is True``); ``False`` for ``mode == 'live'`` or any account
    shape that doesn't carry the field.
    """
    mode = get_account_mode(account)
    return mode == "sandbox" or mode == "mock"

Predicate: is the account in a non-production mode that admits test-only surfaces (comply controller, force_, simulate_)?

Returns True for mode in {'sandbox', 'mock'} (or legacy sandbox is True); False for mode == 'live' or any account shape that doesn't carry the field.

def is_update_media_buy_mutation_allowed(mutation: UpdateMediaBuyMutation,
allowed_actions: Iterable[Any] | None,
*,
allowed_modes: Iterable[str] | None = ('self_serve', 'conditional_self_serve')) ‑> bool
Expand source code
def is_update_media_buy_mutation_allowed(
    mutation: UpdateMediaBuyMutation,
    allowed_actions: Iterable[Any] | None,
    *,
    allowed_modes: Iterable[str] | None = SELF_SERVE_UPDATE_ACTION_MODES,
) -> bool:
    """Return whether ``allowed_actions`` contains a capability covering ``mutation``."""

    return mutation.is_allowed_by(allowed_actions, allowed_modes=allowed_modes)

Return whether allowed_actions contains a capability covering mutation.

def mixed_registry(*,
resolve_by_agent_url: _SignedResolver,
resolve_by_credential: _CredentialResolver) ‑> BuyerAgentRegistry
Expand source code
def mixed_registry(
    *,
    resolve_by_agent_url: _SignedResolver,
    resolve_by_credential: _CredentialResolver,
) -> BuyerAgentRegistry:
    """Transition: accept both signed and bearer traffic.

    Used during the migration window when buyers are upgrading from
    pre-trust bearer auth to signed requests. The framework picks
    the right resolver based on the verified credential kind.
    """
    return _MixedRegistry(
        _resolve_by_agent_url=resolve_by_agent_url,
        _resolve_by_credential=resolve_by_credential,
    )

Transition: accept both signed and bearer traffic.

Used during the migration window when buyers are upgrading from pre-trust bearer auth to signed requests. The framework picks the right resolver based on the verified credential kind.

def normalize_update_media_buy_allowed_actions(allowed_actions: Iterable[Any] | None,
*,
allowed_modes: Iterable[str] | None = None) ‑> tuple[str, ...]
Expand source code
def normalize_update_media_buy_allowed_actions(
    allowed_actions: Iterable[Any] | None,
    *,
    allowed_modes: Iterable[str] | None = None,
) -> tuple[str, ...]:
    """Normalize action declarations to ordered action identifiers.

    Accepts any mix of action strings, generated enum values, wire dictionaries
    like ``{"action": "pause", "mode": "self_serve"}``, and generated
    ``MediaBuyAvailableAction`` models. When ``allowed_modes`` is provided,
    wire entries with a non-matching ``mode`` are filtered out.
    """

    if allowed_actions is None:
        return ()
    allowed_mode_set = set(allowed_modes) if allowed_modes is not None else None
    return _dedupe(
        action_name
        for action in allowed_actions
        if (
            (action_name := _action_name(action)) is not None
            and _action_mode_matches(action, allowed_mode_set)
        )
    )

Normalize action declarations to ordered action identifiers.

Accepts any mix of action strings, generated enum values, wire dictionaries like {"action": "pause", "mode": "self_serve"}, and generated MediaBuyAvailableAction models. When allowed_modes is provided, wire entries with a non-matching mode are filtered out.

def project_account_for_response(account: Account) ‑> Account
Expand source code
def project_account_for_response(account: Account) -> Account:
    """Return a copy of ``account`` safe to serialize on a response.

    Strips :attr:`Account.billing_entity.bank` — the AdCP v3 spec
    marks bank details as write-only. Adopters that persist the full
    :class:`BusinessEntity` (with bank populated for invoicing) MUST
    project through this helper before serializing on any response.

    Returns the input unchanged when ``billing_entity`` is ``None``
    or ``billing_entity.bank`` is already absent — defensive copy
    via ``model_copy()`` so callers can mutate the returned object
    freely without touching the caller's input.

    The original ``account`` object is not modified.
    """
    if account.billing_entity is None or account.billing_entity.bank is None:
        return account.model_copy()
    safe_billing_entity = account.billing_entity.model_copy(update={"bank": None})
    return account.model_copy(update={"billing_entity": safe_billing_entity})

Return a copy of account safe to serialize on a response.

Strips :attr:Account.billing_entity.bank — the AdCP v3 spec marks bank details as write-only. Adopters that persist the full :class:BusinessEntity (with bank populated for invoicing) MUST project through this helper before serializing on any response.

Returns the input unchanged when billing_entity is None or billing_entity.bank is already absent — defensive copy via model_copy() so callers can mutate the returned object freely without touching the caller's input.

The original account object is not modified.

def project_business_entity_for_response(entity: BusinessEntity) ‑> BusinessEntity
Expand source code
def project_business_entity_for_response(entity: BusinessEntity) -> BusinessEntity:
    """Return a copy of ``entity`` with ``bank`` cleared.

    Same posture as :func:`project_account_for_response` but
    operating on a :class:`BusinessEntity` directly — useful for
    adopters serializing standalone billing-entity payloads (admin
    APIs, brand-rights flows) that don't go through the
    :class:`Account` envelope.

    The original ``entity`` is not modified.
    """
    if entity.bank is None:
        return entity.model_copy()
    return entity.model_copy(update={"bank": None})

Return a copy of entity with bank cleared.

Same posture as :func:project_account_for_response() but operating on a :class:BusinessEntity directly — useful for adopters serializing standalone billing-entity payloads (admin APIs, brand-rights flows) that don't go through the :class:Account envelope.

The original entity is not modified.

def project_incomplete_response(*, interval: int, unit: str) ‑> dict[str, typing.Any]
Expand source code
def project_incomplete_response(*, interval: int, unit: str) -> dict[str, Any]:
    """Build the wire-compliant timeout response dict.

    Returns ``{"products": [], "incomplete": [{scope, description, estimated_wait}]}``
    with ``incomplete`` containing at least one entry (``min_length=1`` on
    the wire schema). Uses a raw dict to stay above the import-layering
    boundary (only the whitelist in ``tests/test_import_layering.py`` may
    import from ``adcp.types._generated``).

    ``scope`` is ``"products"`` — the spec's "not all inventory sources were
    searched" scope, which is accurate for a full-search timeout. When the
    deadline fires before ``_invoke_platform_method`` returns, the seller
    genuinely does not know whether pricing / forecast / proposals would have
    been attempted; ``scope='products'`` is the minimal correct signal. A
    follow-up that ships the incremental protocol can enumerate additional
    scopes when the adopter provides partial data.

    ``estimated_wait`` is omitted (``None``) when the plain adopter path is
    used, since the framework has no visibility into how much longer the call
    would have taken.
    """
    description = (
        f"time_budget exhausted ({interval} {unit}); "
        "return the best results achievable within the budget. "
        "Retry with a larger time_budget to receive complete results."
    )
    return {
        "products": [],
        "incomplete": [
            {
                "scope": "products",
                "description": description,
                "estimated_wait": None,
            }
        ],
    }

Build the wire-compliant timeout response dict.

Returns {"products": [], "incomplete": [{scope, description, estimated_wait}]} with incomplete containing at least one entry (min_length=1 on the wire schema). Uses a raw dict to stay above the import-layering boundary (only the whitelist in tests/test_import_layering.py may import from adcp.types._generated).

scope is "products" — the spec's "not all inventory sources were searched" scope, which is accurate for a full-search timeout. When the deadline fires before _invoke_platform_method returns, the seller genuinely does not know whether pricing / forecast / proposals would have been attempted; scope='products' is the minimal correct signal. A follow-up that ships the incremental protocol can enumerate additional scopes when the adopter provides partial data.

estimated_wait is omitted (None) when the plain adopter path is used, since the framework has no visibility into how much longer the call would have taken.

def project_refine_response(result: RefineResult,
refines: list[Any]) ‑> GetProductsResponse
Expand source code
def project_refine_response(
    result: RefineResult,
    refines: list[Any],
) -> GetProductsResponse:
    """Project a :class:`RefineResult` into a wire :class:`GetProductsResponse`.

    Builds ``refinement_applied[]`` from the request's ``refine[]`` and
    the adopter's ``per_refine_outcome``, then attaches ``products`` and
    ``proposals``.

    :raises ValueError: When ``len(per_refine_outcome) != len(refines)``
        (developer-facing — adopter contract violation).
    """
    from adcp.types import GetProductsResponse

    refinement_applied = build_refinement_applied(refines, result.per_refine_outcome)

    return GetProductsResponse(
        products=list(result.products),
        proposals=list(result.proposals) if result.proposals is not None else None,
        refinement_applied=refinement_applied,
    )

Project a :class:RefineResult into a wire :class:GetProductsResponse.

Builds refinement_applied[] from the request's adcp.decisioning.refine[] and the adopter's per_refine_outcome, then attaches products and proposals.

:raises ValueError: When len(per_refine_outcome) != len(refines) (developer-facing — adopter contract violation).

def property_list_capability_enabled(platform: Any) ‑> bool
Expand source code
def property_list_capability_enabled(platform: Any) -> bool:
    """Return True if ``platform.capabilities.media_buy.features.property_list_filtering`` is set.

    Centralises the three-level ``getattr`` chain used in both ``handler.py``
    and ``serve.py`` so they can't drift apart.
    """
    media_buy = getattr(getattr(platform, "capabilities", None), "media_buy", None)
    features = getattr(media_buy, "features", None)
    return bool(getattr(features, "property_list_filtering", False))

Return True if platform.capabilities.media_buy.features.property_list_filtering is set.

Centralises the three-level getattr chain used in both handler.py and serve.py so they can't drift apart.

def ref_account_id(ref: AccountReference | dict[str, Any] | None) ‑> str | None
Expand source code
def ref_account_id(
    ref: AccountReference | dict[str, Any] | None,
) -> str | None:
    """Extract ``account_id`` from an :class:`AccountReference`.

    :class:`AccountReference` is a discriminated union of two shapes:

    * :class:`AccountReferenceById` — ``{account_id: ...}``
    * :class:`AccountReferenceByNaturalKey` — ``{brand: ..., operator: ...}``

    Adopters routinely write the same null-safe access pattern (open-coded
    ``ref.account_id if hasattr(ref, 'account_id') else None``); this helper
    centralises it. Returns ``None`` if ``ref`` is ``None`` or has the
    natural-key shape.

    Both Pydantic models and raw dicts are accepted, since legacy code
    paths may pass dicts straight through from JSON deserialisation.

    :param ref: An :class:`AccountReference`, a raw dict matching either
        shape, or ``None``.
    :returns: The ``account_id`` string when present; ``None`` otherwise.
    """
    if ref is None:
        return None

    if isinstance(ref, dict):
        value = ref.get("account_id")
        return value if isinstance(value, str) else None

    # AccountReference is a RootModel wrapping AccountReference1 |
    # AccountReference2. Its __getattr__ proxies to .root, so a direct
    # ``ref.account_id`` raises AttributeError on the natural-key arm.
    # getattr() with a default is the cleanest cross-arm read.
    value = getattr(ref, "account_id", None)
    return value if isinstance(value, str) else None

Extract account_id from an :class:AccountReference.

:class:AccountReference is a discriminated union of two shapes:

  • :class:AccountReferenceById{account_id: ...}
  • :class:AccountReferenceByNaturalKey{brand: ..., operator: ...}

Adopters routinely write the same null-safe access pattern (open-coded ref.account_id if hasattr(ref, 'account_id') else None); this helper centralises it. Returns None if ref is None or has the natural-key shape.

Both Pydantic models and raw dicts are accepted, since legacy code paths may pass dicts straight through from JSON deserialisation.

:param ref: An :class:AccountReference, a raw dict matching either shape, or None. :returns: The account_id string when present; None otherwise.

def requested_update_media_buy_actions(patch: Any, current_media_buy: Any | None = None) ‑> tuple[str, ...]
Expand source code
def requested_update_media_buy_actions(
    patch: Any,
    current_media_buy: Any | None = None,
) -> tuple[str, ...]:
    """Return ordered, de-duplicated actions requested by a patch."""

    return _dedupe(
        mutation.action for mutation in decompose_update_media_buy(patch, current_media_buy)
    )

Return ordered, de-duplicated actions requested by a patch.

def require_account_match(expected_account_field: str = 'account_id') ‑> Callable[[typing.Any, RequestContext[typing.Any]], Awaitable[ShortCircuit[typing.Any] | None]]
Expand source code
def require_account_match(
    expected_account_field: str = "account_id",
) -> BeforeHook[Any, Any]:
    """Build a :data:`BeforeHook` that requires the request's account
    field equal ``ctx.account.id``.

    The most common security composer — gates a method so a buyer
    can only operate on their own account. Apply via
    :func:`compose_method`::

        wrapped = compose_method(
            base.get_media_buy_delivery,
            before=require_account_match(),
        )

    :param expected_account_field: Name of the field on the request
        Pydantic model carrying the buyer-supplied account id.
        Default ``"account_id"`` matches the AdCP wire convention.
    :returns: A :data:`BeforeHook` that raises :class:`AdcpError`
        with ``PERMISSION_DENIED`` on mismatch (or missing field) and
        falls through (returns ``None``) on match.
    """

    async def hook(req: Any, ctx: RequestContext[Any]) -> ShortCircuit[Any] | None:
        requested = _read_field(req, expected_account_field)
        if requested != ctx.account.id:
            raise AdcpError(
                "PERMISSION_DENIED",
                message=_DENIED_MESSAGE,
                recovery="correctable",
            )
        return None

    return hook

Build a :data:BeforeHook that requires the request's account field equal ctx.account.id.

The most common security composer — gates a method so a buyer can only operate on their own account. Apply via :func:compose_method()::

wrapped = compose_method(
    base.get_media_buy_delivery,
    before=require_account_match(),
)

:param expected_account_field: Name of the field on the request Pydantic model carrying the buyer-supplied account id. Default "account_id" matches the AdCP wire convention. :returns: A :data:BeforeHook that raises :class:AdcpError with PERMISSION_DENIED on mismatch (or missing field) and falls through (returns None) on match.

def require_advertiser_match(expected_advertiser_field: str = 'advertiser_id') ‑> Callable[[typing.Any, RequestContext[typing.Any]], Awaitable[ShortCircuit[typing.Any] | None]]
Expand source code
def require_advertiser_match(
    expected_advertiser_field: str = "advertiser_id",
) -> BeforeHook[Any, Any]:
    """Build a :data:`BeforeHook` that requires the request's
    advertiser field equal ``ctx.account.metadata['advertiser_id']``.

    Use for per-advertiser scope below the account level — adopters
    who run multi-advertiser accounts and need to prevent cross-
    advertiser access within an account.

    :param expected_advertiser_field: Name of the field on the request
        Pydantic model carrying the buyer-supplied advertiser id.
        Default ``"advertiser_id"`` matches the AdCP wire convention.
    :returns: A :data:`BeforeHook` that raises :class:`AdcpError`
        with ``PERMISSION_DENIED`` on mismatch (or missing
        ``advertiser_id`` in metadata) and falls through on match.
    """

    async def hook(req: Any, ctx: RequestContext[Any]) -> ShortCircuit[Any] | None:
        requested = _read_field(req, expected_advertiser_field)
        scoped = _read_metadata_field(ctx.account.metadata, "advertiser_id")
        if requested != scoped:
            raise AdcpError(
                "PERMISSION_DENIED",
                message=_DENIED_MESSAGE,
                recovery="correctable",
            )
        return None

    return hook

Build a :data:BeforeHook that requires the request's advertiser field equal ctx.account.metadata['advertiser_id'].

Use for per-advertiser scope below the account level — adopters who run multi-advertiser accounts and need to prevent cross- advertiser access within an account.

:param expected_advertiser_field: Name of the field on the request Pydantic model carrying the buyer-supplied advertiser id. Default "advertiser_id" matches the AdCP wire convention. :returns: A :data:BeforeHook that raises :class:AdcpError with PERMISSION_DENIED on mismatch (or missing advertiser_id in metadata) and falls through on match.

def require_org_scope(expected_org_field: str = 'organization_id') ‑> Callable[[typing.Any, RequestContext[typing.Any]], Awaitable[ShortCircuit[typing.Any] | None]]
Expand source code
def require_org_scope(
    expected_org_field: str = "organization_id",
) -> BeforeHook[Any, Any]:
    """Build a :data:`BeforeHook` that requires the request's
    organization field equal ``ctx.account.metadata['organization_id']``.

    Use for org-level multi-tenancy where a single org owns multiple
    accounts and the authorization decision is at the org level
    (not the per-account level).

    :param expected_org_field: Name of the field on the request
        Pydantic model carrying the buyer-supplied organization id.
        Default ``"organization_id"`` matches the AdCP wire
        convention.
    :returns: A :data:`BeforeHook` that raises :class:`AdcpError`
        with ``PERMISSION_DENIED`` on mismatch (or missing
        ``organization_id`` in metadata) and falls through on match.
    """

    async def hook(req: Any, ctx: RequestContext[Any]) -> ShortCircuit[Any] | None:
        requested = _read_field(req, expected_org_field)
        scoped = _read_metadata_field(ctx.account.metadata, "organization_id")
        if requested != scoped:
            raise AdcpError(
                "PERMISSION_DENIED",
                message=_DENIED_MESSAGE,
                recovery="correctable",
            )
        return None

    return hook

Build a :data:BeforeHook that requires the request's organization field equal ctx.account.metadata['organization_id'].

Use for org-level multi-tenancy where a single org owns multiple accounts and the authorization decision is at the org level (not the per-account level).

:param expected_org_field: Name of the field on the request Pydantic model carrying the buyer-supplied organization id. Default "organization_id" matches the AdCP wire convention. :returns: A :data:BeforeHook that raises :class:AdcpError with PERMISSION_DENIED on mismatch (or missing organization_id in metadata) and falls through on match.

async def resolve_property_list(ref: Any,
*,
fetcher: PropertyListFetcher) ‑> set[str]
Expand source code
async def resolve_property_list(
    ref: Any,
    *,
    fetcher: PropertyListFetcher,
) -> set[str]:
    """Fetch the buyer's authorized property IDs from the agent at ``ref.agent_url``.

    :param ref: ``PropertyList`` wire object (has ``agent_url``, ``list_id``,
        ``auth_token``).
    :param fetcher: Adopter-supplied :class:`PropertyListFetcher`.
    :returns: Set of allowed property_id strings.
    :raises AdcpError: ``recovery='transient'`` on any fetch failure.
        ``auth_token`` is never included in the error details.
    """
    from adcp.decisioning.types import AdcpError

    list_id: str = ref.list_id
    agent_url: str = str(ref.agent_url)
    auth_token: str | None = getattr(ref, "auth_token", None)

    try:
        ids = await fetcher.fetch(agent_url, list_id, auth_token=auth_token)
        return set(ids)
    except Exception as exc:
        # Log the raw exception server-side; never include it in the wire
        # error message — the exception repr may carry auth_token or other
        # credential-shaped values from the upstream HTTP response.
        logger.warning(
            "[adcp.property_list] fetch failed for list_id=%r agent_url=%r: %s",
            list_id,
            agent_url,
            exc,
        )
        raise AdcpError(
            "SERVICE_UNAVAILABLE",
            message=(
                f"Property list fetch failed for list_id={list_id!r} "
                f"from agent_url={agent_url!r}"
            ),
            recovery="transient",
            details={"list_id": list_id, "agent_url": agent_url},
        ) from exc

Fetch the buyer's authorized property IDs from the agent at ref.agent_url.

:param ref: PropertyListReference wire object (has agent_url, list_id, auth_token). :param fetcher: Adopter-supplied :class:PropertyListFetcher. :returns: Set of allowed property_id strings. :raises AdcpError: recovery='transient' on any fetch failure. auth_token is never included in the error details.

def resolve_time_budget(time_budget: Any) ‑> float | None
Expand source code
def resolve_time_budget(time_budget: Any) -> float | None:
    """Convert ``GetProductsRequest.time_budget`` to a seconds deadline.

    Returns ``None`` when:
    - ``time_budget`` is ``None`` (field absent) — no deadline.
    - ``unit == 'campaign'`` — seller decides timing; SDK does not install a
      deadline. The raw ``time_budget`` value still reaches the adopter via
      ``params``.

    For all other units (seconds / minutes / hours / days), returns
    ``interval * unit_seconds`` as a positive ``float``.
    """
    if time_budget is None:
        return None

    unit = getattr(time_budget, "unit", None)
    if unit is None:
        # Tolerate plain dicts (test fixtures, future schema variants).
        unit = time_budget.get("unit") if isinstance(time_budget, dict) else None
    if unit is None:
        return None

    # Normalise enum to string.
    unit_str = unit.value if hasattr(unit, "value") else str(unit)

    if unit_str == "campaign":
        # Semantically distinct from "omitted" — the seller has the full
        # campaign flight. Log at DEBUG so adopters see the explicit skip.
        logger.debug(
            "[adcp.decisioning] time_budget unit='campaign' — "
            "no SDK-managed deadline; adopter decides timing."
        )
        return None

    factor = _UNIT_TO_SECONDS.get(unit_str)
    if factor is None:
        logger.warning(
            "[adcp.decisioning] Unrecognised time_budget unit %r — " "treating as no deadline.",
            unit_str,
        )
        return None

    interval = getattr(time_budget, "interval", None)
    if interval is None and isinstance(time_budget, dict):
        interval = time_budget.get("interval")
    if not isinstance(interval, int) or interval < 1:
        logger.warning(
            "[adcp.decisioning] Invalid time_budget interval %r — " "treating as no deadline.",
            interval,
        )
        return None

    return float(interval) * factor

Convert GetProductsRequest.time_budget to a seconds deadline.

Returns None when: - adcp.decisioning.time_budget is None (field absent) — no deadline. - unit == 'campaign' — seller decides timing; SDK does not install a deadline. The raw adcp.decisioning.time_budget value still reaches the adopter via params.

For all other units (seconds / minutes / hours / days), returns interval * unit_seconds as a positive float.

def serve(platform: DecisioningPlatform,
*,
name: str | None = None,
executor: ThreadPoolExecutor | None = None,
thread_pool_size: int | None = None,
registry: TaskRegistry | None = None,
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_authz_resolver: BrandAuthorizationResolver | None = None,
brand_identity_resolver: BrandIdentityResolver | None = None,
config_store: ProductConfigStore | None = None,
property_list_fetcher: PropertyListFetcher | None = None,
advertise_all: bool = False,
mock_ad_server: Any | None = None,
enable_debug_endpoints: bool = False,
pre_validation_hooks: dict[str, Any] | None = None,
validate_at_init: bool = True,
**serve_kwargs: Any) ‑> None
Expand source code
def serve(
    platform: DecisioningPlatform,
    *,
    name: str | None = None,
    executor: ThreadPoolExecutor | None = None,
    thread_pool_size: int | None = None,
    registry: TaskRegistry | None = None,
    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_authz_resolver: BrandAuthorizationResolver | None = None,
    brand_identity_resolver: BrandIdentityResolver | None = None,
    config_store: ProductConfigStore | None = None,
    property_list_fetcher: PropertyListFetcher | None = None,
    advertise_all: bool = False,
    mock_ad_server: Any | None = None,
    enable_debug_endpoints: bool = False,
    pre_validation_hooks: dict[str, Any] | None = None,
    validate_at_init: bool = True,
    **serve_kwargs: Any,
) -> None:
    """One-call wrapper — build the handler and serve over MCP.

    Most adopters use this. For full control, use
    :func:`create_adcp_server_from_platform` and compose with
    :func:`adcp.server.create_mcp_server` / ``serve()`` directly.

    :param platform: The :class:`DecisioningPlatform` subclass
        instance.
    :param name: Server name advertised on AdCP capabilities. Defaults
        to the platform class's ``__name__``.
    :param executor: BYO :class:`ThreadPoolExecutor` per
        :func:`create_adcp_server_from_platform` D5 contract.
    :param thread_pool_size: Default-executor size override.
    :param registry: BYO :class:`TaskRegistry`. Default is
        :class:`InMemoryTaskRegistry` (gated for production).
    :param state_reader: Custom :class:`StateReader` impl (D15).
    :param resource_resolver: Custom :class:`ResourceResolver` impl (D15).
    :param webhook_sender: BYO :class:`adcp.webhook_sender.WebhookSender`
        for completion webhook delivery — sync-success auto-emit plus the
        terminal completion / failure notification on the async (handoff)
        path of any spec-eligible verb when the buyer registered
        ``push_notification_config``. Transport only — one attempt, no
        retry. ``None`` disables emission silently.
    :param webhook_supervisor: BYO
        :class:`~adcp.webhook_supervisor.WebhookDeliverySupervisor` for
        reliable delivery (retry, circuit breaker, attempt audit).
        Takes precedence over ``webhook_sender`` for F12 auto-emit
        when both are passed. Production sellers typically pass an
        :class:`~adcp.webhook_supervisor.InMemoryWebhookDeliverySupervisor`
        wrapping their sender.
    :param auto_emit_completion_webhooks: F12 — auto-fire a completion
        webhook on the sync-success arm of mutating tools when the
        request supplied ``push_notification_config.url``. Default
        ``True``. Set ``False`` for adopters who emit webhooks
        manually inside their handlers.
    :param mock_ad_server: Optional :class:`adcp.decisioning.MockAdServer`
        whose ``get_traffic()`` is wired into ``GET /_debug/traffic``
        when ``enable_debug_endpoints=True``. Default ``None`` —
        adopters with no anti-façade recorder leave this off.
    :param enable_debug_endpoints: When ``True``, mount
        ``GET /_debug/traffic`` exposing the JSON dict returned by
        ``mock_ad_server.get_traffic()``. Defaults to ``False``;
        production deployments stay closed. Reference / dev sellers
        flip on so storyboard runners can poll outbound call counts.
        Forwarded to :func:`adcp.server.serve`.
    :param advertise_all: Forwarded to :func:`adcp.server.serve`. When
        ``True``, ``tools/list`` advertises every method on the
        handler regardless of override status. Default ``False`` —
        the override-detection filter trims unimplemented platform
        methods. Adopters with explicit-not-supported intent (e.g.,
        spec-compliance storyboards) pass ``True``.
    :param serve_kwargs: Forwarded to :func:`adcp.server.serve`. Use
        for ``host``, ``port``, ``transport``, ``test_controller``,
        ``context_factory``, ``middleware``, ``validation``,
        ``response_enhancer`` (a server-wide
        :data:`~adcp.server.ResponseEnhancer` applied to every response
        on both transports),
        ``config`` (:class:`adcp.server.ServeConfig` bundle), etc.
        Pass ``config=ServeConfig(transport="a2a", ...)`` to supply
        all server options as a single typed object rather than
        individual kwargs.
        Pass ``validation=ValidationHookConfig(requests="strict",
        responses="strict")`` to enable schema-driven request/response
        validation against the bundled AdCP JSON schemas — sellers who
        want their server to enforce wire conformance turn it on here.
    :param pre_validation_hooks: Optional dict mapping AdCP tool name to
        a ``(tool_name, raw_args) -> raw_args`` callable. The hook runs
        on the raw wire dict **before** schema + Pydantic validation —
        use it to apply spec-mandated defaults for pre-v3 buyers that
        omit required fields. Example::

            serve(
                router,
                pre_validation_hooks={
                    "get_products": lambda n, a: {
                        **a, "buying_mode": a.get("buying_mode", "brief")
                    },
                },
            )

        Hook exceptions surface as ``INVALID_REQUEST`` on the wire.
        The hook receives a shallow copy of the wire args, so it may
        mutate its argument freely or return a new dict — either style
        is safe. Context echo always reflects the original wire input.
    :param validate_at_init: Forwarded to
        :func:`create_adcp_server_from_platform`. Default ``True``
        runs the capabilities-shape boot validator in sync; pass
        ``False`` and run :func:`validate_capabilities_response_shape_async`
        yourself when invoking ``serve()`` from inside a running event
        loop (e.g. ``asyncio.run(your_main())`` that calls
        ``adcp.decisioning.serve`` for a sidecar binary). See #700.
    """
    # Local import to avoid a circular at module-load time. Adopter
    # serves never run during foundation imports anyway.
    from adcp.server.serve import serve as _adcp_serve

    handler, _executor, _registry = create_adcp_server_from_platform(
        platform,
        executor=executor,
        thread_pool_size=thread_pool_size,
        registry=registry,
        state_reader=state_reader,
        resource_resolver=resource_resolver,
        webhook_sender=webhook_sender,
        webhook_supervisor=webhook_supervisor,
        auto_emit_completion_webhooks=auto_emit_completion_webhooks,
        buyer_agent_registry=buyer_agent_registry,
        brand_authz_resolver=brand_authz_resolver,
        brand_identity_resolver=brand_identity_resolver,
        config_store=config_store,
        property_list_fetcher=property_list_fetcher,
        advertise_all=advertise_all,
        validate_at_init=validate_at_init,
    )

    # Phase 1 sandbox-authority — wire the comply controller's account
    # gate to the platform's AccountStore. When a test_controller is
    # present and the adopter hasn't supplied their own resolver, build
    # a closure over ``platform.accounts.resolve`` so the gate refuses
    # for live-mode accounts. Adopters supplying their own resolver
    # (``test_controller_account_resolver=``) take precedence.
    if (
        serve_kwargs.get("test_controller") is not None
        and "test_controller_account_resolver" not in serve_kwargs
    ):
        serve_kwargs["test_controller_account_resolver"] = _build_test_controller_account_resolver(
            platform
        )

    # Compliance-testing capability footgun — adopter declared
    # ``capabilities.compliance_testing`` but didn't wire a
    # ``test_controller=`` to ``serve()``. Buyers reading the projected
    # capabilities response will see ``compliance_testing`` advertised
    # and try to drive scenarios via ``comply_test_controller`` — which
    # then 404s because no controller is registered. Soft-warn rather
    # than fail-fast: adopters may legitimately declare the capability
    # while the controller is being wired in a follow-up PR, and a hard
    # boot error blocks that staged rollout.
    if (
        platform.capabilities.compliance_testing is not None
        and serve_kwargs.get("test_controller") is None
    ):
        warnings.warn(
            (
                "DecisioningCapabilities.compliance_testing is declared but "
                "no test_controller= was passed to serve(). Buyers reading "
                "this seller's capabilities will see compliance_testing "
                "advertised and try to drive scenarios via "
                "comply_test_controller — which will fail because no "
                "controller is registered. Either pass "
                "``test_controller=TestControllerStore(...)`` to ``serve()`` "
                "OR drop ``compliance_testing`` from "
                "``DecisioningCapabilities``. Capability declaration is a "
                "buyer-facing commitment; mismatched-vs-implemented "
                "advertisements are the kind of footgun the spec asks "
                "sellers to avoid."
            ),
            UserWarning,
            stacklevel=2,
        )

    server_name = name or type(platform).__name__
    debug_traffic_source = mock_ad_server.get_traffic if mock_ad_server is not None else None
    if pre_validation_hooks is not None:
        serve_kwargs["pre_validation_hooks"] = pre_validation_hooks
    _adcp_serve(
        handler,
        name=server_name,
        advertise_all=advertise_all,
        enable_debug_endpoints=enable_debug_endpoints,
        debug_traffic_source=debug_traffic_source,
        **serve_kwargs,
    )

One-call wrapper — build the handler and serve over MCP.

Most adopters use this. For full control, use :func:create_adcp_server_from_platform() and compose with :func:create_mcp_server() / serve() directly.

:param platform: The :class:DecisioningPlatform subclass instance. :param name: Server name advertised on AdCP capabilities. Defaults to the platform class's __name__. :param executor: BYO :class:ThreadPoolExecutor per :func:create_adcp_server_from_platform() D5 contract. :param thread_pool_size: Default-executor size override. :param registry: BYO :class:TaskRegistry. Default is :class:InMemoryTaskRegistry (gated for production). :param state_reader: Custom :class:StateReader impl (D15). :param resource_resolver: Custom :class:ResourceResolver impl (D15). :param webhook_sender: BYO :class:WebhookSender for completion webhook delivery — sync-success auto-emit plus the terminal completion / failure notification on the async (handoff) path of any spec-eligible verb when the buyer registered push_notification_config. Transport only — one attempt, no retry. None disables emission silently. :param webhook_supervisor: BYO :class:~adcp.webhook_supervisor.WebhookDeliverySupervisor for reliable delivery (retry, circuit breaker, attempt audit). Takes precedence over webhook_sender for F12 auto-emit when both are passed. Production sellers typically pass an :class:~adcp.webhook_supervisor.InMemoryWebhookDeliverySupervisor wrapping their sender. :param auto_emit_completion_webhooks: F12 — auto-fire a completion webhook on the sync-success arm of mutating tools when the request supplied push_notification_config.url. Default True. Set False for adopters who emit webhooks manually inside their handlers. :param mock_ad_server: Optional :class:MockAdServer whose get_traffic() is wired into GET /_debug/traffic when enable_debug_endpoints=True. Default None — adopters with no anti-façade recorder leave this off. :param enable_debug_endpoints: When True, mount GET /_debug/traffic exposing the JSON dict returned by mock_ad_server.get_traffic(). Defaults to False; production deployments stay closed. Reference / dev sellers flip on so storyboard runners can poll outbound call counts. Forwarded to :func:serve(). :param advertise_all: Forwarded to :func:serve(). When True, tools/list advertises every method on the handler regardless of override status. Default False — the override-detection filter trims unimplemented platform methods. Adopters with explicit-not-supported intent (e.g., spec-compliance storyboards) pass True. :param serve_kwargs: Forwarded to :func:serve(). Use for host, port, transport, test_controller, context_factory, middleware, validation, response_enhancer (a server-wide :data:~adcp.server.ResponseEnhancer applied to every response on both transports), config (:class:ServeConfig bundle), etc. Pass config=ServeConfig(transport="a2a", ...) to supply all server options as a single typed object rather than individual kwargs. Pass validation=ValidationHookConfig(requests="strict", responses="strict") to enable schema-driven request/response validation against the bundled AdCP JSON schemas — sellers who want their server to enforce wire conformance turn it on here. :param pre_validation_hooks: Optional dict mapping AdCP tool name to a (tool_name, raw_args) -> raw_args callable. The hook runs on the raw wire dict before schema + Pydantic validation — use it to apply spec-mandated defaults for pre-v3 buyers that omit required fields. Example::

    serve(
        router,
        pre_validation_hooks={
            "get_products": lambda n, a: {
                **a, "buying_mode": a.get("buying_mode", "brief")
            },
        },
    )

Hook exceptions surface as <code>INVALID\_REQUEST</code> on the wire.
The hook receives a shallow copy of the wire args, so it may
mutate its argument freely or return a new dict — either style
is safe. Context echo always reflects the original wire input.

:param validate_at_init: Forwarded to :func:create_adcp_server_from_platform(). Default True runs the capabilities-shape boot validator in sync; pass False and run :func:validate_capabilities_response_shape_async() yourself when invoking serve() from inside a running event loop (e.g. asyncio.run(your_main()) that calls serve() for a sidecar binary). See #700.

def signing_only_registry(resolve_by_agent_url: _SignedResolver) ‑> BuyerAgentRegistry
Expand source code
def signing_only_registry(
    resolve_by_agent_url: _SignedResolver,
) -> BuyerAgentRegistry:
    """Production-target: accept signed traffic only.

    Adopter supplies an async function that maps a verified
    ``agent_url`` to a :class:`BuyerAgent` (or ``None`` to reject).
    Bearer traffic gets ``PERMISSION_DENIED`` (with ``details``
    omitted — wire-indistinguishable from any other denial) at the
    framework's dispatch layer — the registry deliberately doesn't
    implement bearer lookup.
    """
    return _SigningOnlyRegistry(_resolve_by_agent_url=resolve_by_agent_url)

Production-target: accept signed traffic only.

Adopter supplies an async function that maps a verified agent_url to a :class:BuyerAgent (or None to reject). Bearer traffic gets PERMISSION_DENIED (with details omitted — wire-indistinguishable from any other denial) at the framework's dispatch layer — the registry deliberately doesn't implement bearer lookup.

def to_wire_account(account: DecisioningAccount[Any]) ‑> dict[str, Any]
Expand source code
def to_wire_account(account: DecisioningAccount[Any]) -> dict[str, Any]:
    """Project a framework :class:`Account[TMeta]` to the wire
    ``Account`` shape.

    Strips ``metadata`` and ``auth_info`` (framework-internal — never
    on the wire); renames ``id`` → ``account_id``; passes through
    wire-shaped optional fields. Strips ``billing_entity.bank``
    per the schema's write-only constraint.

    For ``governance_agents``, strips ``authentication`` from every
    element (defense-in-depth — TypeScript erasure means Python type
    hints can't enforce credentials-out at runtime, so the projection
    is explicit at every emit boundary).

    Used by the framework when emitting any response that surfaces an
    :class:`Account`. Adopters never call this directly — they return
    :class:`Account[TMeta]` from :meth:`AccountStore.resolve` /
    :meth:`AccountStore.list` and the framework projects.
    """
    wire: dict[str, Any] = {
        "account_id": account.id,
        "name": account.name,
        "status": account.status,
    }
    projected_entity = _project_billing_entity(account.billing_entity)
    if projected_entity is not None:
        wire["billing_entity"] = projected_entity
    if account.setup is not None:
        wire["setup"] = _maybe_dump(account.setup)
    if account.governance_agents is not None:
        projected_agents = [
            p
            for p in (_project_governance_agent(a) for a in account.governance_agents)
            if p is not None
        ]
        wire["governance_agents"] = projected_agents
    if account.account_scope is not None:
        scope = account.account_scope
        wire["account_scope"] = _enum_value(scope)
    if account.payment_terms is not None:
        terms = account.payment_terms
        wire["payment_terms"] = _enum_value(terms)
    if account.credit_limit is not None:
        wire["credit_limit"] = _maybe_dump(account.credit_limit)
    if account.rate_card is not None:
        wire["rate_card"] = account.rate_card
    if account.reporting_bucket is not None:
        wire["reporting_bucket"] = _maybe_dump(account.reporting_bucket)
    if account.authorization is not None:
        authorization = _project_account_authorization(account.authorization)
        if authorization is not None:
            wire["authorization"] = authorization
    return wire

Project a framework :class:Account[TMeta] to the wire Account shape.

Strips metadata and auth_info (framework-internal — never on the wire); renames idaccount_id; passes through wire-shaped optional fields. Strips billing_entity.bank per the schema's write-only constraint.

For governance_agents, strips authentication from every element (defense-in-depth — TypeScript erasure means Python type hints can't enforce credentials-out at runtime, so the projection is explicit at every emit boundary).

Used by the framework when emitting any response that surfaces an :class:Account. Adopters never call this directly — they return :class:Account[TMeta] from :meth:AccountStore.resolve() / :meth:AccountStore.list and the framework projects.

def to_wire_sync_accounts_row(row: SyncAccountsResultRow) ‑> dict[str, Any]
Expand source code
def to_wire_sync_accounts_row(row: SyncAccountsResultRow) -> dict[str, Any]:
    """Project a :class:`SyncAccountsResultRow` to the wire shape
    returned by ``sync_accounts``.

    Applies the same ``billing_entity.bank`` strip as
    :func:`to_wire_account` — the wire schema marks bank coordinates
    write-only on EVERY response, not just ``list_accounts``.
    Adopters returning a row that spreads a DB record carrying
    ``bank`` (e.g., ``{**db.findByBrand(r.brand), 'action':
    'updated'}``) have it stripped before emit.

    Used by the framework when emitting ``sync_accounts`` responses.
    Adopters never call this directly — they return
    ``list[SyncAccountsResultRow]`` from
    :meth:`AccountStore.upsert` and the framework projects.
    """
    action = row.action
    status = row.status
    wire: dict[str, Any] = {
        "brand": _maybe_dump(row.brand),
        "operator": row.operator,
        "action": _enum_value(action),
        "status": _enum_value(status),
    }
    if row.account_id is not None:
        wire["account_id"] = row.account_id
    if row.name is not None:
        wire["name"] = row.name
    if row.billing is not None:
        wire["billing"] = row.billing
    projected_entity = _project_billing_entity(row.billing_entity)
    if projected_entity is not None:
        wire["billing_entity"] = projected_entity
    if row.account_scope is not None:
        scope = row.account_scope
        wire["account_scope"] = _enum_value(scope)
    if row.setup is not None:
        wire["setup"] = _maybe_dump(row.setup)
    if row.rate_card is not None:
        wire["rate_card"] = row.rate_card
    if row.payment_terms is not None:
        terms = row.payment_terms
        wire["payment_terms"] = _enum_value(terms)
    if row.credit_limit is not None:
        wire["credit_limit"] = _maybe_dump(row.credit_limit)
    if row.errors is not None:
        wire["errors"] = list(row.errors)
    if row.warnings is not None:
        wire["warnings"] = list(row.warnings)
    if row.sandbox is not None:
        wire["sandbox"] = row.sandbox
    return wire

Project a :class:SyncAccountsResultRow to the wire shape returned by sync_accounts.

Applies the same billing_entity.bank strip as :func:to_wire_account() — the wire schema marks bank coordinates write-only on EVERY response, not just list_accounts. Adopters returning a row that spreads a DB record carrying bank (e.g., {**db.findByBrand(r.brand), 'action': 'updated'}) have it stripped before emit.

Used by the framework when emitting sync_accounts responses. Adopters never call this directly — they return list[SyncAccountsResultRow] from :meth:AccountStore.upsert and the framework projects.

def to_wire_sync_governance_row(row: SyncGovernanceResultRow) ‑> dict[str, Any]
Expand source code
def to_wire_sync_governance_row(row: SyncGovernanceResultRow) -> dict[str, Any]:
    """Project a :class:`SyncGovernanceResultRow` to the wire shape
    returned by ``sync_governance``.

    Critically: each ``governance_agents[i]`` is reduced to
    ``{url, categories?}`` only — the spec marks
    ``authentication.credentials`` write-only (the buyer sends the
    bearer; the seller persists it for outbound ``check_governance``
    calls but MUST NOT echo it back). The natural ``{**entry_agent}``
    echo idiom would compile silently against a loose return type
    and ship credentials over the wire AND into the idempotency
    replay cache, arming the buyer (and any subsequent caller hitting
    the same key) to impersonate the seller against the governance
    agent.

    Defense-in-depth: this dispatcher-level strip runs even when an
    adopter returns a loosely-typed row that spreads the input
    governance-agent record verbatim. Same posture as the JS-side
    ``toWireSyncGovernanceRow``.
    """
    wire: dict[str, Any] = {
        "account": _maybe_dump(row.account),
        "status": _enum_value(row.status),
    }
    if row.governance_agents is not None:
        wire["governance_agents"] = [
            p
            for p in (_project_governance_agent(a) for a in row.governance_agents)
            if p is not None
        ]
    if row.errors is not None:
        wire["errors"] = list(row.errors)
    return wire

Project a :class:SyncGovernanceResultRow to the wire shape returned by sync_governance.

Critically: each governance_agents[i] is reduced to {url, categories?} only — the spec marks authentication.credentials write-only (the buyer sends the bearer; the seller persists it for outbound check_governance calls but MUST NOT echo it back). The natural {**entry_agent} echo idiom would compile silently against a loose return type and ship credentials over the wire AND into the idempotency replay cache, arming the buyer (and any subsequent caller hitting the same key) to impersonate the seller against the governance agent.

Defense-in-depth: this dispatcher-level strip runs even when an adopter returns a loosely-typed row that spreads the input governance-agent record verbatim. Same posture as the JS-side toWireSyncGovernanceRow.

def validate_billing_for_agent(*,
requested_billing: BillingMode,
agent: BuyerAgent) ‑> None
Expand source code
def validate_billing_for_agent(
    *,
    requested_billing: BillingMode,
    agent: BuyerAgent,
) -> None:
    """Raise :class:`adcp.decisioning.AdcpError`
    ``BILLING_NOT_PERMITTED_FOR_AGENT`` when ``requested_billing`` is
    not in ``agent.billing_capabilities``.

    Called by the framework's ``sync_accounts`` shim before invoking
    the platform method. Adopters needn't call this directly; the
    framework enforces. Re-exported so platform methods that branch
    on billing mode can short-circuit to the same structured error.

    The wire ``details`` payload deliberately carries only
    ``rejected_billing`` (and an optional ``suggested_billing``) — it
    MUST NOT carry the agent's full ``permitted_billing`` subset. The
    full subset is the agent's commercial relationship with the
    seller; surfacing it on every rejected request would let a
    misconfigured buyer probe and exfiltrate the matrix one mode at
    a time.
    """
    if requested_billing in agent.billing_capabilities:
        return
    # Local import to avoid a cycle (types.py → registry.py would
    # close on import-load order).
    from adcp.decisioning.types import AdcpError

    # Suggest a single permitted mode (deterministic — the
    # alphabetically-first permitted mode) when the agent has any
    # capability at all. We do NOT enumerate the full set; suggesting
    # one mode is sufficient remediation hint without leaking the
    # subset shape on every failed request.
    suggested = sorted(agent.billing_capabilities)[0] if agent.billing_capabilities else None
    details: dict[str, Any] = {"rejected_billing": requested_billing}
    if suggested is not None:
        details["suggested_billing"] = suggested

    raise AdcpError(
        "BILLING_NOT_PERMITTED_FOR_AGENT",
        message=(
            f"Buyer agent {agent.agent_url!r} is not authorized for "
            f"billing={requested_billing!r}. Common cause: this agent "
            "has no payments relationship with the seller (passthrough "
            "only) — accounts under this agent must be operator-billed. "
            "Sellers extending the agent's billing capabilities update "
            "the BuyerAgent.billing_capabilities frozenset in their "
            "durable store."
        ),
        field="billing",
        recovery="correctable",
        details=details,
    )

Raise :class:AdcpError BILLING_NOT_PERMITTED_FOR_AGENT when requested_billing is not in agent.billing_capabilities.

Called by the framework's sync_accounts shim before invoking the platform method. Adopters needn't call this directly; the framework enforces. Re-exported so platform methods that branch on billing mode can short-circuit to the same structured error.

The wire details payload deliberately carries only rejected_billing (and an optional suggested_billing) — it MUST NOT carry the agent's full permitted_billing subset. The full subset is the agent's commercial relationship with the seller; surfacing it on every rejected request would let a misconfigured buyer probe and exfiltrate the matrix one mode at a time.

def validate_capabilities_response_shape(handler: PlatformHandler) ‑> None
Expand source code
def validate_capabilities_response_shape(handler: PlatformHandler) -> None:
    """Boot-time validator for the projected capabilities response.

    Calls ``handler.get_adcp_capabilities()`` with a synthetic request,
    then enforces:

    1. The response validates against the bundled
       ``protocol/get-adcp-capabilities-response.json`` schema (via
       :func:`adcp.validation.schema_validator.validate_response`).
    2. ``supported_protocols`` is present and non-empty
       (spec ``minItems: 1``; doubled-up here so the diagnostic names
       the invariant directly).
    3. When the seller claims ``media_buy``, ``account.supported_billing``
       is present and non-empty (the invariant the v3 ref seller
       violated pre-#402; spec
       ``protocol/get-adcp-capabilities-response.json`` requires
       ``account.required: ["supported_billing"]`` with
       ``minItems: 1``).

    Synchronous entry point — drives the async handler via
    :func:`asyncio.run`, which means **this function cannot be called
    from inside a running event loop**. Async callers (test fixtures,
    Starlette ``lifespan`` handlers, anything inside ``asyncio.run``)
    should use :func:`validate_capabilities_response_shape_async`
    instead and pair it with
    ``create_adcp_server_from_platform(..., validate_at_init=False)``.

    :raises AdcpError: ``INVALID_REQUEST`` with ``recovery="terminal"``
        on any violation; ``details`` carry the offending response and
        a structured issue list so operators can index the failure
        programmatically.
    :raises RuntimeError: when called from inside a running event loop
        (the ``asyncio.run`` machinery raises this directly).
    """
    _validate_response_dict(_invoke_capabilities(handler))

Boot-time validator for the projected capabilities response.

Calls handler.get_adcp_capabilities() with a synthetic request, then enforces:

  1. The response validates against the bundled protocol/get-adcp-capabilities-response.json schema (via :func:validate_response()).
  2. supported_protocols is present and non-empty (spec minItems: 1; doubled-up here so the diagnostic names the invariant directly).
  3. When the seller claims media_buy, account.supported_billing is present and non-empty (the invariant the v3 ref seller violated pre-#402; spec protocol/get-adcp-capabilities-response.json requires account.required: ["supported_billing"] with minItems: 1).

Synchronous entry point — drives the async handler via :func:asyncio.run, which means this function cannot be called from inside a running event loop. Async callers (test fixtures, Starlette lifespan handlers, anything inside asyncio.run) should use :func:validate_capabilities_response_shape_async() instead and pair it with create_adcp_server_from_platform(..., validate_at_init=False).

:raises AdcpError: INVALID_REQUEST with recovery="terminal" on any violation; details carry the offending response and a structured issue list so operators can index the failure programmatically. :raises RuntimeError: when called from inside a running event loop (the asyncio.run machinery raises this directly).

async def validate_capabilities_response_shape_async(handler: PlatformHandler) ‑> None
Expand source code
async def validate_capabilities_response_shape_async(handler: PlatformHandler) -> None:
    """Async sibling of :func:`validate_capabilities_response_shape`.

    Identical diagnostic surface; awaits ``handler.get_adcp_capabilities()``
    directly instead of driving it through :func:`asyncio.run`. Use this
    from async contexts (test fixtures, Starlette ``lifespan``,
    in-process A2A test clients) so the SDK doesn't try to spin up a
    second event loop and crash with ``RuntimeError: asyncio.run()
    cannot be called from a running event loop``.

    Typical pairing — async caller bypasses the init-time sync
    validation and runs the async validator themselves::

        handler, executor, registry = create_adcp_server_from_platform(
            platform, validate_at_init=False,
        )
        await validate_capabilities_response_shape_async(handler)
    """
    _validate_response_dict(await handler.get_adcp_capabilities())

Async sibling of :func:validate_capabilities_response_shape().

Identical diagnostic surface; awaits handler.get_adcp_capabilities() directly instead of driving it through :func:asyncio.run. Use this from async contexts (test fixtures, Starlette lifespan, in-process A2A test clients) so the SDK doesn't try to spin up a second event loop and crash with RuntimeError: asyncio.run() cannot be called from a running event loop.

Typical pairing — async caller bypasses the init-time sync validation and runs the async validator themselves::

handler, executor, registry = create_adcp_server_from_platform(
    platform, validate_at_init=False,
)
await validate_capabilities_response_shape_async(handler)
def validate_platform(platform: DecisioningPlatform) ‑> None
Expand source code
def validate_platform(platform: DecisioningPlatform) -> None:
    """Server-boot validator — fail-fast before the first request.

    Checks (in order):

    1. ``platform.capabilities`` is a populated
       :class:`DecisioningCapabilities` (not the base default).
    2. ``platform.accounts`` is a real :class:`AccountStore`
       (anything truthy with a ``resolve`` method) — None catches
       subclasses that forgot to attach a store.
    3. Each claimed specialism's required methods are implemented
       on the platform subclass. Unknown specialisms emit
       ``UserWarning`` (forward-compat with v6.x+ specs); known
       specialisms missing methods raise an INVALID_REQUEST error.
    4. Each claimed specialism's *recommended* methods (the v6.0 rc.1
       staging set in :data:`RECOMMENDED_METHODS_PER_SPECIALISM` —
       sales-* surface broadening per DX-423) are implemented on the
       platform subclass. Misses emit one ``UserWarning`` per
       method (deduped across overlapping specialisms). Setting
       ``ADCP_DECISIONING_STRICT_VALIDATE_PLATFORM=1`` flips the soft
       warning into a hard INVALID_REQUEST error.
    5. **Governance opt-in fail-fast (D15 round-4):** if any claimed
       specialism is in :data:`GOVERNANCE_SPECIALISMS` AND
       ``capabilities.governance_aware`` is False AND the platform
       hasn't wired a custom :class:`StateReader` (i.e., the dispatch
       hydration helper would supply ``_NotYetWiredStateReader``),
       raise. Silent governance-gate skipping is a security
       regression the framework refuses to ship.

    Catches per-validator exceptions and re-projects to
    ``AdcpError("INVALID_REQUEST")`` so server boot never crashes
    with a raw stack trace — the operator sees one structured
    diagnostic per problem (Round-4 Emma #16).

    :raises AdcpError: on any blocking validation failure. The error
        ``details`` carry per-issue diagnostics for operator triage.
    """
    if not isinstance(platform.capabilities, DecisioningCapabilities):
        raise AdcpError(
            "INVALID_REQUEST",
            message=(
                "DecisioningPlatform.capabilities must be a "
                "DecisioningCapabilities instance — found "
                f"{type(platform.capabilities).__name__!r}. Subclasses MUST "
                "set ``capabilities = DecisioningCapabilities(...)`` on the "
                "class body."
            ),
            recovery="terminal",
        )

    accounts = getattr(platform, "accounts", None)
    if accounts is None:
        raise AdcpError(
            "INVALID_REQUEST",
            message=(
                "DecisioningPlatform.accounts is None — subclasses MUST set "
                "an AccountStore (SingletonAccounts, ExplicitAccounts, "
                "FromAuthAccounts, or a custom AccountStore impl) on the "
                "class body."
            ),
            recovery="terminal",
        )

    # Specialism-method coverage.
    # ``capabilities.specialisms`` is ``list[Specialism | str]`` —
    # spec-known entries are coerced to enum at construction; novel /
    # pre-spec slugs pass through as strings (so this validator can
    # surface them with typo-vs-novel diagnostics). Lookup tables are
    # keyed by AdCP slug strings, so extract a slug regardless of form.
    missing: list[tuple[str, str]] = []
    unknown: list[str] = []
    governance_specialisms_claimed: list[str] = []
    for entry in platform.capabilities.specialisms:
        specialism = entry.value if hasattr(entry, "value") else entry
        if specialism in GOVERNANCE_SPECIALISMS:
            governance_specialisms_claimed.append(specialism)
        try:
            required = REQUIRED_METHODS_PER_SPECIALISM.get(specialism)
        except Exception as exc:
            # Defensive: a custom REQUIRED_METHODS_PER_SPECIALISM impl
            # (test-monkeypatch, etc.) that raises must not crash boot.
            # Round-4 Emma #16 — wrap validator throws.
            logger.warning(
                "REQUIRED_METHODS_PER_SPECIALISM lookup raised for %r: %r",
                specialism,
                exc,
            )
            required = None
        if required is None:
            unknown.append(specialism)
            continue
        for method_name in required:
            if not _has_overridden_method(platform, method_name):
                missing.append((specialism, method_name))

    if unknown:
        # Three buckets:
        #   - typo: close-match to any spec slug → hard fail with hint
        #   - unenforced: spec-recognized but no method-coverage rules in
        #     this framework version → soft UserWarning (Protocol lands
        #     in v6.1+)
        #   - novel: not in spec at all → forward-compat UserWarning
        # The typo detector compares against the full spec enum (not just
        # REQUIRED_METHODS keys) so misspelling a spec slug we don't yet
        # enforce still surfaces as a typo.
        spec_known = sorted(SPEC_SPECIALISM_ENUM)
        typo_suggestions: list[tuple[str, str]] = []
        unenforced: list[str] = []
        novel: list[str] = []
        for slug in unknown:
            if slug in SPEC_SPECIALISM_ENUM:
                # Spec-recognized but not in REQUIRED_METHODS — adopter
                # claimed a real spec slug whose Protocol hasn't shipped
                # method-coverage rules yet.
                unenforced.append(slug)
                continue
            close = difflib.get_close_matches(slug, spec_known, n=1, cutoff=0.7)
            if close:
                typo_suggestions.append((slug, close[0]))
            else:
                novel.append(slug)

        if typo_suggestions:
            hints = "; ".join(
                f"{slug!r} → did you mean {match!r}?" for slug, match in sorted(typo_suggestions)
            )
            raise AdcpError(
                "INVALID_REQUEST",
                message=(
                    f"DecisioningPlatform claims unknown specialism(s) "
                    f"that look like typos: {hints}. "
                    "Forward-compat tolerance applies only to genuinely "
                    "novel specialism slugs (not close spelling matches). "
                    f"Known spec specialisms: {spec_known}"
                ),
                recovery="terminal",
                details={
                    "typo_suggestions": [
                        {"claimed": slug, "did_you_mean": match} for slug, match in typo_suggestions
                    ],
                    "spec_specialisms": spec_known,
                },
            )

        if unenforced:
            warnings.warn(
                (
                    f"DecisioningPlatform claims spec-recognized specialism(s) "
                    f"{sorted(unenforced)!r} that this framework version "
                    f"doesn't yet enforce method coverage for. The claim is "
                    f"valid; required-method validation is skipped until the "
                    f"per-Protocol coverage lands. Implement the spec methods "
                    f"on your platform subclass so buyers don't 404."
                ),
                UserWarning,
                stacklevel=2,
            )

        if novel:
            warnings.warn(
                (
                    f"DecisioningPlatform claims novel specialism(s) "
                    f"{sorted(novel)!r} that aren't in the spec enum at "
                    f"schemas/cache/enums/specialism.json. Your framework "
                    f"version predates the spec, OR you're piloting a future "
                    f"specialism. Required-method validation skipped. "
                    f"Known spec specialisms: {spec_known}"
                ),
                UserWarning,
                stacklevel=2,
            )

    if missing:
        raise AdcpError(
            "INVALID_REQUEST",
            message=(
                "DecisioningPlatform claims specialisms but is missing "
                f"required methods: {missing}. Implement each on your "
                "subclass or remove the specialism from "
                "capabilities.specialisms."
            ),
            recovery="terminal",
            details={"missing": [{"specialism": s, "method": m} for s, m in missing]},
        )

    # Recommended (v6.0 rc.1 staging) coverage — soft-warn by default,
    # hard-fail under ``ADCP_DECISIONING_STRICT_VALIDATE_PLATFORM=1``.
    # Dedup by method name: a platform claiming both ``sales-guaranteed``
    # and ``sales-non-guaranteed`` shares the same recommended set, so
    # ``get_media_buys`` should warn once, not twice. We walk specialisms
    # in declared order and remember the first specialism that surfaced
    # each missing method — that becomes the "blame" specialism in the
    # diagnostic.
    recommended_missing: list[tuple[str, str]] = []
    seen_methods: set[str] = set()
    for entry in platform.capabilities.specialisms:
        specialism = entry.value if hasattr(entry, "value") else entry
        recommended = RECOMMENDED_METHODS_PER_SPECIALISM.get(specialism)
        if recommended is None:
            continue
        for method_name in sorted(recommended):
            if method_name in seen_methods:
                continue
            if not _has_overridden_method(platform, method_name):
                recommended_missing.append((specialism, method_name))
                seen_methods.add(method_name)

    if recommended_missing:
        if _strict_validate_platform():
            raise AdcpError(
                "INVALID_REQUEST",
                message=(
                    "DecisioningPlatform claims sales-* specialism(s) but is "
                    f"missing v6.0 rc.1 required methods: {recommended_missing}. "
                    "Strict mode is enabled "
                    f"({_STRICT_VALIDATE_ENV}=1); implement each on your "
                    "subclass. See the SalesPlatform Protocol docstring at "
                    "src/adcp/decisioning/specialisms/sales.py:184-227 for the "
                    "canonical method list."
                ),
                recovery="terminal",
                details={
                    "missing_recommended": [
                        {"specialism": s, "method": m} for s, m in recommended_missing
                    ],
                    "strict_env_var": _STRICT_VALIDATE_ENV,
                },
            )
        # ``stacklevel=3`` so the warning points at the adopter's
        # ``serve(platform)`` call site, not the SDK internals
        # (validate_platform is invoked from serve, which is invoked by
        # the adopter — three frames up lands on adopter code).
        for specialism, method_name in recommended_missing:
            warnings.warn(
                (
                    f"DecisioningPlatform claims {specialism!r} but is missing "
                    f"{method_name!r} — required by the SalesPlatform Protocol "
                    "for any sales-* specialism in v6.0 rc.1+. See the Protocol "
                    "docstring at src/adcp/decisioning/specialisms/sales.py:"
                    "184-227 for the full required method list. The framework "
                    "currently soft-warns to ease v6.0 rc.1 migration; set "
                    f"{_STRICT_VALIDATE_ENV}=1 to fail-fast at boot instead."
                ),
                UserWarning,
                stacklevel=3,
            )

    # Governance opt-in fail-fast (D15 round-4).
    if governance_specialisms_claimed and not platform.capabilities.governance_aware:
        raise AdcpError(
            "INVALID_REQUEST",
            message=(
                f"Platform claims governance-* specialism(s) "
                f"{governance_specialisms_claimed!r} but "
                "capabilities.governance_aware is False. Set "
                "governance_aware=True AND wire a custom StateReader that "
                "returns real GovernanceContextJWS values, OR drop the "
                "governance-* specialism claim. Silent governance-gate "
                "skipping is a security boundary; the framework refuses "
                "to ship that. See "
                "docs/proposals/decisioning-platform-dispatch-design.md#d15"
            ),
            recovery="terminal",
            details={
                "governance_specialisms": sorted(governance_specialisms_claimed),
                "governance_aware": False,
            },
        )

Server-boot validator — fail-fast before the first request.

Checks (in order):

  1. platform.capabilities is a populated :class:DecisioningCapabilities (not the base default).
  2. platform.accounts is a real :class:AccountStore (anything truthy with a adcp.decisioning.resolve method) — None catches subclasses that forgot to attach a store.
  3. Each claimed specialism's required methods are implemented on the platform subclass. Unknown specialisms emit UserWarning (forward-compat with v6.x+ specs); known specialisms missing methods raise an INVALID_REQUEST error.
  4. Each claimed specialism's recommended methods (the v6.0 rc.1 staging set in :data:RECOMMENDED_METHODS_PER_SPECIALISM — sales-* surface broadening per DX-423) are implemented on the platform subclass. Misses emit one UserWarning per method (deduped across overlapping specialisms). Setting ADCP_DECISIONING_STRICT_VALIDATE_PLATFORM=1 flips the soft warning into a hard INVALID_REQUEST error.
  5. Governance opt-in fail-fast (D15 round-4): if any claimed specialism is in :data:GOVERNANCE_SPECIALISMS AND capabilities.governance_aware is False AND the platform hasn't wired a custom :class:StateReader (i.e., the dispatch hydration helper would supply _NotYetWiredStateReader), raise. Silent governance-gate skipping is a security regression the framework refuses to ship.

Catches per-validator exceptions and re-projects to AdcpError("INVALID_REQUEST") so server boot never crashes with a raw stack trace — the operator sees one structured diagnostic per problem (Round-4 Emma #16).

:raises AdcpError: on any blocking validation failure. The error details carry per-issue diagnostics for operator triage.

def validate_property_list_config(*,
capability_enabled: bool,
fetcher: PropertyListFetcher | None) ‑> None
Expand source code
def validate_property_list_config(
    *,
    capability_enabled: bool,
    fetcher: PropertyListFetcher | None,
) -> None:
    """Boot-time fail-fast: raise when property_list_filtering=True but no fetcher.

    Mirrors :func:`~adcp.decisioning.webhook_emit.validate_webhook_sender_for_platform`:
    a declared capability without the required runtime dependency would silently
    skip filtering at request time — a buyer who sends ``property_list`` would
    receive unfiltered products with ``property_list_applied`` absent or False,
    mismatching what the seller's ``get_adcp_capabilities`` advertised.

    :raises AdcpError: ``recovery='terminal'`` when misconfigured.
    """
    if not capability_enabled:
        return
    if fetcher is not None:
        return

    from adcp.decisioning.types import AdcpError

    raise AdcpError(
        "INVALID_REQUEST",
        message=(
            "Features.property_list_filtering=True is declared in capabilities "
            "but no PropertyListFetcher was wired. Buyers who send "
            "property_list on get_products requests would have their list "
            "filter silently skipped. Pass property_list_fetcher= to "
            "adcp.decisioning.serve.create_adcp_server_from_platform, "
            "or set Features(property_list_filtering=False) to opt out."
        ),
        recovery="terminal",
        details={"missing": "property_list_fetcher"},
    )

Boot-time fail-fast: raise when property_list_filtering=True but no fetcher.

Mirrors :func:~adcp.decisioning.webhook_emit.validate_webhook_sender_for_platform: a declared capability without the required runtime dependency would silently skip filtering at request time — a buyer who sends adcp.decisioning.property_list would receive unfiltered products with property_list_applied absent or False, mismatching what the seller's get_adcp_capabilities advertised.

:raises AdcpError: recovery='terminal' when misconfigured.

Classes

class Account (id: str,
name: str = '',
status: str = 'active',
metadata: TMeta = <factory>,
auth_info: dict[str, Any] | None = None,
billing_entity: BusinessEntity | None = None,
setup: AccountSetup | None = None,
governance_agents: list[GovernanceAgent] | None = None,
account_scope: AccountScope | None = None,
payment_terms: PaymentTerms | None = None,
credit_limit: CreditLimit | None = None,
rate_card: str | None = None,
reporting_bucket: ReportingBucket | None = None,
authorization: AccountAuthorization | dict[str, Any] | None = None,
mode: "Literal['live', 'sandbox', 'mock']" = 'live')
Expand source code
@dataclass
class Account(Generic[TMeta]):
    """The resolved account a request operates on.

    Constructed by the platform's :class:`AccountStore` and threaded
    through every dispatch via :class:`RequestContext`. ``metadata``
    is the typed extension point — adopters define a TypedDict (or
    dataclass) carrying their per-account data (``adapter`` instance,
    OAuth credentials, network IDs, sandbox flags, etc.) and
    parameterize ``Account[TenantMeta]`` so ``ctx.account.metadata.adapter``
    typechecks inside method bodies.

    The framework's idempotency middleware scopes its cache by
    ``account.id``. Adopters in ``'derived'`` resolution mode MUST
    synthesize per-principal IDs (e.g. ``f"training-agent:{principal}"``)
    or buyer-to-buyer cache leakage is possible — see
    :class:`adcp.decisioning.SingletonAccounts`.

    :param id: Stable, globally-unique account identifier within the
        adopter's deployment. Used as the idempotency cache scope key
        and the ``caller_identity`` the framework's idempotency middleware
        reads.
    :param name: Human-readable account name for logging and admin
        UIs. Not used for routing or scoping.
    :param status: Account lifecycle state — ``'pending_approval'``,
        ``'active'``, ``'disabled'``, etc. Adopters consuming the
        ``account-status.json`` enum can use this directly.
    :param metadata: Adopter-defined typed metadata. Defaults to an
        untyped dict for adopters who don't care. Two framework-reserved
        keys when ``metadata`` is a dict-shaped payload:

        - ``mock_upstream_url`` (``str``): For ``mode='mock'`` accounts
          only. Tells :meth:`DecisioningPlatform.upstream_for` which
          mock-server fixture URL to point the adapter's
          :class:`UpstreamHttpClient` at. Read via
          :func:`get_mock_upstream_url`. The adopter populates this in
          ``AccountStore.resolve`` for mock-mode accounts; the framework
          fail-closes when missing.
        - All other keys are adopter-defined.
    :param auth_info: The verified principal that authenticated this
        request, if any. Distinct from ``id`` because one principal
        can act on multiple accounts in 'explicit' resolution mode.

    Wire-aligned optional fields (all default ``None``) carry the AdCP
    v3 commercial / lifecycle / reporting shape. Adopters who don't
    populate these see no behavior change; populated fields project
    through :func:`to_wire_account` onto the wire ``Account`` shape on
    every emit path that surfaces an :class:`Account`. The projection
    strips :attr:`BusinessEntity.bank` (write-only per spec) and
    :attr:`GovernanceAgent.authentication.credentials` (defense-in-depth
    — Python type hints aren't enforced at runtime, so the strip runs
    even if an adopter returns a loosely-typed governance-agent record
    that smuggles credentials through ``cast`` / ``Any``).

    :param billing_entity: Business entity invoiced on this account.
        Carries legal name, tax IDs, address, contacts, and (write-only)
        bank details. The framework's :func:`to_wire_account` strips
        ``bank`` on emit; adopters who load and return a full entity
        from their store no longer leak bank coordinates to buyers.
    :param setup: Setup payload for accounts in ``pending_approval``.
        Carries ``url`` / ``message`` / ``expires_at`` driving the
        ``pending_approval → active`` lifecycle.
    :param governance_agents: Governance agent endpoints registered on
        this account. The wire schema marks ``authentication.credentials``
        write-only — the framework strips ``authentication`` on emit
        regardless of what the adopter populates.
    :param account_scope: ``operator`` / ``brand`` / ``operator_brand``
        / ``agent``.
    :param payment_terms: ``net_15`` / ``net_30`` / ``net_45`` /
        ``net_60`` / ``net_90`` / ``prepay``.
    :param credit_limit: Maximum outstanding balance allowed
        (``{amount, currency}``).
    :param rate_card: Identifier for the rate card applied. Opaque
        seller-side string; emitted unchanged.
    :param reporting_bucket: Cloud storage bucket where the seller
        delivers offline reporting files for this account.
    :param authorization: Caller-specific scope metadata for this
        account on ``list_accounts`` responses. Used when one upstream
        platform exposes multiple account-like grants for a caller, such
        as a TikTok ads-manager account plus separate creator/channel
        publisher-identity grants. This is response metadata about the
        authenticated caller's access, not proof of downstream serving
        authorization for any individual request.
    :param mode: SDK-internal account mode — ``'live'`` (default,
        production), ``'sandbox'`` (adopter's test infra), or
        ``'mock'`` (Phase 2 — SDK routes to mock-server backend).
        Sourced from the adopter's :class:`AccountStore.resolve`
        return value; never echoed to the wire. Drives the
        sandbox-authority gate on ``comply_test_controller`` and other
        test-only surfaces. See
        ``docs/proposals/lifecycle-state-and-sandbox-authority.md``.
    """

    id: str
    name: str = ""
    status: str = "active"
    metadata: TMeta = field(default_factory=lambda: {})  # type: ignore[assignment]
    auth_info: dict[str, Any] | None = None

    # Wire-aligned optional fields. All default to ``None``; adopters
    # populate as their commercial / lifecycle / reporting model
    # requires. The framework projects through ``to_wire_account`` on
    # every emit path that surfaces an Account, applying the
    # write-only strips for ``billing_entity.bank`` and
    # ``governance_agents[].authentication``.
    billing_entity: BusinessEntity | None = None
    setup: AccountSetup | None = None
    governance_agents: list[GovernanceAgent] | None = None
    account_scope: AccountScope | None = None
    payment_terms: PaymentTerms | None = None
    credit_limit: CreditLimit | None = None
    rate_card: str | None = None
    reporting_bucket: ReportingBucket | None = None
    authorization: AccountAuthorization | dict[str, Any] | None = None

    # SDK-internal account mode for sandbox-authority gating. Default
    # ``'live'`` preserves all existing-adopter behavior — pre-mode
    # adopters' accounts read as live. Adopters mark conformance /
    # test accounts ``'sandbox'`` (or ``'mock'`` in Phase 2) in their
    # ``AccountStore.resolve``. Not echoed to the wire by
    # ``to_wire_account`` — purely internal to dispatch.
    mode: Literal["live", "sandbox", "mock"] = "live"

    # Explicit-vs-implicit marker for the observed-modes tracker.
    # Set ``True`` when an :class:`AccountStore` deliberately populated
    # ``mode``; left ``False`` when ``mode`` was left at its default.
    # The fail-closed env-fallback guard in ``observed_modes.py`` only
    # tracks explicit mode values — pre-mode adopters whose resolvers
    # don't stamp this don't trip the guard, preserving back-compat.
    # Built-in stores (``SingletonAccounts(mode=...)``) set this when
    # the adopter passed an explicit mode. Custom :class:`AccountStore`
    # implementations set it on the returned :class:`Account`
    # directly (``account._mode_explicit = True``) when they want the
    # observed-modes tracker to count them. Hidden from ``repr`` to
    # keep test diffs clean.
    _mode_explicit: bool = field(default=False, repr=False, compare=False)

    @property
    def sandbox(self) -> bool:
        """Back-compat accessor for ``account.sandbox``.

        ``True`` when :attr:`mode` is ``'sandbox'`` or ``'mock'``;
        ``False`` for ``'live'``. Adopters reading the legacy
        ``account.sandbox`` boolean keep working — the property
        derives from :attr:`mode`. New code should read :attr:`mode`
        directly to distinguish ``'sandbox'`` from ``'mock'``.
        """
        return self.mode in ("sandbox", "mock")

The resolved account a request operates on.

Constructed by the platform's :class:AccountStore and threaded through every dispatch via :class:RequestContext. metadata is the typed extension point — adopters define a TypedDict (or dataclass) carrying their per-account data (adapter instance, OAuth credentials, network IDs, sandbox flags, etc.) and parameterize Account[TenantMeta] so ctx.account.metadata.adapter typechecks inside method bodies.

The framework's idempotency middleware scopes its cache by account.id. Adopters in 'derived' resolution mode MUST synthesize per-principal IDs (e.g. f"training-agent:{principal}") or buyer-to-buyer cache leakage is possible — see :class:SingletonAccounts.

:param id: Stable, globally-unique account identifier within the adopter's deployment. Used as the idempotency cache scope key and the caller_identity the framework's idempotency middleware reads. :param name: Human-readable account name for logging and admin UIs. Not used for routing or scoping. :param status: Account lifecycle state — 'pending_approval', 'active', 'disabled', etc. Adopters consuming the account-status.json enum can use this directly. :param metadata: Adopter-defined typed metadata. Defaults to an untyped dict for adopters who don't care. Two framework-reserved keys when metadata is a dict-shaped payload:

- <code>mock\_upstream\_url</code> (<code>str</code>): For ``mode='mock'`` accounts
  only. Tells :meth:<code><a title="adcp.decisioning.DecisioningPlatform.upstream_for" href="#adcp.decisioning.DecisioningPlatform.upstream_for">DecisioningPlatform.upstream\_for()</a></code> which
  mock-server fixture URL to point the adapter's
  :class:<code><a title="adcp.decisioning.UpstreamHttpClient" href="#adcp.decisioning.UpstreamHttpClient">UpstreamHttpClient</a></code> at. Read via
  :func:<code><a title="adcp.decisioning.get_mock_upstream_url" href="#adcp.decisioning.get_mock_upstream_url">get\_mock\_upstream\_url()</a></code>. The adopter populates this in
  <code><a title="adcp.decisioning.AccountStore.resolve" href="#adcp.decisioning.AccountStore.resolve">AccountStore.resolve()</a></code> for mock-mode accounts; the framework
  fail-closes when missing.
- All other keys are adopter-defined.

:param auth_info: The verified principal that authenticated this request, if any. Distinct from id because one principal can act on multiple accounts in 'explicit' resolution mode.

Wire-aligned optional fields (all default None) carry the AdCP v3 commercial / lifecycle / reporting shape. Adopters who don't populate these see no behavior change; populated fields project through :func:to_wire_account() onto the wire Account shape on every emit path that surfaces an :class:Account. The projection strips :attr:BusinessEntity.bank (write-only per spec) and :attr:GovernanceAgent.authentication.credentials (defense-in-depth — Python type hints aren't enforced at runtime, so the strip runs even if an adopter returns a loosely-typed governance-agent record that smuggles credentials through cast / Any).

:param billing_entity: Business entity invoiced on this account. Carries legal name, tax IDs, address, contacts, and (write-only) bank details. The framework's :func:to_wire_account() strips bank on emit; adopters who load and return a full entity from their store no longer leak bank coordinates to buyers. :param setup: Setup payload for accounts in pending_approval. Carries url / message / expires_at driving the pending_approval → active lifecycle. :param governance_agents: Governance agent endpoints registered on this account. The wire schema marks authentication.credentials write-only — the framework strips authentication on emit regardless of what the adopter populates. :param account_scope: operator / brand / operator_brand / agent. :param payment_terms: net_15 / net_30 / net_45 / net_60 / net_90 / prepay. :param credit_limit: Maximum outstanding balance allowed ({amount, currency}). :param rate_card: Identifier for the rate card applied. Opaque seller-side string; emitted unchanged. :param reporting_bucket: Cloud storage bucket where the seller delivers offline reporting files for this account. :param authorization: Caller-specific scope metadata for this account on list_accounts responses. Used when one upstream platform exposes multiple account-like grants for a caller, such as a TikTok ads-manager account plus separate creator/channel publisher-identity grants. This is response metadata about the authenticated caller's access, not proof of downstream serving authorization for any individual request. :param mode: SDK-internal account mode — 'live' (default, production), 'sandbox' (adopter's test infra), or 'mock' (Phase 2 — SDK routes to mock-server backend). Sourced from the adopter's :class:AccountStore.resolve() return value; never echoed to the wire. Drives the sandbox-authority gate on comply_test_controller and other test-only surfaces. See docs/proposals/lifecycle-state-and-sandbox-authority.md.

Ancestors

  • typing.Generic

Instance variables

var account_scope : AccountScope | None
var auth_info : dict[str, Any] | None
var authorization : AccountAuthorization | dict[str, Any] | None
var billing_entity : BusinessEntity | None
var credit_limit : CreditLimit | None
var governance_agents : list[GovernanceAgent] | None
var id : str
var metadata : TMeta
var mode : Literal['live', 'sandbox', 'mock']
var name : str
var payment_terms : PaymentTerms | None
var rate_card : str | None
var reporting_bucket : ReportingBucket | None
prop sandbox : bool
Expand source code
@property
def sandbox(self) -> bool:
    """Back-compat accessor for ``account.sandbox``.

    ``True`` when :attr:`mode` is ``'sandbox'`` or ``'mock'``;
    ``False`` for ``'live'``. Adopters reading the legacy
    ``account.sandbox`` boolean keep working — the property
    derives from :attr:`mode`. New code should read :attr:`mode`
    directly to distinguish ``'sandbox'`` from ``'mock'``.
    """
    return self.mode in ("sandbox", "mock")

Back-compat accessor for account.sandbox.

True when :attr:mode is 'sandbox' or 'mock'; False for 'live'. Adopters reading the legacy account.sandbox boolean keep working — the property derives from :attr:mode. New code should read :attr:mode directly to distinguish 'sandbox' from 'mock'.

var setup : AccountSetup | None
var status : str
class AccountNotFoundError (*, message: str | None = None, field: str | None = None, **details: Any)
Expand source code
class AccountNotFoundError(AdcpError):
    """Spec ``ACCOUNT_NOT_FOUND`` (``recovery='terminal'``).

    Raised when the account reference cannot be resolved. The buyer
    verifies the account via ``list_accounts`` or contacts the seller.
    """

    def __init__(
        self,
        *,
        message: str | None = None,
        field: str | None = None,
        **details: Any,
    ) -> None:
        super().__init__(
            "ACCOUNT_NOT_FOUND",
            message=message or "Account not found.",
            recovery="terminal",
            field=field,
            details=dict(details) or None,
        )

Spec ACCOUNT_NOT_FOUND (recovery='terminal').

Raised when the account reference cannot be resolved. The buyer verifies the account via list_accounts or contacts the seller.

Ancestors

  • AdcpError
  • builtins.Exception
  • builtins.BaseException

Inherited members

class AccountStore (*args, **kwargs)
Expand source code
@runtime_checkable
class AccountStore(Protocol, Generic[TMeta]):
    """Resolves a wire reference + auth context to an :class:`Account`.

    The framework calls :meth:`resolve` for every tool dispatch
    (before the handler method runs). Adopters in ``'explicit'`` mode
    use ``ref.account_id`` from the wire; ``'implicit'`` mode reads
    ``ctx.auth_info`` to look up the principal-bound account;
    ``'derived'`` mode synthesizes a per-principal account from the
    one platform.

    The :attr:`resolution` literal is a structural attribute the
    framework reads at server boot — used by :func:`validate_platform`
    to fail fast on misconfigured deployments (e.g.
    ``'derived'`` registered into a multi-tenant ``TenantRegistry``).
    Mirrors the JS-side literal for cross-language parity:
    ``'explicit'`` (wire ref drives lookup), ``'implicit'`` (verified
    auth principal drives lookup), ``'derived'`` (single-platform with
    per-principal id synthesis).

    **Multi-tenant deployments — Account.id is the encoding seam.**

    Applies to seller-side adopters resolving incoming AdCP requests.
    DSP-side adopters wiring this SDK as a client construct
    :class:`~adcp.types.AccountReference` directly for outbound calls
    and don't go through this seam.

    Buyers send a per-tenant ``account_ref`` on the wire; sellers don't
    control what string a buyer picks, and the same ``account_ref``
    ("acme", "default", sequential ids) may arrive from buyers calling
    different seller tenants. The transport sets ``tenant_id`` from the
    Host header (via :class:`~adcp.server.SubdomainTenantMiddleware`),
    and ``resolve()`` is the single layer that mints ``Account.id`` —
    the framework treats that id as opaque from here onward and
    threads it into every downstream store
    (:class:`~adcp.decisioning.ProposalStore`,
    :class:`~adcp.decisioning.TaskRegistry`, framework idempotency
    cache, future media-buy stores) as the canonical scope key.

    Multi-tenant adopters compose the tenant scope INTO ``Account.id``
    here, so every downstream store sees a globally-unique identifier
    and never has to know about tenants::

        class MyAccountStore:
            resolution = "explicit"

            async def resolve(self, ref, auth_info=None):
                tenant_id = self._tenant_from(auth_info)
                buyer_ref = (ref or {}).get("account_id", "default")
                return Account(
                    id=f"{tenant_id}:{buyer_ref}",   # globally unique
                    metadata={"tenant_id": tenant_id},
                )

    Pushing tenant scope DOWN into a downstream store (parsing
    ``account_id`` back out inside ``ProposalStore.put_draft``, or
    threading a separate ``tenant_id`` argument through every
    Protocol method) is the wrong layer: it forces every store to
    re-derive what ``resolve()`` already knows, and adopter
    encoding-convention changes silently break every downstream
    Protocol call site.

    **Pick a stable tenant identifier.** The tenant value baked into
    ``Account.id`` lives forever in every downstream store's row keys
    and the framework's idempotency cache. Use a UUID or immutable
    slug, not a user-facing display name — a tenant rename (vanity
    URL change, white-label rebrand) that mutates the prefix would
    orphan every proposal, task, and cached response keyed under the
    old value.

    :func:`~adcp.decisioning.create_tenant_store` ships this pattern as
    a typed factory with a baked-in per-entry tenant-isolation gate —
    use it directly unless you have a reason to write your own.
    """

    resolution: ClassVar[str]

    def resolve(
        self,
        ref: dict[str, Any] | None,
        auth_info: AuthInfo | None = None,
    ) -> Awaitable[Account[TMeta]] | Account[TMeta]:
        """Return the resolved :class:`Account` or raise on miss.

        :param ref: The wire reference object (typically
            ``request.account`` carrying ``account_id`` /
            ``account_ref``). ``None`` for tools that don't carry an
            explicit account ref — adopters in ``'derived'`` /
            ``'implicit'`` modes ignore it.
        :param auth_info: Verified principal info. ``None`` for
            unauthenticated requests (dev / ``'derived'`` fixtures).
        :raises adcp.decisioning.AdcpError: ``code='ACCOUNT_NOT_FOUND'``
            when the resolution can't produce a valid account.

        Implementations may be sync or async; the dispatch adapter
        detects via :func:`inspect.iscoroutine` at call time.
        """
        ...

    # ----- Optional v6 surfaces -----
    #
    # The methods below are documented on the Protocol class for
    # discoverability but live on the SEPARATE :class:`AccountStoreUpsert`,
    # :class:`AccountStoreList`, and :class:`AccountStoreSyncGovernance`
    # Protocols below — keeping them off the runtime-checkable
    # :class:`AccountStore` Protocol so an adopter who only implements
    # ``resolve`` (the minimum viable AccountStore) still passes
    # ``isinstance(store, AccountStore)``. The framework's dispatch
    # shim probes via :func:`hasattr` for the optional methods at
    # call time and surfaces ``UNSUPPORTED_FEATURE`` when absent.
    #
    # See:
    #   * :meth:`AccountStoreUpsertRequest.upsert_request` — ``sync_accounts``
    #     with request-level fields preserved
    #   * :meth:`AccountStoreUpsert.upsert` — legacy ``sync_accounts``
    #     per-account refs only
    #   * :meth:`AccountStoreList.list` — ``list_accounts``
    #   * :meth:`AccountStoreSyncGovernance.sync_governance`

Resolves a wire reference + auth context to an :class:Account.

The framework calls :meth:adcp.decisioning.resolve for every tool dispatch (before the handler method runs). Adopters in 'explicit' mode use ref.account_id from the wire; 'implicit' mode reads ctx.auth_info to look up the principal-bound account; 'derived' mode synthesizes a per-principal account from the one platform.

The :attr:resolution literal is a structural attribute the framework reads at server boot — used by :func:validate_platform() to fail fast on misconfigured deployments (e.g. 'derived' registered into a multi-tenant TenantRegistry). Mirrors the JS-side literal for cross-language parity: 'explicit' (wire ref drives lookup), 'implicit' (verified auth principal drives lookup), 'derived' (single-platform with per-principal id synthesis).

Multi-tenant deployments — Account.id is the encoding seam.

Applies to seller-side adopters resolving incoming AdCP requests. DSP-side adopters wiring this SDK as a client construct :class:~adcp.types.AccountReference directly for outbound calls and don't go through this seam.

Buyers send a per-tenant account_ref on the wire; sellers don't control what string a buyer picks, and the same account_ref ("acme", "default", sequential ids) may arrive from buyers calling different seller tenants. The transport sets tenant_id from the Host header (via :class:~adcp.server.SubdomainTenantMiddleware), and adcp.decisioning.resolve is the single layer that mints Account.id — the framework treats that id as opaque from here onward and threads it into every downstream store (:class:~adcp.decisioning.ProposalStore, :class:~adcp.decisioning.TaskRegistry, framework idempotency cache, future media-buy stores) as the canonical scope key.

Multi-tenant adopters compose the tenant scope INTO Account.id here, so every downstream store sees a globally-unique identifier and never has to know about tenants::

class MyAccountStore:
    resolution = "explicit"

    async def resolve(self, ref, auth_info=None):
        tenant_id = self._tenant_from(auth_info)
        buyer_ref = (ref or {}).get("account_id", "default")
        return Account(
            id=f"{tenant_id}:{buyer_ref}",   # globally unique
            metadata={"tenant_id": tenant_id},
        )

Pushing tenant scope DOWN into a downstream store (parsing account_id back out inside ProposalStore.put_draft(), or threading a separate tenant_id argument through every Protocol method) is the wrong layer: it forces every store to re-derive what adcp.decisioning.resolve already knows, and adopter encoding-convention changes silently break every downstream Protocol call site.

Pick a stable tenant identifier. The tenant value baked into Account.id lives forever in every downstream store's row keys and the framework's idempotency cache. Use a UUID or immutable slug, not a user-facing display name — a tenant rename (vanity URL change, white-label rebrand) that mutates the prefix would orphan every proposal, task, and cached response keyed under the old value.

:func:~adcp.decisioning.create_tenant_store ships this pattern as a typed factory with a baked-in per-entry tenant-isolation gate — use it directly unless you have a reason to write your own.

Ancestors

  • typing.Protocol
  • typing.Generic

Class variables

var resolution : ClassVar[str]

Methods

def resolve(self,
ref: dict[str, Any] | None,
auth_info: AuthInfo | None = None) ‑> Awaitable[Account[~TMeta]] | Account[~TMeta]
Expand source code
def resolve(
    self,
    ref: dict[str, Any] | None,
    auth_info: AuthInfo | None = None,
) -> Awaitable[Account[TMeta]] | Account[TMeta]:
    """Return the resolved :class:`Account` or raise on miss.

    :param ref: The wire reference object (typically
        ``request.account`` carrying ``account_id`` /
        ``account_ref``). ``None`` for tools that don't carry an
        explicit account ref — adopters in ``'derived'`` /
        ``'implicit'`` modes ignore it.
    :param auth_info: Verified principal info. ``None`` for
        unauthenticated requests (dev / ``'derived'`` fixtures).
    :raises adcp.decisioning.AdcpError: ``code='ACCOUNT_NOT_FOUND'``
        when the resolution can't produce a valid account.

    Implementations may be sync or async; the dispatch adapter
    detects via :func:`inspect.iscoroutine` at call time.
    """
    ...

Return the resolved :class:Account or raise on miss.

:param ref: The wire reference object (typically request.account carrying account_id / account_ref). None for tools that don't carry an explicit account ref — adopters in 'derived' / 'implicit' modes ignore it. :param auth_info: Verified principal info. None for unauthenticated requests (dev / 'derived' fixtures). :raises adcp.decisioning.AdcpError: code='ACCOUNT_NOT_FOUND' when the resolution can't produce a valid account.

Implementations may be sync or async; the dispatch adapter detects via :func:inspect.iscoroutine at call time.

class AccountStoreList (*args, **kwargs)
Expand source code
@runtime_checkable
class AccountStoreList(Protocol, Generic[TMeta]):
    """``list_accounts`` API surface. Optional adopter-side feature.

    Framework wraps the returned account list with the cursor
    envelope and projects each account through
    :func:`to_wire_account` (stripping framework-internal fields and
    applying the write-only strips for ``billing_entity.bank`` and
    ``governance_agents[].authentication``).

    **Security migration note.** Pre-this-release, adopters had no
    way to scope ``list_accounts`` per-principal — impls either
    returned all accounts (over-disclosure) or rejected the
    operation. Post-this-release, scoping becomes possible via
    ``ctx.agent``. **This is opt-in, not automatic.** Multi-tenant
    adopters MUST add principal scoping in their impl; without it,
    every authenticated caller sees every account.
    """

    def list(
        self,
        filter: dict[str, Any] | None = None,
        ctx: ResolveContext | None = None,
    ) -> Awaitable[list[Account[TMeta]]] | list[Account[TMeta]]:
        """Return the accounts visible to the calling principal.

        :param filter: Wire-shape filter object — ``status`` /
            ``sandbox`` / pagination. Pass-through from the parsed
            wire request.
        :param ctx: Per-request context. ``ctx.auth_info`` and
            ``ctx.agent`` carry the caller's principal — adopters
            scope the listing per-principal (e.g., return only
            accounts visible to the calling buyer agent) without
            re-deriving identity from the request.
        """
        ...

list_accounts API surface. Optional adopter-side feature.

Framework wraps the returned account list with the cursor envelope and projects each account through :func:to_wire_account() (stripping framework-internal fields and applying the write-only strips for billing_entity.bank and governance_agents[].authentication).

Security migration note. Pre-this-release, adopters had no way to scope list_accounts per-principal — impls either returned all accounts (over-disclosure) or rejected the operation. Post-this-release, scoping becomes possible via ctx.agent. This is opt-in, not automatic. Multi-tenant adopters MUST add principal scoping in their impl; without it, every authenticated caller sees every account.

Ancestors

  • typing.Protocol
  • typing.Generic

Methods

def list(self,
filter: dict[str, Any] | None = None,
ctx: ResolveContext | None = None) ‑> collections.abc.Awaitable[list[Account[~TMeta]]] | list[Account[~TMeta]]
Expand source code
def list(
    self,
    filter: dict[str, Any] | None = None,
    ctx: ResolveContext | None = None,
) -> Awaitable[list[Account[TMeta]]] | list[Account[TMeta]]:
    """Return the accounts visible to the calling principal.

    :param filter: Wire-shape filter object — ``status`` /
        ``sandbox`` / pagination. Pass-through from the parsed
        wire request.
    :param ctx: Per-request context. ``ctx.auth_info`` and
        ``ctx.agent`` carry the caller's principal — adopters
        scope the listing per-principal (e.g., return only
        accounts visible to the calling buyer agent) without
        re-deriving identity from the request.
    """
    ...

Return the accounts visible to the calling principal.

:param filter: Wire-shape filter object — status / sandbox / pagination. Pass-through from the parsed wire request. :param ctx: Per-request context. ctx.auth_info and ctx.agent carry the caller's principal — adopters scope the listing per-principal (e.g., return only accounts visible to the calling buyer agent) without re-deriving identity from the request.

class AccountStoreSyncGovernance (*args, **kwargs)
Expand source code
@runtime_checkable
class AccountStoreSyncGovernance(Protocol):
    """``sync_governance`` API surface. Optional adopter-side feature.

    Buyers register governance agent endpoints per-account; the
    seller persists the binding and consults the agents during media
    buy lifecycle events via ``check_governance``. Adopters that
    don't model buyer-supplied governance agents (most direct
    sellers) leave this unimplemented and the framework returns
    ``UNSUPPORTED_FEATURE``.
    """

    def sync_governance(
        self,
        entries: list[SyncGovernanceEntry],
        ctx: ResolveContext | None = None,
    ) -> Awaitable[list[SyncGovernanceResultRow]] | list[SyncGovernanceResultRow]:
        """Persist the per-entry governance-agent bindings.

        ``entries`` is the wire request's ``accounts[]`` — each entry
        pairs an :class:`AccountReference` with its
        ``governance_agents[]``. The framework has already deduped on
        ``idempotency_key`` and stripped wire metadata
        (``adcp_major_version``, ``context``, ``ext``) before
        invoking this method.

        **Replace semantics, per spec.** Each call REPLACES the
        previously synced governance agents for the referenced
        account. An entry whose ``governance_agents`` is empty clears
        the binding for that account.

        **Write-only credentials.** Each
        ``governance_agents[i].authentication.credentials`` is the
        bearer the seller presents to that governance agent on
        outbound ``check_governance`` calls. Persist them — silently
        dropping ships unauthenticated requests once cross-agent
        calls are wired. The framework strips ``authentication`` from
        each ``governance_agents[i]`` of every row before
        serialization (:func:`to_wire_sync_governance_row`), so
        credentials never reach the response wire OR the idempotency
        replay cache, even if an adopter returns a loosely-typed row
        that spreads the input. Do not rely on Python type hints
        alone — the strip is enforced at the dispatcher.

        ``ctx.auth_info`` and ``ctx.agent`` carry the caller's
        principal. Adopters MUST gate per-entry persistence by the
        caller's tenant: each entry's ``account.operator`` (or
        ``account_id``) must map to the same tenant the auth
        principal authorizes; otherwise return a row with
        ``status='failed'`` carrying
        ``errors=[{code: 'PERMISSION_DENIED', ...}]`` for that entry.
        Operation-level rejection (``raise AdcpError(...)``) fails
        the whole batch, which is the wrong shape when a single
        entry fails the gate.
        """
        ...

sync_governance API surface. Optional adopter-side feature.

Buyers register governance agent endpoints per-account; the seller persists the binding and consults the agents during media buy lifecycle events via check_governance. Adopters that don't model buyer-supplied governance agents (most direct sellers) leave this unimplemented and the framework returns UNSUPPORTED_FEATURE.

Ancestors

  • typing.Protocol
  • typing.Generic

Methods

def sync_governance(self,
entries: list[SyncGovernanceEntry],
ctx: ResolveContext | None = None) ‑> collections.abc.Awaitable[list[SyncGovernanceResultRow]] | list[SyncGovernanceResultRow]
Expand source code
def sync_governance(
    self,
    entries: list[SyncGovernanceEntry],
    ctx: ResolveContext | None = None,
) -> Awaitable[list[SyncGovernanceResultRow]] | list[SyncGovernanceResultRow]:
    """Persist the per-entry governance-agent bindings.

    ``entries`` is the wire request's ``accounts[]`` — each entry
    pairs an :class:`AccountReference` with its
    ``governance_agents[]``. The framework has already deduped on
    ``idempotency_key`` and stripped wire metadata
    (``adcp_major_version``, ``context``, ``ext``) before
    invoking this method.

    **Replace semantics, per spec.** Each call REPLACES the
    previously synced governance agents for the referenced
    account. An entry whose ``governance_agents`` is empty clears
    the binding for that account.

    **Write-only credentials.** Each
    ``governance_agents[i].authentication.credentials`` is the
    bearer the seller presents to that governance agent on
    outbound ``check_governance`` calls. Persist them — silently
    dropping ships unauthenticated requests once cross-agent
    calls are wired. The framework strips ``authentication`` from
    each ``governance_agents[i]`` of every row before
    serialization (:func:`to_wire_sync_governance_row`), so
    credentials never reach the response wire OR the idempotency
    replay cache, even if an adopter returns a loosely-typed row
    that spreads the input. Do not rely on Python type hints
    alone — the strip is enforced at the dispatcher.

    ``ctx.auth_info`` and ``ctx.agent`` carry the caller's
    principal. Adopters MUST gate per-entry persistence by the
    caller's tenant: each entry's ``account.operator`` (or
    ``account_id``) must map to the same tenant the auth
    principal authorizes; otherwise return a row with
    ``status='failed'`` carrying
    ``errors=[{code: 'PERMISSION_DENIED', ...}]`` for that entry.
    Operation-level rejection (``raise AdcpError(...)``) fails
    the whole batch, which is the wrong shape when a single
    entry fails the gate.
    """
    ...

Persist the per-entry governance-agent bindings.

entries is the wire request's adcp.decisioning.accounts[] — each entry pairs an :class:AccountReference with its governance_agents[]. The framework has already deduped on idempotency_key and stripped wire metadata (adcp_major_version, adcp.decisioning.context, ext) before invoking this method.

Replace semantics, per spec. Each call REPLACES the previously synced governance agents for the referenced account. An entry whose governance_agents is empty clears the binding for that account.

Write-only credentials. Each governance_agents[i].authentication.credentials is the bearer the seller presents to that governance agent on outbound check_governance calls. Persist them — silently dropping ships unauthenticated requests once cross-agent calls are wired. The framework strips authentication from each governance_agents[i] of every row before serialization (:func:to_wire_sync_governance_row()), so credentials never reach the response wire OR the idempotency replay cache, even if an adopter returns a loosely-typed row that spreads the input. Do not rely on Python type hints alone — the strip is enforced at the dispatcher.

ctx.auth_info and ctx.agent carry the caller's principal. Adopters MUST gate per-entry persistence by the caller's tenant: each entry's account.operator (or account_id) must map to the same tenant the auth principal authorizes; otherwise return a row with status='failed' carrying errors=[{code: 'PERMISSION_DENIED', ...}] for that entry. Operation-level rejection (raise AdcpError(…)) fails the whole batch, which is the wrong shape when a single entry fails the gate.

class AccountStoreUpsert (*args, **kwargs)
Expand source code
@runtime_checkable
class AccountStoreUpsert(Protocol):
    """``sync_accounts`` API surface. Optional adopter-side feature
    that complements :class:`AccountStore.resolve`.

    Not parameterized over ``TMeta`` — :meth:`upsert` returns
    :class:`SyncAccountsResultRow` (a wire-shaped row, no per-platform
    metadata) rather than ``Account[TMeta]``, so the type variable
    isn't needed here.

    Adopters implement this on the same object as :class:`AccountStore`
    (Protocols are structural — Python doesn't require explicit
    inheritance) and the framework's dispatch shim picks it up via
    :func:`hasattr`.

    **Backwards-compatible.** ``ctx`` is optional on the platform
    side, so adopter impls written before ctx threading landed (no
    ``ctx`` parameter) keep working — the framework's
    :func:`_call_with_optional_ctx` shim probes via
    :func:`inspect.signature` and drops ``ctx`` for pre-ctx impls.
    """

    def upsert(
        self,
        refs: list[AccountReference],
        ctx: ResolveContext | None = None,
    ) -> Awaitable[list[SyncAccountsResultRow]] | list[SyncAccountsResultRow]:
        """``sync_accounts`` API surface. Framework normalizes the
        wire request; platform upserts and returns per-account result
        rows. Raise :class:`adcp.decisioning.AdcpError` for
        buyer-facing rejection.

        ``ctx.auth_info`` carries the caller's authenticated
        principal; ``ctx.agent`` carries the resolved
        :class:`BuyerAgent` record (when a registry is configured).
        Adopters implementing principal-keyed gates (e.g.,
        per-buyer-agent ``BILLING_NOT_PERMITTED_FOR_AGENT`` on the
        spec's billing surfaces) read the principal here — same
        threading as :meth:`AccountStore.resolve`.

        **Prefer ``ctx.agent`` over ``ctx.auth_info`` for
        commercial-relationship decisions.** ``ctx.agent`` is the
        registry-resolved durable identity (status, billing
        capabilities, default account terms); ``ctx.auth_info``
        carries the raw transport-level credential. For billing gates
        the registry-resolved identity is canonical.
        """
        ...

sync_accounts API surface. Optional adopter-side feature that complements :class:AccountStore.resolve().

Not parameterized over TMeta — :meth:upsert returns :class:SyncAccountsResultRow (a wire-shaped row, no per-platform metadata) rather than Account[TMeta], so the type variable isn't needed here.

Adopters implement this on the same object as :class:AccountStore (Protocols are structural — Python doesn't require explicit inheritance) and the framework's dispatch shim picks it up via :func:hasattr.

Backwards-compatible. ctx is optional on the platform side, so adopter impls written before ctx threading landed (no ctx parameter) keep working — the framework's :func:_call_with_optional_ctx shim probes via :func:inspect.signature and drops ctx for pre-ctx impls.

Ancestors

  • typing.Protocol
  • typing.Generic

Methods

def upsert(self,
refs: list[AccountReference],
ctx: ResolveContext | None = None) ‑> Awaitable[list[SyncAccountsResultRow]] | list[SyncAccountsResultRow]
Expand source code
def upsert(
    self,
    refs: list[AccountReference],
    ctx: ResolveContext | None = None,
) -> Awaitable[list[SyncAccountsResultRow]] | list[SyncAccountsResultRow]:
    """``sync_accounts`` API surface. Framework normalizes the
    wire request; platform upserts and returns per-account result
    rows. Raise :class:`adcp.decisioning.AdcpError` for
    buyer-facing rejection.

    ``ctx.auth_info`` carries the caller's authenticated
    principal; ``ctx.agent`` carries the resolved
    :class:`BuyerAgent` record (when a registry is configured).
    Adopters implementing principal-keyed gates (e.g.,
    per-buyer-agent ``BILLING_NOT_PERMITTED_FOR_AGENT`` on the
    spec's billing surfaces) read the principal here — same
    threading as :meth:`AccountStore.resolve`.

    **Prefer ``ctx.agent`` over ``ctx.auth_info`` for
    commercial-relationship decisions.** ``ctx.agent`` is the
    registry-resolved durable identity (status, billing
    capabilities, default account terms); ``ctx.auth_info``
    carries the raw transport-level credential. For billing gates
    the registry-resolved identity is canonical.
    """
    ...

sync_accounts API surface. Framework normalizes the wire request; platform upserts and returns per-account result rows. Raise :class:AdcpError for buyer-facing rejection.

ctx.auth_info carries the caller's authenticated principal; ctx.agent carries the resolved :class:BuyerAgent record (when a registry is configured). Adopters implementing principal-keyed gates (e.g., per-buyer-agent BILLING_NOT_PERMITTED_FOR_AGENT on the spec's billing surfaces) read the principal here — same threading as :meth:AccountStore.resolve().

Prefer ctx.agent over ctx.auth_info for commercial-relationship decisions. ctx.agent is the registry-resolved durable identity (status, billing capabilities, default account terms); ctx.auth_info carries the raw transport-level credential. For billing gates the registry-resolved identity is canonical.

class AccountStoreUpsertRequest (*args, **kwargs)
Expand source code
@runtime_checkable
class AccountStoreUpsertRequest(Protocol):
    """Full-request ``sync_accounts`` API surface.

    Prefer this over :class:`AccountStoreUpsert` when a store needs
    request-level fields such as ``push_notification_config``,
    ``delete_missing``, or ``dry_run``. The framework calls this hook
    with the parsed :class:`adcp.types.SyncAccountsRequest` and then
    projects the return value through the same ``sync_accounts``
    response path as the legacy ``upsert`` hook.

    Backwards compatibility: stores that only implement
    :meth:`AccountStoreUpsert.upsert` continue to receive
    ``params.accounts`` exactly as before.
    """

    def upsert_request(
        self,
        params: SyncAccountsRequest,
        ctx: ResolveContext | None = None,
    ) -> Awaitable[list[SyncAccountsResultRow]] | list[SyncAccountsResultRow]:
        """Persist a full ``sync_accounts`` request.

        Use this hook when request-envelope data must be stored or
        acted on. ``ctx`` carries the same principal and buyer-agent
        context as the legacy ``upsert`` path.
        """
        raise NotImplementedError

Full-request sync_accounts API surface.

Prefer this over :class:AccountStoreUpsert when a store needs request-level fields such as push_notification_config, delete_missing, or dry_run. The framework calls this hook with the parsed :class:SyncAccountsRequest and then projects the return value through the same sync_accounts response path as the legacy upsert hook.

Backwards compatibility: stores that only implement :meth:AccountStoreUpsert.upsert() continue to receive params.accounts exactly as before.

Ancestors

  • typing.Protocol
  • typing.Generic

Methods

def upsert_request(self,
params: SyncAccountsRequest,
ctx: ResolveContext | None = None) ‑> Awaitable[list[SyncAccountsResultRow]] | list[SyncAccountsResultRow]
Expand source code
def upsert_request(
    self,
    params: SyncAccountsRequest,
    ctx: ResolveContext | None = None,
) -> Awaitable[list[SyncAccountsResultRow]] | list[SyncAccountsResultRow]:
    """Persist a full ``sync_accounts`` request.

    Use this hook when request-envelope data must be stored or
    acted on. ``ctx`` carries the same principal and buyer-agent
    context as the legacy ``upsert`` path.
    """
    raise NotImplementedError

Persist a full sync_accounts request.

Use this hook when request-envelope data must be stored or acted on. ctx carries the same principal and buyer-agent context as the legacy upsert path.

class AdcpError (code: str,
*,
message: str = '',
recovery: "Literal['retry_with_changes', 'correctable', 'transient', 'terminal']" = 'terminal',
field: str | None = None,
suggestion: str | None = None,
retry_after: int | None = None,
details: dict[str, Any] | None = None)
Expand source code
class AdcpError(Exception):
    """Wire-shaped structured error raised by platform methods.

    Distinct from :class:`adcp.exceptions.ADCPError` (the client-side
    connection-failure exception). This is the *server-side* structured
    error the framework's dispatcher catches and projects to the wire
    ``adcp_error`` envelope:

    .. code-block:: json

        {
          "code": "BUDGET_TOO_LOW",
          "message": "total_budget below floor (0.50 CPM × 1000 imp)",
          "recovery": "correctable",
          "field": "total_budget",
          "suggestion": "Increase budget to at least $0.50",
          "retry_after": null,
          "details": {"errors": [...]}
        }

    Adopters raise this from inside Protocol method bodies for any
    buyer-fixable rejection. The framework catches at the dispatch
    seam, serializes to the structured-error envelope, and returns
    the wire response. Adopters do NOT serialize themselves.

    :param code: AdCP error code (e.g. ``BUDGET_TOO_LOW``,
        ``POLICY_VIOLATION``, ``INVALID_REQUEST``,
        ``ACCOUNT_NOT_FOUND``). The full enum is at
        ``schemas/cache/3.0.0/enums/error-code.json``; vendor codes
        outside the enum are accepted (``str``) but buyers won't have
        first-class handling for them.
    :param message: Human-readable error message. Always set.
    :param recovery: Buyer's retry strategy:

        * ``'retry_with_changes'`` — fix the indicated field and retry
        * ``'correctable'`` — same as retry_with_changes (legacy alias)
        * ``'transient'`` — retry as-is after a backoff
        * ``'terminal'`` — do not retry; the request is rejected

    :param field: The request field path that caused the error
        (e.g. ``'total_budget'``, ``'package[2].targeting'``). Buyers
        use this to highlight inputs in their UI.
    :param suggestion: Optional human-readable hint for fixing the
        error.
    :param retry_after: Seconds to wait before retrying. Only
        meaningful with ``recovery='transient'``.
    :param details: Free-form extras for codes that need them
        (e.g. ``{'errors': [...]}`` for multi-error preflight).
    """

    def __init__(
        self,
        code: str,
        *,
        message: str = "",
        recovery: Literal[
            "retry_with_changes", "correctable", "transient", "terminal"
        ] = "terminal",
        field: str | None = None,
        suggestion: str | None = None,
        retry_after: int | None = None,
        details: dict[str, Any] | None = None,
    ) -> None:
        super().__init__(message or code)
        self.code = code
        self.recovery = recovery
        self.field = field
        self.suggestion = suggestion
        self.retry_after = retry_after
        self.details = details or {}

    def __str__(self) -> str:
        return f"AdcpError[{self.code} / {self.recovery}]: {self.args[0]}"

    def to_wire(self) -> dict[str, Any]:
        """Project to the AdCP wire ``adcp_error`` envelope.

        Called by the framework dispatcher when serializing the
        rejection. Adopters don't typically call this directly; it's
        public for testing and for adopter middleware that wants to
        inspect the projection shape.
        """
        out: dict[str, Any] = {
            "code": self.code,
            "message": self.args[0] if self.args else "",
            "recovery": self.recovery,
        }
        if self.field is not None:
            out["field"] = self.field
        if self.suggestion is not None:
            out["suggestion"] = self.suggestion
        if self.retry_after is not None:
            out["retry_after"] = self.retry_after
        if self.details:
            details = sanitize_error_details(self.code, self.details)
            if details:
                out["details"] = details
        return out

Wire-shaped structured error raised by platform methods.

Distinct from :class:ADCPError (the client-side connection-failure exception). This is the server-side structured error the framework's dispatcher catches and projects to the wire adcp_error envelope:

.. code-block:: json

{
  "code": "BUDGET_TOO_LOW",
  "message": "total_budget below floor (0.50 CPM × 1000 imp)",
  "recovery": "correctable",
  "field": "total_budget",
  "suggestion": "Increase budget to at least $0.50",
  "retry_after": null,
  "details": {"errors": [...]}
}

Adopters raise this from inside Protocol method bodies for any buyer-fixable rejection. The framework catches at the dispatch seam, serializes to the structured-error envelope, and returns the wire response. Adopters do NOT serialize themselves.

:param code: AdCP error code (e.g. BUDGET_TOO_LOW, POLICY_VIOLATION, INVALID_REQUEST, ACCOUNT_NOT_FOUND). The full enum is at schemas/cache/3.0.0/enums/error-code.json; vendor codes outside the enum are accepted (str) but buyers won't have first-class handling for them. :param message: Human-readable error message. Always set. :param recovery: Buyer's retry strategy:

* ``'retry_with_changes'`` — fix the indicated field and retry
* ``'correctable'`` — same as retry_with_changes (legacy alias)
* ``'transient'`` — retry as-is after a backoff
* ``'terminal'`` — do not retry; the request is rejected

:param field: The request field path that caused the error (e.g. 'total_budget', 'package[2].targeting'). Buyers use this to highlight inputs in their UI. :param suggestion: Optional human-readable hint for fixing the error. :param retry_after: Seconds to wait before retrying. Only meaningful with recovery='transient'. :param details: Free-form extras for codes that need them (e.g. {'errors': [...]} for multi-error preflight).

Ancestors

  • builtins.Exception
  • builtins.BaseException

Subclasses

Methods

def to_wire(self) ‑> dict[str, typing.Any]
Expand source code
def to_wire(self) -> dict[str, Any]:
    """Project to the AdCP wire ``adcp_error`` envelope.

    Called by the framework dispatcher when serializing the
    rejection. Adopters don't typically call this directly; it's
    public for testing and for adopter middleware that wants to
    inspect the projection shape.
    """
    out: dict[str, Any] = {
        "code": self.code,
        "message": self.args[0] if self.args else "",
        "recovery": self.recovery,
    }
    if self.field is not None:
        out["field"] = self.field
    if self.suggestion is not None:
        out["suggestion"] = self.suggestion
    if self.retry_after is not None:
        out["retry_after"] = self.retry_after
    if self.details:
        details = sanitize_error_details(self.code, self.details)
        if details:
            out["details"] = details
    return out

Project to the AdCP wire adcp_error envelope.

Called by the framework dispatcher when serializing the rejection. Adopters don't typically call this directly; it's public for testing and for adopter middleware that wants to inspect the projection shape.

class ApiKey (header_name: str, key: str, kind: "Literal['api_key']" = 'api_key')
Expand source code
@dataclass(frozen=True)
class ApiKey:
    """Fixed key injected into a named header (e.g. ``X-Api-Key``)."""

    header_name: str
    key: str
    kind: Literal["api_key"] = "api_key"

Fixed key injected into a named header (e.g. X-Api-Key).

Instance variables

var header_name : str
var key : str
var kind : Literal['api_key']
class ApiKeyCredential (kind: "Literal['api_key']", key_id: str)
Expand source code
@dataclass(frozen=True)
class ApiKeyCredential:
    """Bearer / API-key credential. The framework's authentication
    layer extracts the ``key_id`` from a header before this point;
    the registry's :meth:`BuyerAgentRegistry.resolve_by_credential`
    looks it up against the adopter's existing key table.
    """

    kind: Literal["api_key"]
    key_id: str

Bearer / API-key credential. The framework's authentication layer extracts the key_id from a header before this point; the registry's :meth:BuyerAgentRegistry.resolve_by_credential() looks it up against the adopter's existing key table.

Instance variables

var key_id : str
var kind : Literal['api_key']
class AudiencePlatform (*args, **kwargs)
Expand source code
@runtime_checkable
class AudiencePlatform(Protocol, Generic[TMeta]):
    """Sync first-party CRM audiences with delta upsert semantics.

    Methods may be sync (return ``T`` directly) or async (return
    ``Awaitable[T]``); the dispatch adapter detects via
    :func:`asyncio.iscoroutinefunction` and runs sync methods on a
    thread pool.

    Throw :class:`adcp.decisioning.AdcpError` for buyer-fixable
    rejection (``AUDIENCE_TOO_SMALL``, ``REFERENCE_NOT_FOUND``, etc.);
    the framework projects to the wire structured-error envelope.
    """

    def sync_audiences(
        self,
        audiences: Sequence[SyncAudiencesAudience],
        ctx: RequestContext[TMeta],
    ) -> MaybeAsync[SyncAudiencesSuccessResponse]:
        """Push audiences to the platform.

        Framework handles batching, idempotency, and cross-tenant
        scoping; the adopter handles match-rate computation and
        activation lifecycle.

        Sync acknowledgment with status changes via
        ``ctx.publish_status_change``: return per-audience result rows
        immediately (``'pending'`` / ``'matching'`` are valid sync
        outcomes). The match-rate computation and activation pipeline
        run in the background — call
        ``ctx.publish_status_change(resource_type='audience', ...)``
        from the platform's webhook handler / job queue / cron when
        each audience reaches a terminal state.

        :param audiences: List of audience rows projected from the
            wire ``SyncAudiencesRequest.audiences[]`` field. Adopter
            ergonomic — receives the list directly rather than the
            full request.
        :raises adcp.decisioning.AdcpError: for buyer-fixable
            rejection (e.g., ``AUDIENCE_TOO_SMALL``).
        """
        ...

    def poll_audience_statuses(
        self,
        audience_ids: Sequence[str],
        ctx: RequestContext[TMeta],
    ) -> MaybeAsync[Mapping[str, str]]:
        """Batch-poll current status for one or more audiences.

        Sync — this is a state-read, not a mutating operation. Useful
        for buyer-side polling outside the framework's task envelope
        (e.g., querying long-lived audiences) and for adapter code
        that needs to check N audiences at once.

        Returns a ``dict[audience_id, AudienceStatus]``. Audiences not
        found are omitted from the map (callers handle missing keys);
        raise ``AdcpError(code='REFERENCE_NOT_FOUND')`` only when the
        entire batch is unresolvable for the tenant.

        Single-audience polling is
        ``poll_audience_statuses([id], ctx).get(id)``. The batch shape
        composes with upstream identity-graph APIs that natively
        return per-audience-id arrays — adopters do NOT need to wrap
        a single-id lookup over an N-call loop.

        Adopter-internal helper — not surfaced as a wire tool. Used
        by adopter code orchestrating cross-platform audience flows
        and by the framework's optional bulk-status middleware.
        """
        ...

Sync first-party CRM audiences with delta upsert semantics.

Methods may be sync (return T directly) or async (return Awaitable[T]); the dispatch adapter detects via :func:asyncio.iscoroutinefunction and runs sync methods on a thread pool.

Throw :class:AdcpError for buyer-fixable rejection (AUDIENCE_TOO_SMALL, REFERENCE_NOT_FOUND, etc.); the framework projects to the wire structured-error envelope.

Ancestors

  • typing.Protocol
  • typing.Generic

Methods

def poll_audience_statuses(self,
audience_ids: Sequence[str],
ctx: RequestContext[TMeta]) ‑> MaybeAsync[Mapping[str, str]]
Expand source code
def poll_audience_statuses(
    self,
    audience_ids: Sequence[str],
    ctx: RequestContext[TMeta],
) -> MaybeAsync[Mapping[str, str]]:
    """Batch-poll current status for one or more audiences.

    Sync — this is a state-read, not a mutating operation. Useful
    for buyer-side polling outside the framework's task envelope
    (e.g., querying long-lived audiences) and for adapter code
    that needs to check N audiences at once.

    Returns a ``dict[audience_id, AudienceStatus]``. Audiences not
    found are omitted from the map (callers handle missing keys);
    raise ``AdcpError(code='REFERENCE_NOT_FOUND')`` only when the
    entire batch is unresolvable for the tenant.

    Single-audience polling is
    ``poll_audience_statuses([id], ctx).get(id)``. The batch shape
    composes with upstream identity-graph APIs that natively
    return per-audience-id arrays — adopters do NOT need to wrap
    a single-id lookup over an N-call loop.

    Adopter-internal helper — not surfaced as a wire tool. Used
    by adopter code orchestrating cross-platform audience flows
    and by the framework's optional bulk-status middleware.
    """
    ...

Batch-poll current status for one or more audiences.

Sync — this is a state-read, not a mutating operation. Useful for buyer-side polling outside the framework's task envelope (e.g., querying long-lived audiences) and for adapter code that needs to check N audiences at once.

Returns a dict[audience_id, AudienceStatus]. Audiences not found are omitted from the map (callers handle missing keys); raise AdcpError(code='REFERENCE_NOT_FOUND') only when the entire batch is unresolvable for the tenant.

Single-audience polling is poll_audience_statuses([id], ctx).get(id). The batch shape composes with upstream identity-graph APIs that natively return per-audience-id arrays — adopters do NOT need to wrap a single-id lookup over an N-call loop.

Adopter-internal helper — not surfaced as a wire tool. Used by adopter code orchestrating cross-platform audience flows and by the framework's optional bulk-status middleware.

def sync_audiences(self,
audiences: Sequence[SyncAudiencesAudience],
ctx: RequestContext[TMeta]) ‑> MaybeAsync[SyncAudiencesSuccessResponse]
Expand source code
def sync_audiences(
    self,
    audiences: Sequence[SyncAudiencesAudience],
    ctx: RequestContext[TMeta],
) -> MaybeAsync[SyncAudiencesSuccessResponse]:
    """Push audiences to the platform.

    Framework handles batching, idempotency, and cross-tenant
    scoping; the adopter handles match-rate computation and
    activation lifecycle.

    Sync acknowledgment with status changes via
    ``ctx.publish_status_change``: return per-audience result rows
    immediately (``'pending'`` / ``'matching'`` are valid sync
    outcomes). The match-rate computation and activation pipeline
    run in the background — call
    ``ctx.publish_status_change(resource_type='audience', ...)``
    from the platform's webhook handler / job queue / cron when
    each audience reaches a terminal state.

    :param audiences: List of audience rows projected from the
        wire ``SyncAudiencesRequest.audiences[]`` field. Adopter
        ergonomic — receives the list directly rather than the
        full request.
    :raises adcp.decisioning.AdcpError: for buyer-fixable
        rejection (e.g., ``AUDIENCE_TOO_SMALL``).
    """
    ...

Push audiences to the platform.

Framework handles batching, idempotency, and cross-tenant scoping; the adopter handles match-rate computation and activation lifecycle.

Sync acknowledgment with status changes via ctx.publish_status_change: return per-audience result rows immediately ('pending' / 'matching' are valid sync outcomes). The match-rate computation and activation pipeline run in the background — call ctx.publish_status_change(resource_type='audience', ...) from the platform's webhook handler / job queue / cron when each audience reaches a terminal state.

:param audiences: List of audience rows projected from the wire SyncAudiencesRequest.audiences[] field. Adopter ergonomic — receives the list directly rather than the full request. :raises adcp.decisioning.AdcpError: for buyer-fixable rejection (e.g., AUDIENCE_TOO_SMALL).

class AuditingBuyerAgentRegistry (inner: BuyerAgentRegistry,
*,
audit_sink: AuditSink | None = None,
sink_timeout_seconds: float = 5.0)
Expand source code
class AuditingBuyerAgentRegistry:
    """Terminal wrapper that emits one :class:`AuditEvent` per
    resolution outcome from the inner store.

    Wrap the SQL-backed registry with this so every DB lookup
    (``resolved`` / ``miss``) lands in the audit trail. Compliance
    teams reconstruct who tried what when from these records;
    SecOps correlates spikes in ``miss`` events with
    credential-stuffing activity.

    The event ``operation`` is namespaced
    (``"buyer_agent_registry.resolve_by_agent_url"`` /
    ``"...resolve_by_credential"``) so audit queries can filter
    registry traffic from the higher-level skill dispatches.

    :param inner: Wrapped :class:`BuyerAgentRegistry` — typically
        the actual SQL-backed impl.
    :param audit_sink: :class:`AuditSink` to write events to. If
        ``None``, outcomes log at ``DEBUG`` instead.
    :param sink_timeout_seconds: Per-sink timeout. Default 5s
        matching :func:`adcp.audit_sink.make_audit_middleware`. A
        sink that wedges (DB stall, S3 outage) NEVER blocks dispatch.
    """

    def __init__(
        self,
        inner: BuyerAgentRegistry,
        *,
        audit_sink: AuditSink | None = None,
        sink_timeout_seconds: float = 5.0,
    ) -> None:
        self._inner = inner
        self._sink = audit_sink
        self._sink_timeout = sink_timeout_seconds

    async def resolve_by_agent_url(self, agent_url: str) -> BuyerAgent | None:
        tenant_id = _current_tenant_id()
        result = await self._inner.resolve_by_agent_url(agent_url)
        await _emit_audit(
            self._sink,
            operation="buyer_agent_registry.resolve_by_agent_url",
            outcome="resolved" if result is not None else "miss",
            lookup_key=f"agent_url:{agent_url}",
            tenant_id=tenant_id,
            agent=result,
            sink_timeout_seconds=self._sink_timeout,
        )
        return result

    async def resolve_by_credential(self, credential: Credential) -> BuyerAgent | None:
        tenant_id = _current_tenant_id()
        result = await self._inner.resolve_by_credential(credential)
        await _emit_audit(
            self._sink,
            operation="buyer_agent_registry.resolve_by_credential",
            outcome="resolved" if result is not None else "miss",
            lookup_key=_credential_key(credential),
            tenant_id=tenant_id,
            agent=result,
            sink_timeout_seconds=self._sink_timeout,
        )
        return result

Terminal wrapper that emits one :class:AuditEvent per resolution outcome from the inner store.

Wrap the SQL-backed registry with this so every DB lookup (resolved / miss) lands in the audit trail. Compliance teams reconstruct who tried what when from these records; SecOps correlates spikes in miss events with credential-stuffing activity.

The event operation is namespaced ("buyer_agent_registry.resolve_by_agent_url" / "...resolve_by_credential") so audit queries can filter registry traffic from the higher-level skill dispatches.

:param inner: Wrapped :class:BuyerAgentRegistry — typically the actual SQL-backed impl. :param audit_sink: :class:AuditSink to write events to. If None, outcomes log at DEBUG instead. :param sink_timeout_seconds: Per-sink timeout. Default 5s matching :func:make_audit_middleware(). A sink that wedges (DB stall, S3 outage) NEVER blocks dispatch.

Methods

async def resolve_by_agent_url(self, agent_url: str) ‑> BuyerAgent | None
Expand source code
async def resolve_by_agent_url(self, agent_url: str) -> BuyerAgent | None:
    tenant_id = _current_tenant_id()
    result = await self._inner.resolve_by_agent_url(agent_url)
    await _emit_audit(
        self._sink,
        operation="buyer_agent_registry.resolve_by_agent_url",
        outcome="resolved" if result is not None else "miss",
        lookup_key=f"agent_url:{agent_url}",
        tenant_id=tenant_id,
        agent=result,
        sink_timeout_seconds=self._sink_timeout,
    )
    return result
async def resolve_by_credential(self, credential: Credential) ‑> BuyerAgent | None
Expand source code
async def resolve_by_credential(self, credential: Credential) -> BuyerAgent | None:
    tenant_id = _current_tenant_id()
    result = await self._inner.resolve_by_credential(credential)
    await _emit_audit(
        self._sink,
        operation="buyer_agent_registry.resolve_by_credential",
        outcome="resolved" if result is not None else "miss",
        lookup_key=_credential_key(credential),
        tenant_id=tenant_id,
        agent=result,
        sink_timeout_seconds=self._sink_timeout,
    )
    return result
class AuthInfo (kind: str,
key_id: str | None = None,
principal: str | None = None,
scopes: list[str] = <factory>,
credential: Credential | None = <object object>,
agent_url: str | None = None,
operator: str | None = None,
extra: Mapping[str, Any] = <factory>)
Expand source code
@dataclass
class AuthInfo:
    """The verified principal authenticated for a request.

    Populated by the framework's signed-request verifier
    (:func:`adcp.signing.signed_request_verifier`) or a custom
    ``authenticate=`` callable wired via :func:`adcp.decisioning.serve`.
    Threaded onto :attr:`RequestContext.auth_info` so platform methods
    can read scopes, key_id, principal, etc., without parsing
    transport headers.

    **Two field families.** The flat fields (``kind`` / ``key_id`` /
    ``principal`` / ``scopes``) are the v6.0 surface — adopters built
    against the alpha pass these directly. The Tier 2 v3-identity
    fields (``credential`` / ``agent_url`` / ``operator`` / ``extra``)
    carry the typed AdCP v3 commercial identity context the
    :class:`adcp.decisioning.BuyerAgentRegistry` consumes. When an
    adopter constructs ``AuthInfo`` with only the flat fields,
    ``__post_init__`` synthesizes a typed bearer
    :class:`adcp.decisioning.Credential` from them and emits a
    :class:`DeprecationWarning` pointing at the adopter callsite.

    **Deprecation timeline:**

    * **4.4.0** (this release) — flat-field synthesis still works but
      warns. Adopter code stays runnable; the warning points at every
      callsite that constructs ``AuthInfo`` without an explicit
      ``credential=``.
    * **4.5.0** — synthesis is removed; flat-field-only construction
      stops auto-populating ``credential``, and the registry dispatch
      will reject the request with ``PERMISSION_DENIED``. Adopters
      must construct
      the typed credential explicitly:
      ``AuthInfo(credential=ApiKeyCredential(kind="api_key", key_id=...))``
      or use the bundled signed-request verifier middleware.

    The flat fields themselves stay (they carry useful audit / log
    context); only the synthesis-from-flat path is on the removal
    track.

    :param kind: One of ``'signed_request'``, ``'http_sig'``,
        ``'bearer'``, ``'api_key'``, ``'oauth'``, ``'mtls'``,
        ``'derived'``. Drives the legacy → ``credential`` synthesis.
    :param key_id: The signing key id (``kid``) for signed-request /
        http_sig auth, or the API-key id for bearer auth.
    :param principal: The authenticated principal label — for
        signed-request auth this is the verified ``agent_url`` (per
        AdCP v3 convention).
    :param scopes: Granted scopes / capabilities (OAuth or per-token).
    :param credential: Typed v3 :class:`adcp.decisioning.Credential` —
        the canonical surface the registry dispatches on. When
        unset, ``__post_init__`` synthesizes from the legacy fields.
        Adopters wiring v3 auth directly should construct the
        credential themselves and leave the legacy fields empty.
    :param agent_url: Verified buyer-agent URL — populated from
        ``credential.agent_url`` when ``credential`` is an
        :class:`adcp.decisioning.HttpSigCredential`. ``None`` for
        bearer / OAuth / unauthenticated traffic and for
        ``kind="signed_request"`` constructions that don't pass a
        typed credential (the SDK deliberately refuses to derive
        ``agent_url`` from the unverified ``principal`` string —
        see ``__post_init__`` for the rationale).
    :param operator: Operator / transport-tenant label — the AdCP v3
        operator binding (separate from the buyer agent). Distinct
        from ``ToolContext.tenant_id`` only for adopters running the
        AAO community proxy in front of a multi-operator deployment;
        most adopters leave this ``None``.
    :param extra: Adopter passthrough for auth-layer fields the SDK
        doesn't model (custom claims, MFA flags, internal session ids).
    """

    # Sentinel used as the default value of ``credential`` so
    # ``__post_init__`` can distinguish "adopter didn't pass credential
    # at all" (default → synthesize from flat fields) from "adopter
    # explicitly passed credential=None" (default → leave None,
    # don't re-synthesize). Without this sentinel,
    # ``dataclasses.replace(auth, credential=None)`` — the natural
    # idiom for clearing a credential — would re-trigger synthesis
    # from the still-present flat fields, contradicting the adopter's
    # intent.
    _UNSET_CREDENTIAL: ClassVar[Any] = object()

    kind: str
    key_id: str | None = None
    principal: str | None = None
    scopes: list[str] = field(default_factory=list)

    # ----- Tier 2 v3-identity fields -----
    credential: Credential | None = _UNSET_CREDENTIAL
    agent_url: str | None = None
    operator: str | None = None
    extra: Mapping[str, Any] = field(default_factory=dict)

    @staticmethod
    def _synthesize_bearer_credential(
        kind: str,
        key_id: str | None,
        principal: str | None,
        scopes: list[str],
    ) -> Credential | None:
        """Build a typed bearer :class:`Credential` from the flat
        fields, or return ``None`` when the flat fields don't describe
        a bearer credential.

        Signed-request kinds (``"signed_request"`` / ``"http_sig"``)
        intentionally never synthesize — a real
        :class:`HttpSigCredential` requires the
        ``verified_at`` timestamp from RFC 9421 verification, which
        only the verifier middleware has. Synthesizing one here would
        let any code that writes ``kind="signed_request"`` escalate
        bearer traffic onto the verified signed path. The verifier
        middleware constructs :class:`HttpSigCredential` explicitly
        and passes it via ``credential=``.
        """
        from adcp.decisioning.registry import (
            ApiKeyCredential,
            OAuthCredential,
        )

        if kind in {"api_key", "bearer"}:
            if key_id:
                return ApiKeyCredential(kind="api_key", key_id=key_id)
        elif kind == "oauth":
            client_id = key_id or principal
            if client_id:
                return OAuthCredential(
                    kind="oauth",
                    client_id=client_id,
                    scopes=tuple(scopes),
                )
        return None

    @classmethod
    def from_verified_signer(
        cls,
        signer: Any,
        *,
        scopes: list[str] | None = None,
        operator: str | None = None,
        extra: Mapping[str, Any] | None = None,
        max_verified_age_s: float | None = None,
        now: float | None = None,
    ) -> AuthInfo:
        """Build :class:`AuthInfo` from a :class:`adcp.signing.VerifiedSigner`.

        The supported migration target for the AuthInfo flat-field
        deprecation. Verifier middleware that runs RFC 9421
        verification produces a ``VerifiedSigner`` carrying the
        cryptographic claims (``key_id``, ``verified_at``, optional
        ``agent_url``); this helper projects that into a typed
        :class:`HttpSigCredential` + :class:`AuthInfo` ready for the
        commercial-identity registry dispatch.

        ::

            from adcp.signing import (
                VerifyOptions, verify_request_signature,
            )
            from adcp.decisioning import AuthInfo

            signer = verify_request_signature(
                method=request.method,
                url=request.url,
                headers=dict(request.headers),
                body=request.get_data(),
                options=VerifyOptions(...),
            )
            ctx.metadata["adcp.auth_info"] = AuthInfo.from_verified_signer(
                signer, max_verified_age_s=300.0,
            )

        The verifier MUST surface ``signer.agent_url`` for the
        commercial-identity registry to dispatch — without it, the
        framework has no key to look up. The verifier is configured
        with the ``agent_url`` claim shape per the AdCP v3 profile;
        if it's ``None`` here the verifier wasn't told to extract it
        and this helper raises :class:`ValueError` with a pointer to
        the misconfiguration. Buyers don't see this — server boot
        time error.

        :param signer: The :class:`VerifiedSigner` returned from
            :func:`adcp.signing.verify_request_signature`.
        :param scopes: Granted scopes / capabilities. The verifier
            doesn't extract these — adopter middleware fills in
            scopes derived from token introspection or per-key
            policy.
        :param operator: Operator label for AdCP v3 multi-operator
            deployments (AAO community proxy). Most adopters leave
            ``None``.
        :param extra: Adopter passthrough.
        :param max_verified_age_s: Maximum age (seconds) of
            ``signer.verified_at`` permitted at construction time.
            When set and ``now() - signer.verified_at >
            max_verified_age_s``, raises :class:`ValueError` —
            adopter caching a stale ``VerifiedSigner`` and replaying
            it later fails fast rather than passing a stale-but-
            cryptographically-valid credential into the registry
            dispatch. Default ``None`` keeps the v6.0-alpha
            behavior; production sellers SHOULD set a short window
            (e.g. 300s) matching the RFC 9421 nonce TTL.
        :param now: Override ``time.time()`` for ``max_verified_age_s``
            comparisons — only useful in tests. Default ``None``
            uses wall-clock.
        :raises ValueError: when ``signer.agent_url`` is ``None``,
            or when ``max_verified_age_s`` is set and
            ``signer.verified_at`` is older than the window.
        """
        import time

        from adcp.decisioning.registry import HttpSigCredential

        if signer.agent_url is None:
            raise ValueError(
                "VerifiedSigner.agent_url is None — the AdCP request-signing "
                "verifier wasn't configured to extract the agent_url claim. "
                "Set ``VerifyOptions.agent_url=`` (or its source) so the "
                "verifier surfaces it on success. Without an agent_url, the "
                "BuyerAgentRegistry has no key to dispatch on."
            )
        if max_verified_age_s is not None:
            current = now if now is not None else time.time()
            age = current - signer.verified_at
            if age > max_verified_age_s:
                raise ValueError(
                    f"VerifiedSigner.verified_at is {age:.1f}s old, exceeds "
                    f"max_verified_age_s={max_verified_age_s:.1f}s. The "
                    "verifier's signature was valid at the time of verification "
                    "but has aged out of the freshness window — typically "
                    "indicates a cached signer being replayed. Re-verify the "
                    "request signature instead of constructing AuthInfo from "
                    "a stale signer."
                )
        credential = HttpSigCredential(
            kind="http_sig",
            keyid=signer.key_id,
            agent_url=signer.agent_url,
            verified_at=signer.verified_at,
        )
        return cls(
            kind="http_sig",
            key_id=signer.key_id,
            principal=signer.agent_url,
            scopes=list(scopes) if scopes is not None else [],
            credential=credential,
            agent_url=signer.agent_url,
            operator=operator,
            extra=extra if extra is not None else {},
        )

    @classmethod
    def _from_legacy_dict(cls, raw: Mapping[str, Any]) -> AuthInfo:
        """Build :class:`AuthInfo` from a legacy dict-shape metadata
        payload without firing the :class:`DeprecationWarning`.

        The framework's :func:`_extract_auth_info` translates
        ``ctx.metadata['adcp.auth_info']`` dicts into typed
        ``AuthInfo``. That translation happens once per request and
        the warning's stack would point into framework code — not
        useful for adopters. Pre-synthesize the credential and pass
        it via ``credential=`` so :meth:`__post_init__`'s synthesis
        branch is skipped along with the warning.

        Adopter code that constructs ``AuthInfo`` directly (in their
        own ``context_factory`` / auth middleware) goes through
        :meth:`__post_init__` and *does* see the warning, pointing
        at the adopter callsite — which is the actionable signal
        this deprecation is meant to deliver.

        Validates the dict shape — adopters porting JS-side middleware
        sometimes write ``scopes`` as a string instead of a list, or
        pass a raw dict where a typed :class:`Credential` is expected.
        Silently coercing those would mask middleware bugs that only
        surface when the registry dispatch later tries to use the
        malformed credential. Raise :class:`TypeError` with the
        offending field name so adopter logs surface the issue
        immediately at server boot / first request.
        """
        from adcp.decisioning.registry import (
            ApiKeyCredential,
            HttpSigCredential,
            OAuthCredential,
        )

        # Type guards. Empty dict is fine — we project to a
        # ``kind="derived"`` AuthInfo with no credential.
        scopes_raw = raw.get("scopes", [])
        if not isinstance(scopes_raw, (list, tuple)):
            raise TypeError(
                "adcp.auth_info dict has scopes={scopes_raw!r} of type "
                f"{type(scopes_raw).__name__}; expected list / tuple of "
                "strings. Adopter middleware writing a string here (e.g. "
                "comma-separated scopes) needs to split before passing "
                "to AuthInfo.".format(scopes_raw=scopes_raw)
            )
        credential = raw.get("credential")
        if credential is not None and not isinstance(
            credential, (ApiKeyCredential, OAuthCredential, HttpSigCredential)
        ):
            raise TypeError(
                "adcp.auth_info dict has credential of type "
                f"{type(credential).__name__}; expected an instance of "
                "ApiKeyCredential / OAuthCredential / HttpSigCredential. "
                "Construct the typed credential explicitly in your auth "
                "middleware — the framework can't safely build it from a "
                "raw dict because it can't distinguish kinds."
            )
        extra = raw.get("extra", {})
        if not isinstance(extra, Mapping):
            raise TypeError(
                f"adcp.auth_info dict has extra={extra!r} of type "
                f"{type(extra).__name__}; expected a Mapping."
            )

        kind = raw.get("kind", "derived")
        key_id = raw.get("key_id")
        principal = raw.get("principal")
        scopes = list(scopes_raw)
        if credential is None:
            credential = cls._synthesize_bearer_credential(kind, key_id, principal, scopes)
        return cls(
            kind=kind,
            key_id=key_id,
            principal=principal,
            scopes=scopes,
            credential=credential,
            agent_url=raw.get("agent_url"),
            operator=raw.get("operator"),
            extra=extra,
        )

    def __post_init__(self) -> None:
        """Synthesize a typed bearer ``credential`` from the flat
        ``kind`` / ``key_id`` / ``principal`` fields when not supplied
        directly, and emit a :class:`DeprecationWarning` pointing at
        the adopter callsite that needs to migrate.

        Synthesis fires for bearer kinds only; signed-request kinds
        require an explicit :class:`HttpSigCredential` from the
        verifier (see :meth:`_synthesize_bearer_credential` for
        rationale). ``agent_url`` is derived from a present
        :class:`HttpSigCredential` only — never from the
        ``principal`` string, since unverified principals must not
        appear as verified agent URLs.

        Synthesis is one-way: explicit ``credential=`` always wins
        and suppresses the warning.
        """
        from adcp.decisioning.registry import HttpSigCredential

        if self.credential is AuthInfo._UNSET_CREDENTIAL:
            # Default — adopter didn't pass ``credential=`` at all.
            # Synthesize from flat fields if they describe a bearer
            # credential; warn so the adopter migrates.
            synthesized = self._synthesize_bearer_credential(
                self.kind, self.key_id, self.principal, self.scopes
            )
            if synthesized is not None:
                self.credential = synthesized
                warnings.warn(
                    "AuthInfo was constructed without an explicit "
                    "`credential=`; the SDK synthesized "
                    f"{type(synthesized).__name__} from the flat "
                    "`kind` / `key_id` / `principal` fields. The "
                    "synthesis path is deprecated and will be removed "
                    "in adcp 4.5.0. Construct the typed credential "
                    "explicitly, e.g. "
                    '`AuthInfo(credential=ApiKeyCredential(kind="api_key",'
                    " key_id=...))`. See "
                    "docs/proposals/v3-identity-bundle-design.md for "
                    "the v3 identity migration guide.",
                    DeprecationWarning,
                    stacklevel=2,
                )
            else:
                self.credential = None
        # ELSE: adopter passed ``credential=...`` explicitly (real
        # credential or ``None``). Honor their value verbatim — no
        # synthesis, no warning. This makes
        # ``dataclasses.replace(auth, credential=None)`` correctly
        # clear the credential without re-running synthesis.

        if self.agent_url is None and isinstance(self.credential, HttpSigCredential):
            self.agent_url = self.credential.agent_url

The verified principal authenticated for a request.

Populated by the framework's signed-request verifier (:func:adcp.signing.signed_request_verifier) or a custom authenticate= callable wired via :func:serve(). Threaded onto :attr:RequestContext.auth_info so platform methods can read scopes, key_id, principal, etc., without parsing transport headers.

Two field families. The flat fields (kind / key_id / principal / scopes) are the v6.0 surface — adopters built against the alpha pass these directly. The Tier 2 v3-identity fields (credential / agent_url / operator / extra) carry the typed AdCP v3 commercial identity context the :class:BuyerAgentRegistry consumes. When an adopter constructs AuthInfo with only the flat fields, __post_init__ synthesizes a typed bearer :class:adcp.decisioning.Credential from them and emits a :class:DeprecationWarning pointing at the adopter callsite.

Deprecation timeline:

  • 4.4.0 (this release) — flat-field synthesis still works but warns. Adopter code stays runnable; the warning points at every callsite that constructs AuthInfo without an explicit credential=.
  • 4.5.0 — synthesis is removed; flat-field-only construction stops auto-populating credential, and the registry dispatch will reject the request with PERMISSION_DENIED. Adopters must construct the typed credential explicitly: AuthInfo(credential=ApiKeyCredential(kind="api_key", key_id=...)) or use the bundled signed-request verifier middleware.

The flat fields themselves stay (they carry useful audit / log context); only the synthesis-from-flat path is on the removal track.

:param kind: One of 'signed_request', 'http_sig', 'bearer', 'api_key', 'oauth', 'mtls', 'derived'. Drives the legacy → credential synthesis. :param key_id: The signing key id (kid) for signed-request / http_sig auth, or the API-key id for bearer auth. :param principal: The authenticated principal label — for signed-request auth this is the verified agent_url (per AdCP v3 convention). :param scopes: Granted scopes / capabilities (OAuth or per-token). :param credential: Typed v3 :class:adcp.decisioning.Credential — the canonical surface the registry dispatches on. When unset, __post_init__ synthesizes from the legacy fields. Adopters wiring v3 auth directly should construct the credential themselves and leave the legacy fields empty. :param agent_url: Verified buyer-agent URL — populated from credential.agent_url when credential is an :class:HttpSigCredential. None for bearer / OAuth / unauthenticated traffic and for kind="signed_request" constructions that don't pass a typed credential (the SDK deliberately refuses to derive agent_url from the unverified principal string — see __post_init__ for the rationale). :param operator: Operator / transport-tenant label — the AdCP v3 operator binding (separate from the buyer agent). Distinct from ToolContext.tenant_id only for adopters running the AAO community proxy in front of a multi-operator deployment; most adopters leave this None. :param extra: Adopter passthrough for auth-layer fields the SDK doesn't model (custom claims, MFA flags, internal session ids).

Static methods

def from_verified_signer(signer: Any,
*,
scopes: list[str] | None = None,
operator: str | None = None,
extra: Mapping[str, Any] | None = None,
max_verified_age_s: float | None = None,
now: float | None = None) ‑> AuthInfo

Build :class:AuthInfo from a :class:VerifiedSigner.

The supported migration target for the AuthInfo flat-field deprecation. Verifier middleware that runs RFC 9421 verification produces a VerifiedSigner carrying the cryptographic claims (key_id, verified_at, optional agent_url); this helper projects that into a typed :class:HttpSigCredential + :class:AuthInfo ready for the commercial-identity registry dispatch.

::

from adcp.signing import (
    VerifyOptions, verify_request_signature,
)
from adcp.decisioning import AuthInfo

signer = verify_request_signature(
    method=request.method,
    url=request.url,
    headers=dict(request.headers),
    body=request.get_data(),
    options=VerifyOptions(...),
)
ctx.metadata["adcp.auth_info"] = AuthInfo.from_verified_signer(
    signer, max_verified_age_s=300.0,
)

The verifier MUST surface signer.agent_url for the commercial-identity registry to dispatch — without it, the framework has no key to look up. The verifier is configured with the agent_url claim shape per the AdCP v3 profile; if it's None here the verifier wasn't told to extract it and this helper raises :class:ValueError with a pointer to the misconfiguration. Buyers don't see this — server boot time error.

:param signer: The :class:VerifiedSigner returned from :func:verify_request_signature(). :param scopes: Granted scopes / capabilities. The verifier doesn't extract these — adopter middleware fills in scopes derived from token introspection or per-key policy. :param operator: Operator label for AdCP v3 multi-operator deployments (AAO community proxy). Most adopters leave None. :param extra: Adopter passthrough. :param max_verified_age_s: Maximum age (seconds) of signer.verified_at permitted at construction time. When set and now() - signer.verified_at > max_verified_age_s, raises :class:ValueError — adopter caching a stale VerifiedSigner and replaying it later fails fast rather than passing a stale-but- cryptographically-valid credential into the registry dispatch. Default None keeps the v6.0-alpha behavior; production sellers SHOULD set a short window (e.g. 300s) matching the RFC 9421 nonce TTL. :param now: Override time.time() for max_verified_age_s comparisons — only useful in tests. Default None uses wall-clock. :raises ValueError: when signer.agent_url is None, or when max_verified_age_s is set and signer.verified_at is older than the window.

Instance variables

var agent_url : str | None
var credential : Credential | None
var extra : Mapping[str, Any]
var key_id : str | None
var kind : str
var operator : str | None
var principal : str | None
var scopes : list[str]
class AuthRequiredError (*,
message: str | None = None,
field: str | None = None,
suggestion: str | None = None,
**details: Any)
Expand source code
class AuthRequiredError(AdcpError):
    """Spec ``AUTH_REQUIRED`` (``recovery='correctable'``).

    Raised when no credentials were presented. (The schema classifies
    this as ``correctable`` — the buyer fixes by attaching credentials
    and retrying.)
    """

    def __init__(
        self,
        *,
        message: str | None = None,
        field: str | None = None,
        suggestion: str | None = None,
        **details: Any,
    ) -> None:
        super().__init__(
            "AUTH_REQUIRED",
            message=message or "Authentication is required to access this resource.",
            recovery="correctable",
            field=field,
            suggestion=suggestion,
            details=dict(details) or None,
        )

Spec AUTH_REQUIRED (recovery='correctable').

Raised when no credentials were presented. (The schema classifies this as correctable — the buyer fixes by attaching credentials and retrying.)

Ancestors

  • AdcpError
  • builtins.Exception
  • builtins.BaseException

Inherited members

class BillingNotPermittedForAgentError (*,
rejected_billing: list[str],
suggested_billing: list[str] | None = None,
message: str | None = None)
Expand source code
class BillingNotPermittedForAgentError(AdcpError):
    """Spec ``BILLING_NOT_PERMITTED_FOR_AGENT`` (``recovery='correctable'``).

    Raised when the seller's ``supported_billing`` capability accepts
    the requested billing model, but the calling buyer agent's
    commercial relationship with the seller does not. The recovery
    shape is deliberately minimal — ``error.details`` MUST conform to
    ``error-details/billing-not-permitted-for-agent.json``
    (``rejected_billing`` plus an optional single ``suggested_billing``
    retry value, typically ``'operator'``).

    :param rejected_billing: The billing values the agent attempted that
        are not permitted (echoed in ``error.details.rejected_billing``).
    :param suggested_billing: Optional single retry value the seller
        recommends (echoed in ``error.details.suggested_billing``).
    """

    def __init__(
        self,
        *,
        rejected_billing: list[str],
        suggested_billing: list[str] | None = None,
        message: str | None = None,
    ) -> None:
        merged: dict[str, Any] = {"rejected_billing": list(rejected_billing)}
        if suggested_billing is not None:
            merged["suggested_billing"] = list(suggested_billing)
        super().__init__(
            "BILLING_NOT_PERMITTED_FOR_AGENT",
            message=(
                message or "Calling agent is not permitted to use the requested billing value."
            ),
            recovery="correctable",
            details=merged,
        )

Spec BILLING_NOT_PERMITTED_FOR_AGENT (recovery='correctable').

Raised when the seller's supported_billing capability accepts the requested billing model, but the calling buyer agent's commercial relationship with the seller does not. The recovery shape is deliberately minimal — error.details MUST conform to error-details/billing-not-permitted-for-agent.json (rejected_billing plus an optional single suggested_billing retry value, typically 'operator').

:param rejected_billing: The billing values the agent attempted that are not permitted (echoed in error.details.rejected_billing). :param suggested_billing: Optional single retry value the seller recommends (echoed in error.details.suggested_billing).

Ancestors

  • AdcpError
  • builtins.Exception
  • builtins.BaseException

Inherited members

class BrandRightsPlatform (*args, **kwargs)
Expand source code
@runtime_checkable
class BrandRightsPlatform(Protocol, Generic[TMeta]):
    """Brand identity discovery + rights licensing.

    Methods may be sync (return ``T`` directly) or async (return
    ``Awaitable[T]``); the dispatch adapter detects via
    :func:`asyncio.iscoroutinefunction` and runs sync methods on a
    thread pool.

    Throw :class:`adcp.decisioning.AdcpError` for buyer-fixable
    REQUEST rejection (``REFERENCE_NOT_FOUND``, ``INVALID_REQUEST``,
    ``BUDGET_TOO_LOW``). For spec-defined GRANT rejection (rights
    unavailable in jurisdiction, talent dispute pending) return the
    :class:`AcquireRightsRejectedResponse` arm so the buyer sees the
    structured wire response with ``reason`` + ``suggestions``.
    """

    def get_brand_identity(
        self,
        req: GetBrandIdentityRequest,
        ctx: RequestContext[TMeta],
    ) -> MaybeAsync[GetBrandIdentitySuccessResponse]:
        """Read brand identity record — ``brand_id``, ``house``,
        localized ``names``, optional logos / industries /
        ``keller_type``. Sync; no async ceremony.

        :raises adcp.decisioning.AdcpError: ``code='REFERENCE_NOT_FOUND'``
            when the brand reference doesn't resolve to an identity
            the platform tracks.
        """
        ...

    def get_rights(
        self,
        req: GetRightsRequest,
        ctx: RequestContext[TMeta],
    ) -> MaybeAsync[GetRightsSuccessResponse]:
        """List rights matching a brand + use query.

        Sync read; framework wraps the response in the wire envelope.
        Returning an empty ``rights`` array is valid (= "no rights
        available for the requested terms"); throw ``AdcpError`` only
        for buyer-fixable rejection (e.g., unsupported jurisdiction).

        Note: the wire field is ``rights``, NOT ``offerings``.
        Adopters who named their internal model ``offerings``
        translate at this seam.
        """
        ...

    def acquire_rights(
        self,
        req: AcquireRightsRequest,
        ctx: RequestContext[TMeta],
    ) -> MaybeAsync[
        AcquireRightsAcquiredResponse | AcquireRightsPendingResponse | AcquireRightsRejectedResponse
    ]:
        """Acquire rights — buyer commits to an offering.

        Three discriminated wire arms:

        * :class:`AcquireRightsAcquiredResponse` — rights granted
          immediately. Carries ``rights_id``, ``status: 'acquired'``,
          ``brand_id``, ``terms``, ``generation_credentials``
          (scoped per-LLM-provider keys), and ``rights_constraint``
          so the buyer can plumb the grant directly into creative
          generation.
        * :class:`AcquireRightsPendingResponse` — clearance pending
          counter-signature, legal review, or rights-holder approval.
          Carries ``rights_id``, ``status: 'pending_approval'``,
          ``brand_id``, plus optional ``detail`` and
          ``estimated_response_time``. **Async delivery is
          webhook-only** — the buyer's ``push_notification_config.url``
          receives the eventual ``Acquired`` or ``Rejected`` outcome.
          The spec does NOT define a polling tool for
          ``acquire_rights``; do not reach for ``tasks_get`` here.
        * :class:`AcquireRightsRejectedResponse` — terminal rejection.
          Carries ``rights_id``, ``status: 'rejected'``, ``brand_id``,
          ``reason``, and optional ``suggestions[]`` for buyer
          remediation.

        Pre-flight (catalog availability, agency authorization) MUST
        run sync regardless of arm — invalid requests reject before
        allocating any state.

        :raises adcp.decisioning.AdcpError: only for buyer-fixable
            REQUEST rejection (``INVALID_REQUEST``,
            ``BUDGET_TOO_LOW``). For GRANT rejection return the
            :class:`AcquireRightsRejectedResponse` arm — that's the
            structured business outcome path.
        """
        ...

    def verify_brand_claim(
        self,
        req: VerifyBrandClaimRequest,
        ctx: RequestContext[TMeta],
    ) -> MaybeAsync[VerifyBrandClaimResponse]:
        """Verify one brand claim.

        Optional beta 3 surface. Implement when the brand agent can
        authoritatively confirm or reject parent, subsidiary, property, or
        trademark claims.
        """
        ...

    def verify_brand_claims(
        self,
        req: VerifyBrandClaimsRequest,
        ctx: RequestContext[TMeta],
    ) -> MaybeAsync[VerifyBrandClaimsResponseBulk]:
        """Verify many brand claims in one call.

        Optional bulk companion to :meth:`verify_brand_claim`.
        """
        ...

Brand identity discovery + rights licensing.

Methods may be sync (return T directly) or async (return Awaitable[T]); the dispatch adapter detects via :func:asyncio.iscoroutinefunction and runs sync methods on a thread pool.

Throw :class:AdcpError for buyer-fixable REQUEST rejection (REFERENCE_NOT_FOUND, INVALID_REQUEST, BUDGET_TOO_LOW). For spec-defined GRANT rejection (rights unavailable in jurisdiction, talent dispute pending) return the :class:AcquireRightsRejectedResponse arm so the buyer sees the structured wire response with reason + suggestions.

Ancestors

  • typing.Protocol
  • typing.Generic

Methods

def acquire_rights(self,
req: AcquireRightsRequest,
ctx: RequestContext[TMeta]) ‑> MaybeAsync[AcquireRightsAcquiredResponse | AcquireRightsPendingResponse | AcquireRightsRejectedResponse]
Expand source code
def acquire_rights(
    self,
    req: AcquireRightsRequest,
    ctx: RequestContext[TMeta],
) -> MaybeAsync[
    AcquireRightsAcquiredResponse | AcquireRightsPendingResponse | AcquireRightsRejectedResponse
]:
    """Acquire rights — buyer commits to an offering.

    Three discriminated wire arms:

    * :class:`AcquireRightsAcquiredResponse` — rights granted
      immediately. Carries ``rights_id``, ``status: 'acquired'``,
      ``brand_id``, ``terms``, ``generation_credentials``
      (scoped per-LLM-provider keys), and ``rights_constraint``
      so the buyer can plumb the grant directly into creative
      generation.
    * :class:`AcquireRightsPendingResponse` — clearance pending
      counter-signature, legal review, or rights-holder approval.
      Carries ``rights_id``, ``status: 'pending_approval'``,
      ``brand_id``, plus optional ``detail`` and
      ``estimated_response_time``. **Async delivery is
      webhook-only** — the buyer's ``push_notification_config.url``
      receives the eventual ``Acquired`` or ``Rejected`` outcome.
      The spec does NOT define a polling tool for
      ``acquire_rights``; do not reach for ``tasks_get`` here.
    * :class:`AcquireRightsRejectedResponse` — terminal rejection.
      Carries ``rights_id``, ``status: 'rejected'``, ``brand_id``,
      ``reason``, and optional ``suggestions[]`` for buyer
      remediation.

    Pre-flight (catalog availability, agency authorization) MUST
    run sync regardless of arm — invalid requests reject before
    allocating any state.

    :raises adcp.decisioning.AdcpError: only for buyer-fixable
        REQUEST rejection (``INVALID_REQUEST``,
        ``BUDGET_TOO_LOW``). For GRANT rejection return the
        :class:`AcquireRightsRejectedResponse` arm — that's the
        structured business outcome path.
    """
    ...

Acquire rights — buyer commits to an offering.

Three discriminated wire arms:

  • :class:AcquireRightsAcquiredResponse — rights granted immediately. Carries rights_id, status: 'acquired', brand_id, terms, generation_credentials (scoped per-LLM-provider keys), and rights_constraint so the buyer can plumb the grant directly into creative generation.
  • :class:AcquireRightsPendingResponse — clearance pending counter-signature, legal review, or rights-holder approval. Carries rights_id, status: 'pending_approval', brand_id, plus optional detail and estimated_response_time. Async delivery is webhook-only — the buyer's push_notification_config.url receives the eventual Acquired or Rejected outcome. The spec does NOT define a polling tool for acquire_rights; do not reach for tasks_get here.
  • :class:AcquireRightsRejectedResponse — terminal rejection. Carries rights_id, status: 'rejected', brand_id, reason, and optional suggestions[] for buyer remediation.

Pre-flight (catalog availability, agency authorization) MUST run sync regardless of arm — invalid requests reject before allocating any state.

:raises adcp.decisioning.AdcpError: only for buyer-fixable REQUEST rejection (INVALID_REQUEST, BUDGET_TOO_LOW). For GRANT rejection return the :class:AcquireRightsRejectedResponse arm — that's the structured business outcome path.

def get_brand_identity(self,
req: GetBrandIdentityRequest,
ctx: RequestContext[TMeta]) ‑> MaybeAsync[GetBrandIdentitySuccessResponse]
Expand source code
def get_brand_identity(
    self,
    req: GetBrandIdentityRequest,
    ctx: RequestContext[TMeta],
) -> MaybeAsync[GetBrandIdentitySuccessResponse]:
    """Read brand identity record — ``brand_id``, ``house``,
    localized ``names``, optional logos / industries /
    ``keller_type``. Sync; no async ceremony.

    :raises adcp.decisioning.AdcpError: ``code='REFERENCE_NOT_FOUND'``
        when the brand reference doesn't resolve to an identity
        the platform tracks.
    """
    ...

Read brand identity record — brand_id, house, localized names, optional logos / industries / keller_type. Sync; no async ceremony.

:raises adcp.decisioning.AdcpError: code='REFERENCE_NOT_FOUND' when the brand reference doesn't resolve to an identity the platform tracks.

def get_rights(self,
req: GetRightsRequest,
ctx: RequestContext[TMeta]) ‑> MaybeAsync[GetRightsSuccessResponse]
Expand source code
def get_rights(
    self,
    req: GetRightsRequest,
    ctx: RequestContext[TMeta],
) -> MaybeAsync[GetRightsSuccessResponse]:
    """List rights matching a brand + use query.

    Sync read; framework wraps the response in the wire envelope.
    Returning an empty ``rights`` array is valid (= "no rights
    available for the requested terms"); throw ``AdcpError`` only
    for buyer-fixable rejection (e.g., unsupported jurisdiction).

    Note: the wire field is ``rights``, NOT ``offerings``.
    Adopters who named their internal model ``offerings``
    translate at this seam.
    """
    ...

List rights matching a brand + use query.

Sync read; framework wraps the response in the wire envelope. Returning an empty rights array is valid (= "no rights available for the requested terms"); throw AdcpError only for buyer-fixable rejection (e.g., unsupported jurisdiction).

Note: the wire field is rights, NOT offerings. Adopters who named their internal model offerings translate at this seam.

def verify_brand_claim(self,
req: VerifyBrandClaimRequest,
ctx: RequestContext[TMeta]) ‑> MaybeAsync[VerifyBrandClaimResponse]
Expand source code
def verify_brand_claim(
    self,
    req: VerifyBrandClaimRequest,
    ctx: RequestContext[TMeta],
) -> MaybeAsync[VerifyBrandClaimResponse]:
    """Verify one brand claim.

    Optional beta 3 surface. Implement when the brand agent can
    authoritatively confirm or reject parent, subsidiary, property, or
    trademark claims.
    """
    ...

Verify one brand claim.

Optional beta 3 surface. Implement when the brand agent can authoritatively confirm or reject parent, subsidiary, property, or trademark claims.

def verify_brand_claims(self,
req: VerifyBrandClaimsRequest,
ctx: RequestContext[TMeta]) ‑> MaybeAsync[VerifyBrandClaimsResponseBulk]
Expand source code
def verify_brand_claims(
    self,
    req: VerifyBrandClaimsRequest,
    ctx: RequestContext[TMeta],
) -> MaybeAsync[VerifyBrandClaimsResponseBulk]:
    """Verify many brand claims in one call.

    Optional bulk companion to :meth:`verify_brand_claim`.
    """
    ...

Verify many brand claims in one call.

Optional bulk companion to :meth:verify_brand_claim.

class BuyerAgent (agent_url: str,
display_name: str,
status: BuyerAgentStatus | str,
billing_capabilities: frozenset[BillingMode] = frozenset({'operator'}),
default_account_terms: BuyerAgentDefaultTerms | None = None,
allowed_brands: frozenset[str] | None = None,
ext: Mapping[str, Any] = <factory>)
Expand source code
@dataclass(frozen=True)
class BuyerAgent:
    """Commercial identity for a buyer agent we recognize.

    ``agent_url`` is the canonical, on-the-wire identifier — treat
    like a public key: stable enough that rotation requires explicit
    re-onboarding. No separate internal id is exposed; adopters
    attaching internal ids do so via :attr:`ext`.

    .. note::
        Frozen — the registry returns immutable snapshots. To mutate
        (status change, terms update), the adopter writes to their
        durable store and re-resolves on the next request. Mutation
        in-place would create cross-request leakage between concurrent
        platform method invocations sharing the same registry cache.
    """

    agent_url: str
    display_name: str
    status: BuyerAgentStatus | str

    #: Set of legal ``billing`` values for accounts under this agent.
    #: Pre-trust beta default: ``frozenset({"operator"})`` (passthrough
    #: only — agent has no payments relationship). Agent-billable adopters
    #: include ``"agent"`` and/or ``"advertiser"``.
    #:
    #: Real seller business models can permit MULTIPLE modes — e.g., an
    #: agency that's direct-billed for owned brands but
    #: ``operator``-passthrough for agency-mediated brands. The set
    #: shape preserves that.
    billing_capabilities: frozenset[BillingMode] = frozenset({"operator"})

    default_account_terms: BuyerAgentDefaultTerms | None = None

    #: Pre-RFC allowlist of brand domains this agent can transact for.
    #: Once :class:`BrandAuthorizationResolver` lands (Tier 3, gated on
    #: ADCP #3690), this becomes a static fallback layered on top of
    #: per-request authz against ``brand.json``. Both checks AND when
    #: both are configured.
    allowed_brands: frozenset[str] | None = None

    #: Adopter passthrough for internal ids, audit metadata, anything
    #: the SDK doesn't model.
    ext: Mapping[str, Any] = field(default_factory=dict)

Commercial identity for a buyer agent we recognize.

agent_url is the canonical, on-the-wire identifier — treat like a public key: stable enough that rotation requires explicit re-onboarding. No separate internal id is exposed; adopters attaching internal ids do so via :attr:ext.

Note

Frozen — the registry returns immutable snapshots. To mutate (status change, terms update), the adopter writes to their durable store and re-resolves on the next request. Mutation in-place would create cross-request leakage between concurrent platform method invocations sharing the same registry cache.

Instance variables

var agent_url : str
var allowed_brands : frozenset[str] | None

Pre-RFC allowlist of brand domains this agent can transact for. Once :class:BrandAuthorizationResolver lands (Tier 3, gated on ADCP #3690), this becomes a static fallback layered on top of per-request authz against brand.json. Both checks AND when both are configured.

var billing_capabilities : frozenset[typing.Literal['operator', 'agent', 'advertiser']]

Real seller business models can permit MULTIPLE modes — e.g., an agency that's direct-billed for owned brands but operator-passthrough for agency-mediated brands. The set shape preserves that.

var default_account_termsBuyerAgentDefaultTerms | None
var display_name : str
var ext : Mapping[str, typing.Any]

Adopter passthrough for internal ids, audit metadata, anything the SDK doesn't model.

var status : Literal['active', 'suspended', 'blocked'] | str
class BuyerAgentDefaultTerms (rate_card: str | None = None,
payment_terms: str | None = None,
credit_limit: Mapping[str, Any] | None = None,
billing_entity: Mapping[str, Any] | None = None)
Expand source code
@dataclass(frozen=True)
class BuyerAgentDefaultTerms:
    """Commercial defaults applied when accounts are provisioned under
    an agent. Each field optional; the framework merges these with
    per-request overrides under sparse-merge semantics: explicit
    non-null per-request fields override; ``None`` fields fall through
    to the agent's default.

    Mirrors the spec ``Account`` shape on ``schemas/cache/core/account.json``
    (3.0-compliant, 3.1-ready — ``billing_entity`` is the 3.1 field
    that 3.0-only adopters can leave ``None``).
    """

    rate_card: str | None = None
    payment_terms: str | None = None
    credit_limit: Mapping[str, Any] | None = None
    billing_entity: Mapping[str, Any] | None = None

Commercial defaults applied when accounts are provisioned under an agent. Each field optional; the framework merges these with per-request overrides under sparse-merge semantics: explicit non-null per-request fields override; None fields fall through to the agent's default.

Mirrors the spec Account shape on schemas/cache/core/account.json (3.0-compliant, 3.1-ready — billing_entity is the 3.1 field that 3.0-only adopters can leave None).

Instance variables

var billing_entity : collections.abc.Mapping[str, typing.Any] | None
var credit_limit : collections.abc.Mapping[str, typing.Any] | None
var payment_terms : str | None
var rate_card : str | None
class BuyerAgentRegistry (*args, **kwargs)
Expand source code
@runtime_checkable
class BuyerAgentRegistry(Protocol):
    """Adopter-implemented mapping from credential → :class:`BuyerAgent`.

    The framework calls one method per request, dispatched by
    credential kind:

    * :meth:`resolve_by_agent_url` for cryptographically-verified
      signed traffic — the framework already validated the RFC 9421
      signature and extracted ``agent_url`` from the JWK claim.
    * :meth:`resolve_by_credential` for bearer / API-key / OAuth —
      the adopter looks up against their existing key table.

    Returning ``None`` rejects the request with ``PERMISSION_DENIED``
    (``details`` omitted — the unrecognized-agent path MUST be
    indistinguishable on the wire from the recognized-but-denied path
    to prevent cross-tenant onboarding enumeration). Adopters
    typically construct
    via the :func:`signing_only_registry` / :func:`bearer_only_registry`
    / :func:`mixed_registry` factories rather than implementing the
    Protocol directly — the factories carry the posture choice in
    their construction so it's never ambiguous which method is
    intentionally unimplemented.
    """

    async def resolve_by_agent_url(self, agent_url: str) -> BuyerAgent | None:
        """Resolve a verified ``agent_url``. Adopters do NOT re-verify
        the signature here — the framework has already done so. This
        method just looks up the counterparty row in the adopter's
        commercial registry."""

    async def resolve_by_credential(self, credential: Credential) -> BuyerAgent | None:
        """Resolve a bearer / API-key / OAuth credential. For
        pre-trust beta sellers, this IS the existing key table just
        exposed through a typed surface."""

Adopter-implemented mapping from credential → :class:BuyerAgent.

The framework calls one method per request, dispatched by credential kind:

  • :meth:resolve_by_agent_url for cryptographically-verified signed traffic — the framework already validated the RFC 9421 signature and extracted agent_url from the JWK claim.
  • :meth:resolve_by_credential for bearer / API-key / OAuth — the adopter looks up against their existing key table.

Returning None rejects the request with PERMISSION_DENIED (details omitted — the unrecognized-agent path MUST be indistinguishable on the wire from the recognized-but-denied path to prevent cross-tenant onboarding enumeration). Adopters typically construct via the :func:signing_only_registry() / :func:bearer_only_registry() / :func:mixed_registry() factories rather than implementing the Protocol directly — the factories carry the posture choice in their construction so it's never ambiguous which method is intentionally unimplemented.

Ancestors

  • typing.Protocol
  • typing.Generic

Methods

async def resolve_by_agent_url(self, agent_url: str) ‑> BuyerAgent | None
Expand source code
async def resolve_by_agent_url(self, agent_url: str) -> BuyerAgent | None:
    """Resolve a verified ``agent_url``. Adopters do NOT re-verify
    the signature here — the framework has already done so. This
    method just looks up the counterparty row in the adopter's
    commercial registry."""

Resolve a verified agent_url. Adopters do NOT re-verify the signature here — the framework has already done so. This method just looks up the counterparty row in the adopter's commercial registry.

async def resolve_by_credential(self, credential: Credential) ‑> BuyerAgent | None
Expand source code
async def resolve_by_credential(self, credential: Credential) -> BuyerAgent | None:
    """Resolve a bearer / API-key / OAuth credential. For
    pre-trust beta sellers, this IS the existing key table just
    exposed through a typed surface."""

Resolve a bearer / API-key / OAuth credential. For pre-trust beta sellers, this IS the existing key table just exposed through a typed surface.

class CachingBuyerAgentRegistry (inner: BuyerAgentRegistry,
*,
ttl_seconds: float = 60.0,
max_entries: int = 4096,
hit_callback: MetricCallback | None = None,
audit_sink: AuditSink | None = None,
sink_timeout_seconds: float = 5.0,
time_source: Callable[[], float] = <built-in function monotonic>)
Expand source code
class CachingBuyerAgentRegistry:
    """In-process TTL + LRU cache wrapping any
    :class:`BuyerAgentRegistry`.

    Caches BOTH positive and negative resolutions. Negative caching
    is the load-shaping move — an enumeration probe walking arbitrary
    ``agent_url`` strings would otherwise hit the DB once per probe;
    with negative caching it hits the DB at most once per
    ``(tenant, agent_url)`` pair within the TTL window.

    :param inner: The wrapped :class:`BuyerAgentRegistry` — typically
        a :class:`RateLimitedBuyerAgentRegistry` or a SQL-backed impl.
    :param ttl_seconds: How long a resolution stays cached. Default
        60s — long enough to absorb burst traffic during a media-buy
        flight, short enough that a status flip (active → suspended)
        propagates within a minute.
    :param max_entries: LRU cap. Default 4096 — bounded so an
        enumeration probe can't blow up memory, large enough that
        steady-state hot agents stay resident.
    :param hit_callback: Optional counter-style metric hook fired
        on every cache event. Receives ``"hit"`` / ``"miss"`` /
        ``"negative_hit"`` / ``"expired"``.
    :param audit_sink: Optional audit sink — emits ``cached_hit`` /
        ``cached_miss`` events for served-from-cache outcomes.
        Misses fall through to the inner wrapper which emits its
        own event for the actual resolution.
    :param time_source: Override for tests — defaults to
        :func:`time.monotonic`. Use a fake clock to drive TTL expiry.

    Concurrency
    -----------

    The cache uses an ``asyncio.Lock`` to serialize entry insertion
    and LRU promotion. The lock is held only across the dict update —
    the inner ``resolve_*`` call happens OUTSIDE the lock, so
    concurrent misses against different keys race the inner backend
    in parallel. A burst of concurrent misses against the SAME key
    will all hit the backend (no thundering-herd dedup) — adopters
    needing single-flight wrap with their own dedup. The simpler
    "all races to the backend" shape avoids the complexity of an
    in-flight registry while accepting bounded duplicate work.
    """

    def __init__(
        self,
        inner: BuyerAgentRegistry,
        *,
        ttl_seconds: float = 60.0,
        max_entries: int = 4096,
        hit_callback: MetricCallback | None = None,
        audit_sink: AuditSink | None = None,
        sink_timeout_seconds: float = 5.0,
        time_source: Callable[[], float] = time.monotonic,
    ) -> None:
        if ttl_seconds <= 0:
            raise ValueError(f"ttl_seconds must be > 0, got {ttl_seconds!r}")
        if max_entries <= 0:
            raise ValueError(f"max_entries must be > 0, got {max_entries!r}")
        self._inner = inner
        self._ttl = ttl_seconds
        self._max = max_entries
        self._hit_cb = hit_callback
        self._sink = audit_sink
        self._sink_timeout = sink_timeout_seconds
        self._now = time_source
        # OrderedDict gives us O(1) move-to-end for LRU semantics on
        # every hit, plus O(1) popitem(last=False) for eviction.
        self._cache: OrderedDict[tuple[str | None, str], _CacheEntry] = OrderedDict()
        self._lock = asyncio.Lock()

    async def resolve_by_agent_url(self, agent_url: str) -> BuyerAgent | None:
        """Resolve via cache, falling through to ``inner`` on miss."""
        tenant_id = _current_tenant_id()
        lookup_key = f"agent_url:{agent_url}"
        key = (tenant_id, lookup_key)
        cached = await self._lookup(key)
        if cached is not None:
            await _emit_audit(
                self._sink,
                operation="buyer_agent_registry.resolve_by_agent_url",
                outcome="cached_hit" if cached.value is not None else "cached_miss",
                lookup_key=lookup_key,
                tenant_id=tenant_id,
                agent=cached.value,
                sink_timeout_seconds=self._sink_timeout,
            )
            return cached.value
        result = await self._inner.resolve_by_agent_url(agent_url)
        await self._store(key, result)
        return result

    async def resolve_by_credential(self, credential: Credential) -> BuyerAgent | None:
        """Resolve via cache, falling through to ``inner`` on miss."""
        tenant_id = _current_tenant_id()
        lookup_key = _credential_key(credential)
        key = (tenant_id, lookup_key)
        cached = await self._lookup(key)
        if cached is not None:
            await _emit_audit(
                self._sink,
                operation="buyer_agent_registry.resolve_by_credential",
                outcome="cached_hit" if cached.value is not None else "cached_miss",
                lookup_key=lookup_key,
                tenant_id=tenant_id,
                agent=cached.value,
                sink_timeout_seconds=self._sink_timeout,
            )
            return cached.value
        result = await self._inner.resolve_by_credential(credential)
        await self._store(key, result)
        return result

    async def _lookup(self, key: tuple[str | None, str]) -> _CacheEntry | None:
        """Return a non-expired cache entry for ``key`` or ``None``.

        Lock held only for the dict mutation; the entry itself is
        immutable so dropping the lock before returning is safe.
        """
        async with self._lock:
            entry = self._cache.get(key)
            if entry is None:
                self._fire("miss")
                return None
            if entry.expires_at <= self._now():
                # Expired — evict and treat as miss so the caller
                # re-fetches from the inner backend.
                del self._cache[key]
                self._fire("expired")
                return None
            # Promote to most-recently-used.
            self._cache.move_to_end(key)
            self._fire("hit" if entry.value is not None else "negative_hit")
            return entry

    async def _store(self, key: tuple[str | None, str], value: BuyerAgent | None) -> None:
        async with self._lock:
            self._cache[key] = _CacheEntry(
                value=value,
                expires_at=self._now() + self._ttl,
            )
            self._cache.move_to_end(key)
            while len(self._cache) > self._max:
                self._cache.popitem(last=False)

    def _fire(self, label: str) -> None:
        if self._hit_cb is not None:
            try:
                self._hit_cb(label)
            except Exception:  # noqa: BLE001 — metric callback must not break dispatch
                logger.warning(
                    "[adcp.registry_cache] hit_callback raised for label=%s",
                    label,
                    exc_info=True,
                )

    async def invalidate(self, *, tenant_id: str | None, lookup_key: str) -> None:
        """Drop a single ``(tenant_id, lookup_key)`` entry.

        Called by admin / management code on a status flip — e.g.,
        when an operator suspends an agent the management API calls
        ``invalidate`` so the next dispatch sees the new status
        immediately rather than waiting for TTL expiry.

        Async + lock-held because admin paths run concurrently with
        dispatch traffic; mutating the underlying ``OrderedDict``
        while ``_store`` is reordering or evicting can corrupt the
        LRU order or raise ``RuntimeError: OrderedDict mutated
        during iteration``.
        """
        async with self._lock:
            self._cache.pop((tenant_id, lookup_key), None)

    def clear_sync(self) -> None:
        """Drop every cached entry from a sync context.

        Safe to call from any thread or coroutine without an event
        loop. Atomic via the GIL on :meth:`OrderedDict.clear` — no
        lock acquired, so a concurrent async ``_lookup`` / ``_store``
        may observe either the pre-clear or post-clear dict. The
        worst case is one extra round-trip to the inner registry on
        the next resolve, which is exactly what an invalidation is
        supposed to cause.

        Use cases: mutation-observer hooks wired by
        :meth:`PgBuyerAgentRegistry.with_caching`, post-config-reload
        flushes from sync admin code.

        Full-clear (rather than per-key drop) trades a small amount
        of over-invalidation for simplicity: mutations are admin-rare
        and the next traffic burst rebuilds the working set within
        TTL. Adopters needing finer-grained invalidation still have
        :meth:`invalidate` for explicit ``(tenant_id, lookup_key)``
        drops.
        """
        self._cache.clear()

    async def clear(self) -> None:
        """Drop every cached entry. For tests + post-config-reload.

        Async + lock-held for the same reason as :meth:`invalidate`.
        """
        async with self._lock:
            self._cache.clear()

In-process TTL + LRU cache wrapping any :class:BuyerAgentRegistry.

Caches BOTH positive and negative resolutions. Negative caching is the load-shaping move — an enumeration probe walking arbitrary agent_url strings would otherwise hit the DB once per probe; with negative caching it hits the DB at most once per (tenant, agent_url) pair within the TTL window.

:param inner: The wrapped :class:BuyerAgentRegistry — typically a :class:RateLimitedBuyerAgentRegistry or a SQL-backed impl. :param ttl_seconds: How long a resolution stays cached. Default 60s — long enough to absorb burst traffic during a media-buy flight, short enough that a status flip (active → suspended) propagates within a minute. :param max_entries: LRU cap. Default 4096 — bounded so an enumeration probe can't blow up memory, large enough that steady-state hot agents stay resident. :param hit_callback: Optional counter-style metric hook fired on every cache event. Receives "hit" / "miss" / "negative_hit" / "expired". :param audit_sink: Optional audit sink — emits cached_hit / cached_miss events for served-from-cache outcomes. Misses fall through to the inner wrapper which emits its own event for the actual resolution. :param time_source: Override for tests — defaults to :func:time.monotonic. Use a fake clock to drive TTL expiry.

Concurrency

The cache uses an asyncio.Lock to serialize entry insertion and LRU promotion. The lock is held only across the dict update — the inner resolve_* call happens OUTSIDE the lock, so concurrent misses against different keys race the inner backend in parallel. A burst of concurrent misses against the SAME key will all hit the backend (no thundering-herd dedup) — adopters needing single-flight wrap with their own dedup. The simpler "all races to the backend" shape avoids the complexity of an in-flight registry while accepting bounded duplicate work.

Methods

async def clear(self) ‑> None
Expand source code
async def clear(self) -> None:
    """Drop every cached entry. For tests + post-config-reload.

    Async + lock-held for the same reason as :meth:`invalidate`.
    """
    async with self._lock:
        self._cache.clear()

Drop every cached entry. For tests + post-config-reload.

Async + lock-held for the same reason as :meth:invalidate.

def clear_sync(self) ‑> None
Expand source code
def clear_sync(self) -> None:
    """Drop every cached entry from a sync context.

    Safe to call from any thread or coroutine without an event
    loop. Atomic via the GIL on :meth:`OrderedDict.clear` — no
    lock acquired, so a concurrent async ``_lookup`` / ``_store``
    may observe either the pre-clear or post-clear dict. The
    worst case is one extra round-trip to the inner registry on
    the next resolve, which is exactly what an invalidation is
    supposed to cause.

    Use cases: mutation-observer hooks wired by
    :meth:`PgBuyerAgentRegistry.with_caching`, post-config-reload
    flushes from sync admin code.

    Full-clear (rather than per-key drop) trades a small amount
    of over-invalidation for simplicity: mutations are admin-rare
    and the next traffic burst rebuilds the working set within
    TTL. Adopters needing finer-grained invalidation still have
    :meth:`invalidate` for explicit ``(tenant_id, lookup_key)``
    drops.
    """
    self._cache.clear()

Drop every cached entry from a sync context.

Safe to call from any thread or coroutine without an event loop. Atomic via the GIL on :meth:OrderedDict.clear — no lock acquired, so a concurrent async _lookup / _store may observe either the pre-clear or post-clear dict. The worst case is one extra round-trip to the inner registry on the next resolve, which is exactly what an invalidation is supposed to cause.

Use cases: mutation-observer hooks wired by :meth:PgBuyerAgentRegistry.with_caching, post-config-reload flushes from sync admin code.

Full-clear (rather than per-key drop) trades a small amount of over-invalidation for simplicity: mutations are admin-rare and the next traffic burst rebuilds the working set within TTL. Adopters needing finer-grained invalidation still have :meth:invalidate for explicit (tenant_id, lookup_key) drops.

async def invalidate(self, *, tenant_id: str | None, lookup_key: str) ‑> None
Expand source code
async def invalidate(self, *, tenant_id: str | None, lookup_key: str) -> None:
    """Drop a single ``(tenant_id, lookup_key)`` entry.

    Called by admin / management code on a status flip — e.g.,
    when an operator suspends an agent the management API calls
    ``invalidate`` so the next dispatch sees the new status
    immediately rather than waiting for TTL expiry.

    Async + lock-held because admin paths run concurrently with
    dispatch traffic; mutating the underlying ``OrderedDict``
    while ``_store`` is reordering or evicting can corrupt the
    LRU order or raise ``RuntimeError: OrderedDict mutated
    during iteration``.
    """
    async with self._lock:
        self._cache.pop((tenant_id, lookup_key), None)

Drop a single (tenant_id, lookup_key) entry.

Called by admin / management code on a status flip — e.g., when an operator suspends an agent the management API calls invalidate so the next dispatch sees the new status immediately rather than waiting for TTL expiry.

Async + lock-held because admin paths run concurrently with dispatch traffic; mutating the underlying OrderedDict while _store is reordering or evicting can corrupt the LRU order or raise RuntimeError: OrderedDict mutated during iteration.

async def resolve_by_agent_url(self, agent_url: str) ‑> BuyerAgent | None
Expand source code
async def resolve_by_agent_url(self, agent_url: str) -> BuyerAgent | None:
    """Resolve via cache, falling through to ``inner`` on miss."""
    tenant_id = _current_tenant_id()
    lookup_key = f"agent_url:{agent_url}"
    key = (tenant_id, lookup_key)
    cached = await self._lookup(key)
    if cached is not None:
        await _emit_audit(
            self._sink,
            operation="buyer_agent_registry.resolve_by_agent_url",
            outcome="cached_hit" if cached.value is not None else "cached_miss",
            lookup_key=lookup_key,
            tenant_id=tenant_id,
            agent=cached.value,
            sink_timeout_seconds=self._sink_timeout,
        )
        return cached.value
    result = await self._inner.resolve_by_agent_url(agent_url)
    await self._store(key, result)
    return result

Resolve via cache, falling through to inner on miss.

async def resolve_by_credential(self, credential: Credential) ‑> BuyerAgent | None
Expand source code
async def resolve_by_credential(self, credential: Credential) -> BuyerAgent | None:
    """Resolve via cache, falling through to ``inner`` on miss."""
    tenant_id = _current_tenant_id()
    lookup_key = _credential_key(credential)
    key = (tenant_id, lookup_key)
    cached = await self._lookup(key)
    if cached is not None:
        await _emit_audit(
            self._sink,
            operation="buyer_agent_registry.resolve_by_credential",
            outcome="cached_hit" if cached.value is not None else "cached_miss",
            lookup_key=lookup_key,
            tenant_id=tenant_id,
            agent=cached.value,
            sink_timeout_seconds=self._sink_timeout,
        )
        return cached.value
    result = await self._inner.resolve_by_credential(credential)
    await self._store(key, result)
    return result

Resolve via cache, falling through to inner on miss.

class CampaignGovernancePlatform (*args, **kwargs)
Expand source code
@runtime_checkable
class CampaignGovernancePlatform(Protocol, Generic[TMeta]):
    """Runtime governance decisioning for advertiser campaigns.

    A decision API: the agent inspects a proposed action (or running
    delivery) and returns ``approved``, ``denied``, or ``conditions``
    (approved-if). Status changes (plan moving from
    ``pending_approval`` → ``active`` → ``closed``) flow via
    ``ctx.publish_status_change(resource_type='plan', ...)``.

    Methods may be sync (return ``T`` directly) or async (return
    ``Awaitable[T]``); the dispatch adapter detects via
    :func:`asyncio.iscoroutinefunction` and runs sync methods on a
    thread pool.

    Throw :class:`adcp.decisioning.AdcpError` for buyer-fixable
    rejection (``PLAN_NOT_FOUND``, ``INVALID_REQUEST``, etc.). Use
    the :meth:`check_governance` response ``status: 'denied'`` for
    governance decisions that ARE the answer (the plan exists and
    the agent is rejecting the action) — that's a legitimate
    business outcome, not an error.
    """

    def check_governance(
        self,
        req: CheckGovernanceRequest,
        ctx: RequestContext[TMeta],
    ) -> MaybeAsync[CheckGovernanceResponse]:
        """Runtime governance decision.

        Buyer (or seller, on the seller's behalf) sends a proposed
        action; the agent inspects it against the plan and returns
        approved / denied / conditions.

        The ``phase`` field discriminates the context:

        * ``'intent'`` — pre-action; agent decides whether the
          proposed action is permitted at all.
        * ``'delivery'`` — running campaign with actuals; agent
          decides whether to allow further spend / new packages.
        * ``'reconciliation'`` — post-flight; agent confirms the
          campaign's outcome matches what was approved.

        The agent's logic varies by phase.

        :raises adcp.decisioning.AdcpError: for buyer-fixable
            rejection (``PLAN_NOT_FOUND``, ``INVALID_REQUEST``).
            ``status: 'denied'`` on the response is the
            governance-decision-as-answer path — not an error.
        """
        ...

    def sync_plans(
        self,
        req: SyncPlansRequest,
        ctx: RequestContext[TMeta],
    ) -> MaybeAsync[SyncPlansResponse]:
        """Plan CRUD with delta upsert semantics.

        Buyers sync their campaign plans into the governance agent so
        the agent can maintain spend authority + delivery context.
        The agent tracks plan state across the campaign lifecycle
        (pending_approval → active → closed); transitions are emitted
        via ``ctx.publish_status_change(resource_type='plan', ...)``.
        """
        ...

    def report_plan_outcome(
        self,
        req: ReportPlanOutcomeRequest,
        ctx: RequestContext[TMeta],
    ) -> MaybeAsync[ReportPlanOutcomeResponse]:
        """Outcome reporting from sellers.

        Sellers report what actually happened (impressions delivered,
        spend incurred, status transitions) so the agent can
        calibrate future decisions. Typically called at terminal
        plan states or at agreed reconciliation cadences.
        """
        ...

    def get_plan_audit_logs(
        self,
        req: GetPlanAuditLogsRequest,
        ctx: RequestContext[TMeta],
    ) -> MaybeAsync[GetPlanAuditLogsResponse]:
        """Audit log read.

        Returns the chronological history of governance decisions +
        outcome reports for a plan. Buyers and operators use this to
        reconstruct who approved what + when, what conditions were
        attached, and what the seller reported.
        """
        ...

Runtime governance decisioning for advertiser campaigns.

A decision API: the agent inspects a proposed action (or running delivery) and returns approved, denied, or conditions (approved-if). Status changes (plan moving from pending_approvalactiveclosed) flow via ctx.publish_status_change(resource_type='plan', ...).

Methods may be sync (return T directly) or async (return Awaitable[T]); the dispatch adapter detects via :func:asyncio.iscoroutinefunction and runs sync methods on a thread pool.

Throw :class:AdcpError for buyer-fixable rejection (PLAN_NOT_FOUND, INVALID_REQUEST, etc.). Use the :meth:check_governance response status: 'denied' for governance decisions that ARE the answer (the plan exists and the agent is rejecting the action) — that's a legitimate business outcome, not an error.

Ancestors

  • typing.Protocol
  • typing.Generic

Methods

def check_governance(self,
req: CheckGovernanceRequest,
ctx: RequestContext[TMeta]) ‑> MaybeAsync[CheckGovernanceResponse]
Expand source code
def check_governance(
    self,
    req: CheckGovernanceRequest,
    ctx: RequestContext[TMeta],
) -> MaybeAsync[CheckGovernanceResponse]:
    """Runtime governance decision.

    Buyer (or seller, on the seller's behalf) sends a proposed
    action; the agent inspects it against the plan and returns
    approved / denied / conditions.

    The ``phase`` field discriminates the context:

    * ``'intent'`` — pre-action; agent decides whether the
      proposed action is permitted at all.
    * ``'delivery'`` — running campaign with actuals; agent
      decides whether to allow further spend / new packages.
    * ``'reconciliation'`` — post-flight; agent confirms the
      campaign's outcome matches what was approved.

    The agent's logic varies by phase.

    :raises adcp.decisioning.AdcpError: for buyer-fixable
        rejection (``PLAN_NOT_FOUND``, ``INVALID_REQUEST``).
        ``status: 'denied'`` on the response is the
        governance-decision-as-answer path — not an error.
    """
    ...

Runtime governance decision.

Buyer (or seller, on the seller's behalf) sends a proposed action; the agent inspects it against the plan and returns approved / denied / conditions.

The phase field discriminates the context:

  • 'intent' — pre-action; agent decides whether the proposed action is permitted at all.
  • 'delivery' — running campaign with actuals; agent decides whether to allow further spend / new packages.
  • 'reconciliation' — post-flight; agent confirms the campaign's outcome matches what was approved.

The agent's logic varies by phase.

:raises adcp.decisioning.AdcpError: for buyer-fixable rejection (PLAN_NOT_FOUND, INVALID_REQUEST). status: 'denied' on the response is the governance-decision-as-answer path — not an error.

def get_plan_audit_logs(self,
req: GetPlanAuditLogsRequest,
ctx: RequestContext[TMeta]) ‑> MaybeAsync[GetPlanAuditLogsResponse]
Expand source code
def get_plan_audit_logs(
    self,
    req: GetPlanAuditLogsRequest,
    ctx: RequestContext[TMeta],
) -> MaybeAsync[GetPlanAuditLogsResponse]:
    """Audit log read.

    Returns the chronological history of governance decisions +
    outcome reports for a plan. Buyers and operators use this to
    reconstruct who approved what + when, what conditions were
    attached, and what the seller reported.
    """
    ...

Audit log read.

Returns the chronological history of governance decisions + outcome reports for a plan. Buyers and operators use this to reconstruct who approved what + when, what conditions were attached, and what the seller reported.

def report_plan_outcome(self,
req: ReportPlanOutcomeRequest,
ctx: RequestContext[TMeta]) ‑> MaybeAsync[ReportPlanOutcomeResponse]
Expand source code
def report_plan_outcome(
    self,
    req: ReportPlanOutcomeRequest,
    ctx: RequestContext[TMeta],
) -> MaybeAsync[ReportPlanOutcomeResponse]:
    """Outcome reporting from sellers.

    Sellers report what actually happened (impressions delivered,
    spend incurred, status transitions) so the agent can
    calibrate future decisions. Typically called at terminal
    plan states or at agreed reconciliation cadences.
    """
    ...

Outcome reporting from sellers.

Sellers report what actually happened (impressions delivered, spend incurred, status transitions) so the agent can calibrate future decisions. Typically called at terminal plan states or at agreed reconciliation cadences.

def sync_plans(self,
req: SyncPlansRequest,
ctx: RequestContext[TMeta]) ‑> MaybeAsync[SyncPlansResponse]
Expand source code
def sync_plans(
    self,
    req: SyncPlansRequest,
    ctx: RequestContext[TMeta],
) -> MaybeAsync[SyncPlansResponse]:
    """Plan CRUD with delta upsert semantics.

    Buyers sync their campaign plans into the governance agent so
    the agent can maintain spend authority + delivery context.
    The agent tracks plan state across the campaign lifecycle
    (pending_approval → active → closed); transitions are emitted
    via ``ctx.publish_status_change(resource_type='plan', ...)``.
    """
    ...

Plan CRUD with delta upsert semantics.

Buyers sync their campaign plans into the governance agent so the agent can maintain spend authority + delivery context. The agent tracks plan state across the campaign lifecycle (pending_approval → active → closed); transitions are emitted via ctx.publish_status_change(resource_type='plan', ...).

class CapabilityOverlap (pricing_models: frozenset[str] | None = None,
targeting_dimensions: frozenset[str] | None = None,
delivery_types: frozenset[str] | None = None,
signal_types: frozenset[str] | None = None)
Expand source code
@dataclass(frozen=True)
class CapabilityOverlap:
    """Per-product subset of wire capability flags.

    Buyer requests asking for capabilities outside this overlap are
    rejected by the framework before the adapter sees them (see
    :func:`adcp.decisioning.proposal_lifecycle.validate_capability_overlap`).

    Each field is ``None | frozenset[str]``:

    * ``None`` → framework does not gate this axis (legacy / open).
    * ``frozenset`` → buyer choices must be subsets of this set.
      ``frozenset()`` (empty) means deny-all on this axis.

    The None vs empty-set distinction matches Python set intuition:
    "no constraint" is None; "allowed set is empty" is ``frozenset()``.

    **Why no extras dict?** v1.5 deliberately omits an
    ``extras: dict[str, ...]`` escape hatch (per § D4). Adopters with
    novel gating needs subclass :class:`CapabilityOverlap` and add
    typed fields:

    .. code-block:: python

        @dataclass(frozen=True)
        class GAMCapabilityOverlap(CapabilityOverlap):
            line_item_priorities: frozenset[int] | None = None
            forecast_modes: frozenset[str] | None = None

    The subclass leaves a paper trail; a dict bag does not. If the new
    axis turns out to be widely useful, it lands as a typed field on
    :class:`CapabilityOverlap` upstream rather than as an
    undocumented key in a shared dict.

    :param pricing_models: Subset of wire ``pricing_models`` the buyer
        can choose. Validated against the matching
        :attr:`PricingOption.pricing_model` on the buyer's package.
    :param targeting_dimensions: Subset of wire targeting dimensions
        (``geo``, ``device_type``, ``language``, etc.). Validated
        against the keys present on the buyer's ``targeting_overlay``.
    :param delivery_types: Subset of ``{guaranteed, non_guaranteed}``
        the product offers.
    :param signal_types: If the seller integrates signals, which
        signal types this product accepts. ``frozenset()`` means the
        seller explicitly refuses all signals on this product;
        ``None`` means no framework gate.
    """

    pricing_models: frozenset[str] | None = None
    targeting_dimensions: frozenset[str] | None = None
    delivery_types: frozenset[str] | None = None
    signal_types: frozenset[str] | None = None

Per-product subset of wire capability flags.

Buyer requests asking for capabilities outside this overlap are rejected by the framework before the adapter sees them (see :func:validate_capability_overlap()).

Each field is None | frozenset[str]:

  • None → framework does not gate this axis (legacy / open).
  • frozenset → buyer choices must be subsets of this set. frozenset() (empty) means deny-all on this axis.

The None vs empty-set distinction matches Python set intuition: "no constraint" is None; "allowed set is empty" is frozenset().

Why no extras dict? v1.5 deliberately omits an extras: dict[str, ...] escape hatch (per § D4). Adopters with novel gating needs subclass :class:CapabilityOverlap and add typed fields:

.. code-block:: python

@dataclass(frozen=True)
class GAMCapabilityOverlap(CapabilityOverlap):
    line_item_priorities: frozenset[int] | None = None
    forecast_modes: frozenset[str] | None = None

The subclass leaves a paper trail; a dict bag does not. If the new axis turns out to be widely useful, it lands as a typed field on :class:CapabilityOverlap upstream rather than as an undocumented key in a shared dict.

:param pricing_models: Subset of wire pricing_models the buyer can choose. Validated against the matching :attr:PricingOption.pricing_model on the buyer's package. :param targeting_dimensions: Subset of wire targeting dimensions (geo, device_type, language, etc.). Validated against the keys present on the buyer's targeting_overlay. :param delivery_types: Subset of {guaranteed, non_guaranteed} the product offers. :param signal_types: If the seller integrates signals, which signal types this product accepts. frozenset() means the seller explicitly refuses all signals on this product; None means no framework gate.

Instance variables

var delivery_types : frozenset[str] | None
var pricing_models : frozenset[str] | None
var signal_types : frozenset[str] | None
var targeting_dimensions : frozenset[str] | None
class CollectionList (**data: Any)
Expand source code
class CollectionList(AdCPBaseModel):
    model_config = ConfigDict(
        extra='forbid',
    )
    list_id: Annotated[str, Field(description='Unique identifier for this collection list')]
    name: Annotated[str, Field(description='Human-readable name for the list')]
    description: Annotated[str | None, Field(description="Description of the list's purpose")] = (
        None
    )
    account: Annotated[
        account_ref.AccountReference | None,
        Field(
            description='Account that owns this list. Returned as account_id form (seller-assigned identifier).'
        ),
    ] = None
    base_collections: Annotated[
        list[base_collection_source.BaseCollectionSource] | None,
        Field(
            description="Array of collection sources to evaluate. Each entry is a discriminated union: distribution_ids (platform-independent identifiers), publisher_collections (publisher_domain + collection_ids), or publisher_genres (publisher_domain + genres). If omitted, queries the agent's entire collection database."
        ),
    ] = None
    filters: Annotated[
        collection_list_filters.CollectionListFilters | None,
        Field(description='Dynamic filters applied when resolving the list'),
    ] = None
    brand: Annotated[
        brand_ref.BrandReference | None,
        Field(
            description='Brand reference used to automatically apply appropriate rules. Resolved to full brand identity at execution time.'
        ),
    ] = None
    webhook_url: Annotated[
        AnyUrl | None,
        Field(description='URL to receive notifications when the resolved list changes'),
    ] = None
    cache_duration_hours: Annotated[
        int | None,
        Field(
            description='Recommended cache duration for resolved list. Consumers should re-fetch after this period. Defaults to 168 (one week) because collection metadata changes less frequently than property metadata.',
            ge=1,
        ),
    ] = 168
    created_at: Annotated[AwareDatetime | None, Field(description='When the list was created')] = (
        None
    )
    updated_at: Annotated[
        AwareDatetime | None, Field(description='When the list was last modified')
    ] = None
    collection_count: Annotated[
        int | None,
        Field(
            description='Number of collections in the resolved list (at time of last resolution)'
        ),
    ] = None

Base model for AdCP types with spec-compliant serialization.

Defaults to extra='ignore' so unknown fields from newer spec versions are silently dropped rather than causing validation errors. Generated types whose schemas set additionalProperties: true override this with extra='allow' in their own model_config.

Set ADCP_STRICT_VALIDATION=1 in the environment ("1", "true", "yes", "on" are accepted) to flip the default to extra='forbid'. Use this during spec upgrades to catch silently-dropped renamed fields in tests. See :func:_resolve_extra_policy.

Important

The env var is resolved once at module import time. Set it in your shell or CI environment before import adcp runs — mutating os.environ["ADCP_STRICT_VALIDATION"] after the first adcp import has no effect on already-imported model classes (they captured the policy at class-body evaluation).

Consumers who want per-model strict validation can override model_config on their subclass.

Create a new model by parsing and validating input data from keyword arguments.

Raises [ValidationError][pydantic_core.ValidationError] if the input data cannot be validated to form a valid model.

self is explicitly positional-only to allow self as a field name.

Ancestors

Class variables

var account : adcp.types.generated_poc.core.account_ref.AccountReference | None
var base_collections : list[adcp.types.generated_poc.collection.base_collection_source.BaseCollectionSource] | None
var brand : adcp.types.generated_poc.core.brand_ref.BrandReference | None
var cache_duration_hours : int | None
var collection_count : int | None
var created_at : pydantic.types.AwareDatetime | None
var description : str | None
var filters : adcp.types.generated_poc.collection.collection_list_filters.CollectionListFilters | None
var list_id : str
var model_config
var name : str
var updated_at : pydantic.types.AwareDatetime | None
var webhook_url : pydantic.networks.AnyUrl | None

Inherited members

class CollectionListsPlatform (*args, **kwargs)
Expand source code
@runtime_checkable
class CollectionListsPlatform(Protocol, Generic[TMeta]):
    """Collection-list CRUD with fetch-token issuance semantics.

    Parallel shape to :class:`PropertyListsPlatform`; covers
    program-level brand-safety lists (shows, series, podcasts) keyed
    by IMDb / Gracenote / EIDR ids.

    Same security model: ``create_*`` issues a per-seller
    ``fetch_token``, ``delete_*`` revokes it; compromise-driven
    revocation MUST trigger delete.
    """

    def create_collection_list(
        self,
        req: CreateCollectionListRequest,
        ctx: RequestContext[TMeta],
    ) -> MaybeAsync[CreateCollectionListResponse]:
        """Create a collection list. Returns a per-seller-scoped
        ``fetch_token``."""
        ...

    def update_collection_list(
        self,
        req: UpdateCollectionListRequest,
        ctx: RequestContext[TMeta],
    ) -> MaybeAsync[UpdateCollectionListResponse]:
        """Patch an existing collection list."""
        ...

    def get_collection_list(
        self,
        req: GetCollectionListRequest,
        ctx: RequestContext[TMeta],
    ) -> MaybeAsync[GetCollectionListResponse]:
        """Read a collection list by id."""
        ...

    def list_collection_lists(
        self,
        req: ListCollectionListsRequest,
        ctx: RequestContext[TMeta],
    ) -> MaybeAsync[ListCollectionListsResponse]:
        """Discover collection lists the caller is authorized to read."""
        ...

    def delete_collection_list(
        self,
        req: DeleteCollectionListRequest,
        ctx: RequestContext[TMeta],
    ) -> MaybeAsync[DeleteCollectionListResponse]:
        """Delete a collection list. Revokes the ``fetch_token`` and
        signals cache invalidation."""
        ...

Collection-list CRUD with fetch-token issuance semantics.

Parallel shape to :class:PropertyListsPlatform; covers program-level brand-safety lists (shows, series, podcasts) keyed by IMDb / Gracenote / EIDR ids.

Same security model: create_* issues a per-seller fetch_token, delete_* revokes it; compromise-driven revocation MUST trigger delete.

Ancestors

  • typing.Protocol
  • typing.Generic

Methods

def create_collection_list(self,
req: CreateCollectionListRequest,
ctx: RequestContext[TMeta]) ‑> MaybeAsync[CreateCollectionListResponse]
Expand source code
def create_collection_list(
    self,
    req: CreateCollectionListRequest,
    ctx: RequestContext[TMeta],
) -> MaybeAsync[CreateCollectionListResponse]:
    """Create a collection list. Returns a per-seller-scoped
    ``fetch_token``."""
    ...

Create a collection list. Returns a per-seller-scoped fetch_token.

def delete_collection_list(self,
req: DeleteCollectionListRequest,
ctx: RequestContext[TMeta]) ‑> MaybeAsync[DeleteCollectionListResponse]
Expand source code
def delete_collection_list(
    self,
    req: DeleteCollectionListRequest,
    ctx: RequestContext[TMeta],
) -> MaybeAsync[DeleteCollectionListResponse]:
    """Delete a collection list. Revokes the ``fetch_token`` and
    signals cache invalidation."""
    ...

Delete a collection list. Revokes the fetch_token and signals cache invalidation.

def get_collection_list(self,
req: GetCollectionListRequest,
ctx: RequestContext[TMeta]) ‑> MaybeAsync[GetCollectionListResponse]
Expand source code
def get_collection_list(
    self,
    req: GetCollectionListRequest,
    ctx: RequestContext[TMeta],
) -> MaybeAsync[GetCollectionListResponse]:
    """Read a collection list by id."""
    ...

Read a collection list by id.

def list_collection_lists(self,
req: ListCollectionListsRequest,
ctx: RequestContext[TMeta]) ‑> MaybeAsync[ListCollectionListsResponse]
Expand source code
def list_collection_lists(
    self,
    req: ListCollectionListsRequest,
    ctx: RequestContext[TMeta],
) -> MaybeAsync[ListCollectionListsResponse]:
    """Discover collection lists the caller is authorized to read."""
    ...

Discover collection lists the caller is authorized to read.

def update_collection_list(self,
req: UpdateCollectionListRequest,
ctx: RequestContext[TMeta]) ‑> MaybeAsync[UpdateCollectionListResponse]
Expand source code
def update_collection_list(
    self,
    req: UpdateCollectionListRequest,
    ctx: RequestContext[TMeta],
) -> MaybeAsync[UpdateCollectionListResponse]:
    """Patch an existing collection list."""
    ...

Patch an existing collection list.

class ContentStandardsPlatform (*args, **kwargs)
Expand source code
@runtime_checkable
class ContentStandardsPlatform(Protocol, Generic[TMeta]):
    """Content standards CRUD + calibration + delivery validation.

    Methods may be sync (return ``T`` directly) or async (return
    ``Awaitable[T]``); the dispatch adapter detects via
    :func:`asyncio.iscoroutinefunction` and runs sync methods on a
    thread pool.

    Throw :class:`adcp.decisioning.AdcpError` for buyer-fixable
    rejection (``REFERENCE_NOT_FOUND``, ``INVALID_REQUEST``,
    ``POLICY_VIOLATION`` for buyer rights issues, etc.).
    """

    def list_content_standards(
        self,
        req: ListContentStandardsRequest,
        ctx: RequestContext[TMeta],
    ) -> MaybeAsync[ListContentStandardsResponse]:
        """Discover content standards published by this agent."""
        ...

    def get_content_standards(
        self,
        req: GetContentStandardsRequest,
        ctx: RequestContext[TMeta],
    ) -> MaybeAsync[GetContentStandardsResponse]:
        """Read a single content standard by id."""
        ...

    def create_content_standards(
        self,
        req: CreateContentStandardsRequest,
        ctx: RequestContext[TMeta],
    ) -> MaybeAsync[CreateContentStandardsResponse]:
        """Create a new content standard.

        Adopter validates the policy schema and returns the persisted
        record. Idempotent on the buyer's ``idempotency_key``.
        """
        ...

    def update_content_standards(
        self,
        req: UpdateContentStandardsRequest,
        ctx: RequestContext[TMeta],
    ) -> MaybeAsync[UpdateContentStandardsResponse]:
        """Update an existing content standard."""
        ...

    def calibrate_content(
        self,
        req: CalibrateContentRequest,
        ctx: RequestContext[TMeta],
    ) -> MaybeAsync[CalibrateContentResponse]:
        """Calibrate content against the published standards.

        Returns the standard's current calibration profile + any
        flags raised against the submitted content.
        """
        ...

    def validate_content_delivery(
        self,
        req: ValidateContentDeliveryRequest,
        ctx: RequestContext[TMeta],
    ) -> MaybeAsync[ValidateContentDeliveryResponse]:
        """Validate that a delivered media-buy / creative meets the
        buyer's declared content-standards.

        Sellers call this post-flight to confirm adjacency and policy
        conformance before issuing a
        ``validate_content_delivery_artifact`` to a governance agent.
        """
        ...

    # ---- Optional analyzer reads ----

    def get_media_buy_artifacts(
        self,
        req: GetMediaBuyArtifactsRequest,
        ctx: RequestContext[TMeta],
    ) -> MaybeAsync[GetMediaBuyArtifactsResponse]:
        """Read content artifacts produced during a media buy's flight.

        Optional — adopters who don't expose artifact archival omit.
        Required by governance receivers running adjacency validation.
        """
        ...

    def get_creative_features(
        self,
        req: GetCreativeFeaturesRequest,
        ctx: RequestContext[TMeta],
    ) -> MaybeAsync[GetCreativeFeaturesResponse]:
        """Read per-creative analyzed features (object detection,
        scene classification, transcript) extracted during calibration.

        Optional — adopters without analyzer pipelines omit.
        """
        ...

Content standards CRUD + calibration + delivery validation.

Methods may be sync (return T directly) or async (return Awaitable[T]); the dispatch adapter detects via :func:asyncio.iscoroutinefunction and runs sync methods on a thread pool.

Throw :class:AdcpError for buyer-fixable rejection (REFERENCE_NOT_FOUND, INVALID_REQUEST, POLICY_VIOLATION for buyer rights issues, etc.).

Ancestors

  • typing.Protocol
  • typing.Generic

Methods

def calibrate_content(self,
req: CalibrateContentRequest,
ctx: RequestContext[TMeta]) ‑> MaybeAsync[CalibrateContentResponse]
Expand source code
def calibrate_content(
    self,
    req: CalibrateContentRequest,
    ctx: RequestContext[TMeta],
) -> MaybeAsync[CalibrateContentResponse]:
    """Calibrate content against the published standards.

    Returns the standard's current calibration profile + any
    flags raised against the submitted content.
    """
    ...

Calibrate content against the published standards.

Returns the standard's current calibration profile + any flags raised against the submitted content.

def create_content_standards(self,
req: CreateContentStandardsRequest,
ctx: RequestContext[TMeta]) ‑> MaybeAsync[CreateContentStandardsResponse]
Expand source code
def create_content_standards(
    self,
    req: CreateContentStandardsRequest,
    ctx: RequestContext[TMeta],
) -> MaybeAsync[CreateContentStandardsResponse]:
    """Create a new content standard.

    Adopter validates the policy schema and returns the persisted
    record. Idempotent on the buyer's ``idempotency_key``.
    """
    ...

Create a new content standard.

Adopter validates the policy schema and returns the persisted record. Idempotent on the buyer's idempotency_key.

def get_content_standards(self,
req: GetContentStandardsRequest,
ctx: RequestContext[TMeta]) ‑> MaybeAsync[GetContentStandardsResponse]
Expand source code
def get_content_standards(
    self,
    req: GetContentStandardsRequest,
    ctx: RequestContext[TMeta],
) -> MaybeAsync[GetContentStandardsResponse]:
    """Read a single content standard by id."""
    ...

Read a single content standard by id.

def get_creative_features(self,
req: GetCreativeFeaturesRequest,
ctx: RequestContext[TMeta]) ‑> MaybeAsync[GetCreativeFeaturesResponse]
Expand source code
def get_creative_features(
    self,
    req: GetCreativeFeaturesRequest,
    ctx: RequestContext[TMeta],
) -> MaybeAsync[GetCreativeFeaturesResponse]:
    """Read per-creative analyzed features (object detection,
    scene classification, transcript) extracted during calibration.

    Optional — adopters without analyzer pipelines omit.
    """
    ...

Read per-creative analyzed features (object detection, scene classification, transcript) extracted during calibration.

Optional — adopters without analyzer pipelines omit.

def get_media_buy_artifacts(self,
req: GetMediaBuyArtifactsRequest,
ctx: RequestContext[TMeta]) ‑> MaybeAsync[GetMediaBuyArtifactsResponse]
Expand source code
def get_media_buy_artifacts(
    self,
    req: GetMediaBuyArtifactsRequest,
    ctx: RequestContext[TMeta],
) -> MaybeAsync[GetMediaBuyArtifactsResponse]:
    """Read content artifacts produced during a media buy's flight.

    Optional — adopters who don't expose artifact archival omit.
    Required by governance receivers running adjacency validation.
    """
    ...

Read content artifacts produced during a media buy's flight.

Optional — adopters who don't expose artifact archival omit. Required by governance receivers running adjacency validation.

def list_content_standards(self,
req: ListContentStandardsRequest,
ctx: RequestContext[TMeta]) ‑> MaybeAsync[ListContentStandardsResponse]
Expand source code
def list_content_standards(
    self,
    req: ListContentStandardsRequest,
    ctx: RequestContext[TMeta],
) -> MaybeAsync[ListContentStandardsResponse]:
    """Discover content standards published by this agent."""
    ...

Discover content standards published by this agent.

def update_content_standards(self,
req: UpdateContentStandardsRequest,
ctx: RequestContext[TMeta]) ‑> MaybeAsync[UpdateContentStandardsResponse]
Expand source code
def update_content_standards(
    self,
    req: UpdateContentStandardsRequest,
    ctx: RequestContext[TMeta],
) -> MaybeAsync[UpdateContentStandardsResponse]:
    """Update an existing content standard."""
    ...

Update an existing content standard.

def validate_content_delivery(self,
req: ValidateContentDeliveryRequest,
ctx: RequestContext[TMeta]) ‑> MaybeAsync[ValidateContentDeliveryResponse]
Expand source code
def validate_content_delivery(
    self,
    req: ValidateContentDeliveryRequest,
    ctx: RequestContext[TMeta],
) -> MaybeAsync[ValidateContentDeliveryResponse]:
    """Validate that a delivered media-buy / creative meets the
    buyer's declared content-standards.

    Sellers call this post-flight to confirm adjacency and policy
    conformance before issuing a
    ``validate_content_delivery_artifact`` to a governance agent.
    """
    ...

Validate that a delivered media-buy / creative meets the buyer's declared content-standards.

Sellers call this post-flight to confirm adjacency and policy conformance before issuing a validate_content_delivery_artifact to a governance agent.

class CreativeAdServerPlatform (*args, **kwargs)
Expand source code
@runtime_checkable
class CreativeAdServerPlatform(Protocol, Generic[TMeta]):
    """Stateful creative library + per-creative pricing + tag generation.

    Methods may be sync (return ``T`` directly) or async (return
    ``Awaitable[T]``); the dispatch adapter detects via
    :func:`asyncio.iscoroutinefunction` and runs sync methods on a
    thread pool.

    Throw :class:`adcp.decisioning.AdcpError` for buyer-fixable
    rejection.
    """

    def build_creative(
        self,
        req: BuildCreativeRequest,
        ctx: RequestContext[TMeta],
    ) -> MaybeAsync[BuildCreativeSuccessResponse | Sequence[CreativeManifest] | CreativeManifest]:
        """Build / retrieve creative tags. Two invocation modes:

        * **Library lookup**: ``req.creative_id`` references an
          existing creative; return the manifest with tag fields
          populated (``vast_tag``, click trackers, etc.). When
          ``req.media_buy_id`` + ``req.package_id`` are also set,
          generate placement-specific tags with macro substitution
          baked in.
        * **Inline build**: ``req.creative_manifest`` is provided
          directly; transform / wrap it (similar to template archetype
          but with ad-server side effects: register the creative in
          the library, generate the tag, etc.).

        Sync at the wire level — the per-tool
        ``build-creative-response.json`` ``oneOf`` doesn't include a
        ``Submitted`` arm (spec inconsistency tracked as
        ``adcontextprotocol/adcp#3392``). Until the spec rolls
        Submitted into the ``oneOf``, slow tag-generation pipelines
        await in-request; status changes flow via
        ``ctx.publish_status_change``.

        Return shape: see :meth:`CreativeBuilderPlatform.build_creative`
        for the discriminated-arm rules — single
        :class:`CreativeManifest`, ``Sequence[CreativeManifest]`` for
        multi-format, or :class:`BuildCreativeSuccessResponse` envelope
        when you need ``sandbox``/``expires_at``/``preview``.
        """
        ...

    def preview_creative(
        self,
        req: PreviewCreativeRequest,
        ctx: RequestContext[TMeta],
    ) -> MaybeAsync[PreviewCreativeResponse]:
        """Preview-only variant — sandbox URL or inline HTML, expires.

        Always sync. NOT optional for ad-server adopters (distinct from
        :class:`CreativeBuilderPlatform.preview_creative`, which is
        optional) — buyers expect preview surface from any stateful
        creative library.
        """
        ...

    def list_creatives(
        self,
        req: ListCreativesRequest,
        ctx: RequestContext[TMeta],
    ) -> MaybeAsync[ListCreativesResponse]:
        """Read creatives from the library.

        Filters + pagination. When ``req.include_assignments``,
        include the buyer's package-assignment graph. When
        ``req.include_pricing``, include vendor pricing options on
        each creative.
        """
        ...

    def get_creative_delivery(
        self,
        req: GetCreativeDeliveryRequest,
        ctx: RequestContext[TMeta],
    ) -> MaybeAsync[GetCreativeDeliveryResponse]:
        """Per-creative delivery actuals (impressions, spend, pacing).

        Sync — report-running platforms with manual report cycles
        return the latest cached actuals and emit ``delivery_report``
        status changes via ``ctx.publish_status_change`` when fresh
        reports are available.
        """
        ...

    def sync_creatives(
        self,
        req: SyncCreativesRequest,
        ctx: RequestContext[TMeta],
    ) -> SalesResult[SyncCreativesSuccessResponse]:
        """Push creatives. Optional — present-or-absent.

        Return the typed :class:`SyncCreativesSuccessResponse` for the
        sync fast path OR ``ctx.handoff_to_task(fn)`` for HITL —
        brand-suitability, S&P review. ``action: 'created'`` for new
        entries, ``'updated'`` for replacements, ``'unchanged'`` when
        matching. Optional ``status: 'pending_review'`` for sync-arm
        rows awaiting manual review.

        Same wire request type as the sales-* and creative-builder
        archetypes use (``SyncCreativesRequest`` — shared spec shape).
        """
        ...

Stateful creative library + per-creative pricing + tag generation.

Methods may be sync (return T directly) or async (return Awaitable[T]); the dispatch adapter detects via :func:asyncio.iscoroutinefunction and runs sync methods on a thread pool.

Throw :class:AdcpError for buyer-fixable rejection.

Ancestors

  • typing.Protocol
  • typing.Generic

Methods

def build_creative(self,
req: BuildCreativeRequest,
ctx: RequestContext[TMeta]) ‑> MaybeAsync[BuildCreativeSuccessResponse | Sequence[CreativeManifest] | CreativeManifest]
Expand source code
def build_creative(
    self,
    req: BuildCreativeRequest,
    ctx: RequestContext[TMeta],
) -> MaybeAsync[BuildCreativeSuccessResponse | Sequence[CreativeManifest] | CreativeManifest]:
    """Build / retrieve creative tags. Two invocation modes:

    * **Library lookup**: ``req.creative_id`` references an
      existing creative; return the manifest with tag fields
      populated (``vast_tag``, click trackers, etc.). When
      ``req.media_buy_id`` + ``req.package_id`` are also set,
      generate placement-specific tags with macro substitution
      baked in.
    * **Inline build**: ``req.creative_manifest`` is provided
      directly; transform / wrap it (similar to template archetype
      but with ad-server side effects: register the creative in
      the library, generate the tag, etc.).

    Sync at the wire level — the per-tool
    ``build-creative-response.json`` ``oneOf`` doesn't include a
    ``Submitted`` arm (spec inconsistency tracked as
    ``adcontextprotocol/adcp#3392``). Until the spec rolls
    Submitted into the ``oneOf``, slow tag-generation pipelines
    await in-request; status changes flow via
    ``ctx.publish_status_change``.

    Return shape: see :meth:`CreativeBuilderPlatform.build_creative`
    for the discriminated-arm rules — single
    :class:`CreativeManifest`, ``Sequence[CreativeManifest]`` for
    multi-format, or :class:`BuildCreativeSuccessResponse` envelope
    when you need ``sandbox``/``expires_at``/``preview``.
    """
    ...

Build / retrieve creative tags. Two invocation modes:

  • Library lookup: req.creative_id references an existing creative; return the manifest with tag fields populated (vast_tag, click trackers, etc.). When req.media_buy_id + req.package_id are also set, generate placement-specific tags with macro substitution baked in.
  • Inline build: req.creative_manifest is provided directly; transform / wrap it (similar to template archetype but with ad-server side effects: register the creative in the library, generate the tag, etc.).

Sync at the wire level — the per-tool build-creative-response.json oneOf doesn't include a Submitted arm (spec inconsistency tracked as adcontextprotocol/adcp#3392). Until the spec rolls Submitted into the oneOf, slow tag-generation pipelines await in-request; status changes flow via ctx.publish_status_change.

Return shape: see :meth:CreativeBuilderPlatform.build_creative() for the discriminated-arm rules — single :class:CreativeManifest, Sequence[CreativeManifest] for multi-format, or :class:BuildCreativeSuccessResponse envelope when you need sandbox/expires_at/preview.

def get_creative_delivery(self,
req: GetCreativeDeliveryRequest,
ctx: RequestContext[TMeta]) ‑> MaybeAsync[GetCreativeDeliveryResponse]
Expand source code
def get_creative_delivery(
    self,
    req: GetCreativeDeliveryRequest,
    ctx: RequestContext[TMeta],
) -> MaybeAsync[GetCreativeDeliveryResponse]:
    """Per-creative delivery actuals (impressions, spend, pacing).

    Sync — report-running platforms with manual report cycles
    return the latest cached actuals and emit ``delivery_report``
    status changes via ``ctx.publish_status_change`` when fresh
    reports are available.
    """
    ...

Per-creative delivery actuals (impressions, spend, pacing).

Sync — report-running platforms with manual report cycles return the latest cached actuals and emit delivery_report status changes via ctx.publish_status_change when fresh reports are available.

def list_creatives(self,
req: ListCreativesRequest,
ctx: RequestContext[TMeta]) ‑> MaybeAsync[ListCreativesResponse]
Expand source code
def list_creatives(
    self,
    req: ListCreativesRequest,
    ctx: RequestContext[TMeta],
) -> MaybeAsync[ListCreativesResponse]:
    """Read creatives from the library.

    Filters + pagination. When ``req.include_assignments``,
    include the buyer's package-assignment graph. When
    ``req.include_pricing``, include vendor pricing options on
    each creative.
    """
    ...

Read creatives from the library.

Filters + pagination. When req.include_assignments, include the buyer's package-assignment graph. When req.include_pricing, include vendor pricing options on each creative.

def preview_creative(self,
req: PreviewCreativeRequest,
ctx: RequestContext[TMeta]) ‑> MaybeAsync[PreviewCreativeResponse]
Expand source code
def preview_creative(
    self,
    req: PreviewCreativeRequest,
    ctx: RequestContext[TMeta],
) -> MaybeAsync[PreviewCreativeResponse]:
    """Preview-only variant — sandbox URL or inline HTML, expires.

    Always sync. NOT optional for ad-server adopters (distinct from
    :class:`CreativeBuilderPlatform.preview_creative`, which is
    optional) — buyers expect preview surface from any stateful
    creative library.
    """
    ...

Preview-only variant — sandbox URL or inline HTML, expires.

Always sync. NOT optional for ad-server adopters (distinct from :class:CreativeBuilderPlatform.preview_creative(), which is optional) — buyers expect preview surface from any stateful creative library.

def sync_creatives(self,
req: SyncCreativesRequest,
ctx: RequestContext[TMeta]) ‑> SalesResult[SyncCreativesSuccessResponse]
Expand source code
def sync_creatives(
    self,
    req: SyncCreativesRequest,
    ctx: RequestContext[TMeta],
) -> SalesResult[SyncCreativesSuccessResponse]:
    """Push creatives. Optional — present-or-absent.

    Return the typed :class:`SyncCreativesSuccessResponse` for the
    sync fast path OR ``ctx.handoff_to_task(fn)`` for HITL —
    brand-suitability, S&P review. ``action: 'created'`` for new
    entries, ``'updated'`` for replacements, ``'unchanged'`` when
    matching. Optional ``status: 'pending_review'`` for sync-arm
    rows awaiting manual review.

    Same wire request type as the sales-* and creative-builder
    archetypes use (``SyncCreativesRequest`` — shared spec shape).
    """
    ...

Push creatives. Optional — present-or-absent.

Return the typed :class:SyncCreativesSuccessResponse for the sync fast path OR ctx.handoff_to_task(fn) for HITL — brand-suitability, S&P review. action: 'created' for new entries, 'updated' for replacements, 'unchanged' when matching. Optional status: 'pending_review' for sync-arm rows awaiting manual review.

Same wire request type as the sales-* and creative-builder archetypes use (SyncCreativesRequest — shared spec shape).

class CreativeBuilderPlatform (*args, **kwargs)
Expand source code
@runtime_checkable
class CreativeBuilderPlatform(Protocol, Generic[TMeta]):
    """Produces creatives — template-driven or brief-driven (generative).

    Methods may be sync (return ``T`` directly) or async (return
    ``Awaitable[T]``); the dispatch adapter detects via
    :func:`asyncio.iscoroutinefunction` and runs sync methods on a
    thread pool.

    Throw :class:`adcp.decisioning.AdcpError` for buyer-fixable
    rejection (``UNSUPPORTED_FEATURE`` for missing optionals,
    ``POLICY_VIOLATION`` for buyer rights issues, etc.); the framework
    projects to the wire structured-error envelope.
    """

    def build_creative(
        self,
        req: BuildCreativeRequest,
        ctx: RequestContext[TMeta],
    ) -> MaybeAsync[BuildCreativeSuccessResponse | Sequence[CreativeManifest] | CreativeManifest]:
        """Build the creative.

        Single method covers template-driven transform
        (``req.template_id`` + asset slots), brief-to-creative
        generation (``req.brief``), and any hybrid the platform
        supports — adopters route internally on ``req`` shape.

        Return shape is discriminated by the wire spec's Single vs
        Multi response arms:

        * **Single manifest, no metadata**: return a :class:`CreativeManifest`
          directly. Framework wraps as ``{creative_manifest: <manifest>}``.
          Use this for single-format requests (``target_format_id``)
          when you don't need to set ``sandbox`` / ``expires_at`` /
          ``preview``.
        * **Multi-format manifests, no metadata**: return a
          ``Sequence[CreativeManifest]``. Framework wraps as
          ``{creative_manifests: [...]}``. Use for multi-format
          requests (``target_format_ids``) when you don't need rich
          metadata.
        * **Fully-shaped envelope**: return a
          :class:`BuildCreativeSuccessResponse` with ``sandbox`` /
          ``expires_at`` / ``preview`` populated. Framework passes
          through unchanged.

        Adopters route on ``req.target_format_ids`` (multi) vs
        ``req.target_format_id`` (single) and return the matching arm.
        Returning the wrong arm shape is an adopter contract violation
        that surfaces as schema-validation failure on the wire response.

        :raises adcp.decisioning.AdcpError: ``code='POLICY_VIOLATION'``
            (buyer lacks rights to the requested template / brand
            inputs), ``code='INVALID_REQUEST'`` (missing or
            unrecognized template_id).
        """
        ...

    def preview_creative(
        self,
        req: PreviewCreativeRequest,
        ctx: RequestContext[TMeta],
    ) -> MaybeAsync[PreviewCreativeResponse]:
        """Preview-only variant — sandbox URL or inline HTML, expires.

        Always sync. Optional — generative-only adopters that don't
        render preview ahead of generation can omit it; the framework
        returns ``UNSUPPORTED_FEATURE`` to buyers calling
        ``preview_creative`` against a platform that didn't wire this.
        """
        ...

    def sync_creatives(
        self,
        req: SyncCreativesRequest,
        ctx: RequestContext[TMeta],
    ) -> SalesResult[SyncCreativesSuccessResponse]:
        """Sync review surface — present-or-absent.

        Stateless platforms typically auto-approve; adopters needing
        mandatory pre-persist review return
        ``ctx.handoff_to_task(fn)`` to defer to a background task.
        Unified hybrid shape — return the typed
        :class:`SyncCreativesSuccessResponse` for the sync fast path
        OR ``ctx.handoff_to_task(fn)`` for HITL.

        Same wire request type as the sales-* archetypes use
        (``SyncCreativesRequest`` — shared spec shape); the
        per-archetype handler shim narrows the discriminated payload
        when adopters care about archetype-specific fields.
        """
        ...

    def validate_input(
        self,
        req: ValidateInputRequest,
        ctx: RequestContext[TMeta],
    ) -> MaybeAsync[ValidateInputResponse]:
        """Validate buyer-provided creative inputs before build.

        Optional beta 3 preflight surface. Platforms that can predict format
        compatibility without rendering should implement it; otherwise the
        framework returns ``UNSUPPORTED_FEATURE`` when called.
        """
        ...

Produces creatives — template-driven or brief-driven (generative).

Methods may be sync (return T directly) or async (return Awaitable[T]); the dispatch adapter detects via :func:asyncio.iscoroutinefunction and runs sync methods on a thread pool.

Throw :class:AdcpError for buyer-fixable rejection (UNSUPPORTED_FEATURE for missing optionals, POLICY_VIOLATION for buyer rights issues, etc.); the framework projects to the wire structured-error envelope.

Ancestors

  • typing.Protocol
  • typing.Generic

Methods

def build_creative(self,
req: BuildCreativeRequest,
ctx: RequestContext[TMeta]) ‑> MaybeAsync[BuildCreativeSuccessResponse | Sequence[CreativeManifest] | CreativeManifest]
Expand source code
def build_creative(
    self,
    req: BuildCreativeRequest,
    ctx: RequestContext[TMeta],
) -> MaybeAsync[BuildCreativeSuccessResponse | Sequence[CreativeManifest] | CreativeManifest]:
    """Build the creative.

    Single method covers template-driven transform
    (``req.template_id`` + asset slots), brief-to-creative
    generation (``req.brief``), and any hybrid the platform
    supports — adopters route internally on ``req`` shape.

    Return shape is discriminated by the wire spec's Single vs
    Multi response arms:

    * **Single manifest, no metadata**: return a :class:`CreativeManifest`
      directly. Framework wraps as ``{creative_manifest: <manifest>}``.
      Use this for single-format requests (``target_format_id``)
      when you don't need to set ``sandbox`` / ``expires_at`` /
      ``preview``.
    * **Multi-format manifests, no metadata**: return a
      ``Sequence[CreativeManifest]``. Framework wraps as
      ``{creative_manifests: [...]}``. Use for multi-format
      requests (``target_format_ids``) when you don't need rich
      metadata.
    * **Fully-shaped envelope**: return a
      :class:`BuildCreativeSuccessResponse` with ``sandbox`` /
      ``expires_at`` / ``preview`` populated. Framework passes
      through unchanged.

    Adopters route on ``req.target_format_ids`` (multi) vs
    ``req.target_format_id`` (single) and return the matching arm.
    Returning the wrong arm shape is an adopter contract violation
    that surfaces as schema-validation failure on the wire response.

    :raises adcp.decisioning.AdcpError: ``code='POLICY_VIOLATION'``
        (buyer lacks rights to the requested template / brand
        inputs), ``code='INVALID_REQUEST'`` (missing or
        unrecognized template_id).
    """
    ...

Build the creative.

Single method covers template-driven transform (req.template_id + asset slots), brief-to-creative generation (req.brief), and any hybrid the platform supports — adopters route internally on req shape.

Return shape is discriminated by the wire spec's Single vs Multi response arms:

  • Single manifest, no metadata: return a :class:CreativeManifest directly. Framework wraps as {creative_manifest: <manifest>}. Use this for single-format requests (target_format_id) when you don't need to set sandbox / expires_at / preview.
  • Multi-format manifests, no metadata: return a Sequence[CreativeManifest]. Framework wraps as {creative_manifests: [...]}. Use for multi-format requests (target_format_ids) when you don't need rich metadata.
  • Fully-shaped envelope: return a :class:BuildCreativeSuccessResponse with sandbox / expires_at / preview populated. Framework passes through unchanged.

Adopters route on req.target_format_ids (multi) vs req.target_format_id (single) and return the matching arm. Returning the wrong arm shape is an adopter contract violation that surfaces as schema-validation failure on the wire response.

:raises adcp.decisioning.AdcpError: code='POLICY_VIOLATION' (buyer lacks rights to the requested template / brand inputs), code='INVALID_REQUEST' (missing or unrecognized template_id).

def preview_creative(self,
req: PreviewCreativeRequest,
ctx: RequestContext[TMeta]) ‑> MaybeAsync[PreviewCreativeResponse]
Expand source code
def preview_creative(
    self,
    req: PreviewCreativeRequest,
    ctx: RequestContext[TMeta],
) -> MaybeAsync[PreviewCreativeResponse]:
    """Preview-only variant — sandbox URL or inline HTML, expires.

    Always sync. Optional — generative-only adopters that don't
    render preview ahead of generation can omit it; the framework
    returns ``UNSUPPORTED_FEATURE`` to buyers calling
    ``preview_creative`` against a platform that didn't wire this.
    """
    ...

Preview-only variant — sandbox URL or inline HTML, expires.

Always sync. Optional — generative-only adopters that don't render preview ahead of generation can omit it; the framework returns UNSUPPORTED_FEATURE to buyers calling preview_creative against a platform that didn't wire this.

def sync_creatives(self,
req: SyncCreativesRequest,
ctx: RequestContext[TMeta]) ‑> SalesResult[SyncCreativesSuccessResponse]
Expand source code
def sync_creatives(
    self,
    req: SyncCreativesRequest,
    ctx: RequestContext[TMeta],
) -> SalesResult[SyncCreativesSuccessResponse]:
    """Sync review surface — present-or-absent.

    Stateless platforms typically auto-approve; adopters needing
    mandatory pre-persist review return
    ``ctx.handoff_to_task(fn)`` to defer to a background task.
    Unified hybrid shape — return the typed
    :class:`SyncCreativesSuccessResponse` for the sync fast path
    OR ``ctx.handoff_to_task(fn)`` for HITL.

    Same wire request type as the sales-* archetypes use
    (``SyncCreativesRequest`` — shared spec shape); the
    per-archetype handler shim narrows the discriminated payload
    when adopters care about archetype-specific fields.
    """
    ...

Sync review surface — present-or-absent.

Stateless platforms typically auto-approve; adopters needing mandatory pre-persist review return ctx.handoff_to_task(fn) to defer to a background task. Unified hybrid shape — return the typed :class:SyncCreativesSuccessResponse for the sync fast path OR ctx.handoff_to_task(fn) for HITL.

Same wire request type as the sales-* archetypes use (SyncCreativesRequest — shared spec shape); the per-archetype handler shim narrows the discriminated payload when adopters care about archetype-specific fields.

def validate_input(self,
req: ValidateInputRequest,
ctx: RequestContext[TMeta]) ‑> MaybeAsync[ValidateInputResponse]
Expand source code
def validate_input(
    self,
    req: ValidateInputRequest,
    ctx: RequestContext[TMeta],
) -> MaybeAsync[ValidateInputResponse]:
    """Validate buyer-provided creative inputs before build.

    Optional beta 3 preflight surface. Platforms that can predict format
    compatibility without rendering should implement it; otherwise the
    framework returns ``UNSUPPORTED_FEATURE`` when called.
    """
    ...

Validate buyer-provided creative inputs before build.

Optional beta 3 preflight surface. Platforms that can predict format compatibility without rendering should implement it; otherwise the framework returns UNSUPPORTED_FEATURE when called.

class DecisioningCapabilities (specialisms: list[Specialism | str] = <factory>,
creative_agents: list[Any] = <factory>,
config: dict[str, Any] = <factory>,
governance_aware: bool = False,
webhook_signing_managed_externally: bool = False,
auto_paginate: bool = False,
adcp: Adcp | None = None,
account: CapabilitiesAccount | None = None,
media_buy: CapabilitiesMediaBuy | None = None,
signals: Signals | None = None,
governance: Governance | None = None,
sponsored_intelligence: SponsoredIntelligence | None = None,
brand: Brand | None = None,
creative: CapabilitiesCreative | None = None,
measurement: Measurement | None = None,
request_signing: RequestSigning | None = None,
webhook_signing: WebhookSigning | None = None,
identity: Identity | None = None,
compliance_testing: ComplianceTesting | None = None,
experimental_features: list[ExperimentalFeature | str] | None = None,
supported_protocols: list[SupportedProtocol] | None = None,
channels: list[str] = <factory>,
pricing_models: list[str] = <factory>,
supported_billing: list[str] = <factory>)
Expand source code
@dataclass
class DecisioningCapabilities:
    """What a platform claims to support.

    Read by ``validate_platform`` at server boot to confirm each
    declared specialism has the methods it requires, and surfaced via
    the framework's auto-generated ``get_adcp_capabilities`` response
    so buyers can pre-flight without trial-and-error tool calls.

    Capability declaration shape mirrors the AdCP wire spec
    (``protocol/get-adcp-capabilities-response.json``). Adopters import
    the typed sub-models from :mod:`adcp.decisioning.capabilities` —
    that submodule re-exports under wire-spec names, so declarations
    read 1:1 against the spec::

        from adcp.decisioning import DecisioningCapabilities
        from adcp.decisioning.capabilities import (
            Account, MediaBuy, Targeting, GeoMetros,
            IdempotencySupported, Specialism,
        )

        capabilities = DecisioningCapabilities(
            specialisms=[Specialism.sales_non_guaranteed.value],
            adcp=Adcp(
                major_versions=[3],
                idempotency=IdempotencySupported(
                    supported=True, replay_ttl_seconds=86400,
                ),
            ),
            account=Account(supported_billing=["operator"]),
            media_buy=MediaBuy(
                supported_pricing_models=["cpm"],
                execution=Execution(
                    targeting=Targeting(geo_countries=True),
                ),
            ),
        )

    Wire capability blocks (one field per top-level wire field):

    :param adcp: Core protocol info — ``major_versions`` and
        ``idempotency``. Required on the wire; defaults to ``None``
        means the framework will project a non-conformant response
        (the boot-time validator catches this).
    :param account: Account-management capabilities (billing, OAuth,
        sandbox).
    :param media_buy: Media-buy protocol capabilities — pricing
        models, reporting delivery methods, execution targeting, etc.
        Expected when ``media_buy`` is in ``supported_protocols``.
        ``execution.targeting.geo_postal_areas`` may be declared once
        using the native AdCP 3.1 country-keyed model; the framework
        auto-projects it to the deprecated fused boolean model for
        pre-3.1 capability callers.
    :param signals: Signals protocol capabilities. Only emit when
        ``signals`` is in ``supported_protocols``.
    :param governance: Governance protocol capabilities.
    :param sponsored_intelligence: SI protocol capabilities.
    :param brand: Brand protocol capabilities.
    :param creative: Creative protocol capabilities.
    :param measurement: Experimental measurement protocol capabilities.
    :param request_signing: RFC 9421 inbound request signing posture.
    :param webhook_signing: Outbound webhook-signing posture.
    :param identity: Operator key-scoping / compromise-response
        identity posture (advisory in 3.x).
    :param compliance_testing: Deterministic-testing capability via
        ``comply_test_controller``. Omit entirely if unsupported.
    :param experimental_features: Experimental surfaces implemented by
        the platform. Required for experimental capability blocks such
        as ``measurement``.
    :param supported_protocols: Override for the ``supported_protocols``
        wire field. Default ``None`` = derive from
        :attr:`specialisms` via ``SPECIALISM_TO_PROTOCOLS``. Set
        explicitly when claiming a protocol whose specialisms aren't
        all listed (e.g. transitional state, generic seller passing the
        baseline storyboard without claiming a specific specialism).

    SDK-internal dispatch (not wire fields):

    :param specialisms: AdCP specialism slugs the platform claims —
        e.g. ``['sales-non-guaranteed', 'sales-broadcast-tv']``,
        ``['audience-sync']``, ``['signal-marketplace']``, or
        ``['signal-owned']``. Each maps to a ``Protocol`` class under
        :mod:`adcp.decisioning.specialisms`. Drives method-conformance
        validation at boot AND projects to the wire ``specialisms``
        field.
    :param creative_agents: Optional list of creative-agent endpoints
        the platform delegates creative review/generation to. Empty
        list means "no creative-agent integration; review is in-house."
    :param config: Free-form adopter-defined config exposed on
        capabilities. Use sparingly — strongly-typed fields above are
        preferred.
    :param governance_aware: Set ``True`` ONLY when the platform
        implements ``governance-*`` specialisms AND has wired a custom
        :class:`adcp.decisioning.state.StateReader` that returns real
        :data:`adcp.decisioning.state.GovernanceContextJWS` values.
        Defaults ``False`` — non-governance adopters never touch this
        flag.

        Stage 3 dispatch (foundation PR's ``validate_platform``) will
        fail-fast at server boot when a platform claims a
        ``governance-*`` specialism without setting this flag and
        wiring a real ``StateReader`` — silent governance-gate
        skipping is a security regression the framework refuses to
        ship. The flag itself is the contract that lands now; the
        enforcement lands in Stage 3. See
        ``docs/proposals/decisioning-platform-dispatch-design.md#d15``.
    :param webhook_signing_managed_externally: Set ``True`` only when
        the platform advertises ``webhook_signing.supported=True`` but
        signs outbound webhooks through adopter-owned infrastructure
        rather than the SDK's :class:`adcp.webhook_sender.WebhookSender`.
        The framework then trusts the adopter's capability declaration
        when no SDK sender/supervisor is wired.

    Deprecated flat-declaration shortcuts (will be removed in v5):

    :param channels: Inventory channels the platform serves —
        ``'display'``, ``'video'``, etc. Not currently projected to any
        wire field (the spec's ``portfolio.primary_channels`` requires
        ``portfolio.publisher_domains`` alongside, which the flat
        ``channels`` field cannot supply). Use
        ``media_buy=MediaBuy(portfolio=Portfolio(...))`` instead.
        Deprecated; emits ``DeprecationWarning`` at projection.
    :param pricing_models: Pricing models — ``'cpm'``, ``'cpc'``, etc.
        Superseded by ``media_buy.supported_pricing_models``. The
        projection prefers the structured field when both are set;
        emits ``DeprecationWarning`` when ``pricing_models`` is set.
    :param supported_billing: Billing parties this seller invoices —
        any subset of ``{"operator", "agent", "advertiser"}``.
        Superseded by ``account.supported_billing``. The projection
        prefers the structured field when both are set; emits
        ``DeprecationWarning`` when ``supported_billing`` is set
        (alone or alongside ``account``).
    """

    # SDK-internal dispatch (not wire fields)
    specialisms: list[Specialism | str] = field(default_factory=list)
    creative_agents: list[Any] = field(default_factory=list)
    config: dict[str, Any] = field(default_factory=dict)
    governance_aware: bool = False
    webhook_signing_managed_externally: bool = False
    # When True, the framework calls get_products and slices the full result
    # set to the requested page. Only suitable for in-memory / small-catalog
    # adopters whose get_products returns the complete unfiltered product set.
    # Adopters with DB-backed catalogs at production scale MUST leave this
    # False and handle cursor logic natively — returning 100k products only
    # to discard 99 950 is a silent production latency and memory spike.
    auto_paginate: bool = False

    # Wire capability blocks (mirror ``GetAdcpCapabilitiesResponse``)
    adcp: Adcp | None = None
    account: CapabilitiesAccount | None = None
    media_buy: CapabilitiesMediaBuy | None = None
    signals: Signals | None = None
    governance: Governance | None = None
    sponsored_intelligence: SponsoredIntelligence | None = None
    brand: Brand | None = None
    creative: CapabilitiesCreative | None = None
    measurement: Measurement | None = None
    request_signing: RequestSigning | None = None
    webhook_signing: WebhookSigning | None = None
    identity: Identity | None = None
    compliance_testing: ComplianceTesting | None = None
    experimental_features: list[ExperimentalFeature | str] | None = None
    supported_protocols: list[SupportedProtocol] | None = None

    # Deprecated flat-declaration shortcuts (removed in v5)
    channels: list[str] = field(default_factory=list)
    pricing_models: list[str] = field(default_factory=list)
    supported_billing: list[str] = field(default_factory=list)

    def __post_init__(self) -> None:
        """Normalize spec-known specialism strings to enum members.

        Accepts either ``Specialism`` enum members (the type-safe form
        adopters should prefer) or AdCP slug strings (back-compat with
        existing code, novel pre-spec slugs, and intentional-typo paths
        the validator wants to diagnose). Strings that match a known
        ``Specialism`` value are coerced; unknown strings pass through
        unchanged so :func:`adcp.decisioning.dispatch.validate_platform`
        can surface them with typo-detection or forward-compat warnings
        at server boot.

        Adopter code is encouraged to import ``Specialism`` from
        :mod:`adcp.decisioning.capabilities` and write
        ``specialisms=[Specialism.sales_non_guaranteed]`` for clean
        type checks. The string path stays available for config-driven
        declarations, downstream test code, and pre-spec experimental
        slugs.
        """
        coerced: list[Specialism | str] = []
        for entry in self.specialisms:
            if isinstance(entry, Specialism):
                coerced.append(entry)
                continue
            try:
                coerced.append(Specialism(entry))
            except ValueError:
                # Novel / typo / pre-spec slug — keep as string so the
                # validator's typo-vs-novel-vs-unenforced classification
                # at boot can surface the right diagnostic.
                coerced.append(entry)
        self.specialisms = coerced

        # Deprecation warnings for legacy flat fields. Fire at
        # construction so ``stacklevel=2`` points at the adopter's
        # ``DecisioningCapabilities(...)`` declaration site (where the
        # legacy field was set), not at the MCP dispatcher that later
        # called ``get_adcp_capabilities``. Python's warnings registry
        # deduplicates by ``(message, module, lineno)`` so each unique
        # declaration warns once per process.
        if self.supported_billing:
            warnings.warn(
                (
                    "DecisioningCapabilities.supported_billing is deprecated; "
                    "set ``account=Account(supported_billing=[...])`` instead. "
                    "Will be removed in v5."
                ),
                DeprecationWarning,
                stacklevel=2,
            )
        if self.pricing_models:
            warnings.warn(
                (
                    "DecisioningCapabilities.pricing_models is deprecated; "
                    "set ``media_buy=MediaBuy(supported_pricing_models=[...])`` "
                    "instead. Will be removed in v5."
                ),
                DeprecationWarning,
                stacklevel=2,
            )
        if self.channels:
            warnings.warn(
                (
                    "DecisioningCapabilities.channels is deprecated and no longer "
                    "projected to the wire (the spec's ``portfolio.primary_channels`` "
                    "requires ``portfolio.publisher_domains`` alongside, which the "
                    "flat ``channels`` field cannot supply). Set "
                    "``media_buy=MediaBuy(portfolio=Portfolio(...))`` instead. "
                    "Will be removed in v5."
                ),
                DeprecationWarning,
                stacklevel=2,
            )

        # ``supported_protocols`` semantically rolls UP FROM specialisms
        # per spec — it's the storyboard commitment, with specialisms as
        # the sub-claims that contribute to it. The framework's
        # auto-derivation (see ``handler.py:get_adcp_capabilities``) is
        # ergonomic but inverts the spec's data direction. Adopters
        # leaning on auto-derive get a one-shot UserWarning steering
        # them toward declaring ``supported_protocols`` explicitly. The
        # auto-derive path is supported indefinitely; the warning is a
        # gentle nudge toward the spec-aligned form, not a deprecation.
        if self.supported_protocols is None and self.specialisms:
            warnings.warn(
                (
                    "DecisioningCapabilities.supported_protocols was not declared; "
                    "the framework will auto-derive it from ``specialisms`` via "
                    "``SPECIALISM_TO_PROTOCOLS``. Per spec, ``supported_protocols`` is "
                    "the primary storyboard-commitment declaration — set it "
                    "explicitly via ``supported_protocols=[SupportedProtocol.media_buy, "
                    "...]`` so the spec's intent (specialisms roll up to protocols) "
                    "is preserved at the declaration site. Auto-derivation is not "
                    "deprecated; this warning fires once per declaration site."
                ),
                UserWarning,
                stacklevel=2,
            )

What a platform claims to support.

Read by validate_platform() at server boot to confirm each declared specialism has the methods it requires, and surfaced via the framework's auto-generated get_adcp_capabilities response so buyers can pre-flight without trial-and-error tool calls.

Capability declaration shape mirrors the AdCP wire spec (protocol/get-adcp-capabilities-response.json). Adopters import the typed sub-models from :mod:adcp.decisioning.capabilities — that submodule re-exports under wire-spec names, so declarations read 1:1 against the spec::

from adcp.decisioning import DecisioningCapabilities
from adcp.decisioning.capabilities import (
    Account, MediaBuy, Targeting, GeoMetros,
    IdempotencySupported, Specialism,
)

capabilities = DecisioningCapabilities(
    specialisms=[Specialism.sales_non_guaranteed.value],
    adcp=Adcp(
        major_versions=[3],
        idempotency=IdempotencySupported(
            supported=True, replay_ttl_seconds=86400,
        ),
    ),
    account=Account(supported_billing=["operator"]),
    media_buy=MediaBuy(
        supported_pricing_models=["cpm"],
        execution=Execution(
            targeting=Targeting(geo_countries=True),
        ),
    ),
)

Wire capability blocks (one field per top-level wire field):

:param adcp: Core protocol info — major_versions and idempotency. Required on the wire; defaults to None means the framework will project a non-conformant response (the boot-time validator catches this). :param account: Account-management capabilities (billing, OAuth, sandbox). :param media_buy: Media-buy protocol capabilities — pricing models, reporting delivery methods, execution targeting, etc. Expected when media_buy is in supported_protocols. execution.targeting.geo_postal_areas may be declared once using the native AdCP 3.1 country-keyed model; the framework auto-projects it to the deprecated fused boolean model for pre-3.1 capability callers. :param signals: Signals protocol capabilities. Only emit when signals is in supported_protocols. :param governance: Governance protocol capabilities. :param sponsored_intelligence: SI protocol capabilities. :param brand: Brand protocol capabilities. :param creative: Creative protocol capabilities. :param measurement: Experimental measurement protocol capabilities. :param request_signing: RFC 9421 inbound request signing posture. :param webhook_signing: Outbound webhook-signing posture. :param identity: Operator key-scoping / compromise-response identity posture (advisory in 3.x). :param compliance_testing: Deterministic-testing capability via comply_test_controller. Omit entirely if unsupported. :param experimental_features: Experimental surfaces implemented by the platform. Required for experimental capability blocks such as measurement. :param supported_protocols: Override for the supported_protocols wire field. Default None = derive from :attr:adcp.decisioning.specialisms via SPECIALISM_TO_PROTOCOLS. Set explicitly when claiming a protocol whose specialisms aren't all listed (e.g. transitional state, generic seller passing the baseline storyboard without claiming a specific specialism).

SDK-internal dispatch (not wire fields):

:param specialisms: AdCP specialism slugs the platform claims — e.g. ['sales-non-guaranteed', 'sales-broadcast-tv'], ['audience-sync'], ['signal-marketplace'], or ['signal-owned']. Each maps to a Protocol class under :mod:adcp.decisioning.specialisms. Drives method-conformance validation at boot AND projects to the wire adcp.decisioning.specialisms field. :param creative_agents: Optional list of creative-agent endpoints the platform delegates creative review/generation to. Empty list means "no creative-agent integration; review is in-house." :param config: Free-form adopter-defined config exposed on capabilities. Use sparingly — strongly-typed fields above are preferred. :param governance_aware: Set True ONLY when the platform implements governance-* specialisms AND has wired a custom :class:StateReader that returns real :data:GovernanceContextJWS values. Defaults False — non-governance adopters never touch this flag.

Stage 3 dispatch (foundation PR's <code><a title="adcp.decisioning.validate_platform" href="#adcp.decisioning.validate_platform">validate\_platform()</a></code>) will
fail-fast at server boot when a platform claims a
``governance-*`` specialism without setting this flag and
wiring a real <code><a title="adcp.decisioning.StateReader" href="#adcp.decisioning.StateReader">StateReader</a></code> — silent governance-gate
skipping is a security regression the framework refuses to
ship. The flag itself is the contract that lands now; the
enforcement lands in Stage 3. See
``docs/proposals/decisioning-platform-dispatch-design.md#d15``.

:param webhook_signing_managed_externally: Set True only when the platform advertises webhook_signing.supported=True but signs outbound webhooks through adopter-owned infrastructure rather than the SDK's :class:WebhookSender. The framework then trusts the adopter's capability declaration when no SDK sender/supervisor is wired.

Deprecated flat-declaration shortcuts (will be removed in v5):

:param channels: Inventory channels the platform serves — 'display', 'video', etc. Not currently projected to any wire field (the spec's portfolio.primary_channels requires portfolio.publisher_domains alongside, which the flat channels field cannot supply). Use media_buy=MediaBuy(portfolio=Portfolio(...)) instead. Deprecated; emits DeprecationWarning at projection. :param pricing_models: Pricing models — 'cpm', 'cpc', etc. Superseded by media_buy.supported_pricing_models. The projection prefers the structured field when both are set; emits DeprecationWarning when pricing_models is set. :param supported_billing: Billing parties this seller invoices — any subset of {"operator", "agent", "advertiser"}. Superseded by account.supported_billing. The projection prefers the structured field when both are set; emits DeprecationWarning when supported_billing is set (alone or alongside account).

Instance variables

var account : adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response.Account | None
var adcp : adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response.Adcp | None
var auto_paginate : bool
var brand : adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response.Brand | None
var channels : list[str]
var compliance_testing : adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response.ComplianceTesting | None
var config : dict[str, typing.Any]
var creative : adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response.Creative | None
var creative_agents : list[typing.Any]
var experimental_features : list[adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response.ExperimentalFeature | str] | None
var governance : adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response.Governance | None
var governance_aware : bool
var identity : adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response.Identity | None
var measurement : adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response.Measurement | None
var media_buy : adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response.MediaBuy | None
var pricing_models : list[str]
var request_signing : adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response.RequestSigning | None
var signals : adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response.Signals | None
var specialisms : list[adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response.Specialism | str]
var sponsored_intelligence : adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response.SponsoredIntelligence | None
var supported_billing : list[str]
var supported_protocols : list[adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response.SupportedProtocol] | None
var webhook_signing : adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response.WebhookSigning | None
var webhook_signing_managed_externally : bool
class DecisioningPlatform
Expand source code
class DecisioningPlatform:
    """Adopter-facing base class for the v6.0 framework.

    Subclasses set:

    * :attr:`capabilities` — what the platform claims to support
    * :attr:`accounts` — an :class:`AccountStore` instance defining
      how to resolve a wire reference + auth context to an
      :class:`Account`

    Then implement specialism methods directly on the subclass
    (``get_products``, ``create_media_buy``, ``sync_audiences``, etc.).
    Each method takes a typed Pydantic request model + a
    :class:`RequestContext[TMeta]` and returns a typed response (or
    raises :class:`AdcpError`).

    The dispatch adapter (:func:`adcp.decisioning.create_adcp_server_from_platform`)
    discovers methods via ``hasattr``, validates against
    ``capabilities.specialisms``, and routes requests through the
    framework's existing ``adcp.server.serve()`` infrastructure.

    Example::

        class HelloSeller(DecisioningPlatform):
            capabilities = DecisioningCapabilities(
                specialisms=["sales-non-guaranteed"],
                channels=["display"],
                pricing_models=["cpm"],
            )
            accounts = SingletonAccounts(account_id="hello")

            def get_products(self, req, ctx):
                return GetProductsResponse(products=[...])

            def create_media_buy(self, req, ctx):
                return CreateMediaBuySuccess(media_buy_id="mb_1", ...)

    Per-method signatures are documented in the per-specialism
    Protocol classes under :mod:`adcp.decisioning.specialisms` —
    those are the canonical contract reference. The base class
    itself is intentionally minimal so adopters can mix in
    cross-cutting helpers without inheritance constraints.
    """

    #: Required: the platform's capability declaration. Subclasses
    #: override.
    capabilities: DecisioningCapabilities = DecisioningCapabilities()

    #: Required: the platform's account-resolution strategy.
    #: Subclasses set to a :class:`SingletonAccounts`,
    #: :class:`ExplicitAccounts`, :class:`FromAuthAccounts`, or
    #: custom :class:`AccountStore` instance. Type erased to ``Any``
    #: at the base because the typed shape is platform-specific
    #: (different ``TMeta`` per adopter); ``validate_platform``
    #: confirms an :class:`AccountStore` instance is set.
    accounts: AccountStore[Any] = None  # type: ignore[assignment]

    #: Optional: the adopter's production upstream API URL. Adapters
    #: that talk to a real upstream (GAM, Kevel, FreeWheel, etc.) set
    #: this to the canonical production endpoint
    #: (``"https://googleads.googleapis.com"``,
    #: ``"https://api.kevel.co"``, etc.). The value is fixed per
    #: platform — credentials and per-tenant routing flow through
    #: ``ctx.auth_info`` and ``ctx.account.metadata``, not through
    #: this URL.
    #:
    #: Leave ``None`` for platforms that don't talk to an HTTP
    #: upstream (pure in-process, in-memory, or composing via
    #: framework-level resolvers only).
    #:
    #: When :attr:`upstream_url` is ``None``, :meth:`upstream_for`
    #: refuses to construct a client for ``mode='live'`` /
    #: ``mode='sandbox'`` accounts (raising ``CONFIGURATION_ERROR``).
    #: ``mode='mock'`` accounts always read from
    #: ``account.metadata['mock_upstream_url']`` and never consult
    #: this attribute.
    upstream_url: str | None = None

    def get_adcp_capabilities_for_request(
        self,
        params: GetAdcpCapabilitiesRequest | dict[str, Any] | None = None,
        context: ToolContext | None = None,
    ) -> DecisioningCapabilities | None | Awaitable[DecisioningCapabilities | None]:
        """Optionally override capabilities for the current discovery request.

        The class-level :attr:`capabilities` declaration remains the default
        source of truth. Multi-tenant adopters can override this hook when a
        valid capability block depends on request context, such as tenant
        publisher domains or whether the tenant has an active webhook-signing
        credential.

        Return ``None`` to use :attr:`capabilities` unchanged, or return a
        complete :class:`DecisioningCapabilities` instance for this request.
        The framework still performs the canonical
        ``get_adcp_capabilities`` response projection; this hook is not a raw
        wire-response override. Manual postal compatibility projection is not
        needed; request-scoped overrides run before the framework projects
        ``geo_postal_areas`` for the caller's AdCP version. The hook may be
        synchronous or asynchronous.
        """
        del params, context
        return None

    def upstream_for(
        self,
        ctx: RequestContext[Any],
        *,
        auth: UpstreamAuth | None = None,
        default_headers: dict[str, str] | None = None,
        timeout: float = 30.0,
        treat_404_as_none: bool = True,
    ) -> UpstreamHttpClient:
        """Return an :class:`UpstreamHttpClient` pointed at the right URL
        for this request's resolved account.

        Routing rules:

        - ``mode='live'`` / ``mode='sandbox'``: client at
          :attr:`upstream_url`. The adopter's production upstream URL is
          fixed per platform; only credentials vary per tenant (and flow
          through ``auth`` / ``ctx.auth_info``).
        - ``mode='mock'``: client at
          ``ctx.account.metadata['mock_upstream_url']``. The adopter
          populates this on mock-mode accounts; the framework points
          the client at the per-tenant fixture URL. Adapter business
          logic runs unchanged.

        Clients are cached per-platform-instance keyed by
        ``(base_url, id(auth))`` so repeated requests pool connections
        through one ``httpx.AsyncClient``. Different auth strategies
        get distinct clients (the auth is injected at construction
        and can't be swapped per-request from a cached client).

        :param ctx: The current request context. Required for
            ``ctx.account.mode`` and ``ctx.account.metadata``.
        :param auth: Auth strategy for the upstream. Defaults to
            :class:`NoAuth` (no header injected). Adopters typically
            pass a :class:`StaticBearer`, :class:`DynamicBearer`, or
            :class:`ApiKey`. The same auth is used regardless of mode
            — mock-mode fixtures usually accept any token, but adopters
            may want their adapter to send identical headers in mock
            and live so the wire shape matches end-to-end.
        :param default_headers: Headers included on every request
            (e.g. ``X-API-Version``).
        :param timeout: Per-request timeout in seconds. Default 30.0.
        :param treat_404_as_none: When ``True`` (default), GET/DELETE
            404s return ``None`` rather than raising.

        :raises AdcpError: ``CONFIGURATION_ERROR`` when:

            - Account is ``mode='mock'`` but
              ``account.metadata['mock_upstream_url']`` is missing,
              empty, or non-string. Adopter must populate it on
              mock-mode accounts in their ``AccountStore.resolve``.
            - Account is ``mode='live'`` / ``mode='sandbox'`` but
              ``self.upstream_url`` is ``None``. Adopter must declare
              the production URL on their platform subclass.
        """
        account = ctx.account
        mode = get_account_mode(account)

        if mode == "mock":
            base_url = get_mock_upstream_url(account)
            if base_url is None:
                raise AdcpError(
                    "CONFIGURATION_ERROR",
                    message=(
                        "account is mode='mock' but no 'mock_upstream_url' "
                        "string in metadata; populate it in "
                        "AccountStore.resolve for mock-mode accounts. "
                        "See docs/handler-authoring.md#mock-mode-upstream-routing."
                    ),
                    recovery="terminal",
                    field="account.metadata.mock_upstream_url",
                )
        else:
            # mode in {'live', 'sandbox'} — point at the platform's
            # declared production URL. Sandbox is the adopter's own
            # test infra; the URL is the same as live (credentials
            # + tenant routing change, not the URL).
            if self.upstream_url is None:
                raise AdcpError(
                    "CONFIGURATION_ERROR",
                    message=(
                        f"platform {type(self).__name__!s} has no "
                        f"upstream_url declared but resolved account is "
                        f"mode={mode!r}. Set the class attribute "
                        "upstream_url to the production upstream API URL, "
                        "or mark the account mode='mock' and populate "
                        "metadata['mock_upstream_url']."
                    ),
                    recovery="terminal",
                )
            base_url = self.upstream_url

        return self._cached_upstream_client(
            base_url=base_url,
            auth=auth or NoAuth(),
            default_headers=default_headers,
            timeout=timeout,
            treat_404_as_none=treat_404_as_none,
        )

    def _cached_upstream_client(
        self,
        *,
        base_url: str,
        auth: UpstreamAuth,
        default_headers: dict[str, str] | None,
        timeout: float,
        treat_404_as_none: bool,
    ) -> UpstreamHttpClient:
        """Per-instance cached :class:`UpstreamHttpClient` factory.

        Cache key is ``(base_url, id(auth))``. Pooling correctness
        requires keying on the auth instance — different ``DynamicBearer``
        closures for different tenants need distinct clients so the
        token resolver doesn't get accidentally shared, and the
        ``UpstreamHttpClient`` itself owns the underlying
        ``httpx.AsyncClient`` connection pool.

        Cache lives on the platform instance (``__dict__`` lazy init);
        multi-platform processes don't cross-pollute. Adopter code
        does not mutate the cache; lifecycle is "create once, reuse
        for the platform instance's lifetime."
        """
        cache: dict[tuple[str, int], UpstreamHttpClient] | None
        cache = getattr(self, "_upstream_client_cache", None)
        if cache is None:
            cache = {}
            self._upstream_client_cache = cache

        key = (base_url, id(auth))
        existing = cache.get(key)
        if existing is not None:
            return existing

        client = UpstreamHttpClient(
            base_url=base_url,
            auth=auth,
            default_headers=default_headers,
            timeout=timeout,
            treat_404_as_none=treat_404_as_none,
        )
        cache[key] = client
        return client

Adopter-facing base class for the v6.0 framework.

Subclasses set:

Then implement specialism methods directly on the subclass (get_products, create_media_buy, sync_audiences, etc.). Each method takes a typed Pydantic request model + a :class:RequestContext[TMeta] and returns a typed response (or raises :class:AdcpError).

The dispatch adapter (:func:create_adcp_server_from_platform()) discovers methods via hasattr, validates against capabilities.specialisms, and routes requests through the framework's existing serve() infrastructure.

Example::

class HelloSeller(DecisioningPlatform):
    capabilities = DecisioningCapabilities(
        specialisms=["sales-non-guaranteed"],
        channels=["display"],
        pricing_models=["cpm"],
    )
    accounts = SingletonAccounts(account_id="hello")

    def get_products(self, req, ctx):
        return GetProductsResponse(products=[...])

    def create_media_buy(self, req, ctx):
        return CreateMediaBuySuccess(media_buy_id="mb_1", ...)

Per-method signatures are documented in the per-specialism Protocol classes under :mod:adcp.decisioning.specialisms — those are the canonical contract reference. The base class itself is intentionally minimal so adopters can mix in cross-cutting helpers without inheritance constraints.

Subclasses

Class variables

var accountsAccountStore[Any]

Required: the platform's account-resolution strategy. Subclasses set to a :class:SingletonAccounts, :class:ExplicitAccounts, :class:FromAuthAccounts, or custom :class:AccountStore instance. Type erased to Any at the base because the typed shape is platform-specific (different TMeta per adopter); validate_platform() confirms an :class:AccountStore instance is set.

var capabilitiesDecisioningCapabilities

Required: the platform's capability declaration. Subclasses override.

var upstream_url : str | None

When :attr:upstream_url is None, :meth:upstream_for refuses to construct a client for mode='live' / mode='sandbox' accounts (raising CONFIGURATION_ERROR). mode='mock' accounts always read from account.metadata['mock_upstream_url'] and never consult this attribute.

Methods

def get_adcp_capabilities_for_request(self,
params: GetAdcpCapabilitiesRequest | dict[str, Any] | None = None,
context: ToolContext | None = None) ‑> DecisioningCapabilities | None | Awaitable[DecisioningCapabilities | None]
Expand source code
def get_adcp_capabilities_for_request(
    self,
    params: GetAdcpCapabilitiesRequest | dict[str, Any] | None = None,
    context: ToolContext | None = None,
) -> DecisioningCapabilities | None | Awaitable[DecisioningCapabilities | None]:
    """Optionally override capabilities for the current discovery request.

    The class-level :attr:`capabilities` declaration remains the default
    source of truth. Multi-tenant adopters can override this hook when a
    valid capability block depends on request context, such as tenant
    publisher domains or whether the tenant has an active webhook-signing
    credential.

    Return ``None`` to use :attr:`capabilities` unchanged, or return a
    complete :class:`DecisioningCapabilities` instance for this request.
    The framework still performs the canonical
    ``get_adcp_capabilities`` response projection; this hook is not a raw
    wire-response override. Manual postal compatibility projection is not
    needed; request-scoped overrides run before the framework projects
    ``geo_postal_areas`` for the caller's AdCP version. The hook may be
    synchronous or asynchronous.
    """
    del params, context
    return None

Optionally override capabilities for the current discovery request.

The class-level :attr:adcp.decisioning.capabilities declaration remains the default source of truth. Multi-tenant adopters can override this hook when a valid capability block depends on request context, such as tenant publisher domains or whether the tenant has an active webhook-signing credential.

Return None to use :attr:adcp.decisioning.capabilities unchanged, or return a complete :class:DecisioningCapabilities instance for this request. The framework still performs the canonical get_adcp_capabilities response projection; this hook is not a raw wire-response override. Manual postal compatibility projection is not needed; request-scoped overrides run before the framework projects geo_postal_areas for the caller's AdCP version. The hook may be synchronous or asynchronous.

def upstream_for(self,
ctx: RequestContext[Any],
*,
auth: UpstreamAuth | None = None,
default_headers: dict[str, str] | None = None,
timeout: float = 30.0,
treat_404_as_none: bool = True) ‑> UpstreamHttpClient
Expand source code
def upstream_for(
    self,
    ctx: RequestContext[Any],
    *,
    auth: UpstreamAuth | None = None,
    default_headers: dict[str, str] | None = None,
    timeout: float = 30.0,
    treat_404_as_none: bool = True,
) -> UpstreamHttpClient:
    """Return an :class:`UpstreamHttpClient` pointed at the right URL
    for this request's resolved account.

    Routing rules:

    - ``mode='live'`` / ``mode='sandbox'``: client at
      :attr:`upstream_url`. The adopter's production upstream URL is
      fixed per platform; only credentials vary per tenant (and flow
      through ``auth`` / ``ctx.auth_info``).
    - ``mode='mock'``: client at
      ``ctx.account.metadata['mock_upstream_url']``. The adopter
      populates this on mock-mode accounts; the framework points
      the client at the per-tenant fixture URL. Adapter business
      logic runs unchanged.

    Clients are cached per-platform-instance keyed by
    ``(base_url, id(auth))`` so repeated requests pool connections
    through one ``httpx.AsyncClient``. Different auth strategies
    get distinct clients (the auth is injected at construction
    and can't be swapped per-request from a cached client).

    :param ctx: The current request context. Required for
        ``ctx.account.mode`` and ``ctx.account.metadata``.
    :param auth: Auth strategy for the upstream. Defaults to
        :class:`NoAuth` (no header injected). Adopters typically
        pass a :class:`StaticBearer`, :class:`DynamicBearer`, or
        :class:`ApiKey`. The same auth is used regardless of mode
        — mock-mode fixtures usually accept any token, but adopters
        may want their adapter to send identical headers in mock
        and live so the wire shape matches end-to-end.
    :param default_headers: Headers included on every request
        (e.g. ``X-API-Version``).
    :param timeout: Per-request timeout in seconds. Default 30.0.
    :param treat_404_as_none: When ``True`` (default), GET/DELETE
        404s return ``None`` rather than raising.

    :raises AdcpError: ``CONFIGURATION_ERROR`` when:

        - Account is ``mode='mock'`` but
          ``account.metadata['mock_upstream_url']`` is missing,
          empty, or non-string. Adopter must populate it on
          mock-mode accounts in their ``AccountStore.resolve``.
        - Account is ``mode='live'`` / ``mode='sandbox'`` but
          ``self.upstream_url`` is ``None``. Adopter must declare
          the production URL on their platform subclass.
    """
    account = ctx.account
    mode = get_account_mode(account)

    if mode == "mock":
        base_url = get_mock_upstream_url(account)
        if base_url is None:
            raise AdcpError(
                "CONFIGURATION_ERROR",
                message=(
                    "account is mode='mock' but no 'mock_upstream_url' "
                    "string in metadata; populate it in "
                    "AccountStore.resolve for mock-mode accounts. "
                    "See docs/handler-authoring.md#mock-mode-upstream-routing."
                ),
                recovery="terminal",
                field="account.metadata.mock_upstream_url",
            )
    else:
        # mode in {'live', 'sandbox'} — point at the platform's
        # declared production URL. Sandbox is the adopter's own
        # test infra; the URL is the same as live (credentials
        # + tenant routing change, not the URL).
        if self.upstream_url is None:
            raise AdcpError(
                "CONFIGURATION_ERROR",
                message=(
                    f"platform {type(self).__name__!s} has no "
                    f"upstream_url declared but resolved account is "
                    f"mode={mode!r}. Set the class attribute "
                    "upstream_url to the production upstream API URL, "
                    "or mark the account mode='mock' and populate "
                    "metadata['mock_upstream_url']."
                ),
                recovery="terminal",
            )
        base_url = self.upstream_url

    return self._cached_upstream_client(
        base_url=base_url,
        auth=auth or NoAuth(),
        default_headers=default_headers,
        timeout=timeout,
        treat_404_as_none=treat_404_as_none,
    )

Return an :class:UpstreamHttpClient pointed at the right URL for this request's resolved account.

Routing rules:

  • mode='live' / mode='sandbox': client at :attr:upstream_url. The adopter's production upstream URL is fixed per platform; only credentials vary per tenant (and flow through auth / ctx.auth_info).
  • mode='mock': client at ctx.account.metadata['mock_upstream_url']. The adopter populates this on mock-mode accounts; the framework points the client at the per-tenant fixture URL. Adapter business logic runs unchanged.

Clients are cached per-platform-instance keyed by (base_url, id(auth)) so repeated requests pool connections through one httpx.AsyncClient. Different auth strategies get distinct clients (the auth is injected at construction and can't be swapped per-request from a cached client).

:param ctx: The current request context. Required for ctx.account.mode and ctx.account.metadata. :param auth: Auth strategy for the upstream. Defaults to :class:NoAuth (no header injected). Adopters typically pass a :class:StaticBearer, :class:DynamicBearer, or :class:ApiKey. The same auth is used regardless of mode — mock-mode fixtures usually accept any token, but adopters may want their adapter to send identical headers in mock and live so the wire shape matches end-to-end. :param default_headers: Headers included on every request (e.g. X-API-Version). :param timeout: Per-request timeout in seconds. Default 30.0. :param treat_404_as_none: When True (default), GET/DELETE 404s return None rather than raising.

:raises AdcpError: CONFIGURATION_ERROR when:

- Account is ``mode='mock'`` but
  ``account.metadata['mock_upstream_url']`` is missing,
  empty, or non-string. Adopter must populate it on
  mock-mode accounts in their <code><a title="adcp.decisioning.AccountStore.resolve" href="#adcp.decisioning.AccountStore.resolve">AccountStore.resolve()</a></code>.
- Account is ``mode='live'`` / ``mode='sandbox'`` but
  <code>self.upstream\_url</code> is <code>None</code>. Adopter must declare
  the production URL on their platform subclass.
class DynamicBearer (get_token: Callable[[AuthContext | None], Awaitable[str]],
kind: "Literal['dynamic_bearer']" = 'dynamic_bearer')
Expand source code
@dataclass(frozen=True)
class DynamicBearer:
    """Async token factory called per-request.

    ``get_token`` receives the optional ``auth_context`` passed to the
    method call, so the same resolver can return a master key for
    tenant fan-out, a per-operator key, or pass-through of the
    caller's principal. OAuth client-credentials refresh is the
    adopter's responsibility — typically a cached token with
    expiry-driven refresh inside the closure.
    """

    get_token: Callable[[AuthContext | None], Awaitable[str]]
    kind: Literal["dynamic_bearer"] = "dynamic_bearer"

Async token factory called per-request.

get_token receives the optional auth_context passed to the method call, so the same resolver can return a master key for tenant fan-out, a per-operator key, or pass-through of the caller's principal. OAuth client-credentials refresh is the adopter's responsibility — typically a cached token with expiry-driven refresh inside the closure.

Instance variables

var get_token : Callable[[collections.abc.Mapping[str, typing.Any] | None], Awaitable[str]]
var kind : Literal['dynamic_bearer']
class ExplicitAccounts (loader: Callable[[str], Awaitable[Account[TMeta]] | Account[TMeta]])
Expand source code
class ExplicitAccounts(Generic[TMeta]):
    """Multi-tenant where the wire ref identifies the account.

    Use for: salesagent (URL-pattern ``/tenants/<id>/...``), DSPs that
    expose multi-account-per-principal flows, agencies routing across
    publisher accounts via ``account.account_id`` in the body.

    The framework passes ``ref`` from the parsed request body
    (typically ``request.account``); ``resolve`` reads
    ``ref["account_id"]`` and routes through the adopter-supplied
    ``loader``. The wire ref is the source of truth for *which*
    account to resolve.

    Auth scope checks (does this principal have access to the
    requested account?) are NOT performed by ``ExplicitAccounts.resolve``
    — the default loader signature only takes ``account_id``. Adopters
    needing principal-vs-account scope enforcement implement the
    :class:`AccountStore` Protocol directly with a custom resolve that
    reads ``auth_info``, OR add a request middleware that runs before
    the handler. The framework does NOT silently bind ``auth_info`` to
    the lookup; if your loader returns an account a principal shouldn't
    see, you've shipped a cross-tenant data leak.

    Example::

        class SalesAgentSeller(DecisioningPlatform):
            accounts = ExplicitAccounts(loader=load_tenant_from_db)

    :param loader: Callable taking ``account_id: str`` and returning an
        :class:`Account` instance. Sync or async. Raises
        ``AdcpError(code='ACCOUNT_NOT_FOUND')`` on miss.
    """

    resolution: ClassVar[str] = "explicit"

    def __init__(
        self,
        loader: Callable[[str], Awaitable[Account[TMeta]] | Account[TMeta]],
    ) -> None:
        self._loader = loader

    def resolve(
        self,
        ref: dict[str, Any] | None,
        auth_info: AuthInfo | None = None,
    ) -> Awaitable[Account[TMeta]] | Account[TMeta]:
        # Explicit mode resolves purely off the wire ref. Adopters
        # needing principal-vs-account scope checks implement
        # AccountStore directly (see class docstring). The loader
        # signature is account_id-only by contract, so auth_info isn't
        # threaded through here.
        del auth_info
        if not ref or not ref.get("account_id"):
            from adcp.decisioning.types import AdcpError

            raise AdcpError(
                "ACCOUNT_NOT_FOUND",
                message=(
                    "ExplicitAccounts.resolve requires ref with 'account_id'; "
                    "got missing/empty ref"
                ),
                recovery="terminal",
                field="account.account_id",
            )
        return self._loader(ref["account_id"])

Multi-tenant where the wire ref identifies the account.

Use for: salesagent (URL-pattern /tenants/<id>/...), DSPs that expose multi-account-per-principal flows, agencies routing across publisher accounts via account.account_id in the body.

The framework passes ref from the parsed request body (typically request.account); adcp.decisioning.resolve reads ref["account_id"] and routes through the adopter-supplied loader. The wire ref is the source of truth for which account to resolve.

Auth scope checks (does this principal have access to the requested account?) are NOT performed by ExplicitAccounts.resolve() — the default loader signature only takes account_id. Adopters needing principal-vs-account scope enforcement implement the :class:AccountStore Protocol directly with a custom resolve that reads auth_info, OR add a request middleware that runs before the handler. The framework does NOT silently bind auth_info to the lookup; if your loader returns an account a principal shouldn't see, you've shipped a cross-tenant data leak.

Example::

class SalesAgentSeller(DecisioningPlatform):
    accounts = ExplicitAccounts(loader=load_tenant_from_db)

:param loader: Callable taking account_id: str and returning an :class:Account instance. Sync or async. Raises AdcpError(code='ACCOUNT_NOT_FOUND') on miss.

Ancestors

  • typing.Generic

Class variables

var resolution : ClassVar[str]

Methods

def resolve(self,
ref: dict[str, Any] | None,
auth_info: AuthInfo | None = None) ‑> Awaitable[Account[~TMeta]] | Account[~TMeta]
Expand source code
def resolve(
    self,
    ref: dict[str, Any] | None,
    auth_info: AuthInfo | None = None,
) -> Awaitable[Account[TMeta]] | Account[TMeta]:
    # Explicit mode resolves purely off the wire ref. Adopters
    # needing principal-vs-account scope checks implement
    # AccountStore directly (see class docstring). The loader
    # signature is account_id-only by contract, so auth_info isn't
    # threaded through here.
    del auth_info
    if not ref or not ref.get("account_id"):
        from adcp.decisioning.types import AdcpError

        raise AdcpError(
            "ACCOUNT_NOT_FOUND",
            message=(
                "ExplicitAccounts.resolve requires ref with 'account_id'; "
                "got missing/empty ref"
            ),
            recovery="terminal",
            field="account.account_id",
        )
    return self._loader(ref["account_id"])
class FinalizeProposalRequest (proposal_id: str,
recipes: dict[str, Recipe],
proposal_payload: dict[str, Any],
ask: str | None,
parent_request: GetProductsRequest)
Expand source code
@dataclass(frozen=True)
class FinalizeProposalRequest:
    """Framework-internal request shape passed to
    :meth:`ProposalManager.finalize_proposal`.

    Constructed by the dispatcher when a buyer's ``get_products``
    request with ``buying_mode='refine'`` carries a
    ``refine[i].action='finalize'`` entry. Adopter doesn't parse the
    wire envelope; the framework projects.

    :param proposal_id: The draft proposal the buyer is asking to
        finalize. Hydrated from the wire's ``refine[i].proposal_id``
        field.
    :param recipes: ``product_id -> Recipe`` mapping pulled from the
        :class:`adcp.decisioning.ProposalStore` draft. The adopter's
        finalize logic typically lock-prices these and emits the
        committed proposal.
    :param proposal_payload: The draft's wire ``Proposal`` shape
        (the same payload the adopter returned on the prior
        ``get_products`` / ``refine_products`` call). Adopter
        typically modifies this with locked pricing and returns it
        on :class:`FinalizeProposalSuccess`.
    :param ask: The buyer's per-entry refine ``ask`` text — what they
        want finalized. Free-form; adopter consumes.
    :param parent_request: The parent :class:`GetProductsRequest` so
        the adopter sees the full envelope (account, etc.) without
        the framework projecting fields one-by-one.
    """

    proposal_id: str
    recipes: dict[str, Recipe]
    proposal_payload: dict[str, Any]
    ask: str | None
    parent_request: GetProductsRequest

Framework-internal request shape passed to :meth:ProposalManager.finalize_proposal.

Constructed by the dispatcher when a buyer's get_products request with buying_mode='refine' carries a refine[i].action='finalize' entry. Adopter doesn't parse the wire envelope; the framework projects.

:param proposal_id: The draft proposal the buyer is asking to finalize. Hydrated from the wire's adcp.decisioning.refine[i].proposal_id field. :param recipes: product_id -> Recipe mapping pulled from the :class:ProposalStore draft. The adopter's finalize logic typically lock-prices these and emits the committed proposal. :param proposal_payload: The draft's wire Proposal shape (the same payload the adopter returned on the prior get_products / refine_products call). Adopter typically modifies this with locked pricing and returns it on :class:FinalizeProposalSuccess. :param ask: The buyer's per-entry refine ask text — what they want finalized. Free-form; adopter consumes. :param parent_request: The parent :class:GetProductsRequest so the adopter sees the full envelope (account, etc.) without the framework projecting fields one-by-one.

Instance variables

var ask : str | None
var parent_request : GetProductsRequest
var proposal_id : str
var proposal_payload : dict[str, Any]
var recipes : dict[str, Recipe]
class FinalizeProposalSuccess (proposal: dict[str, Any],
expires_at: datetime,
recipes: dict[str, Recipe] | None = None)
Expand source code
@dataclass(frozen=True)
class FinalizeProposalSuccess:
    """Adopter-returned shape from
    :meth:`ProposalManager.finalize_proposal` — inline commit.

    Framework calls
    :meth:`adcp.decisioning.ProposalStore.commit` with these fields
    before projecting the wire response. The buyer sees the committed
    :class:`~adcp.types.Proposal` with ``proposal_status='committed'``
    + ``expires_at`` populated on the next ``get_products`` response
    payload.

    :param proposal: The wire ``Proposal`` shape with locked pricing
        and ``proposal_status='committed'``. Adopter typically
        derives this from
        :attr:`FinalizeProposalRequest.proposal_payload` with
        modifications. **Must be JSON-serializable end-to-end** —
        nested Pydantic models and other non-JSON types don't survive
        a process restart through a durable :class:`ProposalStore`
        backing. Adopters wiring durable stores call ``.model_dump()``
        before assignment, or build dicts directly.
    :param expires_at: Inventory hold deadline. After this (plus the
        adopter's
        :attr:`ProposalCapabilities.expires_at_grace_seconds`
        window), the framework rejects ``create_media_buy`` calls
        referencing the proposal with ``PROPOSAL_EXPIRED``.
    :param recipes: Optional refreshed recipe mapping. ``None``
        (default) preserves the draft's recipes verbatim. Adopters
        whose finalize logic mutates recipe fields (e.g. locking a
        line-item template id) supply a fresh mapping.
    """

    proposal: dict[str, Any]
    expires_at: datetime
    recipes: dict[str, Recipe] | None = None

Adopter-returned shape from :meth:ProposalManager.finalize_proposal — inline commit.

Framework calls :meth:ProposalStore.commit() with these fields before projecting the wire response. The buyer sees the committed :class:~adcp.types.Proposal with proposal_status='committed' + expires_at populated on the next get_products response payload.

:param proposal: The wire Proposal shape with locked pricing and proposal_status='committed'. Adopter typically derives this from :attr:FinalizeProposalRequest.proposal_payload with modifications. Must be JSON-serializable end-to-end — nested Pydantic models and other non-JSON types don't survive a process restart through a durable :class:ProposalStore backing. Adopters wiring durable stores call .model_dump() before assignment, or build dicts directly. :param expires_at: Inventory hold deadline. After this (plus the adopter's :attr:ProposalCapabilities.expires_at_grace_seconds window), the framework rejects create_media_buy calls referencing the proposal with PROPOSAL_EXPIRED. :param recipes: Optional refreshed recipe mapping. None (default) preserves the draft's recipes verbatim. Adopters whose finalize logic mutates recipe fields (e.g. locking a line-item template id) supply a fresh mapping.

Instance variables

var expires_at : datetime
var proposal : dict[str, Any]
var recipes : dict[str, Recipe] | None
class Format (**data: Any)
Expand source code
class Format(AdCPBaseModel):
    model_config = ConfigDict(
        extra='allow',
    )
    format_id: Annotated[
        format_id_1.FormatReferenceStructuredObject,
        Field(
            description="This format's own identifier — a structured object {agent_url, id}, not a string. See /schemas/core/format-id.json for the full shape."
        ),
    ]
    name: Annotated[str, Field(description='Human-readable format name')]
    description: Annotated[
        str | None,
        Field(
            description='Plain text explanation of what this format does and what assets it requires'
        ),
    ] = None
    example_url: Annotated[
        AnyUrl | None,
        Field(
            description='Optional URL to showcase page with examples and interactive demos of this format'
        ),
    ] = None
    accepts_parameters: Annotated[
        list[format_id_parameter.FormatIdParameter] | None,
        Field(
            description='List of parameters this format accepts in format_id. Template formats define which parameters (dimensions, duration, etc.) can be specified when instantiating the format. Empty or omitted means this is a concrete format with fixed parameters.'
        ),
    ] = None
    renders: Annotated[
        list[Renders | Renders1] | None,
        Field(
            description='Specification of rendered pieces for this format. Most formats produce a single render. Companion ad formats (video + banner), adaptive formats, and multi-placement formats produce multiple renders. Each render specifies its role and dimensions.',
            min_length=1,
        ),
    ] = None
    assets: Annotated[
        list[
            Assets
            | Assets10
            | Assets11
            | Assets12
            | Assets13
            | Assets14
            | Assets15
            | Assets16
            | Assets17
            | Assets18
            | Assets19
            | Assets20
            | Assets21
            | Assets22
            | Assets23
            | Assets24
        ]
        | None,
        Field(
            description="Array of all assets supported for this format. Each asset is identified by its asset_id, which must be used as the key in creative manifests. Use the 'required' boolean on each asset to indicate whether it's mandatory."
        ),
    ] = None
    delivery: Annotated[
        dict[str, Any] | None,
        Field(description='Delivery method specifications (e.g., hosted, VAST, third-party tags)'),
    ] = None
    supported_macros: Annotated[
        list[universal_macro.UniversalMacro | str] | None,
        Field(
            description='List of universal macros supported by this format (e.g., MEDIA_BUY_ID, CACHEBUSTER, DEVICE_ID). Used for validation and developer tooling. See docs/creative/universal-macros.mdx for full documentation.'
        ),
    ] = None
    input_format_ids: Annotated[
        list[format_id_1.FormatReferenceStructuredObject] | None,
        Field(
            deprecated=True,
            description='**DEPRECATED in 3.1. Removed at 4.0.** Use `list_transformers` instead — a transformer declares its own `input_format_ids`/`output_format_ids`, so build capability is a property of the transformer (the unit you select and that carries pricing), not a relationship hung on a format. Discover build capability via `list_transformers` (optionally filtered by `input_format_ids`/`output_format_ids`).\n\nMigration: sellers that expressed transform capability by hanging `input_format_ids` on a format SHOULD declare a transformer via `list_transformers` instead. Buyers SHOULD discover build capability via `list_transformers` rather than filtering formats.\n\n*Legacy behavior, retained for 3.1–3.x backward compatibility:* array of format IDs this format accepts as input creative manifests; when present, indicates this format can take existing creatives in these formats as input. SDKs reading 3.1 catalogs MUST continue to honor this field when present; 4.0+ SDKs MAY reject it. New code SHOULD NOT emit this field.',
        ),
    ] = None
    output_format_ids: Annotated[
        list[format_id_1.FormatReferenceStructuredObject] | None,
        Field(
            deprecated=True,
            description='**DEPRECATED in 3.1. Removed at 4.0.** Use `list_transformers` instead — a transformer declares its own `output_format_ids`, so what a builder can produce is a property of the transformer, not a relationship hung on a format. Discover via `list_transformers`.\n\nMigration: sellers that expressed multi-output build capability (e.g. a multi-publisher template) by hanging `output_format_ids` on a format SHOULD declare a transformer via `list_transformers` instead.\n\n*Legacy behavior, retained for 3.1–3.x backward compatibility:* array of format IDs this format can produce as output; when present, indicates this format can build creatives in these output formats. SDKs reading 3.1 catalogs MUST continue to honor this field when present; 4.0+ SDKs MAY reject it. New code SHOULD NOT emit this field.',
        ),
    ] = None
    format_card: Annotated[
        FormatCard | None,
        Field(
            description='Optional standard visual card (300x400px) for displaying this format in user interfaces. Can be rendered via preview_creative or pre-generated.'
        ),
    ] = None
    accessibility: Annotated[
        Accessibility | None,
        Field(
            description='Accessibility posture of this format. Declares the WCAG conformance level that creatives produced by this format will meet.'
        ),
    ] = None
    supported_disclosure_positions: Annotated[
        list[disclosure_position.DisclosurePosition] | None,
        Field(
            description='Disclosure positions this format can render. Buyers use this to determine whether a format can satisfy their compliance requirements before submitting a creative. When omitted, the format makes no disclosure rendering guarantees — creative agents SHOULD treat this as incompatible with briefs that require specific disclosure positions. Values correspond to positions on creative-brief.json required_disclosures.',
            min_length=1,
        ),
    ] = None
    disclosure_capabilities: Annotated[
        list[DisclosureCapability] | None,
        Field(
            description='Structured disclosure capabilities per position with persistence modes. Declares which persistence behaviors each disclosure position supports, enabling persistence-aware matching against provenance render guidance and brief requirements. When present, supersedes supported_disclosure_positions for persistence-aware queries. The flat supported_disclosure_positions field is retained for backward compatibility. Each position MUST appear at most once; validators and agents SHOULD reject duplicates.',
            min_length=1,
        ),
    ] = None
    format_card_detailed: Annotated[
        FormatCardDetailed | None,
        Field(
            description='Optional detailed card with carousel and full specifications. Provides rich format documentation similar to ad spec pages.'
        ),
    ] = None
    reported_metrics: Annotated[
        list[available_metric.AvailableMetric] | None,
        Field(
            description='Metrics this format can produce in delivery reporting. Buyers receive the intersection of format reported_metrics and product available_metrics. If omitted, the format defers entirely to product-level metric declarations.',
            min_length=1,
        ),
    ] = None
    pricing_options: Annotated[
        list[vendor_pricing_option.VendorPricingOption] | None,
        Field(
            deprecated=True,
            description='**DEPRECATED in 3.1. Removed at 4.0.** Use `transformer.pricing_options` (via `list_transformers`) instead — pricing belongs on the transformer (the unit selected and billed), exactly as it belongs on a media-buy product. Once formats only describe output shape, format-level pricing is vestigial.\n\nMigration: transformation/generation agents that charged via `format.pricing_options` SHOULD move the same `vendor-pricing-option` entries onto the corresponding transformer. The applied option is echoed per-leaf on the build_creative response and reconciled via report_usage, unchanged.\n\n*Legacy behavior, retained for 3.1–3.x backward compatibility:* pricing options for this format, used by transformation/generation agents that charge per format adapted, per image generated, or per unit of work; present when the request included include_pricing=true and account. SDKs reading 3.1 catalogs MUST continue to honor this field when present; 4.0+ SDKs MAY reject it. New code SHOULD NOT emit this field.',
            min_length=1,
        ),
    ] = None
    canonical: Annotated[
        canonical_projection_ref.CanonicalProjectionReference | None,
        Field(
            description='Optional v2 canonical-projection annotation. Always an object — bare-string shorthand (`canonical: "image"`) is not supported; the minimal form is `canonical: { "kind": "image" }`. Carries `kind` (which canonical the v1 format projects to) plus optional `asset_source` and `slots_override` for cases where the v1 format\'s shape doesn\'t follow the canonical\'s defaults (e.g., generative entries whose input is `generation_prompt: text` instead of `image_main: image`).\n\nWhen set, SDKs use this annotation as the authoritative v1 → v2 mapping for this format, bypassing the [v1 canonical mapping registry](/schemas/registries/v1-canonical-mapping.json) lookup. Combined with the slot-level `asset_group_id` declarations on each `assets[i]` entry, a v1 format declaration with `canonical` set is fully self-describing for v1↔v2 translation.\n\nResolution order for SDK projection from v1 wire shape to v2 (per RFC #3305 amendment #3767):\n1. If this `canonical` field is set, use it (seller-declared, highest priority). Apply `asset_source` and `slots_override` from the projection ref when present; otherwise inherit the canonical\'s defaults.\n2. Else, look up `format_id` in the canonical mapping registry\'s `format_id_glob` entries.\n3. Else, attempt structural match against the registry\'s `structural` entries (asset types, slot shape, vast_versions, etc.).\n4. Else, fail closed: SDK MUST NOT emit `format_options` for products carrying this format. Surface `FORMAT_PROJECTION_FAILED` on the response `errors[]` suggesting the seller add an explicit `canonical` annotation or file a registry entry.\n\nWhen `canonical.kind` is `custom`, the seller MUST also declare `canonical_format_shape` and `canonical_format_schema` (parallel to ProductFormatDeclaration\'s `format_shape` and `format_schema`) so buyer SDKs can fetch the seller\'s custom format schema.\n\nSee `canonical-projection-ref.json` for full projection semantics and examples (default-slot case, generative case, brief-driven case).'
        ),
    ] = None
    canonical_parameters: Annotated[
        product_format_declaration.ProductFormatDeclaration | None,
        Field(
            deprecated=True,
            description="**DEPRECATED in 3.1. Removed at 4.0.** Use `v1_format_ref` on the v2 `ProductFormatDeclaration` instead — the seller authors a v2 declaration (in `Product.format_options` or `creative.supported_formats`) and links it back to this v1 format via `v1_format_ref: { agent_url, id }`. The directional link from v2 → v1 is the same fact as `canonical_parameters` without the parallel-shape drift surface (v1 file and `canonical_parameters` were two declarations of the same thing; hand-authored, drifting silently).\n\nMigration: every seller currently authoring `canonical_parameters` SHOULD migrate to authoring a v2 declaration on the corresponding product (or capability) with `v1_format_ref` pointing back at this v1 format. v1 files become pure v1 again — no v2-shape mirroring.\n\n*Legacy behavior, retained for 3.1–3.x backward compatibility:* When `canonical` is set, this field carries the full ProductFormatDeclaration that the SDK projects this v1 format into. The `format_kind` MUST equal the `canonical` field value (validators enforce). When set, this is the authoritative source for SDK v1→v2 projection — the registry's structural-match parameter inference is bypassed. SDKs reading 3.1 catalogs MUST continue to honor `canonical_parameters` when present; 4.0+ SDKs MAY reject the field. New code SHOULD NOT emit this field.\n\n**Drift contract (still normative while supported).** Hand-authored `canonical_parameters` MUST satisfy the *narrows* relation against this v1 format's `requirements` and `assets[*]` shape (see canonical-formats.mdx 'Narrows — formal definition'). SDKs that read this v1 file SHOULD lint-time check the equivalence at build/load and emit `FORMAT_PROJECTION_FAILED` if the two disagree.",
        ),
    ] = None

Base model for AdCP types with spec-compliant serialization.

Defaults to extra='ignore' so unknown fields from newer spec versions are silently dropped rather than causing validation errors. Generated types whose schemas set additionalProperties: true override this with extra='allow' in their own model_config.

Set ADCP_STRICT_VALIDATION=1 in the environment ("1", "true", "yes", "on" are accepted) to flip the default to extra='forbid'. Use this during spec upgrades to catch silently-dropped renamed fields in tests. See :func:_resolve_extra_policy.

Important

The env var is resolved once at module import time. Set it in your shell or CI environment before import adcp runs — mutating os.environ["ADCP_STRICT_VALIDATION"] after the first adcp import has no effect on already-imported model classes (they captured the policy at class-body evaluation).

Consumers who want per-model strict validation can override model_config on their subclass.

Create a new model by parsing and validating input data from keyword arguments.

Raises [ValidationError][pydantic_core.ValidationError] if the input data cannot be validated to form a valid model.

self is explicitly positional-only to allow self as a field name.

Ancestors

Class variables

var accepts_parameters : list[adcp.types.generated_poc.enums.format_id_parameter.FormatIdParameter] | None
var accessibility : adcp.types.generated_poc.core.format.Accessibility | None
var assets : list[typing.Union[adcp.types.generated_poc.core.format.Assets, adcp.types.generated_poc.core.format.Assets10, adcp.types.generated_poc.core.format.Assets11, adcp.types.generated_poc.core.format.Assets12, adcp.types.generated_poc.core.format.Assets13, adcp.types.generated_poc.core.format.Assets14, adcp.types.generated_poc.core.format.Assets15, adcp.types.generated_poc.core.format.Assets16, adcp.types.generated_poc.core.format.Assets18, adcp.types.generated_poc.core.format.Assets19, adcp.types.generated_poc.core.format.Assets20, adcp.types.generated_poc.core.format.Assets21, adcp.types.generated_poc.core.format.Assets22, adcp.types.generated_poc.core.format.Assets23, adcp.types.generated_poc.core.format.Assets24, UnknownFormatAsset]] | None
var canonical : adcp.types.generated_poc.core.canonical_projection_ref.CanonicalProjectionReference | None
var delivery : dict[str, typing.Any] | None
var description : str | None
var disclosure_capabilities : list[adcp.types.generated_poc.core.format.DisclosureCapability] | None
var example_url : pydantic.networks.AnyUrl | None
var format_card : adcp.types.generated_poc.core.format.FormatCard | None
var format_card_detailed : adcp.types.generated_poc.core.format.FormatCardDetailed | None
var format_id : adcp.types.generated_poc.core.format_id.FormatReferenceStructuredObject
var model_config
var name : str
var renders : list[adcp.types.generated_poc.core.format.Renders | adcp.types.generated_poc.core.format.Renders1] | None
var reported_metrics : list[adcp.types.generated_poc.enums.available_metric.AvailableMetric] | None
var supported_disclosure_positions : list[adcp.types.generated_poc.enums.disclosure_position.DisclosurePosition] | None
var supported_macros : list[adcp.types.generated_poc.enums.universal_macro.UniversalMacro | str] | None

Instance variables

var canonical_parameters : adcp.types.generated_poc.core.product_format_declaration.ProductFormatDeclaration | None
Expand source code
def __get__(self, obj: BaseModel | None, obj_type: type[BaseModel] | None = None) -> Any:
    if obj is None:
        if self.wrapped_property is not None:
            return self.wrapped_property.__get__(None, obj_type)
        raise AttributeError(self.field_name)

    warnings.warn(self.msg, DeprecationWarning, stacklevel=2)

    if self.wrapped_property is not None:
        return self.wrapped_property.__get__(obj, obj_type)
    return obj.__dict__[self.field_name]

Read-only data descriptor used to emit a runtime deprecation warning before accessing a deprecated field.

Attributes

msg
The deprecation message to be emitted.
wrapped_property
The property instance if the deprecated field is a computed field, or None.
field_name
The name of the field being deprecated.
var input_format_ids : list[adcp.types.generated_poc.core.format_id.FormatReferenceStructuredObject] | None
Expand source code
def __get__(self, obj: BaseModel | None, obj_type: type[BaseModel] | None = None) -> Any:
    if obj is None:
        if self.wrapped_property is not None:
            return self.wrapped_property.__get__(None, obj_type)
        raise AttributeError(self.field_name)

    warnings.warn(self.msg, DeprecationWarning, stacklevel=2)

    if self.wrapped_property is not None:
        return self.wrapped_property.__get__(obj, obj_type)
    return obj.__dict__[self.field_name]

Read-only data descriptor used to emit a runtime deprecation warning before accessing a deprecated field.

Attributes

msg
The deprecation message to be emitted.
wrapped_property
The property instance if the deprecated field is a computed field, or None.
field_name
The name of the field being deprecated.
var output_format_ids : list[adcp.types.generated_poc.core.format_id.FormatReferenceStructuredObject] | None
Expand source code
def __get__(self, obj: BaseModel | None, obj_type: type[BaseModel] | None = None) -> Any:
    if obj is None:
        if self.wrapped_property is not None:
            return self.wrapped_property.__get__(None, obj_type)
        raise AttributeError(self.field_name)

    warnings.warn(self.msg, DeprecationWarning, stacklevel=2)

    if self.wrapped_property is not None:
        return self.wrapped_property.__get__(obj, obj_type)
    return obj.__dict__[self.field_name]

Read-only data descriptor used to emit a runtime deprecation warning before accessing a deprecated field.

Attributes

msg
The deprecation message to be emitted.
wrapped_property
The property instance if the deprecated field is a computed field, or None.
field_name
The name of the field being deprecated.
var pricing_options : list[adcp.types.generated_poc.core.vendor_pricing_option.VendorPricingOption] | None
Expand source code
def __get__(self, obj: BaseModel | None, obj_type: type[BaseModel] | None = None) -> Any:
    if obj is None:
        if self.wrapped_property is not None:
            return self.wrapped_property.__get__(None, obj_type)
        raise AttributeError(self.field_name)

    warnings.warn(self.msg, DeprecationWarning, stacklevel=2)

    if self.wrapped_property is not None:
        return self.wrapped_property.__get__(obj, obj_type)
    return obj.__dict__[self.field_name]

Read-only data descriptor used to emit a runtime deprecation warning before accessing a deprecated field.

Attributes

msg
The deprecation message to be emitted.
wrapped_property
The property instance if the deprecated field is a computed field, or None.
field_name
The name of the field being deprecated.

Inherited members

class FormatReferenceStructuredObject (**data: Any)
Expand source code
class FormatReferenceStructuredObject(AdCPBaseModel):
    model_config = ConfigDict(
        extra='allow',
    )
    agent_url: Annotated[
        AnyUrl,
        Field(
            description="URL of the agent that defines this format (e.g., 'https://creative.adcontextprotocol.org' for standard formats, or 'https://publisher.com/.well-known/adcp/sales' for custom formats). Callers comparing two `format-id` values MUST canonicalize `agent_url` per the AdCP URL canonicalization rules before treating two formats as the same. See docs/reference/url-canonicalization."
        ),
    ]
    id: Annotated[
        str,
        Field(
            description="Format identifier within the agent's namespace (e.g., 'display_static', 'video_hosted', 'audio_standard'). When used alone, references a template format. When combined with dimension/duration fields, creates a parameterized format ID for a specific variant.",
            pattern='^[a-zA-Z0-9_-]+$',
        ),
    ]
    width: Annotated[
        int | None,
        Field(
            description='Width in pixels for visual formats. When specified, height must also be specified. Both fields together create a parameterized format ID for dimension-specific variants.',
            ge=1,
        ),
    ] = None
    height: Annotated[
        int | None,
        Field(
            description='Height in pixels for visual formats. When specified, width must also be specified. Both fields together create a parameterized format ID for dimension-specific variants.',
            ge=1,
        ),
    ] = None
    duration_ms: Annotated[
        float | None,
        Field(
            description='Duration in milliseconds for time-based formats (video, audio). When specified, creates a parameterized format ID. Omit to reference a template format without parameters.',
            ge=1.0,
        ),
    ] = None

Base model for AdCP types with spec-compliant serialization.

Defaults to extra='ignore' so unknown fields from newer spec versions are silently dropped rather than causing validation errors. Generated types whose schemas set additionalProperties: true override this with extra='allow' in their own model_config.

Set ADCP_STRICT_VALIDATION=1 in the environment ("1", "true", "yes", "on" are accepted) to flip the default to extra='forbid'. Use this during spec upgrades to catch silently-dropped renamed fields in tests. See :func:_resolve_extra_policy.

Important

The env var is resolved once at module import time. Set it in your shell or CI environment before import adcp runs — mutating os.environ["ADCP_STRICT_VALIDATION"] after the first adcp import has no effect on already-imported model classes (they captured the policy at class-body evaluation).

Consumers who want per-model strict validation can override model_config on their subclass.

Create a new model by parsing and validating input data from keyword arguments.

Raises [ValidationError][pydantic_core.ValidationError] if the input data cannot be validated to form a valid model.

self is explicitly positional-only to allow self as a field name.

Ancestors

Class variables

var agent_url : pydantic.networks.AnyUrl
var duration_ms : float | None
var height : int | None
var id : str
var model_config
var width : int | None

Inherited members

class FromAuthAccounts (loader: Callable[[str], Awaitable[Account[TMeta]] | Account[TMeta]])
Expand source code
class FromAuthAccounts(Generic[TMeta]):
    """Multi-tenant where the verified auth principal identifies the account.

    Use for: signed-request-bound integrations (one signing key per
    publisher account), OAuth-bearer integrations where the token
    binds to a specific account, MMP / measurement-vendor patterns
    where the principal IS the account holder.

    Reads ``auth_info.principal`` and routes through the adopter-
    supplied ``loader``. The wire ``ref`` is ignored — the auth
    principal is the source of truth.

    Example::

        class MeasurementVendor(DecisioningPlatform):
            accounts = FromAuthAccounts(loader=load_account_for_principal)

    :param loader: Callable taking ``principal: str`` and returning an
        :class:`Account` instance. Sync or async.
    """

    resolution: ClassVar[str] = "implicit"

    def __init__(
        self,
        loader: Callable[[str], Awaitable[Account[TMeta]] | Account[TMeta]],
    ) -> None:
        self._loader = loader

    def resolve(
        self,
        ref: dict[str, Any] | None = None,
        auth_info: AuthInfo | None = None,
    ) -> Awaitable[Account[TMeta]] | Account[TMeta]:
        del ref  # from_auth ignores wire refs
        if auth_info is None or not auth_info.principal:
            from adcp.decisioning.types import AdcpError

            raise AdcpError(
                "AUTH_INVALID",
                message=(
                    "FromAuthAccounts.resolve requires auth_info with a "
                    "verified principal; got None / empty"
                ),
                recovery="terminal",
            )
        return self._loader(auth_info.principal)

Multi-tenant where the verified auth principal identifies the account.

Use for: signed-request-bound integrations (one signing key per publisher account), OAuth-bearer integrations where the token binds to a specific account, MMP / measurement-vendor patterns where the principal IS the account holder.

Reads auth_info.principal and routes through the adopter- supplied loader. The wire ref is ignored — the auth principal is the source of truth.

Example::

class MeasurementVendor(DecisioningPlatform):
    accounts = FromAuthAccounts(loader=load_account_for_principal)

:param loader: Callable taking principal: str and returning an :class:Account instance. Sync or async.

Ancestors

  • typing.Generic

Class variables

var resolution : ClassVar[str]

Methods

def resolve(self,
ref: dict[str, Any] | None = None,
auth_info: AuthInfo | None = None) ‑> Awaitable[Account[~TMeta]] | Account[~TMeta]
Expand source code
def resolve(
    self,
    ref: dict[str, Any] | None = None,
    auth_info: AuthInfo | None = None,
) -> Awaitable[Account[TMeta]] | Account[TMeta]:
    del ref  # from_auth ignores wire refs
    if auth_info is None or not auth_info.principal:
        from adcp.decisioning.types import AdcpError

        raise AdcpError(
            "AUTH_INVALID",
            message=(
                "FromAuthAccounts.resolve requires auth_info with a "
                "verified principal; got None / empty"
            ),
            recovery="terminal",
        )
    return self._loader(auth_info.principal)
class HttpSigCredential (kind: "Literal['http_sig']", keyid: str, agent_url: str, verified_at: float)
Expand source code
@dataclass(frozen=True)
class HttpSigCredential:
    """RFC 9421 HTTP-Signatures credential. ``agent_url`` is
    *cryptographically verified* — the framework has already validated
    the signature against the agent's published JWK before this
    credential is constructed.
    """

    kind: Literal["http_sig"]
    keyid: str
    agent_url: str
    verified_at: float

RFC 9421 HTTP-Signatures credential. agent_url is cryptographically verified — the framework has already validated the signature against the agent's published JWK before this credential is constructed.

Instance variables

var agent_url : str
var keyid : str
var kind : Literal['http_sig']
var verified_at : float
class InMemoryMockAdServer
Expand source code
class InMemoryMockAdServer:
    """Default thread-safe :class:`MockAdServer` implementation.

    Uses a ``threading.Lock`` rather than an ``asyncio.Lock`` because
    the recorder is called from both async platform methods AND from
    sync platform methods (which the framework dispatches on a
    ``ThreadPoolExecutor``). A threading lock is correct in both
    contexts; an asyncio lock would deadlock when acquired from a
    sync method on a worker thread that has no running event loop.
    Contention is negligible — every operation is an in-memory dict
    increment, microseconds at most.
    """

    def __init__(self) -> None:
        self._counts: dict[str, int] = {}
        self._lock = threading.Lock()

    def record_call(self, method: str, args: dict[str, Any]) -> None:
        # ``args`` is intentionally ignored — the count is what matters
        # for anti-façade assertions, and persisting buyer-supplied
        # args in-memory across requests is a footgun (PII retention,
        # unbounded growth). Adopters who want full call records wire
        # their own MockAdServer impl.
        del args
        with self._lock:
            self._counts[method] = self._counts.get(method, 0) + 1

    def get_traffic(self) -> dict[str, int]:
        with self._lock:
            return dict(self._counts)

    def reset(self) -> None:
        with self._lock:
            self._counts.clear()

Default thread-safe :class:MockAdServer implementation.

Uses a threading.Lock rather than an asyncio.Lock because the recorder is called from both async platform methods AND from sync platform methods (which the framework dispatches on a ThreadPoolExecutor). A threading lock is correct in both contexts; an asyncio lock would deadlock when acquired from a sync method on a worker thread that has no running event loop. Contention is negligible — every operation is an in-memory dict increment, microseconds at most.

Methods

def get_traffic(self) ‑> dict[str, int]
Expand source code
def get_traffic(self) -> dict[str, int]:
    with self._lock:
        return dict(self._counts)
def record_call(self, method: str, args: dict[str, Any]) ‑> None
Expand source code
def record_call(self, method: str, args: dict[str, Any]) -> None:
    # ``args`` is intentionally ignored — the count is what matters
    # for anti-façade assertions, and persisting buyer-supplied
    # args in-memory across requests is a footgun (PII retention,
    # unbounded growth). Adopters who want full call records wire
    # their own MockAdServer impl.
    del args
    with self._lock:
        self._counts[method] = self._counts.get(method, 0) + 1
def reset(self) ‑> None
Expand source code
def reset(self) -> None:
    with self._lock:
        self._counts.clear()
class InMemoryProposalStore (*,
draft_ttl: timedelta = datetime.timedelta(days=1),
committed_grace: timedelta = datetime.timedelta(days=7),
clock: Any = None)
Expand source code
class InMemoryProposalStore:
    """Process-local :class:`ProposalStore` reference implementation.

    Storage is a plain ``dict[str, ProposalRecord]`` guarded by an
    :class:`asyncio.Lock`. Adequate for local dev, CI, and tests;
    production deployments wire a durable backing implementing the
    same Protocol.

    **Eviction:**

    * Drafts older than ``draft_ttl`` (default 24h) are evicted on
      every read / write.
    * Committed proposals more than ``committed_grace`` past
      ``expires_at`` (default 7 days) are evicted.

    Eviction runs lazily — no background timer thread. The first
    operation after the eviction window passes triggers cleanup.

    **Cross-tenant safety:** :meth:`get` and
    :meth:`get_by_media_buy_id` honor ``expected_account_id`` —
    cross-tenant probes return ``None``, not the raw record.
    """

    is_durable: ClassVar[bool] = False

    def __init__(
        self,
        *,
        draft_ttl: timedelta = _DEFAULT_DRAFT_TTL,
        committed_grace: timedelta = _DEFAULT_COMMITTED_GRACE,
        clock: Any = None,
    ) -> None:
        """Create an in-memory ProposalStore.

        :param draft_ttl: How long a draft proposal lives without a
            commit before being evicted. Default 24h.
        :param committed_grace: How long a committed (or consumed)
            proposal lives past its ``expires_at`` before eviction.
            Default 7 days.
        :param clock: Test injectable; defaults to
            ``lambda: datetime.now(timezone.utc)``. Tests pin a
            deterministic clock to validate eviction.
        """
        self._records: dict[str, ProposalRecord] = {}
        # Reverse index keyed by (account_id, media_buy_id). Tenant scoping
        # in the key prevents collisions when adopter media_buy_ids overlap
        # across tenants (sequential IDs, deterministic test fixtures, etc.) —
        # a tenant-A index entry is never overwritten by a tenant-B write.
        self._media_buy_index: dict[tuple[str, str], str] = {}
        self._lock = asyncio.Lock()
        self._draft_ttl = draft_ttl
        self._committed_grace = committed_grace
        self._clock = clock or (lambda: datetime.now(timezone.utc))
        self._creation_times: dict[str, datetime] = {}

    def _evict_expired_locked(self) -> None:
        """Remove records past their TTL. Must be called under the lock."""
        now = self._clock()
        to_remove: list[str] = []
        for proposal_id, record in self._records.items():
            created = self._creation_times.get(proposal_id, now)
            if record.state == ProposalState.DRAFT:
                if now - created > self._draft_ttl:
                    to_remove.append(proposal_id)
            elif record.expires_at is not None:
                deadline = record.expires_at + self._committed_grace
                if now > deadline:
                    to_remove.append(proposal_id)
        for proposal_id in to_remove:
            removed = self._records.pop(proposal_id, None)
            self._creation_times.pop(proposal_id, None)
            if removed is not None and removed.media_buy_id is not None:
                self._media_buy_index.pop((removed.account_id, removed.media_buy_id), None)

    async def put_draft(
        self,
        *,
        proposal_id: str,
        account_id: str,
        recipes: Mapping[str, Recipe],
        proposal_payload: Mapping[str, Any],
    ) -> None:
        async with self._lock:
            self._evict_expired_locked()
            existing = self._records.get(proposal_id)
            if existing is not None and existing.state != ProposalState.DRAFT:
                raise AdcpError(
                    "INTERNAL_ERROR",
                    message=(
                        f"Cannot put_draft on proposal {proposal_id!r} in "
                        f"state {existing.state.value!r}; refine iterations "
                        "are only valid on draft proposals. Once committed "
                        "or consumed, a proposal_id is immutable."
                    ),
                    recovery="terminal",
                )
            record = ProposalRecord(
                proposal_id=proposal_id,
                account_id=account_id,
                state=ProposalState.DRAFT,
                recipes=dict(recipes),
                proposal_payload=dict(proposal_payload),
            )
            self._records[proposal_id] = record
            # Track creation time only for fresh records — refine
            # iterations preserve the original creation time so the
            # 24h draft TTL is anchored to the start of the buyer's
            # session, not the most recent iteration.
            if proposal_id not in self._creation_times:
                self._creation_times[proposal_id] = self._clock()

    async def get(
        self,
        proposal_id: str,
        *,
        expected_account_id: str | None = None,
    ) -> ProposalRecord | None:
        async with self._lock:
            self._evict_expired_locked()
            record = self._records.get(proposal_id)
            if record is None:
                return None
            if expected_account_id is not None and record.account_id != expected_account_id:
                # Cross-tenant probe — return None, not raw record.
                return None
            return record

    async def commit(
        self,
        proposal_id: str,
        *,
        expires_at: datetime,
        proposal_payload: Mapping[str, Any],
        expected_account_id: str,
    ) -> None:
        async with self._lock:
            self._evict_expired_locked()
            record = self._records.get(proposal_id)
            if record is None or record.account_id != expected_account_id:
                # Cross-tenant probe collapses to "not in store" — same
                # principal-enumeration defence as :meth:`get`.
                raise AdcpError(
                    "INTERNAL_ERROR",
                    message=(
                        f"Cannot commit proposal {proposal_id!r}: not in "
                        "store for the expected tenant. The framework's "
                        "finalize dispatch must put_draft before commit."
                    ),
                    recovery="terminal",
                )
            payload_dict = dict(proposal_payload)
            if record.state == ProposalState.COMMITTED:
                # Idempotent only when the second commit matches the first.
                same_deadline = record.expires_at == expires_at
                same_payload = dict(record.proposal_payload) == payload_dict
                if same_deadline and same_payload:
                    return
                raise AdcpError(
                    "INTERNAL_ERROR",
                    message=(
                        f"Proposal {proposal_id!r} already committed with a "
                        "different expires_at or payload — re-commit with "
                        "different values is a developer bug."
                    ),
                    recovery="terminal",
                )
            if record.state != ProposalState.DRAFT:
                raise AdcpError(
                    "INTERNAL_ERROR",
                    message=(
                        f"Cannot commit proposal {proposal_id!r} from state "
                        f"{record.state.value!r}; commit requires DRAFT."
                    ),
                    recovery="terminal",
                )
            self._records[proposal_id] = replace(
                record,
                state=ProposalState.COMMITTED,
                expires_at=expires_at,
                proposal_payload=payload_dict,
            )

    async def try_reserve_consumption(
        self,
        proposal_id: str,
        *,
        expected_account_id: str,
    ) -> ProposalRecord:
        async with self._lock:
            self._evict_expired_locked()
            record = self._records.get(proposal_id)
            # Cross-tenant probe collapses to PROPOSAL_NOT_FOUND — same
            # principal-enumeration defense as :meth:`get`.
            if record is None or record.account_id != expected_account_id:
                raise AdcpError(
                    "PROPOSAL_NOT_FOUND",
                    message=(f"Proposal {proposal_id!r} not found."),
                    recovery="correctable",
                    field="proposal_id",
                )
            if record.state != ProposalState.COMMITTED:
                raise AdcpError(
                    "PROPOSAL_NOT_COMMITTED",
                    message=(
                        f"Proposal {proposal_id!r} is in state "
                        f"{record.state.value!r}; create_media_buy "
                        "requires a committed proposal that hasn't "
                        "been accepted or reserved by another request."
                    ),
                    recovery="correctable",
                    field="proposal_id",
                )
            reserved = replace(record, state=ProposalState.CONSUMING)
            self._records[proposal_id] = reserved
            return reserved

    async def finalize_consumption(
        self,
        proposal_id: str,
        *,
        media_buy_id: str,
        expected_account_id: str,
    ) -> None:
        async with self._lock:
            record = self._records.get(proposal_id)
            if record is None or record.account_id != expected_account_id:
                raise AdcpError(
                    "INTERNAL_ERROR",
                    message=(
                        f"finalize_consumption: proposal {proposal_id!r} "
                        "not found for the expected tenant."
                    ),
                    recovery="terminal",
                )
            # Idempotent on already-CONSUMED with the same media_buy_id.
            if record.state == ProposalState.CONSUMED:
                if record.media_buy_id == media_buy_id:
                    return
                raise AdcpError(
                    "INTERNAL_ERROR",
                    message=(
                        f"Proposal {proposal_id!r} already consumed by "
                        f"media_buy_id={record.media_buy_id!r}; cannot "
                        f"re-consume as {media_buy_id!r}."
                    ),
                    recovery="terminal",
                )
            if record.state != ProposalState.CONSUMING:
                raise AdcpError(
                    "INTERNAL_ERROR",
                    message=(
                        f"finalize_consumption requires CONSUMING; "
                        f"proposal {proposal_id!r} is in "
                        f"{record.state.value!r}. Framework must call "
                        "try_reserve_consumption first."
                    ),
                    recovery="terminal",
                )
            self._records[proposal_id] = replace(
                record,
                state=ProposalState.CONSUMED,
                media_buy_id=media_buy_id,
            )
            self._media_buy_index[(record.account_id, media_buy_id)] = proposal_id

    async def release_consumption(
        self,
        proposal_id: str,
        *,
        expected_account_id: str,
    ) -> None:
        async with self._lock:
            record = self._records.get(proposal_id)
            if record is None or record.account_id != expected_account_id:
                # Idempotent — releasing an unknown id is a no-op so the
                # adapter-failure rollback path can be unconditional.
                return
            if record.state == ProposalState.COMMITTED:
                # Already rolled back (e.g., another rollback path ran).
                return
            if record.state != ProposalState.CONSUMING:
                raise AdcpError(
                    "INTERNAL_ERROR",
                    message=(
                        f"release_consumption requires CONSUMING; "
                        f"proposal {proposal_id!r} is in "
                        f"{record.state.value!r}."
                    ),
                    recovery="terminal",
                )
            self._records[proposal_id] = replace(
                record,
                state=ProposalState.COMMITTED,
            )

    async def mark_consumed(
        self,
        proposal_id: str,
        *,
        media_buy_id: str,
        expected_account_id: str,
    ) -> None:
        # Equivalent to try_reserve_consumption + finalize_consumption
        # against a single-threaded write. New dispatch code uses the
        # two-phase methods directly.
        async with self._lock:
            self._evict_expired_locked()
            record = self._records.get(proposal_id)
            if record is None or record.account_id != expected_account_id:
                raise AdcpError(
                    "INTERNAL_ERROR",
                    message=(
                        f"Cannot mark_consumed proposal {proposal_id!r}: "
                        "not in store for the expected tenant."
                    ),
                    recovery="terminal",
                )
            if record.state == ProposalState.CONSUMED:
                # Idempotent only when re-marking with the same media_buy_id.
                if record.media_buy_id == media_buy_id:
                    return
                raise AdcpError(
                    "INTERNAL_ERROR",
                    message=(
                        f"Proposal {proposal_id!r} already consumed by "
                        f"media_buy_id={record.media_buy_id!r}; cannot "
                        f"re-consume as {media_buy_id!r}."
                    ),
                    recovery="terminal",
                )
            if record.state != ProposalState.COMMITTED:
                raise AdcpError(
                    "INTERNAL_ERROR",
                    message=(
                        f"Cannot mark_consumed proposal {proposal_id!r} "
                        f"from state {record.state.value!r}; mark_consumed "
                        "requires COMMITTED."
                    ),
                    recovery="terminal",
                )
            self._records[proposal_id] = replace(
                record,
                state=ProposalState.CONSUMED,
                media_buy_id=media_buy_id,
            )
            self._media_buy_index[(record.account_id, media_buy_id)] = proposal_id

    async def discard(
        self,
        proposal_id: str,
        *,
        expected_account_id: str,
    ) -> None:
        async with self._lock:
            record = self._records.get(proposal_id)
            if record is None or record.account_id != expected_account_id:
                # Idempotent — unknown id or cross-tenant probe is a no-op.
                return
            self._records.pop(proposal_id, None)
            self._creation_times.pop(proposal_id, None)
            if record.media_buy_id is not None:
                self._media_buy_index.pop((record.account_id, record.media_buy_id), None)

    async def get_by_media_buy_id(
        self,
        media_buy_id: str,
        *,
        expected_account_id: str,
    ) -> ProposalRecord | None:
        async with self._lock:
            self._evict_expired_locked()
            proposal_id = self._media_buy_index.get((expected_account_id, media_buy_id))
            if proposal_id is None:
                return None
            record = self._records.get(proposal_id)
            if record is None:
                # Index drift — clean up.
                self._media_buy_index.pop((expected_account_id, media_buy_id), None)
                return None
            return record

Process-local :class:ProposalStore reference implementation.

Storage is a plain dict[str, ProposalRecord] guarded by an :class:asyncio.Lock. Adequate for local dev, CI, and tests; production deployments wire a durable backing implementing the same Protocol.

Eviction:

  • Drafts older than draft_ttl (default 24h) are evicted on every read / write.
  • Committed proposals more than committed_grace past expires_at (default 7 days) are evicted.

Eviction runs lazily — no background timer thread. The first operation after the eviction window passes triggers cleanup.

Cross-tenant safety: :meth:get and :meth:get_by_media_buy_id honor expected_account_id — cross-tenant probes return None, not the raw record.

Create an in-memory ProposalStore.

:param draft_ttl: How long a draft proposal lives without a commit before being evicted. Default 24h. :param committed_grace: How long a committed (or consumed) proposal lives past its expires_at before eviction. Default 7 days. :param clock: Test injectable; defaults to lambda: datetime.now(timezone.utc). Tests pin a deterministic clock to validate eviction.

Class variables

var is_durable : ClassVar[bool]

Methods

async def commit(self,
proposal_id: str,
*,
expires_at: datetime,
proposal_payload: Mapping[str, Any],
expected_account_id: str) ‑> None
Expand source code
async def commit(
    self,
    proposal_id: str,
    *,
    expires_at: datetime,
    proposal_payload: Mapping[str, Any],
    expected_account_id: str,
) -> None:
    async with self._lock:
        self._evict_expired_locked()
        record = self._records.get(proposal_id)
        if record is None or record.account_id != expected_account_id:
            # Cross-tenant probe collapses to "not in store" — same
            # principal-enumeration defence as :meth:`get`.
            raise AdcpError(
                "INTERNAL_ERROR",
                message=(
                    f"Cannot commit proposal {proposal_id!r}: not in "
                    "store for the expected tenant. The framework's "
                    "finalize dispatch must put_draft before commit."
                ),
                recovery="terminal",
            )
        payload_dict = dict(proposal_payload)
        if record.state == ProposalState.COMMITTED:
            # Idempotent only when the second commit matches the first.
            same_deadline = record.expires_at == expires_at
            same_payload = dict(record.proposal_payload) == payload_dict
            if same_deadline and same_payload:
                return
            raise AdcpError(
                "INTERNAL_ERROR",
                message=(
                    f"Proposal {proposal_id!r} already committed with a "
                    "different expires_at or payload — re-commit with "
                    "different values is a developer bug."
                ),
                recovery="terminal",
            )
        if record.state != ProposalState.DRAFT:
            raise AdcpError(
                "INTERNAL_ERROR",
                message=(
                    f"Cannot commit proposal {proposal_id!r} from state "
                    f"{record.state.value!r}; commit requires DRAFT."
                ),
                recovery="terminal",
            )
        self._records[proposal_id] = replace(
            record,
            state=ProposalState.COMMITTED,
            expires_at=expires_at,
            proposal_payload=payload_dict,
        )
async def discard(self, proposal_id: str, *, expected_account_id: str) ‑> None
Expand source code
async def discard(
    self,
    proposal_id: str,
    *,
    expected_account_id: str,
) -> None:
    async with self._lock:
        record = self._records.get(proposal_id)
        if record is None or record.account_id != expected_account_id:
            # Idempotent — unknown id or cross-tenant probe is a no-op.
            return
        self._records.pop(proposal_id, None)
        self._creation_times.pop(proposal_id, None)
        if record.media_buy_id is not None:
            self._media_buy_index.pop((record.account_id, record.media_buy_id), None)
async def finalize_consumption(self, proposal_id: str, *, media_buy_id: str, expected_account_id: str) ‑> None
Expand source code
async def finalize_consumption(
    self,
    proposal_id: str,
    *,
    media_buy_id: str,
    expected_account_id: str,
) -> None:
    async with self._lock:
        record = self._records.get(proposal_id)
        if record is None or record.account_id != expected_account_id:
            raise AdcpError(
                "INTERNAL_ERROR",
                message=(
                    f"finalize_consumption: proposal {proposal_id!r} "
                    "not found for the expected tenant."
                ),
                recovery="terminal",
            )
        # Idempotent on already-CONSUMED with the same media_buy_id.
        if record.state == ProposalState.CONSUMED:
            if record.media_buy_id == media_buy_id:
                return
            raise AdcpError(
                "INTERNAL_ERROR",
                message=(
                    f"Proposal {proposal_id!r} already consumed by "
                    f"media_buy_id={record.media_buy_id!r}; cannot "
                    f"re-consume as {media_buy_id!r}."
                ),
                recovery="terminal",
            )
        if record.state != ProposalState.CONSUMING:
            raise AdcpError(
                "INTERNAL_ERROR",
                message=(
                    f"finalize_consumption requires CONSUMING; "
                    f"proposal {proposal_id!r} is in "
                    f"{record.state.value!r}. Framework must call "
                    "try_reserve_consumption first."
                ),
                recovery="terminal",
            )
        self._records[proposal_id] = replace(
            record,
            state=ProposalState.CONSUMED,
            media_buy_id=media_buy_id,
        )
        self._media_buy_index[(record.account_id, media_buy_id)] = proposal_id
async def get(self, proposal_id: str, *, expected_account_id: str | None = None) ‑> ProposalRecord | None
Expand source code
async def get(
    self,
    proposal_id: str,
    *,
    expected_account_id: str | None = None,
) -> ProposalRecord | None:
    async with self._lock:
        self._evict_expired_locked()
        record = self._records.get(proposal_id)
        if record is None:
            return None
        if expected_account_id is not None and record.account_id != expected_account_id:
            # Cross-tenant probe — return None, not raw record.
            return None
        return record
async def get_by_media_buy_id(self, media_buy_id: str, *, expected_account_id: str) ‑> ProposalRecord | None
Expand source code
async def get_by_media_buy_id(
    self,
    media_buy_id: str,
    *,
    expected_account_id: str,
) -> ProposalRecord | None:
    async with self._lock:
        self._evict_expired_locked()
        proposal_id = self._media_buy_index.get((expected_account_id, media_buy_id))
        if proposal_id is None:
            return None
        record = self._records.get(proposal_id)
        if record is None:
            # Index drift — clean up.
            self._media_buy_index.pop((expected_account_id, media_buy_id), None)
            return None
        return record
async def mark_consumed(self, proposal_id: str, *, media_buy_id: str, expected_account_id: str) ‑> None
Expand source code
async def mark_consumed(
    self,
    proposal_id: str,
    *,
    media_buy_id: str,
    expected_account_id: str,
) -> None:
    # Equivalent to try_reserve_consumption + finalize_consumption
    # against a single-threaded write. New dispatch code uses the
    # two-phase methods directly.
    async with self._lock:
        self._evict_expired_locked()
        record = self._records.get(proposal_id)
        if record is None or record.account_id != expected_account_id:
            raise AdcpError(
                "INTERNAL_ERROR",
                message=(
                    f"Cannot mark_consumed proposal {proposal_id!r}: "
                    "not in store for the expected tenant."
                ),
                recovery="terminal",
            )
        if record.state == ProposalState.CONSUMED:
            # Idempotent only when re-marking with the same media_buy_id.
            if record.media_buy_id == media_buy_id:
                return
            raise AdcpError(
                "INTERNAL_ERROR",
                message=(
                    f"Proposal {proposal_id!r} already consumed by "
                    f"media_buy_id={record.media_buy_id!r}; cannot "
                    f"re-consume as {media_buy_id!r}."
                ),
                recovery="terminal",
            )
        if record.state != ProposalState.COMMITTED:
            raise AdcpError(
                "INTERNAL_ERROR",
                message=(
                    f"Cannot mark_consumed proposal {proposal_id!r} "
                    f"from state {record.state.value!r}; mark_consumed "
                    "requires COMMITTED."
                ),
                recovery="terminal",
            )
        self._records[proposal_id] = replace(
            record,
            state=ProposalState.CONSUMED,
            media_buy_id=media_buy_id,
        )
        self._media_buy_index[(record.account_id, media_buy_id)] = proposal_id
async def put_draft(self,
*,
proposal_id: str,
account_id: str,
recipes: Mapping[str, Recipe],
proposal_payload: Mapping[str, Any]) ‑> None
Expand source code
async def put_draft(
    self,
    *,
    proposal_id: str,
    account_id: str,
    recipes: Mapping[str, Recipe],
    proposal_payload: Mapping[str, Any],
) -> None:
    async with self._lock:
        self._evict_expired_locked()
        existing = self._records.get(proposal_id)
        if existing is not None and existing.state != ProposalState.DRAFT:
            raise AdcpError(
                "INTERNAL_ERROR",
                message=(
                    f"Cannot put_draft on proposal {proposal_id!r} in "
                    f"state {existing.state.value!r}; refine iterations "
                    "are only valid on draft proposals. Once committed "
                    "or consumed, a proposal_id is immutable."
                ),
                recovery="terminal",
            )
        record = ProposalRecord(
            proposal_id=proposal_id,
            account_id=account_id,
            state=ProposalState.DRAFT,
            recipes=dict(recipes),
            proposal_payload=dict(proposal_payload),
        )
        self._records[proposal_id] = record
        # Track creation time only for fresh records — refine
        # iterations preserve the original creation time so the
        # 24h draft TTL is anchored to the start of the buyer's
        # session, not the most recent iteration.
        if proposal_id not in self._creation_times:
            self._creation_times[proposal_id] = self._clock()
async def release_consumption(self, proposal_id: str, *, expected_account_id: str) ‑> None
Expand source code
async def release_consumption(
    self,
    proposal_id: str,
    *,
    expected_account_id: str,
) -> None:
    async with self._lock:
        record = self._records.get(proposal_id)
        if record is None or record.account_id != expected_account_id:
            # Idempotent — releasing an unknown id is a no-op so the
            # adapter-failure rollback path can be unconditional.
            return
        if record.state == ProposalState.COMMITTED:
            # Already rolled back (e.g., another rollback path ran).
            return
        if record.state != ProposalState.CONSUMING:
            raise AdcpError(
                "INTERNAL_ERROR",
                message=(
                    f"release_consumption requires CONSUMING; "
                    f"proposal {proposal_id!r} is in "
                    f"{record.state.value!r}."
                ),
                recovery="terminal",
            )
        self._records[proposal_id] = replace(
            record,
            state=ProposalState.COMMITTED,
        )
async def try_reserve_consumption(self, proposal_id: str, *, expected_account_id: str) ‑> ProposalRecord
Expand source code
async def try_reserve_consumption(
    self,
    proposal_id: str,
    *,
    expected_account_id: str,
) -> ProposalRecord:
    async with self._lock:
        self._evict_expired_locked()
        record = self._records.get(proposal_id)
        # Cross-tenant probe collapses to PROPOSAL_NOT_FOUND — same
        # principal-enumeration defense as :meth:`get`.
        if record is None or record.account_id != expected_account_id:
            raise AdcpError(
                "PROPOSAL_NOT_FOUND",
                message=(f"Proposal {proposal_id!r} not found."),
                recovery="correctable",
                field="proposal_id",
            )
        if record.state != ProposalState.COMMITTED:
            raise AdcpError(
                "PROPOSAL_NOT_COMMITTED",
                message=(
                    f"Proposal {proposal_id!r} is in state "
                    f"{record.state.value!r}; create_media_buy "
                    "requires a committed proposal that hasn't "
                    "been accepted or reserved by another request."
                ),
                recovery="correctable",
                field="proposal_id",
            )
        reserved = replace(record, state=ProposalState.CONSUMING)
        self._records[proposal_id] = reserved
        return reserved
class InMemoryTaskRegistry
Expand source code
class InMemoryTaskRegistry:
    """Process-local task registry — v6.0 reference implementation.

    Storage is a plain ``dict[str, TaskRecord]`` guarded by an
    :class:`asyncio.Lock`. Adequate for local dev, CI, and test
    fixtures; production deployments wire a durable counterpart
    (PostgreSQL, Redis, etc.) implementing the same :class:`TaskRegistry`
    Protocol.

    Production-mode gate: :func:`adcp.decisioning.serve.serve` refuses
    to wire this when ``ADCP_ENV`` indicates production unless
    ``ADCP_DECISIONING_ALLOW_INMEMORY_TASKS=1`` is set. The gate
    reads ``registry.is_durable``; subclassing this class for
    instrumentation does NOT bypass the gate (the ``False`` is
    inherited). Custom durable impls set ``is_durable = True``
    explicitly. Production sellers running ``sales-broadcast-tv``
    or any HITL flow get the explicit refusal so silent in-memory
    fallback can't bite oncall.
    """

    is_durable: ClassVar[bool] = False

    def __init__(self) -> None:
        self._records: dict[str, TaskRecord] = {}
        self._lock = asyncio.Lock()

    async def issue(
        self,
        *,
        account_id: str,
        task_type: str,
        request_context: dict[str, Any] | None = None,
        **_extra: Any,
    ) -> str:
        # Forward-compat: log unrecognized kwargs at DEBUG so adopters
        # who haven't yet upgraded notice when they're missing new
        # framework fields. Don't raise — that would break adopters
        # the moment a new version ships.
        if _extra:
            logger.debug(
                "InMemoryTaskRegistry.issue ignoring unrecognized kwargs: %s",
                list(_extra.keys()),
            )
        # Reject empty/unset account_id at issue-time. Without this,
        # two tenants whose AccountStore returns Account(id="") or the
        # default Account(id="<unset>") share a cache scope class and
        # can read each other's tasks via cross-tenant probe (the
        # equality check passes when both are empty). See
        # tests/test_decisioning_task_registry_cross_tenant.py for the
        # regression suite.
        if not account_id or not account_id.strip() or account_id == "<unset>":
            raise ValueError(
                f"account_id must be a non-empty, non-default string; "
                f"got {account_id!r}. AccountStore.resolve must always "
                "return Account(id=<non-empty>) so cross-tenant cache "
                "scoping works correctly."
            )
        task_id = f"task_{uuid.uuid4().hex[:16]}"
        async with self._lock:
            self._records[task_id] = TaskRecord(
                task_id=task_id,
                account_id=account_id,
                state="submitted",
                task_type=task_type,
                request_context=(dict(request_context) if request_context is not None else None),
            )
        return task_id

    async def update_progress(
        self,
        task_id: str,
        progress: dict[str, Any],
    ) -> None:
        async with self._lock:
            record = self._records.get(task_id)
            if record is None:
                # Silent no-op — the dispatch wrapper expects this method
                # to never raise on transient lookup failure (see Protocol
                # docstring).
                return
            if record.state in ("completed", "failed"):
                # Terminal-state guard: a late progress update from a
                # straggler coroutine MUST NOT mutate a finalized record
                # — it would resurrect "working" appearance against
                # ``tasks/get`` reads that already saw the terminal
                # state. Log + drop is the safe choice (the dispatch
                # wrapper is expected to swallow update failures
                # anyway).
                logger.warning(
                    "InMemoryTaskRegistry.update_progress(task_id=%s) "
                    "dropped: task is already in terminal state %r",
                    task_id,
                    record.state,
                )
                return
            record.progress = dict(progress)
            if record.state == "submitted":
                record.state = "working"
            record.updated_at = time.time()

    async def complete(
        self,
        task_id: str,
        result: dict[str, Any],
    ) -> None:
        # Defense-in-depth credential strip at the persistence boundary.
        # The dispatcher's TaskHandoff path also strips before calling
        # complete() (see ``_project_handoff`` in dispatch.py); the
        # WorkflowHandoff path does NOT (the adopter's external
        # workflow calls ``registry.complete`` directly, off the
        # framework's call stack). Stripping here closes that gap and
        # protects custom registry consumers that bypass the dispatcher
        # entirely. The strip is method-gated by ``record.task_type``
        # (the wire verb name persisted at ``issue()``) and idempotent
        # on already-stripped payloads, so the dispatcher-side double
        # strip is a no-op.
        from adcp.decisioning.account_projection import strip_credentials_from_wire_result

        async with self._lock:
            record = self._records.get(task_id)
            if record is None:
                raise ValueError(f"Task {task_id!r} not found")
            stripped = strip_credentials_from_wire_result(record.task_type, result)
            if record.state == "completed":
                if record.result == stripped:
                    return  # idempotent
                raise ValueError(f"Task {task_id!r} already completed with a different result")
            record.state = "completed"
            record.result = dict(stripped) if isinstance(stripped, dict) else stripped
            record.updated_at = time.time()

    async def fail(
        self,
        task_id: str,
        error: dict[str, Any],
    ) -> None:
        async with self._lock:
            record = self._records.get(task_id)
            if record is None:
                raise ValueError(f"Task {task_id!r} not found")
            if record.state == "failed":
                if record.error == error:
                    return  # idempotent
                raise ValueError(f"Task {task_id!r} already failed with a different error")
            record.state = "failed"
            record.error = dict(error)
            record.updated_at = time.time()

    async def get(
        self,
        task_id: str,
        *,
        expected_account_id: str | None = None,
    ) -> dict[str, Any] | None:
        async with self._lock:
            record = self._records.get(task_id)
            if record is None:
                return None
            if expected_account_id is not None and record.account_id != expected_account_id:
                # Cross-tenant probe — return None, NOT raw record.
                # Critical security boundary: returning the record
                # here enables principal-enumeration via task_id
                # probing. The dispatch path that calls this method
                # always passes the authenticated principal's
                # account_id; adopter impls implementing this Protocol
                # MUST preserve this behavior.
                return None
            return record.to_dict()

    async def discard(self, task_id: str) -> None:
        async with self._lock:
            # Idempotent: pop with default. The Protocol contract
            # tolerates discarding an unknown id (no raise) so the
            # WorkflowHandoff projection's rollback can be unconditional.
            self._records.pop(task_id, None)

Process-local task registry — v6.0 reference implementation.

Storage is a plain dict[str, TaskRecord] guarded by an :class:asyncio.Lock. Adequate for local dev, CI, and test fixtures; production deployments wire a durable counterpart (PostgreSQL, Redis, etc.) implementing the same :class:TaskRegistry Protocol.

Production-mode gate: :func:adcp.decisioning.serve.serve refuses to wire this when ADCP_ENV indicates production unless ADCP_DECISIONING_ALLOW_INMEMORY_TASKS=1 is set. The gate reads registry.is_durable; subclassing this class for instrumentation does NOT bypass the gate (the False is inherited). Custom durable impls set is_durable = True explicitly. Production sellers running sales-broadcast-tv or any HITL flow get the explicit refusal so silent in-memory fallback can't bite oncall.

Class variables

var is_durable : ClassVar[bool]

Methods

async def complete(self, task_id: str, result: dict[str, Any]) ‑> None
Expand source code
async def complete(
    self,
    task_id: str,
    result: dict[str, Any],
) -> None:
    # Defense-in-depth credential strip at the persistence boundary.
    # The dispatcher's TaskHandoff path also strips before calling
    # complete() (see ``_project_handoff`` in dispatch.py); the
    # WorkflowHandoff path does NOT (the adopter's external
    # workflow calls ``registry.complete`` directly, off the
    # framework's call stack). Stripping here closes that gap and
    # protects custom registry consumers that bypass the dispatcher
    # entirely. The strip is method-gated by ``record.task_type``
    # (the wire verb name persisted at ``issue()``) and idempotent
    # on already-stripped payloads, so the dispatcher-side double
    # strip is a no-op.
    from adcp.decisioning.account_projection import strip_credentials_from_wire_result

    async with self._lock:
        record = self._records.get(task_id)
        if record is None:
            raise ValueError(f"Task {task_id!r} not found")
        stripped = strip_credentials_from_wire_result(record.task_type, result)
        if record.state == "completed":
            if record.result == stripped:
                return  # idempotent
            raise ValueError(f"Task {task_id!r} already completed with a different result")
        record.state = "completed"
        record.result = dict(stripped) if isinstance(stripped, dict) else stripped
        record.updated_at = time.time()
async def discard(self, task_id: str) ‑> None
Expand source code
async def discard(self, task_id: str) -> None:
    async with self._lock:
        # Idempotent: pop with default. The Protocol contract
        # tolerates discarding an unknown id (no raise) so the
        # WorkflowHandoff projection's rollback can be unconditional.
        self._records.pop(task_id, None)
async def fail(self, task_id: str, error: dict[str, Any]) ‑> None
Expand source code
async def fail(
    self,
    task_id: str,
    error: dict[str, Any],
) -> None:
    async with self._lock:
        record = self._records.get(task_id)
        if record is None:
            raise ValueError(f"Task {task_id!r} not found")
        if record.state == "failed":
            if record.error == error:
                return  # idempotent
            raise ValueError(f"Task {task_id!r} already failed with a different error")
        record.state = "failed"
        record.error = dict(error)
        record.updated_at = time.time()
async def get(self, task_id: str, *, expected_account_id: str | None = None) ‑> dict[str, typing.Any] | None
Expand source code
async def get(
    self,
    task_id: str,
    *,
    expected_account_id: str | None = None,
) -> dict[str, Any] | None:
    async with self._lock:
        record = self._records.get(task_id)
        if record is None:
            return None
        if expected_account_id is not None and record.account_id != expected_account_id:
            # Cross-tenant probe — return None, NOT raw record.
            # Critical security boundary: returning the record
            # here enables principal-enumeration via task_id
            # probing. The dispatch path that calls this method
            # always passes the authenticated principal's
            # account_id; adopter impls implementing this Protocol
            # MUST preserve this behavior.
            return None
        return record.to_dict()
async def issue(self,
*,
account_id: str,
task_type: str,
request_context: dict[str, Any] | None = None,
**_extra: Any) ‑> str
Expand source code
async def issue(
    self,
    *,
    account_id: str,
    task_type: str,
    request_context: dict[str, Any] | None = None,
    **_extra: Any,
) -> str:
    # Forward-compat: log unrecognized kwargs at DEBUG so adopters
    # who haven't yet upgraded notice when they're missing new
    # framework fields. Don't raise — that would break adopters
    # the moment a new version ships.
    if _extra:
        logger.debug(
            "InMemoryTaskRegistry.issue ignoring unrecognized kwargs: %s",
            list(_extra.keys()),
        )
    # Reject empty/unset account_id at issue-time. Without this,
    # two tenants whose AccountStore returns Account(id="") or the
    # default Account(id="<unset>") share a cache scope class and
    # can read each other's tasks via cross-tenant probe (the
    # equality check passes when both are empty). See
    # tests/test_decisioning_task_registry_cross_tenant.py for the
    # regression suite.
    if not account_id or not account_id.strip() or account_id == "<unset>":
        raise ValueError(
            f"account_id must be a non-empty, non-default string; "
            f"got {account_id!r}. AccountStore.resolve must always "
            "return Account(id=<non-empty>) so cross-tenant cache "
            "scoping works correctly."
        )
    task_id = f"task_{uuid.uuid4().hex[:16]}"
    async with self._lock:
        self._records[task_id] = TaskRecord(
            task_id=task_id,
            account_id=account_id,
            state="submitted",
            task_type=task_type,
            request_context=(dict(request_context) if request_context is not None else None),
        )
    return task_id
async def update_progress(self, task_id: str, progress: dict[str, Any]) ‑> None
Expand source code
async def update_progress(
    self,
    task_id: str,
    progress: dict[str, Any],
) -> None:
    async with self._lock:
        record = self._records.get(task_id)
        if record is None:
            # Silent no-op — the dispatch wrapper expects this method
            # to never raise on transient lookup failure (see Protocol
            # docstring).
            return
        if record.state in ("completed", "failed"):
            # Terminal-state guard: a late progress update from a
            # straggler coroutine MUST NOT mutate a finalized record
            # — it would resurrect "working" appearance against
            # ``tasks/get`` reads that already saw the terminal
            # state. Log + drop is the safe choice (the dispatch
            # wrapper is expected to swallow update failures
            # anyway).
            logger.warning(
                "InMemoryTaskRegistry.update_progress(task_id=%s) "
                "dropped: task is already in terminal state %r",
                task_id,
                record.state,
            )
            return
        record.progress = dict(progress)
        if record.state == "submitted":
            record.state = "working"
        record.updated_at = time.time()
class IncrementalGetProducts (*args, **kwargs)
Expand source code
@runtime_checkable
class IncrementalGetProducts(Protocol):
    """Optional upgrade protocol for streaming partial get_products results.

    **Status: Protocol declaration only.** The dispatch routing for this
    path ships in a follow-up to issue #495. Implementing this Protocol
    today has no runtime effect — the framework still calls the plain
    ``get_products`` method. Declare it now so adopters and code generators
    can reference the type; re-enable via the follow-up PR once the dispatch
    branch is wired.

    When the dispatch path lands: the framework routes ``get_products``
    calls through this streaming path instead of the plain method. Batches
    are collected until the ``time_budget`` deadline; remaining scopes are
    projected to ``incomplete[]``.

    Until then, a ``time_budget`` timeout returns
    ``products: []`` + ``incomplete: [{scope: 'products'}]``.

    Usage::

        from adcp.decisioning import IncrementalGetProducts, ProductsCheckpoint
        from adcp.types import GetProductsRequest
        from adcp.decisioning import RequestContext
        from typing import AsyncIterator

        class MySeller(DecisioningPlatform, IncrementalGetProducts):
            async def get_products_incremental(
                self,
                req: GetProductsRequest,
                ctx: RequestContext,
                checkpoint: ProductsCheckpoint,
            ) -> AsyncIterator[dict]:
                for batch in self._stream_products(req):
                    checkpoint.add_batch(batch)
                    yield batch

    Note: ``get_products_incremental`` MUST be an ``async def`` that yields —
    i.e., an async generator function. The framework detects it via
    ``asyncio.isasyncgenfunction``, not ``asyncio.iscoroutinefunction``. If
    your data source is synchronous, wrap the yield in an ``async def`` body
    rather than returning a sync generator.

    ``campaign`` unit: if ``req.time_budget.unit == 'campaign'``, the
    framework does not install a deadline; this method is called the same as
    the plain path, and the adopter may yield indefinitely (within the
    campaign flight window).
    """

    async def get_products_incremental(
        self,
        req: GetProductsRequest,
        ctx: RequestContext,
        checkpoint: ProductsCheckpoint,
    ) -> AsyncIterator[dict[str, Any]]:
        """Yield partial product batches until complete or deadline fires."""
        ...

Optional upgrade protocol for streaming partial get_products results.

Status: Protocol declaration only. The dispatch routing for this path ships in a follow-up to issue #495. Implementing this Protocol today has no runtime effect — the framework still calls the plain get_products method. Declare it now so adopters and code generators can reference the type; re-enable via the follow-up PR once the dispatch branch is wired.

When the dispatch path lands: the framework routes get_products calls through this streaming path instead of the plain method. Batches are collected until the adcp.decisioning.time_budget deadline; remaining scopes are projected to incomplete[].

Until then, a adcp.decisioning.time_budget timeout returns products: [] + incomplete: [{scope: 'products'}].

Usage::

from adcp.decisioning import IncrementalGetProducts, ProductsCheckpoint
from adcp.types import GetProductsRequest
from adcp.decisioning import RequestContext
from typing import AsyncIterator

class MySeller(DecisioningPlatform, IncrementalGetProducts):
    async def get_products_incremental(
        self,
        req: GetProductsRequest,
        ctx: RequestContext,
        checkpoint: ProductsCheckpoint,
    ) -> AsyncIterator[dict]:
        for batch in self._stream_products(req):
            checkpoint.add_batch(batch)
            yield batch

Note: get_products_incremental MUST be an async def that yields — i.e., an async generator function. The framework detects it via asyncio.isasyncgenfunction, not asyncio.iscoroutinefunction. If your data source is synchronous, wrap the yield in an async def body rather than returning a sync generator.

campaign unit: if req.time_budget.unit == 'campaign', the framework does not install a deadline; this method is called the same as the plain path, and the adopter may yield indefinitely (within the campaign flight window).

Ancestors

  • typing.Protocol
  • typing.Generic

Methods

async def get_products_incremental(self,
req: GetProductsRequest,
ctx: RequestContext,
checkpoint: ProductsCheckpoint) ‑> AsyncIterator[dict[str, Any]]
Expand source code
async def get_products_incremental(
    self,
    req: GetProductsRequest,
    ctx: RequestContext,
    checkpoint: ProductsCheckpoint,
) -> AsyncIterator[dict[str, Any]]:
    """Yield partial product batches until complete or deadline fires."""
    ...

Yield partial product batches until complete or deadline fires.

class LazyPlatformRouter (*,
accounts: AccountStore[Any],
factory: PlatformFactory,
capabilities: DecisioningCapabilities,
proposal_managers: Mapping[str, ProposalManager] | None = None,
proposal_stores: Mapping[str, ProposalStore] | None = None,
proposal_store_factory: Callable[[str], ProposalStore | None] | None = None,
cache_size: int = 256,
cache_ttl_seconds: float = 3600.0)
Expand source code
class LazyPlatformRouter(DecisioningPlatform):
    """Per-tenant ``DecisioningPlatform`` constructed on first request.

    A :class:`PlatformRouter` variant that defers building per-tenant
    platforms to first-request lookup, with a bounded LRU + TTL cache.
    Drop-in replacement: ``isinstance(router, DecisioningPlatform)`` is
    true, ``serve()`` accepts it identically, and it shares the same
    ``ctx.account.metadata['tenant_id']`` resolution path.

    **When to reach for this over** :class:`PlatformRouter`:

    * **N tenants × per-tenant SDK auth handshake.** Eagerly building
      every platform at boot scales O(N) and the auth handshake (e.g.,
      Google Ad Manager service-account, Kevel API key) typically does
      network I/O — so 50-500 tenants means the boot path either takes
      minutes or you write your own lazy layer.
    * **Hot-add / hot-rotate of tenants.** Today's eager router pins
      every platform at construction; adding a new tenant requires a
      restart. The lazy router builds on first request, with
      :meth:`invalidate` for explicit eviction when a tenant rotates.
    * **Memory profile under tenant churn.** The eager router holds
      every platform for the process lifetime. The lazy router's
      bounded cache evicts platforms for inactive tenants — strictly
      safer for long-lived processes.

    **Async factory.** Building per-tenant adapters typically does I/O.
    The factory may be sync or async; the router awaits at call time
    (matches :class:`adcp.server.CallableSubdomainTenantRouter`'s
    convention). Sync factories that block the event loop should be
    refactored to async or routed through :func:`asyncio.to_thread`
    inside the factory.

    Example::

        from adcp.decisioning import LazyPlatformRouter, DecisioningCapabilities, serve

        async def build_platform(tenant_id: str) -> DecisioningPlatform:
            cfg = await load_tenant_config(tenant_id)
            if cfg.adapter == "google_ad_manager":
                return WonderstruckGamPlatform(cfg)
            elif cfg.adapter == "kevel":
                return KevelPlatform(cfg)
            return MockSellerPlatform(cfg)

        router = LazyPlatformRouter(
            accounts=tenant_routing_account_store,
            factory=build_platform,
            capabilities=DecisioningCapabilities(specialisms=[...]),
            cache_size=256,             # default
            cache_ttl_seconds=3600.0,   # default; 0 = size-only eviction
        )

        serve(router, ...)

    **Invalidation.** Adopters call :meth:`invalidate` from tenant
    rotation / deactivation paths::

        router.invalidate("tenant-a")   # specific tenant
        router.invalidate()             # all platforms — ops "drop everything"

    Behavior on ``invalidate`` of an in-flight request: the request
    that already grabbed the platform reference completes normally
    (caller holds the ref); the next request gets a fresh build. No
    request cancellation. Mirrors
    :class:`adcp.server.CallableSubdomainTenantRouter`'s contract.

    **Thundering herd.** If two concurrent requests for the same cold
    tenant hit, both await the factory; asyncio cooperative scheduling
    means no corruption (last-write wins, both refs are equivalent),
    but the auth handshake runs 2x. Singleflight (one-build-per-tenant
    under contention) is intentionally NOT in v1 — adopters reporting
    DB pressure / API rate-limit spikes is the trigger to add it.

    **Capabilities** are adopter-supplied (the union of what every
    tenant's platform serves). The router can't introspect children at
    boot — it doesn't know what tenants exist yet — so the adopter is
    the source of truth here.

    :param accounts: The adopter's :class:`AccountStore`. Same role as
        :class:`PlatformRouter`: resolves every request to an
        :class:`Account` whose ``metadata['tenant_id']`` keys the
        factory.
    :param factory: Callable taking a ``tenant_id`` string and
        returning a :class:`DecisioningPlatform` (sync or async). Must
        not return ``None`` — return a typed
        :class:`adcp.decisioning.types.AdcpError` raise from inside the
        factory if the tenant is invalid.
    :param capabilities: The router's wire-shape capability declaration
        — should be the union of every child platform's specialisms.
    :param proposal_managers: Optional ``{tenant_id: ProposalManager}``
        — eager (managers are dict-cheap to hold). Per-tenant
        ``get_products`` routes to the manager when wired; otherwise
        falls through to the lazily-resolved platform's
        ``get_products``.
    :param proposal_stores: Optional ``{tenant_id: ProposalStore}`` —
        eager per-tenant proposal store dict. Mirrors the eager
        :class:`PlatformRouter`'s ``proposal_stores=`` shape for
        adopters with a small known tenant set. Mutually exclusive
        with ``proposal_store_factory``. Stores are dict-cheap to
        hold; the lazy machinery applies only to ``platforms`` (which
        wrap upstream connections / credentials and warrant the LRU
        eviction). See #722.
    :param proposal_store_factory: Optional
        ``Callable[[str], ProposalStore | None]`` — lazy-build flavor.
        **Called on every** :meth:`proposal_store_for_tenant`
        **invocation** (no internal caching), and the framework's
        ``proposal_dispatch`` calls the accessor 2–3× per request on
        the proposal path. If your factory does non-trivial work
        (opens a connection pool, reads config, etc.), wrap it with
        :func:`functools.lru_cache` or your own memoization — most
        ``ProposalStore`` implementations hold long-lived DB pool
        references and adopters typically return the same instance
        per tenant. Return ``None`` for tenants that don't need a
        store (pure-catalog mode without finalize). Mutually exclusive
        with ``proposal_stores``. See #722.
    :param cache_size: Maximum number of cached :class:`DecisioningPlatform`
        instances. Bounded LRU eviction past this size. Default 256.
        Adopters with more concurrent active tenants override.
    :param cache_ttl_seconds: Per-entry TTL in seconds. ``0`` means
        size-only eviction (no time-based expiry). Default 3600.0
        (one hour). Distinct from
        :class:`adcp.server.CallableSubdomainTenantRouter` which
        rejects ``0`` — there, *tenants* go stale; here, *platform
        adapters* don't, *unless* your factory reads mutable config
        (rotating API keys, adapter selection driven by a config
        store). In that case, override to a value that bounds your
        rotation lag, or call :meth:`invalidate` from your rotation
        path.

    :raises ValueError: when ``cache_size <= 0`` or
        ``cache_ttl_seconds < 0``.
    """

    def __init__(
        self,
        *,
        accounts: AccountStore[Any],
        factory: PlatformFactory,
        capabilities: DecisioningCapabilities,
        proposal_managers: Mapping[str, ProposalManager] | None = None,
        proposal_stores: Mapping[str, ProposalStore] | None = None,
        proposal_store_factory: Callable[[str], ProposalStore | None] | None = None,
        cache_size: int = 256,
        cache_ttl_seconds: float = 3600.0,
    ) -> None:
        if cache_size <= 0:
            raise ValueError(
                f"cache_size must be > 0, got {cache_size}. The whole point of "
                "LazyPlatformRouter is to bound resident memory; an unbounded "
                "cache would re-introduce the eager-router's leak profile."
            )
        if cache_ttl_seconds < 0:
            raise ValueError(
                f"cache_ttl_seconds must be >= 0, got {cache_ttl_seconds}. "
                "Pass 0 for size-only eviction (no time-based expiry)."
            )

        if proposal_stores is not None and proposal_store_factory is not None:
            raise ValueError(
                "LazyPlatformRouter: pass either proposal_stores= (eager "
                "per-tenant dict) or proposal_store_factory= (lazy), not "
                "both. The eager dict pre-binds tenants; the factory "
                "resolves on first request. See #722."
            )

        self.accounts = accounts
        self.capabilities = capabilities
        self._factory = factory
        self._proposal_managers: dict[str, ProposalManager] = dict(proposal_managers or {})
        # #722: parity with PlatformRouter — accept proposal_stores=
        # (eager) or proposal_store_factory= (lazy). The eager dict is
        # the typical small-tenant-set shape; the factory composes with
        # the lazy-build philosophy of LazyPlatformRouter (matches
        # ``factory=`` for platforms).
        self._proposal_stores: dict[str, ProposalStore] = dict(proposal_stores or {})
        self._proposal_store_factory: Callable[[str], ProposalStore | None] | None = (
            proposal_store_factory
        )

        # Cross-store consistency check on the manager-eager subset.
        # ``proposal_managers`` is eager (dict-cheap), so even though
        # we can't enumerate every possible tenant, we CAN walk the
        # known managers and refuse to construct if a finalize-capable
        # manager has no store wired for its tenant (eager dict miss
        # AND no factory). Recovers ~80% of the boot-time validation
        # the eager :class:`PlatformRouter` provides at line 376-386 —
        # adopters migrating from eager to lazy don't silently lose
        # the wiring-gap signal. The factory-only path defers to
        # first-request validation (the factory might legitimately
        # return ``None`` for some tenants).
        for tenant_id, manager in self._proposal_managers.items():
            caps = getattr(manager, "capabilities", None)
            finalize_supported = bool(getattr(caps, "finalize", False))
            if (
                finalize_supported
                and tenant_id not in self._proposal_stores
                and self._proposal_store_factory is None
            ):
                raise ValueError(
                    f"Tenant {tenant_id!r} wired a ProposalManager declaring "
                    f"finalize=True, but no ProposalStore was registered for "
                    f"that tenant and no proposal_store_factory was configured. "
                    f"Wire one via "
                    f"proposal_stores={{{tenant_id!r}: InMemoryProposalStore()}}, "
                    "supply a proposal_store_factory=, or remove the finalize "
                    "capability."
                )

        self._cache_size = cache_size
        self._cache_ttl = cache_ttl_seconds
        # OrderedDict gives LRU-by-move-to-end and bounded popitem(last=False).
        # Entry: (DecisioningPlatform, expires_at_monotonic). When ttl=0,
        # expires_at = math.inf so the time check never trips.
        self._cache: OrderedDict[str, tuple[DecisioningPlatform, float]] = OrderedDict()
        # Per-tenant generation counter. Bumped by :meth:`invalidate`
        # (specific tenant) and the global counter is bumped by the
        # ``invalidate(None)`` flush. :meth:`_resolve_platform`
        # snapshots the (tenant, global) generation pair before
        # awaiting the factory and refuses to cache a build that lost
        # the rollover race — otherwise a slow build outracing an
        # ``invalidate`` call would silently resurrect the stale
        # platform after operators thought it was gone.
        self._tenant_generations: dict[str, int] = {}
        self._global_generation: int = 0

        # Synthesize delegating methods, mirroring PlatformRouter's
        # construction. ``get_products`` is special-cased below for
        # proposal_managers routing.
        for method_name in sorted(_all_specialism_methods()):
            if method_name in _ACCOUNT_STORE_METHODS:
                continue
            if method_name == "get_products":
                continue
            self.__dict__[method_name] = self._make_delegate(method_name)

    # ----- public introspection / control --------------------------------

    @property
    def cached_tenants(self) -> frozenset[str]:
        """The set of tenant ids currently in the cache.

        Read-only snapshot; mutations to the cache after this property
        is read are not reflected.
        """
        return frozenset(self._cache)

    def invalidate(self, tenant_id: str | None = None) -> None:
        """Drop a cached platform (or every cached platform).

        Adopters call this from tenant rotation / deactivation paths to
        evict before the TTL fires. Safe to call when the tenant isn't
        currently cached (no-op).

        :param tenant_id: Specific tenant to evict. ``None`` clears the
            entire cache.
        """
        if tenant_id is None:
            self._cache.clear()
            self._global_generation += 1
            return
        self._cache.pop(tenant_id, None)
        self._tenant_generations[tenant_id] = self._tenant_generations.get(tenant_id, 0) + 1

    # ----- per-tenant dispatch -------------------------------------------

    async def _resolve_platform(self, tenant_id: str) -> DecisioningPlatform:
        """Return the platform for ``tenant_id``, building via the factory on miss.

        The factory may be sync or async; the result is awaited if
        awaitable. Cache writes happen after a successful build — a
        factory that raises is NOT cached, so the next request retries.

        **Invalidate-during-build race.** :meth:`invalidate` bumps a
        per-tenant or global generation counter. This method snapshots
        both before awaiting the factory and refuses to cache the
        result if either advanced — the freshly-built platform is
        returned to the in-flight caller (which already paid for it),
        but the cache slot stays empty so the next request rebuilds.
        Without this, a slow factory racing an ``invalidate(tenant_id)``
        would silently resurrect the platform after operators thought
        it was gone.
        """
        cached = self._cache_get(tenant_id)
        if cached is not _LAZY_CACHE_MISS:
            return cached  # type: ignore[return-value]

        tenant_gen_at_start = self._tenant_generations.get(tenant_id, 0)
        global_gen_at_start = self._global_generation

        result = self._factory(tenant_id)
        if inspect.isawaitable(result):
            result = await result

        if result is None:
            raise AdcpError(
                "ACCOUNT_NOT_FOUND",
                message=(
                    f"LazyPlatformRouter factory returned None for "
                    f"tenant_id={tenant_id!r}. The factory must return a "
                    "DecisioningPlatform (the lazy-router equivalent of an "
                    "unknown tenant in PlatformRouter.platforms). Raise "
                    "AdcpError from inside the factory if the tenant should "
                    "be rejected."
                ),
                recovery="terminal",
                field="account.metadata.tenant_id",
            )

        # Type guard: the factory's return type union is checked at
        # static analysis; runtime mis-typed returns surface here as
        # validation rather than silent corruption.
        if not isinstance(result, DecisioningPlatform):
            raise AdcpError(
                "INTERNAL_ERROR",
                message=(
                    f"LazyPlatformRouter factory returned a "
                    f"{type(result).__name__!r} for tenant_id={tenant_id!r}; "
                    "expected a DecisioningPlatform instance."
                ),
                recovery="terminal",
            )

        # Skip the cache write if invalidation raced past us.
        invalidated = (
            self._tenant_generations.get(tenant_id, 0) != tenant_gen_at_start
            or self._global_generation != global_gen_at_start
        )
        if not invalidated:
            self._cache_put(tenant_id, result)
        return result

    def _cache_get(self, tenant_id: str) -> DecisioningPlatform | object:
        entry = self._cache.get(tenant_id)
        if entry is None:
            return _LAZY_CACHE_MISS
        platform, expires_at = entry
        if self._cache_ttl > 0 and time.monotonic() > expires_at:
            self._cache.pop(tenant_id, None)
            return _LAZY_CACHE_MISS
        # LRU touch — most-recently used to the end.
        self._cache.move_to_end(tenant_id)
        return platform

    def _cache_put(self, tenant_id: str, platform: DecisioningPlatform) -> None:
        # ttl=0 → never expire; sort everything by LRU only. Use +inf
        # so the same time-check branch above stays trivial.
        if self._cache_ttl > 0:
            expires_at = time.monotonic() + self._cache_ttl
        else:
            expires_at = float("inf")
        self._cache[tenant_id] = (platform, expires_at)
        self._cache.move_to_end(tenant_id)
        while len(self._cache) > self._cache_size:
            self._cache.popitem(last=False)

    async def _platform_for_method(
        self,
        ctx: RequestContext[Any],
        method_name: str,
    ) -> DecisioningPlatform:
        """Async equivalent of :meth:`PlatformRouter._platform_for`.

        :raises AdcpError: ``ACCOUNT_NOT_FOUND`` when the factory rejects
            the tenant (returns None / raises). ``UNSUPPORTED_FEATURE``
            when the platform exists but doesn't implement the method.
        """
        tenant_id = _tenant_id_from_ctx(ctx)
        platform = await self._resolve_platform(tenant_id)
        method = getattr(platform, method_name, None)
        if method is None or not callable(method):
            raise AdcpError(
                "UNSUPPORTED_FEATURE",
                message=(
                    f"Tenant {tenant_id!r}'s platform "
                    f"({type(platform).__name__}) does not implement "
                    f"{method_name!r}. The router advertises this method "
                    "because at least one tenant supports it, but this "
                    "tenant's platform doesn't."
                ),
                recovery="terminal",
            )
        return platform

    def _make_delegate(self, method_name: str) -> Any:
        """Create a delegating callable for ``method_name``.

        Async closure that resolves the tenant, awaits the factory if
        cold, looks up the method, and delegates with sync/async
        handling matching :meth:`PlatformRouter._make_delegate`.
        """
        router = self

        async def _delegate(*args: Any, **kwargs: Any) -> Any:
            ctx = _resolve_ctx_from_args(args, kwargs)
            platform = await router._platform_for_method(ctx, method_name)
            method = getattr(platform, method_name)
            if inspect.iscoroutinefunction(method):
                return await method(*args, **kwargs)
            return await asyncio.to_thread(method, *args, **kwargs)

        _delegate.__name__ = method_name
        _delegate.__qualname__ = f"LazyPlatformRouter.{method_name}"
        return _delegate

    async def refine_get_products(self, *args: Any, **kwargs: Any) -> Any:
        """Refine entry point — delegates to :meth:`get_products`.

        Mirrors :meth:`PlatformRouter.refine_get_products`; the handler's
        refine pathway dispatches via
        ``_invoke_platform_method(platform, "refine_get_products", ...)``
        when the platform's :func:`has_refine_support` returns True.
        """
        return await self.get_products(*args, **kwargs)

    async def get_products(self, *args: Any, **kwargs: Any) -> Any:
        """Per-tenant ``get_products`` dispatch.

        Resolves the tenant, then routes to the wired
        :class:`ProposalManager` when one exists for the tenant
        (refine vs. plain selection mirrors PlatformRouter); otherwise
        falls through to the lazily-resolved platform's
        ``get_products``.
        """
        ctx = _resolve_ctx_from_args(args, kwargs)
        tenant_id = _tenant_id_from_ctx(ctx)
        manager = self._proposal_managers.get(tenant_id)

        if manager is not None:
            method_name = _select_proposal_method(manager, args, kwargs)
            method = getattr(manager, method_name)
            if inspect.iscoroutinefunction(method):
                return await method(*args, **kwargs)
            return await asyncio.to_thread(method, *args, **kwargs)

        platform = await self._platform_for_method(ctx, "get_products")
        method = getattr(platform, "get_products")
        if inspect.iscoroutinefunction(method):
            return await method(*args, **kwargs)
        return await asyncio.to_thread(method, *args, **kwargs)

    def proposal_manager_for_tenant(self, tenant_id: str) -> ProposalManager | None:
        """Return the :class:`ProposalManager` for ``tenant_id``, or ``None``."""
        return self._proposal_managers.get(tenant_id)

    def proposal_store_for_tenant(self, tenant_id: str) -> ProposalStore | None:
        """Return the :class:`ProposalStore` for ``tenant_id``, or ``None``.

        Resolution order:

        1. Eager ``proposal_stores`` dict, when wired.
        2. ``proposal_store_factory(tenant_id)``, when wired. The
           factory is invoked on every call — adopters who need
           caching wrap the factory themselves (most ``ProposalStore``
           implementations hold long-lived pool references and are
           cheap to return on repeat calls).
        3. ``None`` — the tenant has no store wired and the framework's
           ``proposal_dispatch`` falls through to the v1 (no-proposal)
           path.

        Sibling-API parity with :meth:`PlatformRouter.proposal_store_for_tenant`
        for adopters wiring a :class:`ProposalStore` per #722. The
        framework's ``proposal_dispatch`` duck-types this method via
        ``hasattr(platform, "proposal_store_for_tenant")``.
        """
        store = self._proposal_stores.get(tenant_id)
        if store is not None:
            return store
        if self._proposal_store_factory is not None:
            return self._proposal_store_factory(tenant_id)
        return None

    async def platform_for_tenant(self, tenant_id: str) -> DecisioningPlatform:
        """Return the platform for ``tenant_id``, building via the factory if needed.

        Sibling-API parity with :meth:`PlatformRouter.platform_for_tenant`
        for adopters with admin / health endpoints that need direct
        access to a tenant's platform. Triggers the factory on cache
        miss (this is a write-path call, not just a getter).

        :raises AdcpError: ``ACCOUNT_NOT_FOUND`` when the factory
            rejects the tenant. ``INTERNAL_ERROR`` when the factory
            returns a non-:class:`DecisioningPlatform` value.
        """
        return await self._resolve_platform(tenant_id)

Per-tenant DecisioningPlatform constructed on first request.

A :class:PlatformRouter variant that defers building per-tenant platforms to first-request lookup, with a bounded LRU + TTL cache. Drop-in replacement: isinstance(router, DecisioningPlatform) is true, serve() accepts it identically, and it shares the same ctx.account.metadata['tenant_id'] resolution path.

When to reach for this over :class:PlatformRouter:

  • N tenants × per-tenant SDK auth handshake. Eagerly building every platform at boot scales O(N) and the auth handshake (e.g., Google Ad Manager service-account, Kevel API key) typically does network I/O — so 50-500 tenants means the boot path either takes minutes or you write your own lazy layer.
  • Hot-add / hot-rotate of tenants. Today's eager router pins every platform at construction; adding a new tenant requires a restart. The lazy router builds on first request, with :meth:invalidate for explicit eviction when a tenant rotates.
  • Memory profile under tenant churn. The eager router holds every platform for the process lifetime. The lazy router's bounded cache evicts platforms for inactive tenants — strictly safer for long-lived processes.

Async factory. Building per-tenant adapters typically does I/O. The factory may be sync or async; the router awaits at call time (matches :class:CallableSubdomainTenantRouter's convention). Sync factories that block the event loop should be refactored to async or routed through :func:asyncio.to_thread inside the factory.

Example::

from adcp.decisioning import LazyPlatformRouter, DecisioningCapabilities, serve

async def build_platform(tenant_id: str) -> DecisioningPlatform:
    cfg = await load_tenant_config(tenant_id)
    if cfg.adapter == "google_ad_manager":
        return WonderstruckGamPlatform(cfg)
    elif cfg.adapter == "kevel":
        return KevelPlatform(cfg)
    return MockSellerPlatform(cfg)

router = LazyPlatformRouter(
    accounts=tenant_routing_account_store,
    factory=build_platform,
    capabilities=DecisioningCapabilities(specialisms=[...]),
    cache_size=256,             # default
    cache_ttl_seconds=3600.0,   # default; 0 = size-only eviction
)

serve(router, ...)

Invalidation. Adopters call :meth:invalidate from tenant rotation / deactivation paths::

router.invalidate("tenant-a")   # specific tenant
router.invalidate()             # all platforms — ops "drop everything"

Behavior on invalidate of an in-flight request: the request that already grabbed the platform reference completes normally (caller holds the ref); the next request gets a fresh build. No request cancellation. Mirrors :class:CallableSubdomainTenantRouter's contract.

Thundering herd. If two concurrent requests for the same cold tenant hit, both await the factory; asyncio cooperative scheduling means no corruption (last-write wins, both refs are equivalent), but the auth handshake runs 2x. Singleflight (one-build-per-tenant under contention) is intentionally NOT in v1 — adopters reporting DB pressure / API rate-limit spikes is the trigger to add it.

Capabilities are adopter-supplied (the union of what every tenant's platform serves). The router can't introspect children at boot — it doesn't know what tenants exist yet — so the adopter is the source of truth here.

:param accounts: The adopter's :class:AccountStore. Same role as :class:PlatformRouter: resolves every request to an :class:Account whose metadata['tenant_id'] keys the factory. :param factory: Callable taking a tenant_id string and returning a :class:DecisioningPlatform (sync or async). Must not return None — return a typed :class:AdcpError raise from inside the factory if the tenant is invalid. :param capabilities: The router's wire-shape capability declaration — should be the union of every child platform's specialisms. :param proposal_managers: Optional {tenant_id: ProposalManager} — eager (managers are dict-cheap to hold). Per-tenant get_products routes to the manager when wired; otherwise falls through to the lazily-resolved platform's get_products. :param proposal_stores: Optional {tenant_id: ProposalStore} — eager per-tenant proposal store dict. Mirrors the eager :class:PlatformRouter's proposal_stores= shape for adopters with a small known tenant set. Mutually exclusive with proposal_store_factory. Stores are dict-cheap to hold; the lazy machinery applies only to platforms (which wrap upstream connections / credentials and warrant the LRU eviction). See #722. :param proposal_store_factory: Optional Callable[[str], ProposalStore | None] — lazy-build flavor. Called on every :meth:proposal_store_for_tenant invocation (no internal caching), and the framework's adcp.decisioning.proposal_dispatch calls the accessor 2–3× per request on the proposal path. If your factory does non-trivial work (opens a connection pool, reads config, etc.), wrap it with :func:functools.lru_cache or your own memoization — most ProposalStore implementations hold long-lived DB pool references and adopters typically return the same instance per tenant. Return None for tenants that don't need a store (pure-catalog mode without finalize). Mutually exclusive with proposal_stores. See #722. :param cache_size: Maximum number of cached :class:DecisioningPlatform instances. Bounded LRU eviction past this size. Default 256. Adopters with more concurrent active tenants override. :param cache_ttl_seconds: Per-entry TTL in seconds. 0 means size-only eviction (no time-based expiry). Default 3600.0 (one hour). Distinct from :class:CallableSubdomainTenantRouter which rejects 0 — there, tenants go stale; here, platform adapters don't, unless your factory reads mutable config (rotating API keys, adapter selection driven by a config store). In that case, override to a value that bounds your rotation lag, or call :meth:invalidate from your rotation path.

:raises ValueError: when cache_size <= 0 or cache_ttl_seconds < 0.

Ancestors

Instance variables

prop cached_tenants : frozenset[str]
Expand source code
@property
def cached_tenants(self) -> frozenset[str]:
    """The set of tenant ids currently in the cache.

    Read-only snapshot; mutations to the cache after this property
    is read are not reflected.
    """
    return frozenset(self._cache)

The set of tenant ids currently in the cache.

Read-only snapshot; mutations to the cache after this property is read are not reflected.

Methods

async def get_products(self, *args: Any, **kwargs: Any) ‑> Any
Expand source code
async def get_products(self, *args: Any, **kwargs: Any) -> Any:
    """Per-tenant ``get_products`` dispatch.

    Resolves the tenant, then routes to the wired
    :class:`ProposalManager` when one exists for the tenant
    (refine vs. plain selection mirrors PlatformRouter); otherwise
    falls through to the lazily-resolved platform's
    ``get_products``.
    """
    ctx = _resolve_ctx_from_args(args, kwargs)
    tenant_id = _tenant_id_from_ctx(ctx)
    manager = self._proposal_managers.get(tenant_id)

    if manager is not None:
        method_name = _select_proposal_method(manager, args, kwargs)
        method = getattr(manager, method_name)
        if inspect.iscoroutinefunction(method):
            return await method(*args, **kwargs)
        return await asyncio.to_thread(method, *args, **kwargs)

    platform = await self._platform_for_method(ctx, "get_products")
    method = getattr(platform, "get_products")
    if inspect.iscoroutinefunction(method):
        return await method(*args, **kwargs)
    return await asyncio.to_thread(method, *args, **kwargs)

Per-tenant get_products dispatch.

Resolves the tenant, then routes to the wired :class:ProposalManager when one exists for the tenant (refine vs. plain selection mirrors PlatformRouter); otherwise falls through to the lazily-resolved platform's get_products.

def invalidate(self, tenant_id: str | None = None) ‑> None
Expand source code
def invalidate(self, tenant_id: str | None = None) -> None:
    """Drop a cached platform (or every cached platform).

    Adopters call this from tenant rotation / deactivation paths to
    evict before the TTL fires. Safe to call when the tenant isn't
    currently cached (no-op).

    :param tenant_id: Specific tenant to evict. ``None`` clears the
        entire cache.
    """
    if tenant_id is None:
        self._cache.clear()
        self._global_generation += 1
        return
    self._cache.pop(tenant_id, None)
    self._tenant_generations[tenant_id] = self._tenant_generations.get(tenant_id, 0) + 1

Drop a cached platform (or every cached platform).

Adopters call this from tenant rotation / deactivation paths to evict before the TTL fires. Safe to call when the tenant isn't currently cached (no-op).

:param tenant_id: Specific tenant to evict. None clears the entire cache.

async def platform_for_tenant(self, tenant_id: str) ‑> DecisioningPlatform
Expand source code
async def platform_for_tenant(self, tenant_id: str) -> DecisioningPlatform:
    """Return the platform for ``tenant_id``, building via the factory if needed.

    Sibling-API parity with :meth:`PlatformRouter.platform_for_tenant`
    for adopters with admin / health endpoints that need direct
    access to a tenant's platform. Triggers the factory on cache
    miss (this is a write-path call, not just a getter).

    :raises AdcpError: ``ACCOUNT_NOT_FOUND`` when the factory
        rejects the tenant. ``INTERNAL_ERROR`` when the factory
        returns a non-:class:`DecisioningPlatform` value.
    """
    return await self._resolve_platform(tenant_id)

Return the platform for tenant_id, building via the factory if needed.

Sibling-API parity with :meth:PlatformRouter.platform_for_tenant() for adopters with admin / health endpoints that need direct access to a tenant's platform. Triggers the factory on cache miss (this is a write-path call, not just a getter).

:raises AdcpError: ACCOUNT_NOT_FOUND when the factory rejects the tenant. INTERNAL_ERROR when the factory returns a non-:class:DecisioningPlatform value.

def proposal_manager_for_tenant(self, tenant_id: str) ‑> ProposalManager | None
Expand source code
def proposal_manager_for_tenant(self, tenant_id: str) -> ProposalManager | None:
    """Return the :class:`ProposalManager` for ``tenant_id``, or ``None``."""
    return self._proposal_managers.get(tenant_id)

Return the :class:ProposalManager for tenant_id, or None.

def proposal_store_for_tenant(self, tenant_id: str) ‑> ProposalStore | None
Expand source code
def proposal_store_for_tenant(self, tenant_id: str) -> ProposalStore | None:
    """Return the :class:`ProposalStore` for ``tenant_id``, or ``None``.

    Resolution order:

    1. Eager ``proposal_stores`` dict, when wired.
    2. ``proposal_store_factory(tenant_id)``, when wired. The
       factory is invoked on every call — adopters who need
       caching wrap the factory themselves (most ``ProposalStore``
       implementations hold long-lived pool references and are
       cheap to return on repeat calls).
    3. ``None`` — the tenant has no store wired and the framework's
       ``proposal_dispatch`` falls through to the v1 (no-proposal)
       path.

    Sibling-API parity with :meth:`PlatformRouter.proposal_store_for_tenant`
    for adopters wiring a :class:`ProposalStore` per #722. The
    framework's ``proposal_dispatch`` duck-types this method via
    ``hasattr(platform, "proposal_store_for_tenant")``.
    """
    store = self._proposal_stores.get(tenant_id)
    if store is not None:
        return store
    if self._proposal_store_factory is not None:
        return self._proposal_store_factory(tenant_id)
    return None

Return the :class:ProposalStore for tenant_id, or None.

Resolution order:

  1. Eager proposal_stores dict, when wired.
  2. proposal_store_factory(tenant_id), when wired. The factory is invoked on every call — adopters who need caching wrap the factory themselves (most ProposalStore implementations hold long-lived pool references and are cheap to return on repeat calls).
  3. None — the tenant has no store wired and the framework's adcp.decisioning.proposal_dispatch falls through to the v1 (no-proposal) path.

Sibling-API parity with :meth:PlatformRouter.proposal_store_for_tenant() for adopters wiring a :class:ProposalStore per #722. The framework's adcp.decisioning.proposal_dispatch duck-types this method via hasattr(platform, "proposal_store_for_tenant").

async def refine_get_products(self, *args: Any, **kwargs: Any) ‑> Any
Expand source code
async def refine_get_products(self, *args: Any, **kwargs: Any) -> Any:
    """Refine entry point — delegates to :meth:`get_products`.

    Mirrors :meth:`PlatformRouter.refine_get_products`; the handler's
    refine pathway dispatches via
    ``_invoke_platform_method(platform, "refine_get_products", ...)``
    when the platform's :func:`has_refine_support` returns True.
    """
    return await self.get_products(*args, **kwargs)

Refine entry point — delegates to :meth:get_products.

Mirrors :meth:PlatformRouter.refine_get_products(); the handler's refine pathway dispatches via _invoke_platform_method(platform, "refine_get_products", ...) when the platform's :func:has_refine_support() returns True.

Inherited members

class MediaBuyNotFoundError (*,
media_buy_id: str | None = None,
message: str | None = None,
field: str | None = None,
**details: Any)
Expand source code
class MediaBuyNotFoundError(AdcpError):
    """Spec ``MEDIA_BUY_NOT_FOUND`` (``recovery='correctable'``).

    Raised when the referenced media buy does not exist or is not
    accessible to the requesting agent. Sellers MUST return this code
    uniformly for any ``media_buy_id`` not owned by the calling
    account — never distinguish "exists in another tenant" from
    "does not exist".
    """

    def __init__(
        self,
        *,
        media_buy_id: str | None = None,
        message: str | None = None,
        field: str | None = None,
        **details: Any,
    ) -> None:
        merged: dict[str, Any] = dict(details)
        if media_buy_id is not None:
            merged["media_buy_id"] = media_buy_id
        super().__init__(
            "MEDIA_BUY_NOT_FOUND",
            message=message or "Media buy not found.",
            recovery="correctable",
            field=field,
            details=merged or None,
        )

Spec MEDIA_BUY_NOT_FOUND (recovery='correctable').

Raised when the referenced media buy does not exist or is not accessible to the requesting agent. Sellers MUST return this code uniformly for any media_buy_id not owned by the calling account — never distinguish "exists in another tenant" from "does not exist".

Ancestors

  • AdcpError
  • builtins.Exception
  • builtins.BaseException

Inherited members

class MediaBuyStore (*args, **kwargs)
Expand source code
@runtime_checkable
class MediaBuyStore(Protocol):
    """Adopter-supplied persistence + echo for ``targeting_overlay``.

    Three methods cover the full lifecycle of a per-package overlay:

    * :meth:`persist_from_create` records overlays from a successful
      ``create_media_buy``, joining the request's per-package overlay
      with the response's seller-assigned ``package_id`` (or
      ``buyer_ref`` when present).
    * :meth:`merge_from_update` applies ``update_media_buy`` patches
      with deep-merge semantics: keys absent from the patch keep prior
      values, keys present with non-null values replace, keys present
      and ``None`` clear.
    * :meth:`backfill` fills in missing
      ``packages[].targeting_overlay`` on a ``get_media_buys`` response
      from the persisted store. Mutates the response in place; packages
      the seller already echoed are left untouched.

    Methods may be sync or async — the wrapper awaits at call time.
    """

    def persist_from_create(
        self,
        account_id: str,
        request: Any,
        result: Any,
    ) -> Any: ...

    def merge_from_update(
        self,
        account_id: str,
        media_buy_id: str,
        patch: Any,
    ) -> Any: ...

    def backfill(self, account_id: str, result: Any) -> Any: ...

Adopter-supplied persistence + echo for targeting_overlay.

Three methods cover the full lifecycle of a per-package overlay:

  • :meth:persist_from_create records overlays from a successful create_media_buy, joining the request's per-package overlay with the response's seller-assigned package_id (or buyer_ref when present).
  • :meth:merge_from_update applies adcp.decisioning.update_media_buy patches with deep-merge semantics: keys absent from the patch keep prior values, keys present with non-null values replace, keys present and None clear.
  • :meth:backfill fills in missing packages[].targeting_overlay on a get_media_buys response from the persisted store. Mutates the response in place; packages the seller already echoed are left untouched.

Methods may be sync or async — the wrapper awaits at call time.

Ancestors

  • typing.Protocol
  • typing.Generic

Methods

def backfill(self, account_id: str, result: Any) ‑> Any
Expand source code
def backfill(self, account_id: str, result: Any) -> Any: ...
def merge_from_update(self, account_id: str, media_buy_id: str, patch: Any) ‑> Any
Expand source code
def merge_from_update(
    self,
    account_id: str,
    media_buy_id: str,
    patch: Any,
) -> Any: ...
def persist_from_create(self, account_id: str, request: Any, result: Any) ‑> Any
Expand source code
def persist_from_create(
    self,
    account_id: str,
    request: Any,
    result: Any,
) -> Any: ...
class MockAdServer (*args, **kwargs)
Expand source code
@runtime_checkable
class MockAdServer(Protocol):
    """Outbound-traffic recorder for adopter platform methods.

    Implementations count named upstream calls so storyboard runners
    can assert the platform actually did work (anti-façade contract).
    The Protocol is deliberately minimal — ``record_call`` /
    ``get_traffic`` / ``reset`` — so adopters can swap in a counter
    backed by Prometheus, statsd, or a real ad-server SDK without
    changing the platform's call sites.
    """

    def record_call(self, method: str, args: dict[str, Any]) -> None:
        """Increment the counter for ``method``.

        :param method: A dotted name identifying the upstream operation
            — e.g. ``"creative.upload"``, ``"media_buy.create"``,
            ``"delivery.read"``. Adopters pick the namespace; runners
            assert against whatever the platform records.
        :param args: A dict of call arguments for diagnostic / audit
            purposes. Implementations MAY ignore the dict (the default
            in-memory impl does — only the count matters for
            anti-façade assertions). Implementations that persist or
            log args MUST treat them as untrusted: they may carry
            buyer-supplied content.
        """
        ...

    def get_traffic(self) -> dict[str, int]:
        """Return a snapshot of current per-method counts.

        Return value is a fresh dict — callers may mutate it without
        affecting subsequent reads. Order is implementation-defined.
        """
        ...

    def reset(self) -> None:
        """Clear all counters. Test-isolation hook — storyboard runners
        call this between scenarios so previous-step calls don't leak
        into the next assertion."""
        ...

Outbound-traffic recorder for adopter platform methods.

Implementations count named upstream calls so storyboard runners can assert the platform actually did work (anti-façade contract). The Protocol is deliberately minimal — record_call / get_traffic / reset — so adopters can swap in a counter backed by Prometheus, statsd, or a real ad-server SDK without changing the platform's call sites.

Ancestors

  • typing.Protocol
  • typing.Generic

Methods

def get_traffic(self) ‑> dict[str, int]
Expand source code
def get_traffic(self) -> dict[str, int]:
    """Return a snapshot of current per-method counts.

    Return value is a fresh dict — callers may mutate it without
    affecting subsequent reads. Order is implementation-defined.
    """
    ...

Return a snapshot of current per-method counts.

Return value is a fresh dict — callers may mutate it without affecting subsequent reads. Order is implementation-defined.

def record_call(self, method: str, args: dict[str, Any]) ‑> None
Expand source code
def record_call(self, method: str, args: dict[str, Any]) -> None:
    """Increment the counter for ``method``.

    :param method: A dotted name identifying the upstream operation
        — e.g. ``"creative.upload"``, ``"media_buy.create"``,
        ``"delivery.read"``. Adopters pick the namespace; runners
        assert against whatever the platform records.
    :param args: A dict of call arguments for diagnostic / audit
        purposes. Implementations MAY ignore the dict (the default
        in-memory impl does — only the count matters for
        anti-façade assertions). Implementations that persist or
        log args MUST treat them as untrusted: they may carry
        buyer-supplied content.
    """
    ...

Increment the counter for method.

:param method: A dotted name identifying the upstream operation — e.g. "creative.upload", "media_buy.create", "delivery.read". Adopters pick the namespace; runners assert against whatever the platform records. :param args: A dict of call arguments for diagnostic / audit purposes. Implementations MAY ignore the dict (the default in-memory impl does — only the count matters for anti-façade assertions). Implementations that persist or log args MUST treat them as untrusted: they may carry buyer-supplied content.

def reset(self) ‑> None
Expand source code
def reset(self) -> None:
    """Clear all counters. Test-isolation hook — storyboard runners
    call this between scenarios so previous-step calls don't leak
    into the next assertion."""
    ...

Clear all counters. Test-isolation hook — storyboard runners call this between scenarios so previous-step calls don't leak into the next assertion.

class MockProposalManager (*,
mock_upstream_url: str,
auth: UpstreamAuth | None = None,
sales_specialism: SalesSpecialism = 'sales-non-guaranteed',
default_headers: Mapping[str, str] | None = None,
timeout: float = 30.0)
Expand source code
class MockProposalManager:
    """v1 default forwarder. Dispatches ``get_products`` /
    ``refine_products`` to a running mock-server.

    Symmetric with :meth:`DecisioningPlatform.upstream_for`'s
    mock-mode dispatch (see Phase 2 — ``mock_upstream_url`` on
    ``Account.metadata``). Adopter declares a ``mock_upstream_url``
    pointing at ``bin/adcp.js mock-server <specialism>``; the
    framework forwards ``get_products`` requests verbatim and the
    mock-server returns wire-shaped products carrying stub recipes.

    The on-ramp story: adopters who don't yet have proposal logic of
    their own start with this class pointed at the appropriate
    mock-server specialism. Their first working seller agent runs
    against the mock fixtures with zero adopter code on the proposal
    side. They implement their own :class:`ProposalManager` subclass
    incrementally as they replace mock-served slices with real
    assembly logic.

    The mock-server lifecycle is **not** managed by the SDK. Adopters
    or CI start it as needed (``bin/adcp.js mock-server
    sales-non-guaranteed``) and pass the resulting URL to this
    class's constructor. Same posture as the
    :meth:`DecisioningPlatform.upstream_for` mock-mode dispatch.

    Example::

        manager = MockProposalManager(
            mock_upstream_url="http://localhost:4500",
        )
        router = PlatformRouter(
            accounts=...,
            platforms={"default": MyPlatform()},
            proposal_managers={"default": manager},
            capabilities=...,
        )
        serve(router)

    :param mock_upstream_url: URL of the running mock-server. The
        forwarder POSTs ``GetProductsRequest`` payloads to
        ``{mock_upstream_url}/get_products``.
    :param auth: Optional auth strategy applied to outbound mock-
        server calls. Defaults to :class:`NoAuth` — mock-servers
        typically run unauthenticated on localhost. Adopters running
        a shared mock-server behind a token proxy pass a
        :class:`StaticBearer` / :class:`ApiKey` here.
    :param sales_specialism: Which sales specialism this mock manager
        serves. Defaults to ``"sales-non-guaranteed"`` (the catalog-
        style mock-server fixture). Adopters wiring a guaranteed
        mock pass ``"sales-guaranteed"`` so the framework's
        capability projection matches the fixtures.
    :param default_headers: Headers forwarded on every mock-server
        request (e.g. ``X-Tenant-Id``).
    :param timeout: Per-request timeout in seconds. Default 30.0.
    """

    def __init__(
        self,
        *,
        mock_upstream_url: str,
        auth: UpstreamAuth | None = None,
        sales_specialism: SalesSpecialism = "sales-non-guaranteed",
        default_headers: Mapping[str, str] | None = None,
        timeout: float = 30.0,
    ) -> None:
        if not mock_upstream_url or not isinstance(mock_upstream_url, str):
            raise AdcpError(
                "CONFIGURATION_ERROR",
                message=(
                    "MockProposalManager requires a non-empty "
                    "``mock_upstream_url`` pointing at a running "
                    "``bin/adcp.js mock-server <specialism>`` instance."
                ),
                recovery="terminal",
                field="mock_upstream_url",
            )

        # Per-instance capabilities — populated from the constructor
        # arg so a single class can serve guaranteed or non-guaranteed
        # mock fixtures without subclassing.
        self.capabilities: ProposalCapabilities = ProposalCapabilities(
            sales_specialism=sales_specialism,
        )
        self._mock_upstream_url = mock_upstream_url
        self._client = create_upstream_http_client(
            mock_upstream_url,
            auth=auth or NoAuth(),
            default_headers=default_headers,
            timeout=timeout,
            # Mock-server should never 404 on the canonical paths;
            # surface as a structured error if it does.
            treat_404_as_none=False,
        )

    @property
    def mock_upstream_url(self) -> str:
        """The configured mock-server URL — useful for diagnostics."""
        return self._mock_upstream_url

    @property
    def client(self) -> UpstreamHttpClient:
        """The underlying :class:`UpstreamHttpClient`. Adopter
        subclasses extending the forwarder may need direct access for
        custom paths; production users typically don't reach in here.
        """
        return self._client

    async def aclose(self) -> None:
        """Release the underlying connection pool. Idempotent."""
        await self._client.aclose()

    async def get_products(
        self,
        req: GetProductsRequest,
        ctx: RequestContext[Any],
    ) -> dict[str, Any]:
        """Forward to ``POST {mock_upstream_url}/get_products``.

        Returns the parsed JSON dict verbatim — the framework's
        wire-projection layer handles serialization back to the
        buyer. Mock-server returns spec-shaped
        :class:`~adcp.types.GetProductsResponse` payloads, so the
        dict round-trips cleanly through the existing
        ``GetProductsResponse`` validation.
        """
        del ctx  # The mock-server is stateless; no per-request routing.
        payload = _request_to_dict(req)
        result: Any = await self._client.post("/get_products", json=payload)
        # Mock-server should always return a dict; defensive cast for
        # the type checker.
        if not isinstance(result, dict):
            raise AdcpError(
                "SERVICE_UNAVAILABLE",
                message=(
                    f"mock-server at {self._mock_upstream_url} returned "
                    f"a non-dict payload from /get_products: "
                    f"{type(result).__name__}"
                ),
                recovery="transient",
            )
        return result

    async def refine_products(
        self,
        req: GetProductsRequest,
        ctx: RequestContext[Any],
    ) -> dict[str, Any]:
        """Forward refine-mode requests to the mock-server.

        Per the spec, refine rides on the same ``get_products``
        endpoint with ``buying_mode='refine'``. The forwarder POSTs
        to the same URL; the mock-server distinguishes by reading
        ``buying_mode`` on the payload.
        """
        # Same wire envelope, same upstream endpoint. The capability
        # flag governs WHETHER the framework dispatches refine
        # requests here vs. ``get_products``; the actual upstream
        # call is identical.
        return await self.get_products(req, ctx)

v1 default forwarder. Dispatches get_products / refine_products to a running mock-server.

Symmetric with :meth:DecisioningPlatform.upstream_for()'s mock-mode dispatch (see Phase 2 — mock_upstream_url on Account.metadata). Adopter declares a mock_upstream_url pointing at bin/adcp.js mock-server <specialism>; the framework forwards get_products requests verbatim and the mock-server returns wire-shaped products carrying stub recipes.

The on-ramp story: adopters who don't yet have proposal logic of their own start with this class pointed at the appropriate mock-server specialism. Their first working seller agent runs against the mock fixtures with zero adopter code on the proposal side. They implement their own :class:ProposalManager subclass incrementally as they replace mock-served slices with real assembly logic.

The mock-server lifecycle is not managed by the SDK. Adopters or CI start it as needed (bin/adcp.js mock-server sales-non-guaranteed) and pass the resulting URL to this class's constructor. Same posture as the :meth:DecisioningPlatform.upstream_for() mock-mode dispatch.

Example::

manager = MockProposalManager(
    mock_upstream_url="http://localhost:4500",
)
router = PlatformRouter(
    accounts=...,
    platforms={"default": MyPlatform()},
    proposal_managers={"default": manager},
    capabilities=...,
)
serve(router)

:param mock_upstream_url: URL of the running mock-server. The forwarder POSTs GetProductsRequest payloads to {mock_upstream_url}/get_products. :param auth: Optional auth strategy applied to outbound mock- server calls. Defaults to :class:NoAuth — mock-servers typically run unauthenticated on localhost. Adopters running a shared mock-server behind a token proxy pass a :class:StaticBearer / :class:ApiKey here. :param sales_specialism: Which sales specialism this mock manager serves. Defaults to "sales-non-guaranteed" (the catalog- style mock-server fixture). Adopters wiring a guaranteed mock pass "sales-guaranteed" so the framework's capability projection matches the fixtures. :param default_headers: Headers forwarded on every mock-server request (e.g. X-Tenant-Id). :param timeout: Per-request timeout in seconds. Default 30.0.

Instance variables

prop clientUpstreamHttpClient
Expand source code
@property
def client(self) -> UpstreamHttpClient:
    """The underlying :class:`UpstreamHttpClient`. Adopter
    subclasses extending the forwarder may need direct access for
    custom paths; production users typically don't reach in here.
    """
    return self._client

The underlying :class:UpstreamHttpClient. Adopter subclasses extending the forwarder may need direct access for custom paths; production users typically don't reach in here.

prop mock_upstream_url : str
Expand source code
@property
def mock_upstream_url(self) -> str:
    """The configured mock-server URL — useful for diagnostics."""
    return self._mock_upstream_url

The configured mock-server URL — useful for diagnostics.

Methods

async def aclose(self) ‑> None
Expand source code
async def aclose(self) -> None:
    """Release the underlying connection pool. Idempotent."""
    await self._client.aclose()

Release the underlying connection pool. Idempotent.

async def get_products(self,
req: GetProductsRequest,
ctx: RequestContext[Any]) ‑> dict[str, Any]
Expand source code
async def get_products(
    self,
    req: GetProductsRequest,
    ctx: RequestContext[Any],
) -> dict[str, Any]:
    """Forward to ``POST {mock_upstream_url}/get_products``.

    Returns the parsed JSON dict verbatim — the framework's
    wire-projection layer handles serialization back to the
    buyer. Mock-server returns spec-shaped
    :class:`~adcp.types.GetProductsResponse` payloads, so the
    dict round-trips cleanly through the existing
    ``GetProductsResponse`` validation.
    """
    del ctx  # The mock-server is stateless; no per-request routing.
    payload = _request_to_dict(req)
    result: Any = await self._client.post("/get_products", json=payload)
    # Mock-server should always return a dict; defensive cast for
    # the type checker.
    if not isinstance(result, dict):
        raise AdcpError(
            "SERVICE_UNAVAILABLE",
            message=(
                f"mock-server at {self._mock_upstream_url} returned "
                f"a non-dict payload from /get_products: "
                f"{type(result).__name__}"
            ),
            recovery="transient",
        )
    return result

Forward to POST {mock_upstream_url}/get_products.

Returns the parsed JSON dict verbatim — the framework's wire-projection layer handles serialization back to the buyer. Mock-server returns spec-shaped :class:~adcp.types.GetProductsResponse payloads, so the dict round-trips cleanly through the existing GetProductsResponse validation.

async def refine_products(self,
req: GetProductsRequest,
ctx: RequestContext[Any]) ‑> dict[str, Any]
Expand source code
async def refine_products(
    self,
    req: GetProductsRequest,
    ctx: RequestContext[Any],
) -> dict[str, Any]:
    """Forward refine-mode requests to the mock-server.

    Per the spec, refine rides on the same ``get_products``
    endpoint with ``buying_mode='refine'``. The forwarder POSTs
    to the same URL; the mock-server distinguishes by reading
    ``buying_mode`` on the payload.
    """
    # Same wire envelope, same upstream endpoint. The capability
    # flag governs WHETHER the framework dispatches refine
    # requests here vs. ``get_products``; the actual upstream
    # call is identical.
    return await self.get_products(req, ctx)

Forward refine-mode requests to the mock-server.

Per the spec, refine rides on the same get_products endpoint with buying_mode='refine'. The forwarder POSTs to the same URL; the mock-server distinguishes by reading buying_mode on the payload.

class NoAuth (kind: "Literal['none']" = 'none')
Expand source code
@dataclass(frozen=True)
class NoAuth:
    """No authentication header injected. For unauthenticated services."""

    kind: Literal["none"] = "none"

No authentication header injected. For unauthenticated services.

Instance variables

var kind : Literal['none']
class OAuthCredential (kind: "Literal['oauth']",
client_id: str,
scopes: tuple[str, ...] = (),
expires_at: float | None = None)
Expand source code
@dataclass(frozen=True)
class OAuthCredential:
    """OAuth client-credentials grant. Includes the verified scopes."""

    kind: Literal["oauth"]
    client_id: str
    scopes: tuple[str, ...] = ()
    expires_at: float | None = None

OAuth client-credentials grant. Includes the verified scopes.

Instance variables

var client_id : str
var expires_at : float | None
var kind : Literal['oauth']
var scopes : tuple[str, ...]
class OwnedSignalsPlatform (*args, **kwargs)
Expand source code
@runtime_checkable
class OwnedSignalsPlatform(Protocol, Generic[TMeta]):
    """Catalog discovery for seller-owned first-party signals.

    Methods may be sync (return ``T`` directly) or async (return
    ``Awaitable[T]``); the dispatch adapter detects via
    :func:`asyncio.iscoroutinefunction` and runs sync methods on a
    thread pool so a blocking sync handler doesn't serialize the event
    loop.

    Throw :class:`adcp.decisioning.AdcpError` for buyer-fixable
    rejection (``SIGNAL_NOT_FOUND``, ``POLICY_VIOLATION``,
    ``INVALID_REQUEST``, etc.); the framework projects to the wire
    structured-error envelope.
    """

    def get_signals(
        self,
        req: GetSignalsRequest,
        ctx: RequestContext[TMeta],
    ) -> DiscoveryResult[GetSignalsResponse]:
        """Catalog discovery — query your signal index, return signals
        matching the buyer's filters (industry, intent type, audience
        size, etc.).

        Return :class:`GetSignalsResponse` directly for the sync fast
        path. Brief-driven discovery MAY hand off via
        ``ctx.handoff_to_task(fn)`` when provider discovery needs
        background work (cross-provider fan-out, identity-graph lookups);
        the framework projects the handoff to the wire ``submitted``
        envelope. The buyer reaches the terminal
        :class:`GetSignalsResponse` via ``tasks/get`` polling, and — when
        the request carried ``push_notification_config`` — the framework
        also delivers the terminal completion / failure webhook to the
        buyer's URL from the background completion path (exactly once).

        Wholesale (``discovery_mode='wholesale'``) MUST return
        synchronously — a raw catalog read with no seller-side
        composition to background. Returning a handoff from a wholesale
        call is rejected at the framework layer with
        ``AdcpError(INVALID_REQUEST, field='discovery_mode')``.

        .. note::
            ``get_signals`` ships ONLY the ``submitted`` and ``working``
            async arms — it has **no** ``input_required`` arm (unlike
            ``get_products`` and ``create_media_buy``). Signal discovery
            cannot pause mid-task to solicit buyer clarification; the
            seller either completes the discovery or fails the task.

        :raises adcp.decisioning.AdcpError: ``code='POLICY_VIOLATION'``
            when the buyer doesn't have rights to the requested data
            category.
        """
        ...

Catalog discovery for seller-owned first-party signals.

Methods may be sync (return T directly) or async (return Awaitable[T]); the dispatch adapter detects via :func:asyncio.iscoroutinefunction and runs sync methods on a thread pool so a blocking sync handler doesn't serialize the event loop.

Throw :class:AdcpError for buyer-fixable rejection (SIGNAL_NOT_FOUND, POLICY_VIOLATION, INVALID_REQUEST, etc.); the framework projects to the wire structured-error envelope.

Ancestors

  • typing.Protocol
  • typing.Generic

Subclasses

Methods

def get_signals(self,
req: GetSignalsRequest,
ctx: RequestContext[TMeta]) ‑> DiscoveryResult[GetSignalsResponse]
Expand source code
def get_signals(
    self,
    req: GetSignalsRequest,
    ctx: RequestContext[TMeta],
) -> DiscoveryResult[GetSignalsResponse]:
    """Catalog discovery — query your signal index, return signals
    matching the buyer's filters (industry, intent type, audience
    size, etc.).

    Return :class:`GetSignalsResponse` directly for the sync fast
    path. Brief-driven discovery MAY hand off via
    ``ctx.handoff_to_task(fn)`` when provider discovery needs
    background work (cross-provider fan-out, identity-graph lookups);
    the framework projects the handoff to the wire ``submitted``
    envelope. The buyer reaches the terminal
    :class:`GetSignalsResponse` via ``tasks/get`` polling, and — when
    the request carried ``push_notification_config`` — the framework
    also delivers the terminal completion / failure webhook to the
    buyer's URL from the background completion path (exactly once).

    Wholesale (``discovery_mode='wholesale'``) MUST return
    synchronously — a raw catalog read with no seller-side
    composition to background. Returning a handoff from a wholesale
    call is rejected at the framework layer with
    ``AdcpError(INVALID_REQUEST, field='discovery_mode')``.

    .. note::
        ``get_signals`` ships ONLY the ``submitted`` and ``working``
        async arms — it has **no** ``input_required`` arm (unlike
        ``get_products`` and ``create_media_buy``). Signal discovery
        cannot pause mid-task to solicit buyer clarification; the
        seller either completes the discovery or fails the task.

    :raises adcp.decisioning.AdcpError: ``code='POLICY_VIOLATION'``
        when the buyer doesn't have rights to the requested data
        category.
    """
    ...

Catalog discovery — query your signal index, return signals matching the buyer's filters (industry, intent type, audience size, etc.).

Return :class:GetSignalsResponse directly for the sync fast path. Brief-driven discovery MAY hand off via ctx.handoff_to_task(fn) when provider discovery needs background work (cross-provider fan-out, identity-graph lookups); the framework projects the handoff to the wire submitted envelope. The buyer reaches the terminal :class:GetSignalsResponse via tasks/get polling, and — when the request carried push_notification_config — the framework also delivers the terminal completion / failure webhook to the buyer's URL from the background completion path (exactly once).

Wholesale (discovery_mode='wholesale') MUST return synchronously — a raw catalog read with no seller-side composition to background. Returning a handoff from a wholesale call is rejected at the framework layer with AdcpError(INVALID_REQUEST, field='discovery_mode').

Note

get_signals ships ONLY the submitted and working async arms — it has no input_required arm (unlike get_products and create_media_buy). Signal discovery cannot pause mid-task to solicit buyer clarification; the seller either completes the discovery or fails the task.

:raises adcp.decisioning.AdcpError: code='POLICY_VIOLATION' when the buyer doesn't have rights to the requested data category.

class PermissionDeniedError (*,
scope: "Literal['agent', 'billing'] | None" = None,
reason: str | None = None,
message: str | None = None,
field: str | None = None,
suggestion: str | None = None,
**details: Any)
Expand source code
class PermissionDeniedError(AdcpError):
    """Spec ``PERMISSION_DENIED`` (``recovery='correctable'``).

    Raised when the authenticated caller is not authorized for the
    requested action under the seller's policies, or a required signed
    credential is missing/invalid.

    For per-agent commercial-status rejections (suspended / blocked),
    raise :class:`AdcpError` with code ``"AGENT_SUSPENDED"`` /
    ``"AGENT_BLOCKED"`` directly — those are dedicated AdCP 3.1 codes
    with their own ``recovery="terminal"`` semantics, not flavors of
    ``PERMISSION_DENIED``.

    :param scope: When the gate is a per-agent non-status provisioning
        constraint, set to ``'agent'`` (with ``reason``); when
        billing-relationship, set to ``'billing'``. Sellers MUST emit
        ``scope='agent'`` only when buyer-agent identity has been
        established (signed-request derivation or credential-to-agent
        mapping); otherwise omit.
    :param reason: When ``scope='agent'``, the registered provisioning
        gate that fired (``'sandbox_only'``, etc.) — see
        ``error-details/agent-permission-denied.json``.
    :param message: Optional human-readable override of the default.
    :param details: Additional fields merged into ``error.details``.
    """

    def __init__(
        self,
        *,
        scope: Literal["agent", "billing"] | None = None,
        reason: str | None = None,
        message: str | None = None,
        field: str | None = None,
        suggestion: str | None = None,
        **details: Any,
    ) -> None:
        # Hard-reject the AdCP 3.0.5 placeholder ``status=`` kwarg.
        # Without this, the kwarg would land in ``**details`` and emit
        # ``error.details.status`` — a field 3.1 removed from
        # ``agent-permission-denied.json``, which seller-side
        # schema-validating receivers would reject. For per-agent
        # commercial status, raise ``AdcpError`` with code
        # ``"AGENT_SUSPENDED"`` / ``"AGENT_BLOCKED"`` directly.
        if "status" in details:
            raise TypeError(
                "PermissionDeniedError.status= was removed in AdCP 3.1 "
                "(spec PR adcontextprotocol/adcp#3906). The "
                "placeholder details.status field is gone from "
                "error-details/agent-permission-denied.json. Migrate to: "
                "raise AdcpError('AGENT_SUSPENDED', recovery='terminal') "
                "or AdcpError('AGENT_BLOCKED', recovery='terminal') for "
                "per-agent commercial-status rejections, or pass "
                "reason='sandbox_only' for the remaining provisioning "
                "gate."
            )

        merged_details: dict[str, Any] = dict(details)
        if scope is not None:
            merged_details["scope"] = scope
        if reason is not None:
            merged_details["reason"] = reason

        super().__init__(
            "PERMISSION_DENIED",
            message=message or "Caller is not authorized for the requested action.",
            recovery="correctable",
            field=field,
            suggestion=suggestion,
            details=merged_details or None,
        )

Spec PERMISSION_DENIED (recovery='correctable').

Raised when the authenticated caller is not authorized for the requested action under the seller's policies, or a required signed credential is missing/invalid.

For per-agent commercial-status rejections (suspended / blocked), raise :class:AdcpError with code "AGENT_SUSPENDED" / "AGENT_BLOCKED" directly — those are dedicated AdCP 3.1 codes with their own recovery="terminal" semantics, not flavors of PERMISSION_DENIED.

:param scope: When the gate is a per-agent non-status provisioning constraint, set to 'agent' (with reason); when billing-relationship, set to 'billing'. Sellers MUST emit scope='agent' only when buyer-agent identity has been established (signed-request derivation or credential-to-agent mapping); otherwise omit. :param reason: When scope='agent', the registered provisioning gate that fired ('sandbox_only', etc.) — see error-details/agent-permission-denied.json. :param message: Optional human-readable override of the default. :param details: Additional fields merged into error.details.

Ancestors

  • AdcpError
  • builtins.Exception
  • builtins.BaseException

Inherited members

class PgProposalStore (*,
pool: AsyncConnectionPool,
table_name: str = 'adcp_proposal_drafts',
recipe_decoder: Callable[[Mapping[str, Any]], Recipe] | None = None)
Expand source code
class PgProposalStore:
    """PostgreSQL-backed :class:`~adcp.decisioning.ProposalStore`.

    Durable counterpart to :class:`~adcp.decisioning.InMemoryProposalStore`.
    Set ``is_durable = True`` so production-mode gates accept it without
    requiring the dev-mode bypass.

    :param pool: ``psycopg_pool.AsyncConnectionPool`` owned by the
        caller. Each operation acquires a short-lived connection;
        :meth:`try_reserve_consumption` holds one for the duration of
        its CAS transaction.
    :param table_name: Override the default table name. Useful for
        adopters with one Postgres serving multiple AdCP instances or
        whose ``proposal_drafts`` table is already taken. Defaults to
        ``adcp_proposal_drafts``.
    :param recipe_decoder: Callable ``(payload: dict) -> Recipe`` used
        to rehydrate stored recipe payloads back to typed
        :class:`Recipe` instances. Adopters with subclasses
        (``GAMRecipe``, ``KevelRecipe``, etc.) MUST supply a decoder
        that branches on ``recipe_kind``. Defaults to
        :meth:`Recipe.model_validate` which only works for the base
        ``Recipe`` shape.

    :raises ImportError: when psycopg/psycopg-pool are not installed.
    :raises ValueError: when ``table_name`` is not a safe ASCII
        identifier (``[a-z_][a-z0-9_]{0,62}``).
    """

    is_durable: ClassVar[bool] = True

    def __init__(
        self,
        *,
        pool: AsyncConnectionPool,
        table_name: str = DEFAULT_TABLE_NAME,
        recipe_decoder: Callable[[Mapping[str, Any]], Recipe] | None = None,
    ) -> None:
        if not PG_AVAILABLE:
            raise ImportError(_INSTALL_HINT)
        if not _SAFE_IDENTIFIER_RE.fullmatch(table_name):
            raise ValueError(
                f"table_name must match [a-z_][a-z0-9_]{{0,62}} (ASCII only), "
                f"got {table_name!r}"
            )
        self._pool = pool
        self._table = table_name
        self._recipe_decoder = recipe_decoder or _default_recipe_decoder

        t = self._table
        # put_draft: insert a fresh DRAFT row, or rewrite an existing DRAFT
        # row in place. ON CONFLICT DO UPDATE is gated on state='draft' so
        # a buyer probing put_draft against a COMMITTED/CONSUMED record
        # falls through to a zero-row UPDATE — we then SELECT to surface
        # the INTERNAL_ERROR with the actual current state.
        self._sql_put_draft = (  # noqa: S608 — table name whitelisted
            f"INSERT INTO {t} "
            f"(account_id, proposal_id, state, recipes, proposal_payload, "
            f" recipe_schema_version, created_at, updated_at) "
            f"VALUES (%s, %s, 'draft', %s::jsonb, %s::jsonb, %s, now(), now()) "
            f"ON CONFLICT (account_id, proposal_id) DO UPDATE SET "
            f"  recipes          = EXCLUDED.recipes, "
            f"  proposal_payload = EXCLUDED.proposal_payload, "
            f"  recipe_schema_version = EXCLUDED.recipe_schema_version, "
            f"  updated_at       = now() "
            f"WHERE {t}.state = 'draft' "
            f"RETURNING xmax = 0 AS inserted"
        )
        self._sql_get_state = (  # noqa: S608
            f"SELECT state, expires_at, proposal_payload FROM {t} "
            f"WHERE account_id = %s AND proposal_id = %s"
        )
        # Tenant-scoped SELECT FOR UPDATE used by commit() to lock the
        # row before the state-machine check + UPDATE. Mirrors the
        # try_reserve_consumption pattern.
        self._sql_select_state_for_update = (  # noqa: S608
            f"SELECT state, expires_at, proposal_payload FROM {t} "
            f"WHERE account_id = %s AND proposal_id = %s FOR UPDATE"
        )
        self._sql_commit = (  # noqa: S608
            f"UPDATE {t} SET "
            f"  state            = 'committed', "
            f"  expires_at       = %s, "
            f"  proposal_payload = %s::jsonb, "
            f"  updated_at       = now() "
            f"WHERE account_id = %s AND proposal_id = %s AND state = 'draft' "
            f"RETURNING proposal_id"
        )
        # try_reserve_consumption uses SELECT ... FOR UPDATE inside a tx
        # so two parallel callers serialize on the row lock. The CAS
        # check (state='committed') happens after the lock is held; the
        # loser sees CONSUMING/CONSUMED and raises PROPOSAL_NOT_COMMITTED.
        self._sql_select_for_update = (  # noqa: S608
            f"SELECT state, recipes, proposal_payload, expires_at, "
            f"       media_buy_id, recipe_schema_version "
            f"FROM {t} WHERE account_id = %s AND proposal_id = %s FOR UPDATE"
        )
        self._sql_reserve = (  # noqa: S608
            f"UPDATE {t} SET state = 'consuming', updated_at = now() "
            f"WHERE account_id = %s AND proposal_id = %s AND state = 'committed'"
        )
        self._sql_finalize = (  # noqa: S608
            f"UPDATE {t} SET "
            f"  state        = 'consumed', "
            f"  media_buy_id = %s, "
            f"  updated_at   = now() "
            f"WHERE account_id = %s AND proposal_id = %s AND state = 'consuming' "
            f"RETURNING proposal_id"
        )
        self._sql_release = (  # noqa: S608
            f"UPDATE {t} SET state = 'committed', updated_at = now() "
            f"WHERE account_id = %s AND proposal_id = %s AND state = 'consuming' "
            f"RETURNING proposal_id"
        )
        self._sql_mark_consumed = (  # noqa: S608
            f"UPDATE {t} SET "
            f"  state        = 'consumed', "
            f"  media_buy_id = %s, "
            f"  updated_at   = now() "
            f"WHERE account_id = %s AND proposal_id = %s AND state = 'committed' "
            f"RETURNING proposal_id"
        )
        self._sql_discard = (  # noqa: S608
            f"DELETE FROM {t} WHERE account_id = %s AND proposal_id = %s"
        )
        self._sql_get_by_media_buy_id = (  # noqa: S608
            f"SELECT proposal_id, account_id, state, recipes, proposal_payload, "
            f"       expires_at, media_buy_id, recipe_schema_version "
            f"FROM {t} WHERE account_id = %s AND media_buy_id = %s"
        )

    # -- schema bootstrap -----------------------------------------------

    async def create_schema(self) -> None:
        """Create the proposal store table + supporting indexes.

        Honors the ``table_name`` kwarg the store was constructed with.
        Idempotent via ``CREATE TABLE IF NOT EXISTS`` — safe to call on
        every application boot. The equivalent raw DDL ships at
        :file:`adcp/decisioning/pg/proposal_store.sql` in the installed
        package for adopters using a migration tool.
        """
        t = self._table
        statements = [
            f"""CREATE TABLE IF NOT EXISTS {t} (
                account_id              TEXT        COLLATE "C" NOT NULL,
                proposal_id             TEXT        COLLATE "C" NOT NULL,
                state                   TEXT        NOT NULL
                    CHECK (state IN ('draft', 'committed', 'consuming', 'consumed')),
                recipes                 JSONB       NOT NULL DEFAULT '{{}}'::jsonb,
                proposal_payload        JSONB       NOT NULL,
                expires_at              TIMESTAMPTZ,
                media_buy_id            TEXT        COLLATE "C",
                recipe_schema_version   INTEGER     NOT NULL DEFAULT 1,
                created_at              TIMESTAMPTZ NOT NULL DEFAULT now(),
                updated_at              TIMESTAMPTZ NOT NULL DEFAULT now(),
                PRIMARY KEY (account_id, proposal_id)
            )""",
            f"""CREATE UNIQUE INDEX IF NOT EXISTS {t}_media_buy_idx
                ON {t} (account_id, media_buy_id)
                WHERE media_buy_id IS NOT NULL""",
            f"""CREATE INDEX IF NOT EXISTS {t}_expires_idx
                ON {t} (expires_at)
                WHERE expires_at IS NOT NULL""",
        ]
        async with self._pool.connection() as conn:
            for stmt in statements:
                await conn.execute(stmt)

    # -- ProposalStore Protocol -----------------------------------------

    async def put_draft(
        self,
        *,
        proposal_id: str,
        account_id: str,
        recipes: Mapping[str, Recipe],
        proposal_payload: Mapping[str, Any],
    ) -> None:
        recipes_json = _encode_recipes(recipes)
        payload_json = json.dumps(dict(proposal_payload))
        async with self._pool.connection() as conn:
            cur = await conn.execute(
                self._sql_put_draft,
                (account_id, proposal_id, recipes_json, payload_json, 1),
            )
            row = await cur.fetchone()
            if row is not None:
                # Either inserted fresh (xmax=0) or rewrote a DRAFT.
                return
            # ON CONFLICT DO UPDATE matched zero rows — the record is in
            # a non-DRAFT state. Re-fetch to surface the current state in
            # the error message.
            cur2 = await conn.execute(self._sql_get_state, (account_id, proposal_id))
            existing = await cur2.fetchone()
            if existing is None:
                # Race: row vanished between the failed INSERT and the
                # follow-up SELECT (concurrent discard from another
                # worker). Surface as INTERNAL_ERROR so the framework's
                # outer dispatcher can decide whether to retry; we don't
                # transparently retry here because put_draft is meant to
                # be one-shot per dispatch.
                raise AdcpError(
                    "INTERNAL_ERROR",
                    message=(
                        f"PgProposalStore.put_draft: proposal {proposal_id!r} "
                        "vanished between conflict and refetch. Concurrent "
                        "discard suspected."
                    ),
                    recovery="terminal",
                )
            state_str = existing[0]
            raise AdcpError(
                "INTERNAL_ERROR",
                message=(
                    f"Cannot put_draft on proposal {proposal_id!r} in "
                    f"state {state_str!r}; refine iterations are only "
                    "valid on draft proposals. Once committed or "
                    "consumed, a proposal_id is immutable."
                ),
                recovery="terminal",
            )

    async def get(
        self,
        proposal_id: str,
        *,
        expected_account_id: str | None = None,
    ) -> ProposalRecord | None:
        # The Protocol allows expected_account_id=None — historically a
        # convenience for diagnostic / admin callers. We still serve
        # that case but route it through a separate query without the
        # account predicate so the tenancy-aware fast path is purely
        # parameterised; a future code reader can't accidentally pass
        # None into a tenancy-required call.
        if expected_account_id is None:
            sql = (  # noqa: S608 — table name pre-validated at construction
                f"SELECT proposal_id, account_id, state, recipes, "
                f"proposal_payload, expires_at, media_buy_id, "
                f"recipe_schema_version FROM {self._table} "
                f"WHERE proposal_id = %s"
            )
            params: tuple[Any, ...] = (proposal_id,)
        else:
            sql = (  # noqa: S608
                f"SELECT proposal_id, account_id, state, recipes, "
                f"proposal_payload, expires_at, media_buy_id, "
                f"recipe_schema_version FROM {self._table} "
                f"WHERE account_id = %s AND proposal_id = %s"
            )
            params = (expected_account_id, proposal_id)
        async with self._pool.connection() as conn:
            cur = await conn.execute(sql, params)
            row = await cur.fetchone()
            if row is None:
                return None
            return self._row_to_record(row)

    async def commit(
        self,
        proposal_id: str,
        *,
        expires_at: datetime,
        proposal_payload: Mapping[str, Any],
        expected_account_id: str,
    ) -> None:
        payload_dict = dict(proposal_payload)
        payload_json = json.dumps(payload_dict)
        # Atomic: SELECT FOR UPDATE → state-machine check → UPDATE,
        # all inside one transaction. The SELECT predicate is keyed on
        # (account_id, proposal_id) so a cross-tenant probe collapses
        # to "not in store" without touching another tenant's row.
        # The row lock prevents a concurrent put_draft / commit from
        # racing the validate-then-update sequence.
        async with self._pool.connection() as conn:
            async with conn.transaction():
                cur = await conn.execute(
                    self._sql_select_state_for_update,
                    (expected_account_id, proposal_id),
                )
                existing = await cur.fetchone()
                if existing is None:
                    raise AdcpError(
                        "INTERNAL_ERROR",
                        message=(
                            f"Cannot commit proposal {proposal_id!r}: not "
                            "in store for the expected tenant. The "
                            "framework's finalize dispatch must put_draft "
                            "before commit."
                        ),
                        recovery="terminal",
                    )
                current_state, current_expires_at, current_payload = existing
                if current_state == "committed":
                    # Idempotent only when the second commit matches the
                    # first.
                    same_deadline = _ensure_utc(current_expires_at) == expires_at
                    cur_payload_dict = (
                        current_payload
                        if isinstance(current_payload, dict)
                        else json.loads(current_payload) if current_payload is not None else {}
                    )
                    same_payload = cur_payload_dict == payload_dict
                    if same_deadline and same_payload:
                        return
                    raise AdcpError(
                        "INTERNAL_ERROR",
                        message=(
                            f"Proposal {proposal_id!r} already committed "
                            "with a different expires_at or payload — "
                            "re-commit with different values is a developer "
                            "bug."
                        ),
                        recovery="terminal",
                    )
                if current_state != "draft":
                    raise AdcpError(
                        "INTERNAL_ERROR",
                        message=(
                            f"Cannot commit proposal {proposal_id!r} from "
                            f"state {current_state!r}; commit requires "
                            "DRAFT."
                        ),
                        recovery="terminal",
                    )
                update_cur = await conn.execute(
                    self._sql_commit,
                    (expires_at, payload_json, expected_account_id, proposal_id),
                )
                if await update_cur.fetchone() is None:
                    # Should not happen under the row lock, but fail loud
                    # if it does — silent zero-row UPDATE would let the
                    # caller believe the transition landed.
                    raise AdcpError(
                        "INTERNAL_ERROR",
                        message=(
                            f"PgProposalStore.commit: UPDATE returned zero "
                            f"rows for proposal {proposal_id!r} despite "
                            "passing the FOR UPDATE state check. Schema "
                            "drift suspected."
                        ),
                        recovery="terminal",
                    )

    async def try_reserve_consumption(
        self,
        proposal_id: str,
        *,
        expected_account_id: str,
    ) -> ProposalRecord:
        # Single-connection transaction so SELECT FOR UPDATE + UPDATE
        # serialize across parallel callers.
        async with self._pool.connection() as conn:
            async with conn.transaction():
                cur = await conn.execute(
                    self._sql_select_for_update,
                    (expected_account_id, proposal_id),
                )
                row = await cur.fetchone()
                if row is None:
                    raise AdcpError(
                        "PROPOSAL_NOT_FOUND",
                        message=(f"Proposal {proposal_id!r} not found."),
                        recovery="correctable",
                        field="proposal_id",
                    )
                (
                    state_str,
                    recipes_raw,
                    payload_raw,
                    expires_at_raw,
                    media_buy_id,
                    recipe_schema_version,
                ) = row
                if state_str != "committed":
                    raise AdcpError(
                        "PROPOSAL_NOT_COMMITTED",
                        message=(
                            f"Proposal {proposal_id!r} is in state "
                            f"{state_str!r}; create_media_buy requires a "
                            "committed proposal that hasn't been accepted "
                            "or reserved by another request."
                        ),
                        recovery="correctable",
                        field="proposal_id",
                    )
                await conn.execute(
                    self._sql_reserve,
                    (expected_account_id, proposal_id),
                )
                # Build the in-memory record reflecting the transition.
                return ProposalRecord(
                    proposal_id=proposal_id,
                    account_id=expected_account_id,
                    state=ProposalState.CONSUMING,
                    recipes=_decode_recipes(recipes_raw, self._recipe_decoder),
                    proposal_payload=_decode_payload(payload_raw),
                    expires_at=_ensure_utc(expires_at_raw),
                    media_buy_id=media_buy_id,
                    recipe_schema_version=int(recipe_schema_version or 1),
                )

    async def finalize_consumption(
        self,
        proposal_id: str,
        *,
        media_buy_id: str,
        expected_account_id: str,
    ) -> None:
        async with self._pool.connection() as conn:
            cur = await conn.execute(
                self._sql_finalize,
                (media_buy_id, expected_account_id, proposal_id),
            )
            if await cur.fetchone() is not None:
                return
            # Zero rows updated. Determine why.
            cur2 = await conn.execute(self._sql_get_state, (expected_account_id, proposal_id))
            existing = await cur2.fetchone()
            if existing is None:
                raise AdcpError(
                    "INTERNAL_ERROR",
                    message=(
                        f"finalize_consumption: proposal {proposal_id!r} "
                        "not found for the expected tenant."
                    ),
                    recovery="terminal",
                )
            state_str = existing[0]
            if state_str == "consumed":
                # Idempotent on re-call with the same media_buy_id.
                cur3 = await conn.execute(
                    f"SELECT media_buy_id FROM {self._table} "  # noqa: S608
                    f"WHERE account_id = %s AND proposal_id = %s",
                    (expected_account_id, proposal_id),
                )
                row = await cur3.fetchone()
                existing_media_buy_id = row[0] if row else None
                if existing_media_buy_id == media_buy_id:
                    return
                raise AdcpError(
                    "INTERNAL_ERROR",
                    message=(
                        f"Proposal {proposal_id!r} already consumed by "
                        f"media_buy_id={existing_media_buy_id!r}; cannot "
                        f"re-consume as {media_buy_id!r}."
                    ),
                    recovery="terminal",
                )
            raise AdcpError(
                "INTERNAL_ERROR",
                message=(
                    f"finalize_consumption requires CONSUMING; "
                    f"proposal {proposal_id!r} is in {state_str!r}. "
                    "Framework must call try_reserve_consumption first."
                ),
                recovery="terminal",
            )

    async def release_consumption(
        self,
        proposal_id: str,
        *,
        expected_account_id: str,
    ) -> None:
        async with self._pool.connection() as conn:
            cur = await conn.execute(
                self._sql_release,
                (expected_account_id, proposal_id),
            )
            if await cur.fetchone() is not None:
                return
            # Zero rows updated. Idempotent: no-op on unknown id /
            # cross-tenant probe, no-op on already-COMMITTED.
            cur2 = await conn.execute(self._sql_get_state, (expected_account_id, proposal_id))
            existing = await cur2.fetchone()
            if existing is None:
                # Unknown id or cross-tenant — idempotent no-op so the
                # adapter-failure rollback path can be unconditional.
                return
            state_str = existing[0]
            if state_str == "committed":
                # Already rolled back.
                return
            raise AdcpError(
                "INTERNAL_ERROR",
                message=(
                    f"release_consumption requires CONSUMING; "
                    f"proposal {proposal_id!r} is in {state_str!r}."
                ),
                recovery="terminal",
            )

    async def mark_consumed(
        self,
        proposal_id: str,
        *,
        media_buy_id: str,
        expected_account_id: str,
    ) -> None:
        # Tenant-scoped SELECT FOR UPDATE → state-machine check →
        # UPDATE. Cross-tenant probes collapse to "not in store".
        async with self._pool.connection() as conn:
            async with conn.transaction():
                cur = await conn.execute(
                    f"SELECT state, media_buy_id FROM {self._table} "  # noqa: S608
                    f"WHERE account_id = %s AND proposal_id = %s FOR UPDATE",
                    (expected_account_id, proposal_id),
                )
                row = await cur.fetchone()
                if row is None:
                    raise AdcpError(
                        "INTERNAL_ERROR",
                        message=(
                            f"Cannot mark_consumed proposal {proposal_id!r}: "
                            "not in store for the expected tenant."
                        ),
                        recovery="terminal",
                    )
                state_str, existing_media_buy_id = row
                if state_str == "consumed":
                    if existing_media_buy_id == media_buy_id:
                        return
                    raise AdcpError(
                        "INTERNAL_ERROR",
                        message=(
                            f"Proposal {proposal_id!r} already consumed by "
                            f"media_buy_id={existing_media_buy_id!r}; cannot "
                            f"re-consume as {media_buy_id!r}."
                        ),
                        recovery="terminal",
                    )
                if state_str != "committed":
                    raise AdcpError(
                        "INTERNAL_ERROR",
                        message=(
                            f"Cannot mark_consumed proposal {proposal_id!r} "
                            f"from state {state_str!r}; mark_consumed "
                            "requires COMMITTED."
                        ),
                        recovery="terminal",
                    )
                await conn.execute(
                    self._sql_mark_consumed,
                    (media_buy_id, expected_account_id, proposal_id),
                )

    async def discard(
        self,
        proposal_id: str,
        *,
        expected_account_id: str,
    ) -> None:
        async with self._pool.connection() as conn:
            await conn.execute(self._sql_discard, (expected_account_id, proposal_id))

    async def get_by_media_buy_id(
        self,
        media_buy_id: str,
        *,
        expected_account_id: str,
    ) -> ProposalRecord | None:
        async with self._pool.connection() as conn:
            cur = await conn.execute(
                self._sql_get_by_media_buy_id,
                (expected_account_id, media_buy_id),
            )
            row = await cur.fetchone()
            if row is None:
                return None
            return self._row_to_record(row)

    # -- helpers --------------------------------------------------------

    def _row_to_record(self, row: tuple[Any, ...]) -> ProposalRecord:
        """Project a SELECT row tuple to a typed :class:`ProposalRecord`."""
        (
            proposal_id,
            account_id,
            state_str,
            recipes_raw,
            payload_raw,
            expires_at_raw,
            media_buy_id,
            recipe_schema_version,
        ) = row
        return ProposalRecord(
            proposal_id=proposal_id,
            account_id=account_id,
            state=ProposalState(state_str),
            recipes=_decode_recipes(recipes_raw, self._recipe_decoder),
            proposal_payload=_decode_payload(payload_raw),
            expires_at=_ensure_utc(expires_at_raw),
            media_buy_id=media_buy_id,
            recipe_schema_version=int(recipe_schema_version or 1),
        )

PostgreSQL-backed :class:~adcp.decisioning.ProposalStore.

Durable counterpart to :class:~adcp.decisioning.InMemoryProposalStore. Set is_durable = True so production-mode gates accept it without requiring the dev-mode bypass.

:param pool: psycopg_pool.AsyncConnectionPool owned by the caller. Each operation acquires a short-lived connection; :meth:try_reserve_consumption holds one for the duration of its CAS transaction. :param table_name: Override the default table name. Useful for adopters with one Postgres serving multiple AdCP instances or whose proposal_drafts table is already taken. Defaults to adcp_proposal_drafts. :param recipe_decoder: Callable (payload: dict) -> Recipe used to rehydrate stored recipe payloads back to typed :class:Recipe instances. Adopters with subclasses (GAMRecipe, KevelRecipe, etc.) MUST supply a decoder that branches on recipe_kind. Defaults to :meth:Recipe.model_validate which only works for the base Recipe shape.

:raises ImportError: when psycopg/psycopg-pool are not installed. :raises ValueError: when table_name is not a safe ASCII identifier ([a-z_][a-z0-9_]{0,62}).

Class variables

var is_durable : ClassVar[bool]

Static methods

def migration_sql(table_name='adcp_proposal_drafts')

Methods

async def commit(self,
proposal_id: str,
*,
expires_at: datetime,
proposal_payload: Mapping[str, Any],
expected_account_id: str) ‑> None
Expand source code
async def commit(
    self,
    proposal_id: str,
    *,
    expires_at: datetime,
    proposal_payload: Mapping[str, Any],
    expected_account_id: str,
) -> None:
    payload_dict = dict(proposal_payload)
    payload_json = json.dumps(payload_dict)
    # Atomic: SELECT FOR UPDATE → state-machine check → UPDATE,
    # all inside one transaction. The SELECT predicate is keyed on
    # (account_id, proposal_id) so a cross-tenant probe collapses
    # to "not in store" without touching another tenant's row.
    # The row lock prevents a concurrent put_draft / commit from
    # racing the validate-then-update sequence.
    async with self._pool.connection() as conn:
        async with conn.transaction():
            cur = await conn.execute(
                self._sql_select_state_for_update,
                (expected_account_id, proposal_id),
            )
            existing = await cur.fetchone()
            if existing is None:
                raise AdcpError(
                    "INTERNAL_ERROR",
                    message=(
                        f"Cannot commit proposal {proposal_id!r}: not "
                        "in store for the expected tenant. The "
                        "framework's finalize dispatch must put_draft "
                        "before commit."
                    ),
                    recovery="terminal",
                )
            current_state, current_expires_at, current_payload = existing
            if current_state == "committed":
                # Idempotent only when the second commit matches the
                # first.
                same_deadline = _ensure_utc(current_expires_at) == expires_at
                cur_payload_dict = (
                    current_payload
                    if isinstance(current_payload, dict)
                    else json.loads(current_payload) if current_payload is not None else {}
                )
                same_payload = cur_payload_dict == payload_dict
                if same_deadline and same_payload:
                    return
                raise AdcpError(
                    "INTERNAL_ERROR",
                    message=(
                        f"Proposal {proposal_id!r} already committed "
                        "with a different expires_at or payload — "
                        "re-commit with different values is a developer "
                        "bug."
                    ),
                    recovery="terminal",
                )
            if current_state != "draft":
                raise AdcpError(
                    "INTERNAL_ERROR",
                    message=(
                        f"Cannot commit proposal {proposal_id!r} from "
                        f"state {current_state!r}; commit requires "
                        "DRAFT."
                    ),
                    recovery="terminal",
                )
            update_cur = await conn.execute(
                self._sql_commit,
                (expires_at, payload_json, expected_account_id, proposal_id),
            )
            if await update_cur.fetchone() is None:
                # Should not happen under the row lock, but fail loud
                # if it does — silent zero-row UPDATE would let the
                # caller believe the transition landed.
                raise AdcpError(
                    "INTERNAL_ERROR",
                    message=(
                        f"PgProposalStore.commit: UPDATE returned zero "
                        f"rows for proposal {proposal_id!r} despite "
                        "passing the FOR UPDATE state check. Schema "
                        "drift suspected."
                    ),
                    recovery="terminal",
                )
async def create_schema(self) ‑> None
Expand source code
async def create_schema(self) -> None:
    """Create the proposal store table + supporting indexes.

    Honors the ``table_name`` kwarg the store was constructed with.
    Idempotent via ``CREATE TABLE IF NOT EXISTS`` — safe to call on
    every application boot. The equivalent raw DDL ships at
    :file:`adcp/decisioning/pg/proposal_store.sql` in the installed
    package for adopters using a migration tool.
    """
    t = self._table
    statements = [
        f"""CREATE TABLE IF NOT EXISTS {t} (
            account_id              TEXT        COLLATE "C" NOT NULL,
            proposal_id             TEXT        COLLATE "C" NOT NULL,
            state                   TEXT        NOT NULL
                CHECK (state IN ('draft', 'committed', 'consuming', 'consumed')),
            recipes                 JSONB       NOT NULL DEFAULT '{{}}'::jsonb,
            proposal_payload        JSONB       NOT NULL,
            expires_at              TIMESTAMPTZ,
            media_buy_id            TEXT        COLLATE "C",
            recipe_schema_version   INTEGER     NOT NULL DEFAULT 1,
            created_at              TIMESTAMPTZ NOT NULL DEFAULT now(),
            updated_at              TIMESTAMPTZ NOT NULL DEFAULT now(),
            PRIMARY KEY (account_id, proposal_id)
        )""",
        f"""CREATE UNIQUE INDEX IF NOT EXISTS {t}_media_buy_idx
            ON {t} (account_id, media_buy_id)
            WHERE media_buy_id IS NOT NULL""",
        f"""CREATE INDEX IF NOT EXISTS {t}_expires_idx
            ON {t} (expires_at)
            WHERE expires_at IS NOT NULL""",
    ]
    async with self._pool.connection() as conn:
        for stmt in statements:
            await conn.execute(stmt)

Create the proposal store table + supporting indexes.

Honors the table_name kwarg the store was constructed with. Idempotent via CREATE TABLE IF NOT EXISTS — safe to call on every application boot. The equivalent raw DDL ships at :file:adcp/decisioning/pg/proposal_store.sql in the installed package for adopters using a migration tool.

async def discard(self, proposal_id: str, *, expected_account_id: str) ‑> None
Expand source code
async def discard(
    self,
    proposal_id: str,
    *,
    expected_account_id: str,
) -> None:
    async with self._pool.connection() as conn:
        await conn.execute(self._sql_discard, (expected_account_id, proposal_id))
async def finalize_consumption(self, proposal_id: str, *, media_buy_id: str, expected_account_id: str) ‑> None
Expand source code
async def finalize_consumption(
    self,
    proposal_id: str,
    *,
    media_buy_id: str,
    expected_account_id: str,
) -> None:
    async with self._pool.connection() as conn:
        cur = await conn.execute(
            self._sql_finalize,
            (media_buy_id, expected_account_id, proposal_id),
        )
        if await cur.fetchone() is not None:
            return
        # Zero rows updated. Determine why.
        cur2 = await conn.execute(self._sql_get_state, (expected_account_id, proposal_id))
        existing = await cur2.fetchone()
        if existing is None:
            raise AdcpError(
                "INTERNAL_ERROR",
                message=(
                    f"finalize_consumption: proposal {proposal_id!r} "
                    "not found for the expected tenant."
                ),
                recovery="terminal",
            )
        state_str = existing[0]
        if state_str == "consumed":
            # Idempotent on re-call with the same media_buy_id.
            cur3 = await conn.execute(
                f"SELECT media_buy_id FROM {self._table} "  # noqa: S608
                f"WHERE account_id = %s AND proposal_id = %s",
                (expected_account_id, proposal_id),
            )
            row = await cur3.fetchone()
            existing_media_buy_id = row[0] if row else None
            if existing_media_buy_id == media_buy_id:
                return
            raise AdcpError(
                "INTERNAL_ERROR",
                message=(
                    f"Proposal {proposal_id!r} already consumed by "
                    f"media_buy_id={existing_media_buy_id!r}; cannot "
                    f"re-consume as {media_buy_id!r}."
                ),
                recovery="terminal",
            )
        raise AdcpError(
            "INTERNAL_ERROR",
            message=(
                f"finalize_consumption requires CONSUMING; "
                f"proposal {proposal_id!r} is in {state_str!r}. "
                "Framework must call try_reserve_consumption first."
            ),
            recovery="terminal",
        )
async def get(self, proposal_id: str, *, expected_account_id: str | None = None) ‑> ProposalRecord | None
Expand source code
async def get(
    self,
    proposal_id: str,
    *,
    expected_account_id: str | None = None,
) -> ProposalRecord | None:
    # The Protocol allows expected_account_id=None — historically a
    # convenience for diagnostic / admin callers. We still serve
    # that case but route it through a separate query without the
    # account predicate so the tenancy-aware fast path is purely
    # parameterised; a future code reader can't accidentally pass
    # None into a tenancy-required call.
    if expected_account_id is None:
        sql = (  # noqa: S608 — table name pre-validated at construction
            f"SELECT proposal_id, account_id, state, recipes, "
            f"proposal_payload, expires_at, media_buy_id, "
            f"recipe_schema_version FROM {self._table} "
            f"WHERE proposal_id = %s"
        )
        params: tuple[Any, ...] = (proposal_id,)
    else:
        sql = (  # noqa: S608
            f"SELECT proposal_id, account_id, state, recipes, "
            f"proposal_payload, expires_at, media_buy_id, "
            f"recipe_schema_version FROM {self._table} "
            f"WHERE account_id = %s AND proposal_id = %s"
        )
        params = (expected_account_id, proposal_id)
    async with self._pool.connection() as conn:
        cur = await conn.execute(sql, params)
        row = await cur.fetchone()
        if row is None:
            return None
        return self._row_to_record(row)
async def get_by_media_buy_id(self, media_buy_id: str, *, expected_account_id: str) ‑> ProposalRecord | None
Expand source code
async def get_by_media_buy_id(
    self,
    media_buy_id: str,
    *,
    expected_account_id: str,
) -> ProposalRecord | None:
    async with self._pool.connection() as conn:
        cur = await conn.execute(
            self._sql_get_by_media_buy_id,
            (expected_account_id, media_buy_id),
        )
        row = await cur.fetchone()
        if row is None:
            return None
        return self._row_to_record(row)
async def mark_consumed(self, proposal_id: str, *, media_buy_id: str, expected_account_id: str) ‑> None
Expand source code
async def mark_consumed(
    self,
    proposal_id: str,
    *,
    media_buy_id: str,
    expected_account_id: str,
) -> None:
    # Tenant-scoped SELECT FOR UPDATE → state-machine check →
    # UPDATE. Cross-tenant probes collapse to "not in store".
    async with self._pool.connection() as conn:
        async with conn.transaction():
            cur = await conn.execute(
                f"SELECT state, media_buy_id FROM {self._table} "  # noqa: S608
                f"WHERE account_id = %s AND proposal_id = %s FOR UPDATE",
                (expected_account_id, proposal_id),
            )
            row = await cur.fetchone()
            if row is None:
                raise AdcpError(
                    "INTERNAL_ERROR",
                    message=(
                        f"Cannot mark_consumed proposal {proposal_id!r}: "
                        "not in store for the expected tenant."
                    ),
                    recovery="terminal",
                )
            state_str, existing_media_buy_id = row
            if state_str == "consumed":
                if existing_media_buy_id == media_buy_id:
                    return
                raise AdcpError(
                    "INTERNAL_ERROR",
                    message=(
                        f"Proposal {proposal_id!r} already consumed by "
                        f"media_buy_id={existing_media_buy_id!r}; cannot "
                        f"re-consume as {media_buy_id!r}."
                    ),
                    recovery="terminal",
                )
            if state_str != "committed":
                raise AdcpError(
                    "INTERNAL_ERROR",
                    message=(
                        f"Cannot mark_consumed proposal {proposal_id!r} "
                        f"from state {state_str!r}; mark_consumed "
                        "requires COMMITTED."
                    ),
                    recovery="terminal",
                )
            await conn.execute(
                self._sql_mark_consumed,
                (media_buy_id, expected_account_id, proposal_id),
            )
async def put_draft(self,
*,
proposal_id: str,
account_id: str,
recipes: Mapping[str, Recipe],
proposal_payload: Mapping[str, Any]) ‑> None
Expand source code
async def put_draft(
    self,
    *,
    proposal_id: str,
    account_id: str,
    recipes: Mapping[str, Recipe],
    proposal_payload: Mapping[str, Any],
) -> None:
    recipes_json = _encode_recipes(recipes)
    payload_json = json.dumps(dict(proposal_payload))
    async with self._pool.connection() as conn:
        cur = await conn.execute(
            self._sql_put_draft,
            (account_id, proposal_id, recipes_json, payload_json, 1),
        )
        row = await cur.fetchone()
        if row is not None:
            # Either inserted fresh (xmax=0) or rewrote a DRAFT.
            return
        # ON CONFLICT DO UPDATE matched zero rows — the record is in
        # a non-DRAFT state. Re-fetch to surface the current state in
        # the error message.
        cur2 = await conn.execute(self._sql_get_state, (account_id, proposal_id))
        existing = await cur2.fetchone()
        if existing is None:
            # Race: row vanished between the failed INSERT and the
            # follow-up SELECT (concurrent discard from another
            # worker). Surface as INTERNAL_ERROR so the framework's
            # outer dispatcher can decide whether to retry; we don't
            # transparently retry here because put_draft is meant to
            # be one-shot per dispatch.
            raise AdcpError(
                "INTERNAL_ERROR",
                message=(
                    f"PgProposalStore.put_draft: proposal {proposal_id!r} "
                    "vanished between conflict and refetch. Concurrent "
                    "discard suspected."
                ),
                recovery="terminal",
            )
        state_str = existing[0]
        raise AdcpError(
            "INTERNAL_ERROR",
            message=(
                f"Cannot put_draft on proposal {proposal_id!r} in "
                f"state {state_str!r}; refine iterations are only "
                "valid on draft proposals. Once committed or "
                "consumed, a proposal_id is immutable."
            ),
            recovery="terminal",
        )
async def release_consumption(self, proposal_id: str, *, expected_account_id: str) ‑> None
Expand source code
async def release_consumption(
    self,
    proposal_id: str,
    *,
    expected_account_id: str,
) -> None:
    async with self._pool.connection() as conn:
        cur = await conn.execute(
            self._sql_release,
            (expected_account_id, proposal_id),
        )
        if await cur.fetchone() is not None:
            return
        # Zero rows updated. Idempotent: no-op on unknown id /
        # cross-tenant probe, no-op on already-COMMITTED.
        cur2 = await conn.execute(self._sql_get_state, (expected_account_id, proposal_id))
        existing = await cur2.fetchone()
        if existing is None:
            # Unknown id or cross-tenant — idempotent no-op so the
            # adapter-failure rollback path can be unconditional.
            return
        state_str = existing[0]
        if state_str == "committed":
            # Already rolled back.
            return
        raise AdcpError(
            "INTERNAL_ERROR",
            message=(
                f"release_consumption requires CONSUMING; "
                f"proposal {proposal_id!r} is in {state_str!r}."
            ),
            recovery="terminal",
        )
async def try_reserve_consumption(self, proposal_id: str, *, expected_account_id: str) ‑> ProposalRecord
Expand source code
async def try_reserve_consumption(
    self,
    proposal_id: str,
    *,
    expected_account_id: str,
) -> ProposalRecord:
    # Single-connection transaction so SELECT FOR UPDATE + UPDATE
    # serialize across parallel callers.
    async with self._pool.connection() as conn:
        async with conn.transaction():
            cur = await conn.execute(
                self._sql_select_for_update,
                (expected_account_id, proposal_id),
            )
            row = await cur.fetchone()
            if row is None:
                raise AdcpError(
                    "PROPOSAL_NOT_FOUND",
                    message=(f"Proposal {proposal_id!r} not found."),
                    recovery="correctable",
                    field="proposal_id",
                )
            (
                state_str,
                recipes_raw,
                payload_raw,
                expires_at_raw,
                media_buy_id,
                recipe_schema_version,
            ) = row
            if state_str != "committed":
                raise AdcpError(
                    "PROPOSAL_NOT_COMMITTED",
                    message=(
                        f"Proposal {proposal_id!r} is in state "
                        f"{state_str!r}; create_media_buy requires a "
                        "committed proposal that hasn't been accepted "
                        "or reserved by another request."
                    ),
                    recovery="correctable",
                    field="proposal_id",
                )
            await conn.execute(
                self._sql_reserve,
                (expected_account_id, proposal_id),
            )
            # Build the in-memory record reflecting the transition.
            return ProposalRecord(
                proposal_id=proposal_id,
                account_id=expected_account_id,
                state=ProposalState.CONSUMING,
                recipes=_decode_recipes(recipes_raw, self._recipe_decoder),
                proposal_payload=_decode_payload(payload_raw),
                expires_at=_ensure_utc(expires_at_raw),
                media_buy_id=media_buy_id,
                recipe_schema_version=int(recipe_schema_version or 1),
            )
class PgTaskRegistry (*, pool: AsyncConnectionPool)
Expand source code
class PgTaskRegistry:
    """PostgreSQL-backed :class:`~adcp.decisioning.TaskRegistry` — v6.1.

    Durable counterpart to :class:`~adcp.decisioning.InMemoryTaskRegistry`.
    Set ``is_durable = True`` so the production-mode gate in
    :func:`adcp.decisioning.serve.create_adcp_server_from_platform` accepts it
    without requiring ``ADCP_DECISIONING_ALLOW_INMEMORY_TASKS=1``.

    Parameters
    ----------
    pool:
        An :class:`psycopg_pool.AsyncConnectionPool` owned by the caller.
        Each registry operation acquires a short-lived connection from the
        pool and returns it immediately after the query. No long-lived
        transactions, no cross-operation state.

    Notes
    -----
    Unlike :class:`~adcp.signing.PgReplayStore`, this class uses a fixed
    ``decisioning_tasks`` table name. Multi-tenant table-name isolation is not
    supported in this release — callers requiring strict schema separation
    should use separate databases or schemas.
    """

    is_durable: ClassVar[bool] = True

    def __init__(self, *, pool: AsyncConnectionPool, _table: str = _DEFAULT_TABLE) -> None:
        if not PG_AVAILABLE:
            raise ImportError(_INSTALL_HINT)
        if not _SAFE_IDENTIFIER_RE.fullmatch(_table):
            raise ValueError(f"_table must match [a-z_][a-z0-9_]* (ASCII only), got {_table!r}")
        self._pool = pool
        self._table = _table

        # Pre-format queries at construction so the hot path avoids f-strings per call.
        # _table is whitelisted by _SAFE_IDENTIFIER_RE above.
        self._sql_insert = (  # noqa: S608 — table name is whitelisted
            f"INSERT INTO {self._table}"
            f" (task_id, account_id, state, task_type, created_at, updated_at)"
            f" VALUES (%s, %s, 'submitted', %s, %s, %s)"
        )
        self._sql_update_progress = (  # noqa: S608
            f"UPDATE {self._table}"
            f" SET state = CASE state WHEN 'submitted' THEN 'working' ELSE state END,"
            f"     progress = %s::jsonb, updated_at = %s"
            f" WHERE task_id = %s AND state NOT IN ('completed', 'failed')"
        )
        self._sql_complete = (  # noqa: S608
            f"UPDATE {self._table}"
            f" SET state = 'completed', result = %s::jsonb, updated_at = %s"
            f" WHERE task_id = %s AND state NOT IN ('completed', 'failed')"
            f" RETURNING task_id"
        )
        self._sql_fail = (  # noqa: S608
            f"UPDATE {self._table}"
            f" SET state = 'failed', error = %s::jsonb, updated_at = %s"
            f" WHERE task_id = %s AND state NOT IN ('completed', 'failed')"
            f" RETURNING task_id"
        )
        # Explicit ``::text`` cast on the optional account-filter
        # parameter so psycopg's bind-param type inference doesn't
        # fail with ``IndeterminateDatatype: could not determine
        # data type of parameter $2``. Without the cast, the
        # ``%s IS NULL`` predicate gives psycopg no type context
        # for the parameter and the query fails at prepare time.
        self._sql_get = (  # noqa: S608
            f"SELECT task_id, account_id, state, task_type,"
            f"       progress, result, error, created_at, updated_at"
            f" FROM {self._table}"
            f" WHERE task_id = %s AND (%s::text IS NULL OR account_id = %s)"
        )
        self._sql_get_state_result = (  # noqa: S608
            f"SELECT state, result FROM {self._table} WHERE task_id = %s"
        )
        self._sql_get_state_error = (  # noqa: S608
            f"SELECT state, error FROM {self._table} WHERE task_id = %s"
        )
        self._sql_discard = f"DELETE FROM {self._table} WHERE task_id = %s"  # noqa: S608
        self._sql_ddl = (  # noqa: S608
            f"CREATE TABLE IF NOT EXISTS {self._table} ("
            f'    task_id     TEXT             COLLATE "C" NOT NULL PRIMARY KEY,'
            f'    account_id  TEXT             COLLATE "C" NOT NULL,'
            f"    state       TEXT             NOT NULL DEFAULT 'submitted',"
            f"    task_type   TEXT             NOT NULL,"
            f"    progress    JSONB,"
            f"    result      JSONB,"
            f"    error       JSONB,"
            f"    created_at  DOUBLE PRECISION NOT NULL,"
            f"    updated_at  DOUBLE PRECISION NOT NULL"
            f");"
            f"CREATE INDEX IF NOT EXISTS {self._table}_account_idx"  # noqa: S608
            f"    ON {self._table} (account_id);"
        )

    # -- schema bootstrap -----------------------------------------------

    async def create_schema(self) -> None:
        """Create the task registry table and supporting index.

        Honors the ``_table`` kwarg the store was constructed with.
        Idempotent via ``CREATE TABLE IF NOT EXISTS`` — safe to call on every
        application boot. The equivalent raw DDL ships at
        ``adcp/decisioning/pg/decisioning_tasks.sql`` in the installed package
        for adopters using a migration tool (Alembic, Flyway, psql).
        """
        async with self._pool.connection() as conn:
            await conn.execute(self._sql_ddl)

    # -- TaskRegistry Protocol ------------------------------------------

    async def issue(
        self,
        *,
        account_id: str,
        task_type: str,
    ) -> str:
        """Allocate a task_id, persist a ``submitted`` row, return the id.

        Mirrors :meth:`~adcp.decisioning.InMemoryTaskRegistry.issue` including
        the account_id validation guard — empty or sentinel account_ids would
        allow cross-tenant task-id probing via the ``WHERE account_id = %s``
        predicate collapsing multiple tenants into one slot.
        """
        if not account_id or not account_id.strip() or account_id == "<unset>":
            raise ValueError(
                f"account_id must be a non-empty, non-default string; "
                f"got {account_id!r}. AccountStore.resolve must always "
                "return Account(id=<non-empty>) so cross-tenant cache "
                "scoping works correctly."
            )
        task_id = f"task_{uuid.uuid4().hex[:16]}"
        now = time.time()
        async with self._pool.connection() as conn:
            await conn.execute(self._sql_insert, (task_id, account_id, task_type, now, now))
        return task_id

    async def update_progress(
        self,
        task_id: str,
        progress: dict[str, Any],
    ) -> None:
        """Write a progress payload; transition ``submitted`` → ``working``.

        Silently no-ops when the task is already in a terminal state or
        unknown — the dispatch wrapper expects this method never to raise on
        transient conditions (see :class:`~adcp.decisioning.TaskRegistry`
        docstring).

        The ``state NOT IN ('completed', 'failed')`` predicate is evaluated
        server-side so a concurrent terminal write cannot be overwritten by a
        straggler progress event.
        """
        async with self._pool.connection() as conn:
            await conn.execute(
                self._sql_update_progress,
                (json.dumps(progress), time.time(), task_id),
            )
            # Zero rows updated means unknown task_id or terminal state — silent
            # no-op per Protocol contract. The InMemoryTaskRegistry logs a
            # WARNING on terminal-state drops; we omit the extra SELECT needed
            # to distinguish the two cases since the dispatch wrapper swallows
            # the result either way.

    async def complete(
        self,
        task_id: str,
        result: dict[str, Any],
    ) -> None:
        """Mark the task ``completed`` with ``result`` as the terminal artifact.

        Idempotent on repeated calls with an equal ``result``; raises
        :class:`ValueError` on conflicting re-completion.

        Uses an atomic ``UPDATE ... RETURNING`` so concurrent workers cannot
        race each other into double-completion without detection.
        """
        async with self._pool.connection() as conn:
            cur = await conn.execute(self._sql_complete, (json.dumps(result), time.time(), task_id))
            if await cur.fetchone() is not None:
                return  # updated successfully

            # Zero rows in RETURNING — task is unknown or already terminal.
            cur2 = await conn.execute(self._sql_get_state_result, (task_id,))
            row = await cur2.fetchone()
            if row is None:
                raise ValueError(f"Task {task_id!r} not found")
            state, existing_result = row
            if state == "completed":
                if existing_result == result:
                    return  # idempotent
                raise ValueError(f"Task {task_id!r} already completed with a different result")
            raise ValueError(f"Task {task_id!r} already in terminal state {state!r}")

    async def fail(
        self,
        task_id: str,
        error: dict[str, Any],
    ) -> None:
        """Mark the task ``failed`` with ``error`` as the terminal payload.

        Idempotent on repeated calls with an equal ``error``; raises
        :class:`ValueError` on conflicting re-failure.
        """
        async with self._pool.connection() as conn:
            cur = await conn.execute(self._sql_fail, (json.dumps(error), time.time(), task_id))
            if await cur.fetchone() is not None:
                return  # updated successfully

            # Zero rows in RETURNING — task is unknown or already terminal.
            cur2 = await conn.execute(self._sql_get_state_error, (task_id,))
            row = await cur2.fetchone()
            if row is None:
                raise ValueError(f"Task {task_id!r} not found")
            state, existing_error = row
            if state == "failed":
                if existing_error == error:
                    return  # idempotent
                raise ValueError(f"Task {task_id!r} already failed with a different error")
            raise ValueError(f"Task {task_id!r} already in terminal state {state!r}")

    async def get(
        self,
        task_id: str,
        *,
        expected_account_id: str | None = None,
    ) -> dict[str, Any] | None:
        """Look up a task record; cross-tenant probes return ``None``.

        The ``expected_account_id`` predicate is enforced at the SQL level
        (``WHERE account_id = %s``), not as a Python-level filter after fetch.
        This guarantees the row is never materialized for a mismatched probe,
        eliminating the fetch-then-filter anti-pattern.
        """
        async with self._pool.connection() as conn:
            cur = await conn.execute(
                self._sql_get, (task_id, expected_account_id, expected_account_id)
            )
            row = await cur.fetchone()
            if row is None:
                return None
            return {
                "task_id": row[0],
                "account_id": row[1],
                "state": row[2],
                "task_type": row[3],
                "progress": row[4],
                "result": row[5],
                "error": row[6],
                "created_at": row[7],
                "updated_at": row[8],
            }

    async def discard(self, task_id: str) -> None:
        """Remove a task_id from the registry — rollback path.

        Idempotent: discarding an unknown task_id is a no-op (no raise),
        matching the :class:`~adcp.decisioning.InMemoryTaskRegistry` contract.
        """
        async with self._pool.connection() as conn:
            await conn.execute(self._sql_discard, (task_id,))

PostgreSQL-backed :class:~adcp.decisioning.TaskRegistry — v6.1.

Durable counterpart to :class:~adcp.decisioning.InMemoryTaskRegistry. Set is_durable = True so the production-mode gate in :func:adcp.decisioning.serve.create_adcp_server_from_platform accepts it without requiring ADCP_DECISIONING_ALLOW_INMEMORY_TASKS=1.

Parameters

pool: An :class:psycopg_pool.AsyncConnectionPool owned by the caller. Each registry operation acquires a short-lived connection from the pool and returns it immediately after the query. No long-lived transactions, no cross-operation state.

Notes

Unlike :class:~adcp.signing.PgReplayStore, this class uses a fixed decisioning_tasks table name. Multi-tenant table-name isolation is not supported in this release — callers requiring strict schema separation should use separate databases or schemas.

Class variables

var is_durable : ClassVar[bool]

Methods

async def complete(self, task_id: str, result: dict[str, Any]) ‑> None
Expand source code
async def complete(
    self,
    task_id: str,
    result: dict[str, Any],
) -> None:
    """Mark the task ``completed`` with ``result`` as the terminal artifact.

    Idempotent on repeated calls with an equal ``result``; raises
    :class:`ValueError` on conflicting re-completion.

    Uses an atomic ``UPDATE ... RETURNING`` so concurrent workers cannot
    race each other into double-completion without detection.
    """
    async with self._pool.connection() as conn:
        cur = await conn.execute(self._sql_complete, (json.dumps(result), time.time(), task_id))
        if await cur.fetchone() is not None:
            return  # updated successfully

        # Zero rows in RETURNING — task is unknown or already terminal.
        cur2 = await conn.execute(self._sql_get_state_result, (task_id,))
        row = await cur2.fetchone()
        if row is None:
            raise ValueError(f"Task {task_id!r} not found")
        state, existing_result = row
        if state == "completed":
            if existing_result == result:
                return  # idempotent
            raise ValueError(f"Task {task_id!r} already completed with a different result")
        raise ValueError(f"Task {task_id!r} already in terminal state {state!r}")

Mark the task completed with result as the terminal artifact.

Idempotent on repeated calls with an equal result; raises :class:ValueError on conflicting re-completion.

Uses an atomic UPDATE … RETURNING so concurrent workers cannot race each other into double-completion without detection.

async def create_schema(self) ‑> None
Expand source code
async def create_schema(self) -> None:
    """Create the task registry table and supporting index.

    Honors the ``_table`` kwarg the store was constructed with.
    Idempotent via ``CREATE TABLE IF NOT EXISTS`` — safe to call on every
    application boot. The equivalent raw DDL ships at
    ``adcp/decisioning/pg/decisioning_tasks.sql`` in the installed package
    for adopters using a migration tool (Alembic, Flyway, psql).
    """
    async with self._pool.connection() as conn:
        await conn.execute(self._sql_ddl)

Create the task registry table and supporting index.

Honors the _table kwarg the store was constructed with. Idempotent via CREATE TABLE IF NOT EXISTS — safe to call on every application boot. The equivalent raw DDL ships at adcp/decisioning/pg/decisioning_tasks.sql in the installed package for adopters using a migration tool (Alembic, Flyway, psql).

async def discard(self, task_id: str) ‑> None
Expand source code
async def discard(self, task_id: str) -> None:
    """Remove a task_id from the registry — rollback path.

    Idempotent: discarding an unknown task_id is a no-op (no raise),
    matching the :class:`~adcp.decisioning.InMemoryTaskRegistry` contract.
    """
    async with self._pool.connection() as conn:
        await conn.execute(self._sql_discard, (task_id,))

Remove a task_id from the registry — rollback path.

Idempotent: discarding an unknown task_id is a no-op (no raise), matching the :class:~adcp.decisioning.InMemoryTaskRegistry contract.

async def fail(self, task_id: str, error: dict[str, Any]) ‑> None
Expand source code
async def fail(
    self,
    task_id: str,
    error: dict[str, Any],
) -> None:
    """Mark the task ``failed`` with ``error`` as the terminal payload.

    Idempotent on repeated calls with an equal ``error``; raises
    :class:`ValueError` on conflicting re-failure.
    """
    async with self._pool.connection() as conn:
        cur = await conn.execute(self._sql_fail, (json.dumps(error), time.time(), task_id))
        if await cur.fetchone() is not None:
            return  # updated successfully

        # Zero rows in RETURNING — task is unknown or already terminal.
        cur2 = await conn.execute(self._sql_get_state_error, (task_id,))
        row = await cur2.fetchone()
        if row is None:
            raise ValueError(f"Task {task_id!r} not found")
        state, existing_error = row
        if state == "failed":
            if existing_error == error:
                return  # idempotent
            raise ValueError(f"Task {task_id!r} already failed with a different error")
        raise ValueError(f"Task {task_id!r} already in terminal state {state!r}")

Mark the task failed with error as the terminal payload.

Idempotent on repeated calls with an equal error; raises :class:ValueError on conflicting re-failure.

async def get(self, task_id: str, *, expected_account_id: str | None = None) ‑> dict[str, typing.Any] | None
Expand source code
async def get(
    self,
    task_id: str,
    *,
    expected_account_id: str | None = None,
) -> dict[str, Any] | None:
    """Look up a task record; cross-tenant probes return ``None``.

    The ``expected_account_id`` predicate is enforced at the SQL level
    (``WHERE account_id = %s``), not as a Python-level filter after fetch.
    This guarantees the row is never materialized for a mismatched probe,
    eliminating the fetch-then-filter anti-pattern.
    """
    async with self._pool.connection() as conn:
        cur = await conn.execute(
            self._sql_get, (task_id, expected_account_id, expected_account_id)
        )
        row = await cur.fetchone()
        if row is None:
            return None
        return {
            "task_id": row[0],
            "account_id": row[1],
            "state": row[2],
            "task_type": row[3],
            "progress": row[4],
            "result": row[5],
            "error": row[6],
            "created_at": row[7],
            "updated_at": row[8],
        }

Look up a task record; cross-tenant probes return None.

The expected_account_id predicate is enforced at the SQL level (WHERE account_id = %s), not as a Python-level filter after fetch. This guarantees the row is never materialized for a mismatched probe, eliminating the fetch-then-filter anti-pattern.

async def issue(self, *, account_id: str, task_type: str) ‑> str
Expand source code
async def issue(
    self,
    *,
    account_id: str,
    task_type: str,
) -> str:
    """Allocate a task_id, persist a ``submitted`` row, return the id.

    Mirrors :meth:`~adcp.decisioning.InMemoryTaskRegistry.issue` including
    the account_id validation guard — empty or sentinel account_ids would
    allow cross-tenant task-id probing via the ``WHERE account_id = %s``
    predicate collapsing multiple tenants into one slot.
    """
    if not account_id or not account_id.strip() or account_id == "<unset>":
        raise ValueError(
            f"account_id must be a non-empty, non-default string; "
            f"got {account_id!r}. AccountStore.resolve must always "
            "return Account(id=<non-empty>) so cross-tenant cache "
            "scoping works correctly."
        )
    task_id = f"task_{uuid.uuid4().hex[:16]}"
    now = time.time()
    async with self._pool.connection() as conn:
        await conn.execute(self._sql_insert, (task_id, account_id, task_type, now, now))
    return task_id

Allocate a task_id, persist a submitted row, return the id.

Mirrors :meth:~adcp.decisioning.InMemoryTaskRegistry.issue including the account_id validation guard — empty or sentinel account_ids would allow cross-tenant task-id probing via the WHERE account_id = %s predicate collapsing multiple tenants into one slot.

async def update_progress(self, task_id: str, progress: dict[str, Any]) ‑> None
Expand source code
async def update_progress(
    self,
    task_id: str,
    progress: dict[str, Any],
) -> None:
    """Write a progress payload; transition ``submitted`` → ``working``.

    Silently no-ops when the task is already in a terminal state or
    unknown — the dispatch wrapper expects this method never to raise on
    transient conditions (see :class:`~adcp.decisioning.TaskRegistry`
    docstring).

    The ``state NOT IN ('completed', 'failed')`` predicate is evaluated
    server-side so a concurrent terminal write cannot be overwritten by a
    straggler progress event.
    """
    async with self._pool.connection() as conn:
        await conn.execute(
            self._sql_update_progress,
            (json.dumps(progress), time.time(), task_id),
        )
        # Zero rows updated means unknown task_id or terminal state — silent
        # no-op per Protocol contract. The InMemoryTaskRegistry logs a
        # WARNING on terminal-state drops; we omit the extra SELECT needed
        # to distinguish the two cases since the dispatch wrapper swallows
        # the result either way.

Write a progress payload; transition submittedworking.

Silently no-ops when the task is already in a terminal state or unknown — the dispatch wrapper expects this method never to raise on transient conditions (see :class:~adcp.decisioning.TaskRegistry docstring).

The state NOT IN ('completed', 'failed') predicate is evaluated server-side so a concurrent terminal write cannot be overwritten by a straggler progress event.

class PostgresTaskRegistry (*, pool: AsyncConnectionPool)
Expand source code
class PgTaskRegistry:
    """PostgreSQL-backed :class:`~adcp.decisioning.TaskRegistry` — v6.1.

    Durable counterpart to :class:`~adcp.decisioning.InMemoryTaskRegistry`.
    Set ``is_durable = True`` so the production-mode gate in
    :func:`adcp.decisioning.serve.create_adcp_server_from_platform` accepts it
    without requiring ``ADCP_DECISIONING_ALLOW_INMEMORY_TASKS=1``.

    Parameters
    ----------
    pool:
        An :class:`psycopg_pool.AsyncConnectionPool` owned by the caller.
        Each registry operation acquires a short-lived connection from the
        pool and returns it immediately after the query. No long-lived
        transactions, no cross-operation state.

    Notes
    -----
    Unlike :class:`~adcp.signing.PgReplayStore`, this class uses a fixed
    ``decisioning_tasks`` table name. Multi-tenant table-name isolation is not
    supported in this release — callers requiring strict schema separation
    should use separate databases or schemas.
    """

    is_durable: ClassVar[bool] = True

    def __init__(self, *, pool: AsyncConnectionPool, _table: str = _DEFAULT_TABLE) -> None:
        if not PG_AVAILABLE:
            raise ImportError(_INSTALL_HINT)
        if not _SAFE_IDENTIFIER_RE.fullmatch(_table):
            raise ValueError(f"_table must match [a-z_][a-z0-9_]* (ASCII only), got {_table!r}")
        self._pool = pool
        self._table = _table

        # Pre-format queries at construction so the hot path avoids f-strings per call.
        # _table is whitelisted by _SAFE_IDENTIFIER_RE above.
        self._sql_insert = (  # noqa: S608 — table name is whitelisted
            f"INSERT INTO {self._table}"
            f" (task_id, account_id, state, task_type, created_at, updated_at)"
            f" VALUES (%s, %s, 'submitted', %s, %s, %s)"
        )
        self._sql_update_progress = (  # noqa: S608
            f"UPDATE {self._table}"
            f" SET state = CASE state WHEN 'submitted' THEN 'working' ELSE state END,"
            f"     progress = %s::jsonb, updated_at = %s"
            f" WHERE task_id = %s AND state NOT IN ('completed', 'failed')"
        )
        self._sql_complete = (  # noqa: S608
            f"UPDATE {self._table}"
            f" SET state = 'completed', result = %s::jsonb, updated_at = %s"
            f" WHERE task_id = %s AND state NOT IN ('completed', 'failed')"
            f" RETURNING task_id"
        )
        self._sql_fail = (  # noqa: S608
            f"UPDATE {self._table}"
            f" SET state = 'failed', error = %s::jsonb, updated_at = %s"
            f" WHERE task_id = %s AND state NOT IN ('completed', 'failed')"
            f" RETURNING task_id"
        )
        # Explicit ``::text`` cast on the optional account-filter
        # parameter so psycopg's bind-param type inference doesn't
        # fail with ``IndeterminateDatatype: could not determine
        # data type of parameter $2``. Without the cast, the
        # ``%s IS NULL`` predicate gives psycopg no type context
        # for the parameter and the query fails at prepare time.
        self._sql_get = (  # noqa: S608
            f"SELECT task_id, account_id, state, task_type,"
            f"       progress, result, error, created_at, updated_at"
            f" FROM {self._table}"
            f" WHERE task_id = %s AND (%s::text IS NULL OR account_id = %s)"
        )
        self._sql_get_state_result = (  # noqa: S608
            f"SELECT state, result FROM {self._table} WHERE task_id = %s"
        )
        self._sql_get_state_error = (  # noqa: S608
            f"SELECT state, error FROM {self._table} WHERE task_id = %s"
        )
        self._sql_discard = f"DELETE FROM {self._table} WHERE task_id = %s"  # noqa: S608
        self._sql_ddl = (  # noqa: S608
            f"CREATE TABLE IF NOT EXISTS {self._table} ("
            f'    task_id     TEXT             COLLATE "C" NOT NULL PRIMARY KEY,'
            f'    account_id  TEXT             COLLATE "C" NOT NULL,'
            f"    state       TEXT             NOT NULL DEFAULT 'submitted',"
            f"    task_type   TEXT             NOT NULL,"
            f"    progress    JSONB,"
            f"    result      JSONB,"
            f"    error       JSONB,"
            f"    created_at  DOUBLE PRECISION NOT NULL,"
            f"    updated_at  DOUBLE PRECISION NOT NULL"
            f");"
            f"CREATE INDEX IF NOT EXISTS {self._table}_account_idx"  # noqa: S608
            f"    ON {self._table} (account_id);"
        )

    # -- schema bootstrap -----------------------------------------------

    async def create_schema(self) -> None:
        """Create the task registry table and supporting index.

        Honors the ``_table`` kwarg the store was constructed with.
        Idempotent via ``CREATE TABLE IF NOT EXISTS`` — safe to call on every
        application boot. The equivalent raw DDL ships at
        ``adcp/decisioning/pg/decisioning_tasks.sql`` in the installed package
        for adopters using a migration tool (Alembic, Flyway, psql).
        """
        async with self._pool.connection() as conn:
            await conn.execute(self._sql_ddl)

    # -- TaskRegistry Protocol ------------------------------------------

    async def issue(
        self,
        *,
        account_id: str,
        task_type: str,
    ) -> str:
        """Allocate a task_id, persist a ``submitted`` row, return the id.

        Mirrors :meth:`~adcp.decisioning.InMemoryTaskRegistry.issue` including
        the account_id validation guard — empty or sentinel account_ids would
        allow cross-tenant task-id probing via the ``WHERE account_id = %s``
        predicate collapsing multiple tenants into one slot.
        """
        if not account_id or not account_id.strip() or account_id == "<unset>":
            raise ValueError(
                f"account_id must be a non-empty, non-default string; "
                f"got {account_id!r}. AccountStore.resolve must always "
                "return Account(id=<non-empty>) so cross-tenant cache "
                "scoping works correctly."
            )
        task_id = f"task_{uuid.uuid4().hex[:16]}"
        now = time.time()
        async with self._pool.connection() as conn:
            await conn.execute(self._sql_insert, (task_id, account_id, task_type, now, now))
        return task_id

    async def update_progress(
        self,
        task_id: str,
        progress: dict[str, Any],
    ) -> None:
        """Write a progress payload; transition ``submitted`` → ``working``.

        Silently no-ops when the task is already in a terminal state or
        unknown — the dispatch wrapper expects this method never to raise on
        transient conditions (see :class:`~adcp.decisioning.TaskRegistry`
        docstring).

        The ``state NOT IN ('completed', 'failed')`` predicate is evaluated
        server-side so a concurrent terminal write cannot be overwritten by a
        straggler progress event.
        """
        async with self._pool.connection() as conn:
            await conn.execute(
                self._sql_update_progress,
                (json.dumps(progress), time.time(), task_id),
            )
            # Zero rows updated means unknown task_id or terminal state — silent
            # no-op per Protocol contract. The InMemoryTaskRegistry logs a
            # WARNING on terminal-state drops; we omit the extra SELECT needed
            # to distinguish the two cases since the dispatch wrapper swallows
            # the result either way.

    async def complete(
        self,
        task_id: str,
        result: dict[str, Any],
    ) -> None:
        """Mark the task ``completed`` with ``result`` as the terminal artifact.

        Idempotent on repeated calls with an equal ``result``; raises
        :class:`ValueError` on conflicting re-completion.

        Uses an atomic ``UPDATE ... RETURNING`` so concurrent workers cannot
        race each other into double-completion without detection.
        """
        async with self._pool.connection() as conn:
            cur = await conn.execute(self._sql_complete, (json.dumps(result), time.time(), task_id))
            if await cur.fetchone() is not None:
                return  # updated successfully

            # Zero rows in RETURNING — task is unknown or already terminal.
            cur2 = await conn.execute(self._sql_get_state_result, (task_id,))
            row = await cur2.fetchone()
            if row is None:
                raise ValueError(f"Task {task_id!r} not found")
            state, existing_result = row
            if state == "completed":
                if existing_result == result:
                    return  # idempotent
                raise ValueError(f"Task {task_id!r} already completed with a different result")
            raise ValueError(f"Task {task_id!r} already in terminal state {state!r}")

    async def fail(
        self,
        task_id: str,
        error: dict[str, Any],
    ) -> None:
        """Mark the task ``failed`` with ``error`` as the terminal payload.

        Idempotent on repeated calls with an equal ``error``; raises
        :class:`ValueError` on conflicting re-failure.
        """
        async with self._pool.connection() as conn:
            cur = await conn.execute(self._sql_fail, (json.dumps(error), time.time(), task_id))
            if await cur.fetchone() is not None:
                return  # updated successfully

            # Zero rows in RETURNING — task is unknown or already terminal.
            cur2 = await conn.execute(self._sql_get_state_error, (task_id,))
            row = await cur2.fetchone()
            if row is None:
                raise ValueError(f"Task {task_id!r} not found")
            state, existing_error = row
            if state == "failed":
                if existing_error == error:
                    return  # idempotent
                raise ValueError(f"Task {task_id!r} already failed with a different error")
            raise ValueError(f"Task {task_id!r} already in terminal state {state!r}")

    async def get(
        self,
        task_id: str,
        *,
        expected_account_id: str | None = None,
    ) -> dict[str, Any] | None:
        """Look up a task record; cross-tenant probes return ``None``.

        The ``expected_account_id`` predicate is enforced at the SQL level
        (``WHERE account_id = %s``), not as a Python-level filter after fetch.
        This guarantees the row is never materialized for a mismatched probe,
        eliminating the fetch-then-filter anti-pattern.
        """
        async with self._pool.connection() as conn:
            cur = await conn.execute(
                self._sql_get, (task_id, expected_account_id, expected_account_id)
            )
            row = await cur.fetchone()
            if row is None:
                return None
            return {
                "task_id": row[0],
                "account_id": row[1],
                "state": row[2],
                "task_type": row[3],
                "progress": row[4],
                "result": row[5],
                "error": row[6],
                "created_at": row[7],
                "updated_at": row[8],
            }

    async def discard(self, task_id: str) -> None:
        """Remove a task_id from the registry — rollback path.

        Idempotent: discarding an unknown task_id is a no-op (no raise),
        matching the :class:`~adcp.decisioning.InMemoryTaskRegistry` contract.
        """
        async with self._pool.connection() as conn:
            await conn.execute(self._sql_discard, (task_id,))

PostgreSQL-backed :class:~adcp.decisioning.TaskRegistry — v6.1.

Durable counterpart to :class:~adcp.decisioning.InMemoryTaskRegistry. Set is_durable = True so the production-mode gate in :func:adcp.decisioning.serve.create_adcp_server_from_platform accepts it without requiring ADCP_DECISIONING_ALLOW_INMEMORY_TASKS=1.

Parameters

pool: An :class:psycopg_pool.AsyncConnectionPool owned by the caller. Each registry operation acquires a short-lived connection from the pool and returns it immediately after the query. No long-lived transactions, no cross-operation state.

Notes

Unlike :class:~adcp.signing.PgReplayStore, this class uses a fixed decisioning_tasks table name. Multi-tenant table-name isolation is not supported in this release — callers requiring strict schema separation should use separate databases or schemas.

Class variables

var is_durable : ClassVar[bool]

Methods

async def complete(self, task_id: str, result: dict[str, Any]) ‑> None
Expand source code
async def complete(
    self,
    task_id: str,
    result: dict[str, Any],
) -> None:
    """Mark the task ``completed`` with ``result`` as the terminal artifact.

    Idempotent on repeated calls with an equal ``result``; raises
    :class:`ValueError` on conflicting re-completion.

    Uses an atomic ``UPDATE ... RETURNING`` so concurrent workers cannot
    race each other into double-completion without detection.
    """
    async with self._pool.connection() as conn:
        cur = await conn.execute(self._sql_complete, (json.dumps(result), time.time(), task_id))
        if await cur.fetchone() is not None:
            return  # updated successfully

        # Zero rows in RETURNING — task is unknown or already terminal.
        cur2 = await conn.execute(self._sql_get_state_result, (task_id,))
        row = await cur2.fetchone()
        if row is None:
            raise ValueError(f"Task {task_id!r} not found")
        state, existing_result = row
        if state == "completed":
            if existing_result == result:
                return  # idempotent
            raise ValueError(f"Task {task_id!r} already completed with a different result")
        raise ValueError(f"Task {task_id!r} already in terminal state {state!r}")

Mark the task completed with result as the terminal artifact.

Idempotent on repeated calls with an equal result; raises :class:ValueError on conflicting re-completion.

Uses an atomic UPDATE … RETURNING so concurrent workers cannot race each other into double-completion without detection.

async def create_schema(self) ‑> None
Expand source code
async def create_schema(self) -> None:
    """Create the task registry table and supporting index.

    Honors the ``_table`` kwarg the store was constructed with.
    Idempotent via ``CREATE TABLE IF NOT EXISTS`` — safe to call on every
    application boot. The equivalent raw DDL ships at
    ``adcp/decisioning/pg/decisioning_tasks.sql`` in the installed package
    for adopters using a migration tool (Alembic, Flyway, psql).
    """
    async with self._pool.connection() as conn:
        await conn.execute(self._sql_ddl)

Create the task registry table and supporting index.

Honors the _table kwarg the store was constructed with. Idempotent via CREATE TABLE IF NOT EXISTS — safe to call on every application boot. The equivalent raw DDL ships at adcp/decisioning/pg/decisioning_tasks.sql in the installed package for adopters using a migration tool (Alembic, Flyway, psql).

async def discard(self, task_id: str) ‑> None
Expand source code
async def discard(self, task_id: str) -> None:
    """Remove a task_id from the registry — rollback path.

    Idempotent: discarding an unknown task_id is a no-op (no raise),
    matching the :class:`~adcp.decisioning.InMemoryTaskRegistry` contract.
    """
    async with self._pool.connection() as conn:
        await conn.execute(self._sql_discard, (task_id,))

Remove a task_id from the registry — rollback path.

Idempotent: discarding an unknown task_id is a no-op (no raise), matching the :class:~adcp.decisioning.InMemoryTaskRegistry contract.

async def fail(self, task_id: str, error: dict[str, Any]) ‑> None
Expand source code
async def fail(
    self,
    task_id: str,
    error: dict[str, Any],
) -> None:
    """Mark the task ``failed`` with ``error`` as the terminal payload.

    Idempotent on repeated calls with an equal ``error``; raises
    :class:`ValueError` on conflicting re-failure.
    """
    async with self._pool.connection() as conn:
        cur = await conn.execute(self._sql_fail, (json.dumps(error), time.time(), task_id))
        if await cur.fetchone() is not None:
            return  # updated successfully

        # Zero rows in RETURNING — task is unknown or already terminal.
        cur2 = await conn.execute(self._sql_get_state_error, (task_id,))
        row = await cur2.fetchone()
        if row is None:
            raise ValueError(f"Task {task_id!r} not found")
        state, existing_error = row
        if state == "failed":
            if existing_error == error:
                return  # idempotent
            raise ValueError(f"Task {task_id!r} already failed with a different error")
        raise ValueError(f"Task {task_id!r} already in terminal state {state!r}")

Mark the task failed with error as the terminal payload.

Idempotent on repeated calls with an equal error; raises :class:ValueError on conflicting re-failure.

async def get(self, task_id: str, *, expected_account_id: str | None = None) ‑> dict[str, typing.Any] | None
Expand source code
async def get(
    self,
    task_id: str,
    *,
    expected_account_id: str | None = None,
) -> dict[str, Any] | None:
    """Look up a task record; cross-tenant probes return ``None``.

    The ``expected_account_id`` predicate is enforced at the SQL level
    (``WHERE account_id = %s``), not as a Python-level filter after fetch.
    This guarantees the row is never materialized for a mismatched probe,
    eliminating the fetch-then-filter anti-pattern.
    """
    async with self._pool.connection() as conn:
        cur = await conn.execute(
            self._sql_get, (task_id, expected_account_id, expected_account_id)
        )
        row = await cur.fetchone()
        if row is None:
            return None
        return {
            "task_id": row[0],
            "account_id": row[1],
            "state": row[2],
            "task_type": row[3],
            "progress": row[4],
            "result": row[5],
            "error": row[6],
            "created_at": row[7],
            "updated_at": row[8],
        }

Look up a task record; cross-tenant probes return None.

The expected_account_id predicate is enforced at the SQL level (WHERE account_id = %s), not as a Python-level filter after fetch. This guarantees the row is never materialized for a mismatched probe, eliminating the fetch-then-filter anti-pattern.

async def issue(self, *, account_id: str, task_type: str) ‑> str
Expand source code
async def issue(
    self,
    *,
    account_id: str,
    task_type: str,
) -> str:
    """Allocate a task_id, persist a ``submitted`` row, return the id.

    Mirrors :meth:`~adcp.decisioning.InMemoryTaskRegistry.issue` including
    the account_id validation guard — empty or sentinel account_ids would
    allow cross-tenant task-id probing via the ``WHERE account_id = %s``
    predicate collapsing multiple tenants into one slot.
    """
    if not account_id or not account_id.strip() or account_id == "<unset>":
        raise ValueError(
            f"account_id must be a non-empty, non-default string; "
            f"got {account_id!r}. AccountStore.resolve must always "
            "return Account(id=<non-empty>) so cross-tenant cache "
            "scoping works correctly."
        )
    task_id = f"task_{uuid.uuid4().hex[:16]}"
    now = time.time()
    async with self._pool.connection() as conn:
        await conn.execute(self._sql_insert, (task_id, account_id, task_type, now, now))
    return task_id

Allocate a task_id, persist a submitted row, return the id.

Mirrors :meth:~adcp.decisioning.InMemoryTaskRegistry.issue including the account_id validation guard — empty or sentinel account_ids would allow cross-tenant task-id probing via the WHERE account_id = %s predicate collapsing multiple tenants into one slot.

async def update_progress(self, task_id: str, progress: dict[str, Any]) ‑> None
Expand source code
async def update_progress(
    self,
    task_id: str,
    progress: dict[str, Any],
) -> None:
    """Write a progress payload; transition ``submitted`` → ``working``.

    Silently no-ops when the task is already in a terminal state or
    unknown — the dispatch wrapper expects this method never to raise on
    transient conditions (see :class:`~adcp.decisioning.TaskRegistry`
    docstring).

    The ``state NOT IN ('completed', 'failed')`` predicate is evaluated
    server-side so a concurrent terminal write cannot be overwritten by a
    straggler progress event.
    """
    async with self._pool.connection() as conn:
        await conn.execute(
            self._sql_update_progress,
            (json.dumps(progress), time.time(), task_id),
        )
        # Zero rows updated means unknown task_id or terminal state — silent
        # no-op per Protocol contract. The InMemoryTaskRegistry logs a
        # WARNING on terminal-state drops; we omit the extra SELECT needed
        # to distinguish the two cases since the dispatch wrapper swallows
        # the result either way.

Write a progress payload; transition submittedworking.

Silently no-ops when the task is already in a terminal state or unknown — the dispatch wrapper expects this method never to raise on transient conditions (see :class:~adcp.decisioning.TaskRegistry docstring).

The state NOT IN ('completed', 'failed') predicate is evaluated server-side so a concurrent terminal write cannot be overwritten by a straggler progress event.

class PlatformRouter (*,
accounts: AccountStore[Any],
platforms: Mapping[str, DecisioningPlatform],
capabilities: DecisioningCapabilities,
proposal_managers: Mapping[str, ProposalManager] | None = None,
proposal_stores: Mapping[str, ProposalStore] | None = None)
Expand source code
class PlatformRouter(DecisioningPlatform):
    """Drop-in :class:`DecisioningPlatform` that fans calls out across N tenants.

    Each per-specialism call resolves a tenant from
    ``ctx.account.metadata['tenant_id']`` and delegates to the
    matching child :class:`DecisioningPlatform`. The router's class-
    level ``capabilities`` is the union of every child's specialisms;
    individual calls that the resolved tenant's platform doesn't
    implement raise ``UNSUPPORTED_FEATURE``.

    The set of methods the router exposes is computed at construction
    by walking the framework's known specialism Protocols (see
    :data:`_KNOWN_SPECIALISM_PROTOCOLS`). New Protocols added to the
    SDK appear on the router automatically — adopters don't have to
    update their router wiring when the specialism surface grows.

    :param accounts: The adopter's :class:`AccountStore`. Resolves
        every request to an :class:`Account` whose
        ``metadata['tenant_id']`` keys :attr:`platforms`. This is the
        adopter's responsibility — typically the store reads
        :func:`adcp.server.current_tenant` (set by
        :class:`SubdomainTenantMiddleware`) and writes the tenant id
        onto the account metadata.
    :param platforms: ``{tenant_id: DecisioningPlatform}`` mapping. The
        router copies the dict shallowly at construction; later
        mutations to the source dict are NOT reflected. Pass a fresh
        dict per ``serve()`` call.
    :param proposal_managers: Optional ``{tenant_id: ProposalManager}``
        mapping for the two-platform composition (see
        ``docs/proposals/product-architecture.md``). When a tenant has
        a wired :class:`ProposalManager`, the router routes
        ``get_products`` (and refine-mode ``get_products`` when the
        manager declares :attr:`ProposalCapabilities.refine` and
        implements ``refine_products``) to that manager instead of the
        tenant's :class:`DecisioningPlatform`. Tenants without an
        entry fall through to ``platform.get_products`` —
        backward-compatible per tenant. Keys MUST be a subset of
        :attr:`platforms`; orphan tenants raise at construction.
        Single-tenant adopters use a one-entry router with
        ``{"default": MyProposalManager(...)}``.
    :param capabilities: The router's wire-shape capability
        declaration. Should be the union of every child platform's
        specialisms — the framework's ``tools/list`` filter reads this
        to advertise the right tools, and ``validate_platform`` reads
        the same value to verify each claimed specialism has its
        required methods (which the router's synthesized delegation
        provides).

    :raises ValueError: when :attr:`platforms` is empty (a router with
        no children is misconfiguration, not a valid empty state), or
        when :attr:`proposal_managers` contains tenant_ids not present
        in :attr:`platforms`.
    """

    def __init__(
        self,
        *,
        accounts: AccountStore[Any],
        platforms: Mapping[str, DecisioningPlatform],
        capabilities: DecisioningCapabilities,
        proposal_managers: Mapping[str, ProposalManager] | None = None,
        proposal_stores: Mapping[str, ProposalStore] | None = None,
    ) -> None:
        if not platforms:
            raise ValueError(
                "PlatformRouter requires at least one child platform; "
                "got an empty mapping. A router with no children would "
                "404 every request."
            )

        # Shallow copy — adopter-side dict mutations don't leak into the
        # router's view. Children are NOT defensively copied (they're
        # framework-instance singletons by contract).
        self._platforms: dict[str, DecisioningPlatform] = dict(platforms)

        # Per-tenant ProposalManager binding. Validate keys are a
        # subset of platforms — every tenant that wires a manager must
        # have a corresponding execution-side platform; orphan tenants
        # would silently route nothing.
        self._proposal_managers: dict[str, ProposalManager] = dict(proposal_managers or {})
        if self._proposal_managers:
            orphans = set(self._proposal_managers) - set(self._platforms)
            if orphans:
                raise ValueError(
                    f"proposal_managers keys must be a subset of platforms keys; "
                    f"orphan tenant_id(s): {sorted(orphans)}"
                )

        # Per-tenant ProposalStore binding (v1.5 § D5). Same orphan
        # validation; no auto-allocation — finalize-capable managers
        # without a wired store are a hard error so multi-worker
        # deployments don't silently lose proposals at the first
        # worker that didn't see put_draft.
        self._proposal_stores: dict[str, ProposalStore] = dict(proposal_stores or {})
        if self._proposal_stores:
            orphans = set(self._proposal_stores) - set(self._platforms)
            if orphans:
                raise ValueError(
                    f"proposal_stores keys must be a subset of platforms keys; "
                    f"orphan tenant_id(s): {sorted(orphans)}"
                )

        # Cross-store consistency check: a tenant declaring
        # finalize=True needs a wired store. The error message names
        # the exact kwarg to add — adopters get a 30-second copy-paste
        # rather than a debugging session at first finalize request.
        for tenant_id, manager in self._proposal_managers.items():
            caps = getattr(manager, "capabilities", None)
            finalize_supported = bool(getattr(caps, "finalize", False))
            if finalize_supported and tenant_id not in self._proposal_stores:
                raise ValueError(
                    f"Tenant {tenant_id!r} wired a ProposalManager declaring "
                    f"finalize=True, but no ProposalStore was registered for "
                    f"that tenant. Wire one via "
                    f"proposal_stores={{{tenant_id!r}: InMemoryProposalStore()}}, "
                    "or remove the finalize capability."
                )

        self.accounts = accounts
        self.capabilities = capabilities

        # Synthesize a delegating method per specialism method name.
        # Bound at construction so ``getattr(router, name)`` resolves
        # to a callable that closes over ``self`` and ``method_name``.
        # The framework's dispatcher uses ``getattr(platform, name)``
        # to find methods; instance-level callables work for that.
        # Sorted for deterministic synthesis order — easier to debug
        # than the underlying frozenset iteration order.
        #
        # ``get_products`` is special-cased: when proposal_managers is
        # wired the router needs to inspect the request's buying_mode
        # and the manager's capabilities before delegating. The
        # synthesized delegation can't do that — it just forwards.
        # Skip get_products here; the explicit method below handles it.
        for method_name in sorted(_all_specialism_methods()):
            if method_name in _ACCOUNT_STORE_METHODS:
                # Defensive: AccountStore methods MUST stay on the
                # router's accounts store, not be synthesized as
                # tenant-keyed delegations. Skip.
                continue
            if method_name == "get_products":
                # Handled explicitly by ``self.get_products`` below to
                # support per-tenant proposal_manager routing.
                continue
            self.__dict__[method_name] = self._make_delegate(method_name)

    # ----- per-tenant dispatch helpers --------------------------------

    def _platform_for(
        self,
        ctx: RequestContext[Any],
        method_name: str,
    ) -> DecisioningPlatform:
        """Look up the child platform for ``ctx``'s tenant.

        :raises AdcpError: ``ACCOUNT_NOT_FOUND`` when the tenant id
            resolves to no registered platform. ``UNSUPPORTED_FEATURE``
            when the platform exists but doesn't implement
            ``method_name``.
        """
        tenant_id = _tenant_id_from_ctx(ctx)
        platform = self._platforms.get(tenant_id)
        if platform is None:
            raise AdcpError(
                "ACCOUNT_NOT_FOUND",
                message=(
                    f"PlatformRouter has no platform for "
                    f"tenant_id={tenant_id!r}. Register one in the "
                    "``platforms=`` mapping passed to the router, or "
                    "fix the AccountStore to resolve to a known tenant."
                ),
                recovery="terminal",
                field="account.metadata.tenant_id",
            )

        method = getattr(platform, method_name, None)
        if method is None or not callable(method):
            raise AdcpError(
                "UNSUPPORTED_FEATURE",
                message=(
                    f"Tenant {tenant_id!r}'s platform "
                    f"({type(platform).__name__}) does not implement "
                    f"{method_name!r}. The router advertises this method "
                    "because at least one child platform supports it, "
                    "but this tenant's platform doesn't."
                ),
                recovery="terminal",
            )
        return platform

    async def refine_get_products(self, *args: Any, **kwargs: Any) -> Any:
        """Refine entry point — delegates to :meth:`get_products`.

        The handler's refine pathway dispatches via
        ``_invoke_platform_method(platform, "refine_get_products", ...)``
        when the platform's :func:`has_refine_support` returns True. The
        router's get_products already handles refine routing internally
        (per-tenant ProposalManager.refine_products selection), so this
        method just forwards. Keeps the handler's existing call shape
        intact without router-specific branching there.
        """
        return await self.get_products(*args, **kwargs)

    async def get_products(self, *args: Any, **kwargs: Any) -> Any:
        """Per-tenant ``get_products`` dispatch.

        Resolves the tenant from ``ctx.account.metadata['tenant_id']``
        (same path as every other router delegation). When the tenant
        has a wired :class:`ProposalManager`, routes the call to it;
        when refine-mode + capability + method-presence all hold,
        routes to ``proposal_manager.refine_products``; otherwise
        falls through to the tenant's
        :meth:`DecisioningPlatform.get_products`.

        The fall-through path (no proposal_manager wired for this
        tenant) is bit-identical to the synthesized delegation
        :meth:`_make_delegate` would have produced — adopters with
        zero proposal_managers configured see the same behaviour as
        before this method existed.
        """
        ctx = _resolve_ctx_from_args(args, kwargs)
        tenant_id = _tenant_id_from_ctx(ctx)
        manager = self._proposal_managers.get(tenant_id)

        if manager is not None:
            method_name = _select_proposal_method(manager, args, kwargs)
            method = getattr(manager, method_name)
            if inspect.iscoroutinefunction(method):
                return await method(*args, **kwargs)
            return await asyncio.to_thread(method, *args, **kwargs)

        # No proposal_manager for this tenant — fall through to the
        # platform. Reuses the same lookup helper as the synthesized
        # delegations so error projection is identical.
        platform = self._platform_for(ctx, "get_products")
        method = getattr(platform, "get_products")
        if inspect.iscoroutinefunction(method):
            return await method(*args, **kwargs)
        return await asyncio.to_thread(method, *args, **kwargs)

    def _make_delegate(self, method_name: str) -> Any:
        """Create a delegating callable for ``method_name``.

        The returned callable forwards every positional and keyword
        argument verbatim to the child platform's same-named method.
        The dispatcher invokes platform methods either as
        ``method(params, ctx)`` (positional) or
        ``method(**arg_projector, ctx=ctx)`` (kwargs); the synthesized
        ``*args, **kwargs`` shape covers both.

        Sync-vs-async dispatch is decided at the dispatcher
        (:func:`adcp.decisioning.dispatch._invoke_platform_method`)
        by checking the router's method. The delegate is always
        ``async def`` so the dispatcher takes its async path and
        awaits the result. Inside the delegate we re-dispatch on the
        CHILD platform: async children are awaited directly; sync
        children run via :func:`asyncio.to_thread` so a blocking sync
        handler doesn't serialize the event loop.
        """
        router = self

        async def _delegate(*args: Any, **kwargs: Any) -> Any:
            ctx = _resolve_ctx_from_args(args, kwargs)
            platform = router._platform_for(ctx, method_name)
            method = getattr(platform, method_name)

            if inspect.iscoroutinefunction(method):
                return await method(*args, **kwargs)

            # Sync child — push to a thread so the loop stays
            # responsive. The framework's standard sync-platform
            # dispatch goes through its own ThreadPoolExecutor with a
            # contextvars snapshot; ``asyncio.to_thread`` does the same
            # using the running loop's default executor with copied
            # context.
            return await asyncio.to_thread(method, *args, **kwargs)

        _delegate.__name__ = method_name
        _delegate.__qualname__ = f"PlatformRouter.{method_name}"
        return _delegate

    # ----- introspection ---------------------------------------------

    @property
    def tenants(self) -> frozenset[str]:
        """The set of tenant ids the router knows about.

        Read-only view; mutations to the source mapping after
        construction are NOT reflected.
        """
        return frozenset(self._platforms)

    def platform_for_tenant(self, tenant_id: str) -> DecisioningPlatform:
        """Return the child platform registered for ``tenant_id``.

        :raises KeyError: when no platform is registered for the
            tenant. Adopter callers handle this; the router's runtime
            dispatch path uses :meth:`_platform_for` instead, which
            projects to ``ACCOUNT_NOT_FOUND``.
        """
        return self._platforms[tenant_id]

    def proposal_manager_for_tenant(self, tenant_id: str) -> ProposalManager | None:
        """Return the :class:`ProposalManager` for ``tenant_id``, or
        ``None`` when the tenant falls through to its platform's own
        ``get_products``.
        """
        return self._proposal_managers.get(tenant_id)

    def proposal_store_for_tenant(self, tenant_id: str) -> ProposalStore | None:
        """Return the :class:`ProposalStore` for ``tenant_id``, or
        ``None`` when the tenant has no store wired.

        Tenants without a wired store cannot dispatch finalize / expiry
        / consume paths — the cross-store consistency check at
        construction prevents declaring ``finalize=True`` without a
        store, but tenants running pure-catalog mode with no finalize
        legitimately have no store.
        """
        return self._proposal_stores.get(tenant_id)

Drop-in :class:DecisioningPlatform that fans calls out across N tenants.

Each per-specialism call resolves a tenant from ctx.account.metadata['tenant_id'] and delegates to the matching child :class:DecisioningPlatform. The router's class- level adcp.decisioning.capabilities is the union of every child's specialisms; individual calls that the resolved tenant's platform doesn't implement raise UNSUPPORTED_FEATURE.

The set of methods the router exposes is computed at construction by walking the framework's known specialism Protocols (see :data:_KNOWN_SPECIALISM_PROTOCOLS). New Protocols added to the SDK appear on the router automatically — adopters don't have to update their router wiring when the specialism surface grows.

:param accounts: The adopter's :class:AccountStore. Resolves every request to an :class:Account whose metadata['tenant_id'] keys :attr:platforms. This is the adopter's responsibility — typically the store reads :func:current_tenant() (set by :class:SubdomainTenantMiddleware) and writes the tenant id onto the account metadata. :param platforms: {tenant_id: DecisioningPlatform} mapping. The router copies the dict shallowly at construction; later mutations to the source dict are NOT reflected. Pass a fresh dict per serve() call. :param proposal_managers: Optional {tenant_id: ProposalManager} mapping for the two-platform composition (see docs/proposals/product-architecture.md). When a tenant has a wired :class:ProposalManager, the router routes get_products (and refine-mode get_products when the manager declares :attr:ProposalCapabilities.refine and implements refine_products) to that manager instead of the tenant's :class:DecisioningPlatform. Tenants without an entry fall through to platform.get_products — backward-compatible per tenant. Keys MUST be a subset of :attr:platforms; orphan tenants raise at construction. Single-tenant adopters use a one-entry router with {"default": MyProposalManager(...)}. :param capabilities: The router's wire-shape capability declaration. Should be the union of every child platform's specialisms — the framework's tools/list filter reads this to advertise the right tools, and validate_platform() reads the same value to verify each claimed specialism has its required methods (which the router's synthesized delegation provides).

:raises ValueError: when :attr:platforms is empty (a router with no children is misconfiguration, not a valid empty state), or when :attr:proposal_managers contains tenant_ids not present in :attr:platforms.

Ancestors

Instance variables

prop tenants : frozenset[str]
Expand source code
@property
def tenants(self) -> frozenset[str]:
    """The set of tenant ids the router knows about.

    Read-only view; mutations to the source mapping after
    construction are NOT reflected.
    """
    return frozenset(self._platforms)

The set of tenant ids the router knows about.

Read-only view; mutations to the source mapping after construction are NOT reflected.

Methods

async def get_products(self, *args: Any, **kwargs: Any) ‑> Any
Expand source code
async def get_products(self, *args: Any, **kwargs: Any) -> Any:
    """Per-tenant ``get_products`` dispatch.

    Resolves the tenant from ``ctx.account.metadata['tenant_id']``
    (same path as every other router delegation). When the tenant
    has a wired :class:`ProposalManager`, routes the call to it;
    when refine-mode + capability + method-presence all hold,
    routes to ``proposal_manager.refine_products``; otherwise
    falls through to the tenant's
    :meth:`DecisioningPlatform.get_products`.

    The fall-through path (no proposal_manager wired for this
    tenant) is bit-identical to the synthesized delegation
    :meth:`_make_delegate` would have produced — adopters with
    zero proposal_managers configured see the same behaviour as
    before this method existed.
    """
    ctx = _resolve_ctx_from_args(args, kwargs)
    tenant_id = _tenant_id_from_ctx(ctx)
    manager = self._proposal_managers.get(tenant_id)

    if manager is not None:
        method_name = _select_proposal_method(manager, args, kwargs)
        method = getattr(manager, method_name)
        if inspect.iscoroutinefunction(method):
            return await method(*args, **kwargs)
        return await asyncio.to_thread(method, *args, **kwargs)

    # No proposal_manager for this tenant — fall through to the
    # platform. Reuses the same lookup helper as the synthesized
    # delegations so error projection is identical.
    platform = self._platform_for(ctx, "get_products")
    method = getattr(platform, "get_products")
    if inspect.iscoroutinefunction(method):
        return await method(*args, **kwargs)
    return await asyncio.to_thread(method, *args, **kwargs)

Per-tenant get_products dispatch.

Resolves the tenant from ctx.account.metadata['tenant_id'] (same path as every other router delegation). When the tenant has a wired :class:ProposalManager, routes the call to it; when refine-mode + capability + method-presence all hold, routes to proposal_manager.refine_products; otherwise falls through to the tenant's :meth:DecisioningPlatform.get_products.

The fall-through path (no proposal_manager wired for this tenant) is bit-identical to the synthesized delegation :meth:_make_delegate would have produced — adopters with zero proposal_managers configured see the same behaviour as before this method existed.

def platform_for_tenant(self, tenant_id: str) ‑> DecisioningPlatform
Expand source code
def platform_for_tenant(self, tenant_id: str) -> DecisioningPlatform:
    """Return the child platform registered for ``tenant_id``.

    :raises KeyError: when no platform is registered for the
        tenant. Adopter callers handle this; the router's runtime
        dispatch path uses :meth:`_platform_for` instead, which
        projects to ``ACCOUNT_NOT_FOUND``.
    """
    return self._platforms[tenant_id]

Return the child platform registered for tenant_id.

:raises KeyError: when no platform is registered for the tenant. Adopter callers handle this; the router's runtime dispatch path uses :meth:_platform_for instead, which projects to ACCOUNT_NOT_FOUND.

def proposal_manager_for_tenant(self, tenant_id: str) ‑> ProposalManager | None
Expand source code
def proposal_manager_for_tenant(self, tenant_id: str) -> ProposalManager | None:
    """Return the :class:`ProposalManager` for ``tenant_id``, or
    ``None`` when the tenant falls through to its platform's own
    ``get_products``.
    """
    return self._proposal_managers.get(tenant_id)

Return the :class:ProposalManager for tenant_id, or None when the tenant falls through to its platform's own get_products.

def proposal_store_for_tenant(self, tenant_id: str) ‑> ProposalStore | None
Expand source code
def proposal_store_for_tenant(self, tenant_id: str) -> ProposalStore | None:
    """Return the :class:`ProposalStore` for ``tenant_id``, or
    ``None`` when the tenant has no store wired.

    Tenants without a wired store cannot dispatch finalize / expiry
    / consume paths — the cross-store consistency check at
    construction prevents declaring ``finalize=True`` without a
    store, but tenants running pure-catalog mode with no finalize
    legitimately have no store.
    """
    return self._proposal_stores.get(tenant_id)

Return the :class:ProposalStore for tenant_id, or None when the tenant has no store wired.

Tenants without a wired store cannot dispatch finalize / expiry / consume paths — the cross-store consistency check at construction prevents declaring finalize=True without a store, but tenants running pure-catalog mode with no finalize legitimately have no store.

async def refine_get_products(self, *args: Any, **kwargs: Any) ‑> Any
Expand source code
async def refine_get_products(self, *args: Any, **kwargs: Any) -> Any:
    """Refine entry point — delegates to :meth:`get_products`.

    The handler's refine pathway dispatches via
    ``_invoke_platform_method(platform, "refine_get_products", ...)``
    when the platform's :func:`has_refine_support` returns True. The
    router's get_products already handles refine routing internally
    (per-tenant ProposalManager.refine_products selection), so this
    method just forwards. Keeps the handler's existing call shape
    intact without router-specific branching there.
    """
    return await self.get_products(*args, **kwargs)

Refine entry point — delegates to :meth:get_products.

The handler's refine pathway dispatches via _invoke_platform_method(platform, "refine_get_products", ...) when the platform's :func:has_refine_support() returns True. The router's get_products already handles refine routing internally (per-tenant ProposalManager.refine_products selection), so this method just forwards. Keeps the handler's existing call shape intact without router-specific branching there.

Inherited members

class ProductConfigStore (*args, **kwargs)
Expand source code
@runtime_checkable
class ProductConfigStore(Protocol):
    """Adopter-supplied lookup for seller-side product configuration.

    The framework calls ``lookup_implementation_configs`` once per
    ``create_media_buy`` request, before invoking the platform method.
    Adopters return a dict keyed by ``product_id``; the framework injects
    it as ``configs=`` on the platform method call.

    If the store returns a partial dict (some product_ids missing), the
    adopter handles it — the framework does not fabricate entries for missing
    ids.  If the store raises, the framework surfaces
    ``SERVICE_UNAVAILABLE`` with ``recovery='transient'`` to the buyer.

    The ``ctx`` parameter carries the resolved ``RequestContext``, including
    ``ctx.account`` for multi-tenant stores that need tenant isolation.
    """

    async def lookup_implementation_configs(
        self,
        product_ids: list[str],
        ctx: RequestContext[Any],
    ) -> dict[str, dict[str, Any]]:
        """Return seller-side config keyed by product_id.

        :param product_ids: Deduplicated list of product ids from
            ``CreateMediaBuyRequest.packages[*].product_id``.  Empty when
            the request uses ``proposal_id`` without an explicit
            ``packages`` array.
        :param ctx: Resolved request context — use ``ctx.account`` for
            tenant scoping.
        :returns: Dict mapping each product_id to its config dict.  Missing
            keys mean "no config for this product"; the framework passes the
            partial dict to the adopter unchanged.
        """
        ...

Adopter-supplied lookup for seller-side product configuration.

The framework calls lookup_implementation_configs once per create_media_buy request, before invoking the platform method. Adopters return a dict keyed by product_id; the framework injects it as configs= on the platform method call.

If the store returns a partial dict (some product_ids missing), the adopter handles it — the framework does not fabricate entries for missing ids. If the store raises, the framework surfaces SERVICE_UNAVAILABLE with recovery='transient' to the buyer.

The ctx parameter carries the resolved RequestContext, including ctx.account for multi-tenant stores that need tenant isolation.

Ancestors

  • typing.Protocol
  • typing.Generic

Methods

async def lookup_implementation_configs(self,
product_ids: list[str],
ctx: RequestContext[Any]) ‑> dict[str, dict[str, Any]]
Expand source code
async def lookup_implementation_configs(
    self,
    product_ids: list[str],
    ctx: RequestContext[Any],
) -> dict[str, dict[str, Any]]:
    """Return seller-side config keyed by product_id.

    :param product_ids: Deduplicated list of product ids from
        ``CreateMediaBuyRequest.packages[*].product_id``.  Empty when
        the request uses ``proposal_id`` without an explicit
        ``packages`` array.
    :param ctx: Resolved request context — use ``ctx.account`` for
        tenant scoping.
    :returns: Dict mapping each product_id to its config dict.  Missing
        keys mean "no config for this product"; the framework passes the
        partial dict to the adopter unchanged.
    """
    ...

Return seller-side config keyed by product_id.

:param product_ids: Deduplicated list of product ids from CreateMediaBuyRequest.packages[*].product_id. Empty when the request uses proposal_id without an explicit packages array. :param ctx: Resolved request context — use ctx.account for tenant scoping. :returns: Dict mapping each product_id to its config dict. Missing keys mean "no config for this product"; the framework passes the partial dict to the adopter unchanged.

class ProductsCheckpoint
Expand source code
class ProductsCheckpoint:
    """Accumulates partial ``GetProductsResponse`` batches during streaming.

    Passed to ``IncrementalGetProducts.get_products_incremental`` so the
    framework can collect whatever batches the adopter yields before the
    deadline. The framework reads ``checkpoint.batches`` after timeout to
    project ``products`` and ``incomplete[]``.

    Adopters do not instantiate this directly — the framework creates it and
    passes it in.
    """

    def __init__(self) -> None:
        self.batches: list[dict[str, Any]] = []

    def add_batch(self, batch: dict[str, Any]) -> None:
        """Record a partial response batch."""
        self.batches.append(batch)

Accumulates partial GetProductsResponse batches during streaming.

Passed to IncrementalGetProducts.get_products_incremental() so the framework can collect whatever batches the adopter yields before the deadline. The framework reads checkpoint.batches after timeout to project products and incomplete[].

Adopters do not instantiate this directly — the framework creates it and passes it in.

Methods

def add_batch(self, batch: dict[str, Any]) ‑> None
Expand source code
def add_batch(self, batch: dict[str, Any]) -> None:
    """Record a partial response batch."""
    self.batches.append(batch)

Record a partial response batch.

class PropertyListFetcher (*args, **kwargs)
Expand source code
@runtime_checkable
class PropertyListFetcher(Protocol):
    """Adopter-supplied protocol for fetching a buyer's authorized property IDs.

    The framework calls :meth:`fetch` when ``property_list_filtering`` is
    declared in capabilities and the request carries a ``PropertyList``
    reference.  Adopters plug in their own HTTP client — the framework ships
    no hidden HTTP dependency.

    Typical implementation::

        class MyFetcher:
            def __init__(self, client: httpx.AsyncClient) -> None:
                self._client = client

            async def fetch(
                self,
                agent_url: str,
                list_id: str,
                *,
                auth_token: str | None = None,
            ) -> list[str]:
                headers = {"Authorization": f"Bearer {auth_token}"} if auth_token else {}
                resp = await self._client.get(
                    f"{agent_url}/property-lists/{list_id}",
                    headers=headers,
                )
                resp.raise_for_status()
                return resp.json()["property_ids"]

    Wire the fetcher via::

        create_adcp_server_from_platform(platform, property_list_fetcher=MyFetcher(client))
    """

    async def fetch(
        self,
        agent_url: str,
        list_id: str,
        *,
        auth_token: str | None = None,
    ) -> list[str]:
        """Fetch and return the list of allowed property_id strings.

        :param agent_url: Buyer agent URL from the wire ``PropertyList`` reference.
        :param list_id: Property list identifier.
        :param auth_token: Optional JWT/bearer token.  Never log this value.
        :returns: List of allowed property_id strings (``^[a-z0-9_]+$`` format).
        :raises Exception: Any exception; the framework wraps it in
            :class:`~adcp.decisioning.types.AdcpError` with ``recovery='transient'``.
        """
        ...

Adopter-supplied protocol for fetching a buyer's authorized property IDs.

The framework calls :meth:fetch when property_list_filtering is declared in capabilities and the request carries a PropertyListReference reference. Adopters plug in their own HTTP client — the framework ships no hidden HTTP dependency.

Typical implementation::

class MyFetcher:
    def __init__(self, client: httpx.AsyncClient) -> None:
        self._client = client

    async def fetch(
        self,
        agent_url: str,
        list_id: str,
        *,
        auth_token: str | None = None,
    ) -> list[str]:
        headers = {"Authorization": f"Bearer {auth_token}"} if auth_token else {}
        resp = await self._client.get(
            f"{agent_url}/property-lists/{list_id}",
            headers=headers,
        )
        resp.raise_for_status()
        return resp.json()["property_ids"]

Wire the fetcher via::

create_adcp_server_from_platform(platform, property_list_fetcher=MyFetcher(client))

Ancestors

  • typing.Protocol
  • typing.Generic

Methods

async def fetch(self, agent_url: str, list_id: str, *, auth_token: str | None = None) ‑> list[str]
Expand source code
async def fetch(
    self,
    agent_url: str,
    list_id: str,
    *,
    auth_token: str | None = None,
) -> list[str]:
    """Fetch and return the list of allowed property_id strings.

    :param agent_url: Buyer agent URL from the wire ``PropertyList`` reference.
    :param list_id: Property list identifier.
    :param auth_token: Optional JWT/bearer token.  Never log this value.
    :returns: List of allowed property_id strings (``^[a-z0-9_]+$`` format).
    :raises Exception: Any exception; the framework wraps it in
        :class:`~adcp.decisioning.types.AdcpError` with ``recovery='transient'``.
    """
    ...

Fetch and return the list of allowed property_id strings.

:param agent_url: Buyer agent URL from the wire PropertyListReference reference. :param list_id: Property list identifier. :param auth_token: Optional JWT/bearer token. Never log this value. :returns: List of allowed property_id strings (^[a-z0-9_]+$ format). :raises Exception: Any exception; the framework wraps it in :class:~adcp.decisioning.types.AdcpError with recovery='transient'.

class PropertyList (**data: Any)
Expand source code
class PropertyListReference(AdCPBaseModel):
    model_config = ConfigDict(
        extra='forbid',
    )
    agent_url: Annotated[AnyUrl, Field(description='URL of the agent managing the property list')]
    list_id: Annotated[
        str, Field(description='Identifier for the property list within the agent', min_length=1)
    ]
    auth_token: Annotated[
        str | None,
        Field(
            description='JWT or other authorization token for accessing the list. Optional if the list is public or caller has implicit access.'
        ),
    ] = None

Base model for AdCP types with spec-compliant serialization.

Defaults to extra='ignore' so unknown fields from newer spec versions are silently dropped rather than causing validation errors. Generated types whose schemas set additionalProperties: true override this with extra='allow' in their own model_config.

Set ADCP_STRICT_VALIDATION=1 in the environment ("1", "true", "yes", "on" are accepted) to flip the default to extra='forbid'. Use this during spec upgrades to catch silently-dropped renamed fields in tests. See :func:_resolve_extra_policy.

Important

The env var is resolved once at module import time. Set it in your shell or CI environment before import adcp runs — mutating os.environ["ADCP_STRICT_VALIDATION"] after the first adcp import has no effect on already-imported model classes (they captured the policy at class-body evaluation).

Consumers who want per-model strict validation can override model_config on their subclass.

Create a new model by parsing and validating input data from keyword arguments.

Raises [ValidationError][pydantic_core.ValidationError] if the input data cannot be validated to form a valid model.

self is explicitly positional-only to allow self as a field name.

Ancestors

Class variables

var agent_url : pydantic.networks.AnyUrl
var auth_token : str | None
var list_id : str
var model_config
class PropertyListReference (**data: Any)
Expand source code
class PropertyListReference(AdCPBaseModel):
    model_config = ConfigDict(
        extra='forbid',
    )
    agent_url: Annotated[AnyUrl, Field(description='URL of the agent managing the property list')]
    list_id: Annotated[
        str, Field(description='Identifier for the property list within the agent', min_length=1)
    ]
    auth_token: Annotated[
        str | None,
        Field(
            description='JWT or other authorization token for accessing the list. Optional if the list is public or caller has implicit access.'
        ),
    ] = None

Base model for AdCP types with spec-compliant serialization.

Defaults to extra='ignore' so unknown fields from newer spec versions are silently dropped rather than causing validation errors. Generated types whose schemas set additionalProperties: true override this with extra='allow' in their own model_config.

Set ADCP_STRICT_VALIDATION=1 in the environment ("1", "true", "yes", "on" are accepted) to flip the default to extra='forbid'. Use this during spec upgrades to catch silently-dropped renamed fields in tests. See :func:_resolve_extra_policy.

Important

The env var is resolved once at module import time. Set it in your shell or CI environment before import adcp runs — mutating os.environ["ADCP_STRICT_VALIDATION"] after the first adcp import has no effect on already-imported model classes (they captured the policy at class-body evaluation).

Consumers who want per-model strict validation can override model_config on their subclass.

Create a new model by parsing and validating input data from keyword arguments.

Raises [ValidationError][pydantic_core.ValidationError] if the input data cannot be validated to form a valid model.

self is explicitly positional-only to allow self as a field name.

Ancestors

Class variables

var agent_url : pydantic.networks.AnyUrl
var auth_token : str | None
var list_id : str
var model_config

Inherited members

class PropertyListsPlatform (*args, **kwargs)
Expand source code
@runtime_checkable
class PropertyListsPlatform(Protocol, Generic[TMeta]):
    """Property-list CRUD with fetch-token issuance semantics.

    Methods may be sync (return ``T`` directly) or async (return
    ``Awaitable[T]``); the dispatch adapter detects via
    :func:`asyncio.iscoroutinefunction` and runs sync methods on a
    thread pool.

    Throw :class:`adcp.decisioning.AdcpError` for buyer-fixable
    rejection (``REFERENCE_NOT_FOUND``, ``POLICY_VIOLATION``).
    """

    def create_property_list(
        self,
        req: CreatePropertyListRequest,
        ctx: RequestContext[TMeta],
    ) -> MaybeAsync[CreatePropertyListResponse]:
        """Create a property list.

        Returns a ``fetch_token`` the buyer stores in their secret
        manager. Token is scoped to this ``list_id``; MUST NOT be
        reused across lists.
        """
        ...

    def update_property_list(
        self,
        req: UpdatePropertyListRequest,
        ctx: RequestContext[TMeta],
    ) -> MaybeAsync[UpdatePropertyListResponse]:
        """Patch an existing property list."""
        ...

    def get_property_list(
        self,
        req: GetPropertyListRequest,
        ctx: RequestContext[TMeta],
    ) -> MaybeAsync[GetPropertyListResponse]:
        """Read a property list by id.

        Sellers call this with the ``fetch_token`` from
        :meth:`create_property_list`.
        """
        ...

    def list_property_lists(
        self,
        req: ListPropertyListsRequest,
        ctx: RequestContext[TMeta],
    ) -> MaybeAsync[ListPropertyListsResponse]:
        """Discover property lists the caller is authorized to read."""
        ...

    def delete_property_list(
        self,
        req: DeletePropertyListRequest,
        ctx: RequestContext[TMeta],
    ) -> MaybeAsync[DeletePropertyListResponse]:
        """Delete a property list.

        MUST revoke the ``fetch_token`` immediately and signal cache
        invalidation to sellers (reduced ``cache_valid_until`` or a
        list-changed webhook). Compromise-driven revocation MUST
        also trigger this path.
        """
        ...

Property-list CRUD with fetch-token issuance semantics.

Methods may be sync (return T directly) or async (return Awaitable[T]); the dispatch adapter detects via :func:asyncio.iscoroutinefunction and runs sync methods on a thread pool.

Throw :class:AdcpError for buyer-fixable rejection (REFERENCE_NOT_FOUND, POLICY_VIOLATION).

Ancestors

  • typing.Protocol
  • typing.Generic

Methods

def create_property_list(self,
req: CreatePropertyListRequest,
ctx: RequestContext[TMeta]) ‑> MaybeAsync[CreatePropertyListResponse]
Expand source code
def create_property_list(
    self,
    req: CreatePropertyListRequest,
    ctx: RequestContext[TMeta],
) -> MaybeAsync[CreatePropertyListResponse]:
    """Create a property list.

    Returns a ``fetch_token`` the buyer stores in their secret
    manager. Token is scoped to this ``list_id``; MUST NOT be
    reused across lists.
    """
    ...

Create a property list.

Returns a fetch_token the buyer stores in their secret manager. Token is scoped to this list_id; MUST NOT be reused across lists.

def delete_property_list(self,
req: DeletePropertyListRequest,
ctx: RequestContext[TMeta]) ‑> MaybeAsync[DeletePropertyListResponse]
Expand source code
def delete_property_list(
    self,
    req: DeletePropertyListRequest,
    ctx: RequestContext[TMeta],
) -> MaybeAsync[DeletePropertyListResponse]:
    """Delete a property list.

    MUST revoke the ``fetch_token`` immediately and signal cache
    invalidation to sellers (reduced ``cache_valid_until`` or a
    list-changed webhook). Compromise-driven revocation MUST
    also trigger this path.
    """
    ...

Delete a property list.

MUST revoke the fetch_token immediately and signal cache invalidation to sellers (reduced cache_valid_until or a list-changed webhook). Compromise-driven revocation MUST also trigger this path.

def get_property_list(self,
req: GetPropertyListRequest,
ctx: RequestContext[TMeta]) ‑> MaybeAsync[GetPropertyListResponse]
Expand source code
def get_property_list(
    self,
    req: GetPropertyListRequest,
    ctx: RequestContext[TMeta],
) -> MaybeAsync[GetPropertyListResponse]:
    """Read a property list by id.

    Sellers call this with the ``fetch_token`` from
    :meth:`create_property_list`.
    """
    ...

Read a property list by id.

Sellers call this with the fetch_token from :meth:create_property_list.

def list_property_lists(self,
req: ListPropertyListsRequest,
ctx: RequestContext[TMeta]) ‑> MaybeAsync[ListPropertyListsResponse]
Expand source code
def list_property_lists(
    self,
    req: ListPropertyListsRequest,
    ctx: RequestContext[TMeta],
) -> MaybeAsync[ListPropertyListsResponse]:
    """Discover property lists the caller is authorized to read."""
    ...

Discover property lists the caller is authorized to read.

def update_property_list(self,
req: UpdatePropertyListRequest,
ctx: RequestContext[TMeta]) ‑> MaybeAsync[UpdatePropertyListResponse]
Expand source code
def update_property_list(
    self,
    req: UpdatePropertyListRequest,
    ctx: RequestContext[TMeta],
) -> MaybeAsync[UpdatePropertyListResponse]:
    """Patch an existing property list."""
    ...

Patch an existing property list.

class Proposal (**data: Any)
Expand source code
class Proposal(AdCPBaseModel):
    model_config = ConfigDict(
        extra='allow',
    )
    proposal_id: Annotated[
        str,
        Field(
            description='Unique identifier for this proposal. Used to finalize a draft proposal and to execute a committed proposal via create_media_buy.',
            max_length=255,
        ),
    ]
    name: Annotated[
        str, Field(description='Human-readable name for this media plan proposal', max_length=500)
    ]
    description: Annotated[
        str | None,
        Field(
            description='Explanation of the proposal strategy and what it achieves', max_length=2000
        ),
    ] = None
    allocations: Annotated[
        list[product_allocation.ProductAllocation],
        Field(
            description='Budget allocations across products. Allocation percentages MUST sum to 100. Publishers are responsible for ensuring the sum equals 100; buyers SHOULD validate this before execution.',
            min_length=1,
        ),
    ]
    proposal_status: Annotated[
        proposal_status_1.ProposalStatus | None,
        Field(
            description="Lifecycle status of this proposal and the per-proposal source of truth for whether finalization is required before create_media_buy. When absent, the proposal is ready to buy (backward compatible). 'draft' means indicative pricing — finalize via refine before purchasing. 'committed' means firm pricing with inventory reserved until expires_at and executable via create_media_buy."
        ),
    ] = None
    expires_at: Annotated[
        AwareDatetime | None,
        Field(
            description='When this proposal expires and can no longer be executed. For draft proposals, indicates when indicative pricing becomes stale. For committed proposals, indicates when the inventory hold lapses — the buyer must call create_media_buy before this time.'
        ),
    ] = None
    insertion_order: Annotated[
        insertion_order_1.InsertionOrder | None,
        Field(
            description='Formal insertion order attached to a committed proposal. Present when the seller requires a signed agreement before the media buy can proceed. The buyer references the io_id in io_acceptance on create_media_buy.'
        ),
    ] = None
    total_budget_guidance: Annotated[
        TotalBudgetGuidance | None, Field(description='Optional budget guidance for this proposal')
    ] = None
    brief_alignment: Annotated[
        str | None,
        Field(
            description='Explanation of how this proposal aligns with the campaign brief',
            max_length=2000,
        ),
    ] = None
    forecast: Annotated[
        delivery_forecast.DeliveryForecast | None,
        Field(
            description='Aggregate forecasted delivery metrics for the entire proposal. When both proposal-level and allocation-level forecasts are present, the proposal-level forecast is authoritative for total delivery estimation.'
        ),
    ] = None
    ext: ext_1.ExtensionObject | None = None

Base model for AdCP types with spec-compliant serialization.

Defaults to extra='ignore' so unknown fields from newer spec versions are silently dropped rather than causing validation errors. Generated types whose schemas set additionalProperties: true override this with extra='allow' in their own model_config.

Set ADCP_STRICT_VALIDATION=1 in the environment ("1", "true", "yes", "on" are accepted) to flip the default to extra='forbid'. Use this during spec upgrades to catch silently-dropped renamed fields in tests. See :func:_resolve_extra_policy.

Important

The env var is resolved once at module import time. Set it in your shell or CI environment before import adcp runs — mutating os.environ["ADCP_STRICT_VALIDATION"] after the first adcp import has no effect on already-imported model classes (they captured the policy at class-body evaluation).

Consumers who want per-model strict validation can override model_config on their subclass.

Create a new model by parsing and validating input data from keyword arguments.

Raises [ValidationError][pydantic_core.ValidationError] if the input data cannot be validated to form a valid model.

self is explicitly positional-only to allow self as a field name.

Ancestors

Class variables

var allocations : list[adcp.types.generated_poc.core.product_allocation.ProductAllocation]
var brief_alignment : str | None
var description : str | None
var expires_at : pydantic.types.AwareDatetime | None
var ext : adcp.types.generated_poc.core.ext.ExtensionObject | None
var forecast : adcp.types.generated_poc.core.delivery_forecast.DeliveryForecast | None
var insertion_order : adcp.types.generated_poc.core.insertion_order.InsertionOrder | None
var model_config
var name : str
var proposal_id : str
var proposal_status : adcp.types.generated_poc.enums.proposal_status.ProposalStatus | None
var total_budget_guidance : adcp.types.generated_poc.core.proposal.TotalBudgetGuidance | None

Inherited members

class ProposalCapabilities (sales_specialism: SalesSpecialism,
refine: bool = False,
finalize: bool = False,
expires_at_grace_seconds: int = 0,
dynamic_products: bool = False,
rate_card_pricing: bool = False,
availability_reservations: bool = False,
multi_decisioning: bool = False,
auto_commit_on_put_draft: bool = False,
auto_commit_ttl_seconds: int = 604800,
derive_packages_from_allocations: bool = False)
Expand source code
@dataclass(frozen=True)
class ProposalCapabilities:
    """Capability declaration for a :class:`ProposalManager`.

    Sales-axis-scoped: proposal handling is a sales-specialism concern,
    not a generic platform-wide concept. The :attr:`sales_specialism`
    field declares which AdCP sales specialism this manager serves;
    capability flags declare which optional behaviours it supports.

    The framework reads this declaration at :func:`serve` time to
    decide which dispatch paths apply (e.g. ``refine_products`` is
    only invoked when :attr:`refine` is True).

    :param sales_specialism: Which AdCP sales specialism this manager
        serves. ``"sales-guaranteed"`` for guaranteed-direct flows
        with proposal lifecycle (``finalize`` → committed proposal →
        media buy); ``"sales-non-guaranteed"`` for catalog-style
        flows where ``get_products`` returns a static catalog and
        buyers reference products directly at ``create_media_buy``.
    :param refine: When True, the manager implements
        :meth:`ProposalManager.refine_products` and the framework
        routes ``get_products`` requests with ``buying_mode='refine'``
        to that method. When False, refine requests fall through to
        ``get_products`` (or surface ``UNSUPPORTED_FEATURE`` if the
        manager rejects them).
    :param dynamic_products: Signal-driven product assembly — the
        manager constructs products from buyer signals at request
        time rather than enumerating a static catalogue. The
        framework treats this as a hint today; future PRs may
        validate that ``InventoryStore`` / ``SignalStore`` primitives
        are wired when this flag is set.
    :param rate_card_pricing: The manager consults rate cards (per
        buyer relationship per product) when emitting prices.
        Informational in v1; future PRs may validate that a
        ``RateCardStore`` primitive is wired.
    :param availability_reservations: The manager reserves capacity
        at proposal time (typical for guaranteed). Informational in
        v1; the ``finalize`` transition that drives the actual hold
        lands in a subsequent PR.
    :param multi_decisioning: The manager emits products whose
        recipes route to >1 :class:`DecisioningPlatform` per request
        (the Prebid salesagent shape — GAM for guaranteed-direct,
        Kevel for non-guaranteed-remnant in the same proposal).
        Informational in v1; the per-recipe-kind routing lands in
        a subsequent PR alongside the typed-recipe registry.
    :param auto_commit_on_put_draft: Opt-in shortcut for managers
        that issue directly-consumable proposals from ``get_products``
        without a separate ``finalize_proposal`` step. When ``True``,
        the framework calls :meth:`ProposalStore.commit` immediately
        after :meth:`ProposalStore.put_draft` on every proposal
        returned, promoting ``DRAFT → COMMITTED`` so that
        ``create_media_buy(proposal_id=X)`` can call
        ``try_reserve_consumption`` without a separate buyer round-trip.
        Mutually exclusive with ``finalize=True`` (finalize is the
        explicit lifecycle; auto-commit is the bypass). Adopters
        wiring their own commit lifecycle (e.g. webhook-driven
        approval) leave this ``False``. See #723.
    :param auto_commit_ttl_seconds: TTL applied to the auto-committed
        proposal's ``expires_at``. Used only when
        :attr:`auto_commit_on_put_draft` is ``True``. Defaults to
        ``604800`` (7 days), matching the salesagent's adopter
        choice. Tune up for long-running RFPs; tune down for
        spot-market flows. Cap is enforced soft (a warning fires for
        values > 30 days) — buyers retrying past the TTL get
        ``PROPOSAL_EXPIRED`` and re-request the brief.
    """

    sales_specialism: SalesSpecialism

    refine: bool = False
    finalize: bool = False
    expires_at_grace_seconds: int = 0
    dynamic_products: bool = False
    rate_card_pricing: bool = False
    availability_reservations: bool = False
    # ``multi_decisioning`` retained for v1 source-compat (adopters who
    # set it pass through harmlessly). Per v1.5 § D2 / Resolutions §6,
    # the framework no longer reads this field. Stops appearing on new
    # adopter declarations; v1.6+ removes it entirely.
    multi_decisioning: bool = False
    auto_commit_on_put_draft: bool = False
    auto_commit_ttl_seconds: int = 7 * 24 * 3600  # 7 days, salesagent default
    derive_packages_from_allocations: bool = False
    """Opt-in: when ``True``, the framework auto-derives ``req.packages``
    from the proposal's ``allocations[]`` array on
    ``create_media_buy(proposal_id=..., total_budget=...)`` calls with
    no explicit ``packages[]``. Default ``False`` preserves the pre-#727
    semantics (framework leaves ``req.packages`` empty; seller adapter
    handles it). Adopters whose ``create_media_buy`` adapter currently
    reads ``ctx.recipes`` directly should leave this off; adopters who
    want the spec-text behaviour ("publisher converts the proposal's
    allocation percentages into packages automatically") flip it on or
    implement :meth:`ProposalManager.derive_packages` for custom math.

    Either flipping this to ``True`` OR implementing
    ``derive_packages`` activates the framework's auto-injection. See
    :func:`adcp.decisioning.derive_packages_from_proposal` for the
    built-in even-percentage helper.
    """

    def __post_init__(self) -> None:
        # Spec only allows the two slugs at v1. Adopter passing a
        # typo or a different sales-* flavour gets a structured
        # error rather than a silent miss at dispatch time.
        valid = ("sales-guaranteed", "sales-non-guaranteed")
        if self.sales_specialism not in valid:
            raise AdcpError(
                "INVALID_REQUEST",
                message=(
                    "ProposalCapabilities.sales_specialism must be one of "
                    f"{valid!r}. Got {self.sales_specialism!r}. v1 scopes "
                    "ProposalManager to the two core sales specialisms; "
                    "broader specialism support lands in subsequent PRs."
                ),
                recovery="terminal",
                field="sales_specialism",
            )
        # ``expires_at_grace_seconds`` must be non-negative; a negative
        # value would shrink the inventory hold rather than extend it,
        # which contradicts the design's intent.
        if self.expires_at_grace_seconds < 0:
            raise AdcpError(
                "INVALID_REQUEST",
                message=(
                    "ProposalCapabilities.expires_at_grace_seconds must be "
                    f">= 0; got {self.expires_at_grace_seconds!r}. The "
                    "grace window extends the inventory hold past "
                    "expires_at; negative values would shrink it."
                ),
                recovery="terminal",
                field="expires_at_grace_seconds",
            )
        # #723: auto-commit and finalize are mutually exclusive
        # lifecycles. ``finalize=True`` says "buyer drives DRAFT →
        # COMMITTED explicitly"; ``auto_commit_on_put_draft=True`` says
        # "framework promotes on put_draft so no explicit step is
        # needed." Both on at once produces a state-machine race
        # (the framework auto-commits, then the buyer's finalize call
        # rejects because the proposal is no longer DRAFT). Loud-fail
        # at construction.
        if self.auto_commit_on_put_draft and self.finalize:
            raise AdcpError(
                "INVALID_REQUEST",
                message=(
                    "ProposalCapabilities: auto_commit_on_put_draft=True and "
                    "finalize=True are mutually exclusive. auto-commit "
                    "skips the explicit finalize step (proposals from "
                    "get_products are committed-on-issuance); finalize "
                    "requires the buyer to drive the transition. Pick one. "
                    "See #723."
                ),
                recovery="terminal",
                field="auto_commit_on_put_draft",
            )
        # #723 product safety: auto-commit on guaranteed-direct issues
        # a silent inventory hold on every ``get_products`` call. GAM /
        # ad-server proposal lifecycles require explicit reservation
        # precisely because trafficking ops won't accept silent holds
        # — a 7-day default TTL would burn inventory across thousands
        # of catalog probes per day. Loud-fail; adopters who need
        # auto-commit on guaranteed-direct can re-evaluate the
        # commercial commitment by wiring the explicit ``finalize``
        # path instead.
        if self.auto_commit_on_put_draft and self.sales_specialism == "sales-guaranteed":
            raise AdcpError(
                "INVALID_REQUEST",
                message=(
                    "ProposalCapabilities: auto_commit_on_put_draft=True is "
                    "not permitted on sales_specialism='sales-guaranteed'. "
                    "Auto-commit issues a silent inventory hold on every "
                    "get_products call (7-day default TTL); guaranteed-"
                    "direct flows require explicit buyer-driven reservation "
                    "via the finalize=True lifecycle to avoid unintended "
                    "commitments. Either switch to "
                    "sales_specialism='sales-non-guaranteed' (catalog / "
                    "spot-market flows where auto-commit is appropriate) "
                    "or set finalize=True instead."
                ),
                recovery="terminal",
                field="auto_commit_on_put_draft",
            )
        if self.auto_commit_ttl_seconds <= 0:
            raise AdcpError(
                "INVALID_REQUEST",
                message=(
                    "ProposalCapabilities.auto_commit_ttl_seconds must be "
                    f"> 0; got {self.auto_commit_ttl_seconds!r}. Zero or "
                    "negative TTL would mark proposals expired on commit, "
                    "making every consumption attempt fail with "
                    "PROPOSAL_EXPIRED."
                ),
                recovery="terminal",
                field="auto_commit_ttl_seconds",
            )
        # Soft-cap warning: a TTL longer than 30 days holds inventory
        # for an entire month per catalog probe. Operators can extend
        # for long-running RFP flows, but the SDK surfaces a heads-up
        # so the default doesn't drift past what the adopter intended.
        _soft_cap_seconds = 30 * 24 * 3600
        if self.auto_commit_on_put_draft and self.auto_commit_ttl_seconds > _soft_cap_seconds:
            import warnings as _warnings

            _warnings.warn(
                f"ProposalCapabilities.auto_commit_ttl_seconds="
                f"{self.auto_commit_ttl_seconds} exceeds the soft cap of "
                f"{_soft_cap_seconds} (30 days). Auto-committed proposals "
                "hold inventory for the full TTL — verify your commercial "
                "model supports holds this long. The framework permits "
                "it; this warning fires once per declaration site so the "
                "choice is visible at boot.",
                UserWarning,
                stacklevel=3,
            )

Capability declaration for a :class:ProposalManager.

Sales-axis-scoped: proposal handling is a sales-specialism concern, not a generic platform-wide concept. The :attr:sales_specialism field declares which AdCP sales specialism this manager serves; capability flags declare which optional behaviours it supports.

The framework reads this declaration at :func:serve() time to decide which dispatch paths apply (e.g. refine_products is only invoked when :attr:adcp.decisioning.refine is True).

:param sales_specialism: Which AdCP sales specialism this manager serves. "sales-guaranteed" for guaranteed-direct flows with proposal lifecycle (finalize → committed proposal → media buy); "sales-non-guaranteed" for catalog-style flows where get_products returns a static catalog and buyers reference products directly at create_media_buy. :param refine: When True, the manager implements :meth:ProposalManager.refine_products() and the framework routes get_products requests with buying_mode='refine' to that method. When False, refine requests fall through to get_products (or surface UNSUPPORTED_FEATURE if the manager rejects them). :param dynamic_products: Signal-driven product assembly — the manager constructs products from buyer signals at request time rather than enumerating a static catalogue. The framework treats this as a hint today; future PRs may validate that InventoryStore / SignalStore primitives are wired when this flag is set. :param rate_card_pricing: The manager consults rate cards (per buyer relationship per product) when emitting prices. Informational in v1; future PRs may validate that a RateCardStore primitive is wired. :param availability_reservations: The manager reserves capacity at proposal time (typical for guaranteed). Informational in v1; the finalize transition that drives the actual hold lands in a subsequent PR. :param multi_decisioning: The manager emits products whose recipes route to >1 :class:DecisioningPlatform per request (the Prebid salesagent shape — GAM for guaranteed-direct, Kevel for non-guaranteed-remnant in the same proposal). Informational in v1; the per-recipe-kind routing lands in a subsequent PR alongside the typed-recipe registry. :param auto_commit_on_put_draft: Opt-in shortcut for managers that issue directly-consumable proposals from get_products without a separate finalize_proposal step. When True, the framework calls :meth:ProposalStore.commit() immediately after :meth:ProposalStore.put_draft() on every proposal returned, promoting DRAFT → COMMITTED so that create_media_buy(proposal_id=X) can call try_reserve_consumption without a separate buyer round-trip. Mutually exclusive with finalize=True (finalize is the explicit lifecycle; auto-commit is the bypass). Adopters wiring their own commit lifecycle (e.g. webhook-driven approval) leave this False. See #723. :param auto_commit_ttl_seconds: TTL applied to the auto-committed proposal's expires_at. Used only when :attr:auto_commit_on_put_draft is True. Defaults to 604800 (7 days), matching the salesagent's adopter choice. Tune up for long-running RFPs; tune down for spot-market flows. Cap is enforced soft (a warning fires for values > 30 days) — buyers retrying past the TTL get PROPOSAL_EXPIRED and re-request the brief.

Instance variables

var auto_commit_on_put_draft : bool
var auto_commit_ttl_seconds : int
var availability_reservations : bool
var derive_packages_from_allocations : bool

Opt-in: when True, the framework auto-derives req.packages from the proposal's allocations[] array on create_media_buy(proposal_id=..., total_budget=...) calls with no explicit packages[]. Default False preserves the pre-#727 semantics (framework leaves req.packages empty; seller adapter handles it). Adopters whose create_media_buy adapter currently reads ctx.recipes directly should leave this off; adopters who want the spec-text behaviour ("publisher converts the proposal's allocation percentages into packages automatically") flip it on or implement :meth:ProposalManager.derive_packages for custom math.

Either flipping this to True OR implementing adcp.decisioning.derive_packages activates the framework's auto-injection. See :func:derive_packages_from_proposal() for the built-in even-percentage helper.

var dynamic_products : bool
var expires_at_grace_seconds : int
var finalize : bool
var multi_decisioning : bool
var rate_card_pricing : bool
var refine : bool
var sales_specialism : Literal['sales-guaranteed', 'sales-non-guaranteed']
class ProposalManager (*args, **kwargs)
Expand source code
@runtime_checkable
class ProposalManager(Protocol):
    """Assembles proposals from buyer briefs.

    Reads inventory, signals, rate cards, availability. Produces
    proposals where each :class:`~adcp.types.Product` carries a typed
    ``implementation_config`` (a recipe; see :class:`Recipe`) the
    bound :class:`DecisioningPlatform` consumes at ``create_media_buy``.

    Methods may be sync or async; the dispatch adapter detects via
    :func:`asyncio.iscoroutinefunction` and runs sync methods on a
    thread pool. Same convention as the existing
    :class:`SalesPlatform` Protocol so a single thread pool serves
    both surfaces.

    v1 surface (this PR):

    * :meth:`get_products` — initial product discovery from a buyer
      brief. Required.
    * :meth:`refine_products` — refine-mode iteration (capability-
      gated by :attr:`ProposalCapabilities.refine`). Optional;
      adopters who don't implement refine return non-refine products
      via ``get_products`` and the framework surfaces
      ``UNSUPPORTED_FEATURE`` on refine requests when this method is
      absent.

    Future-state surfaces (deferred to subsequent PRs):

    * ``finalize`` transition handling (``buying_mode='refine'`` +
      ``action='finalize'`` → committed proposal with locked pricing
      + ``expires_at``)
    * Capability-overlap declaration on :class:`Recipe` + framework
      validation
    * Recipe lifecycle (session cache → persisted store → hydration
      at ``create_media_buy``)

    Throw :class:`adcp.decisioning.AdcpError` for buyer-fixable
    rejection (``BUDGET_TOO_LOW``, ``POLICY_VIOLATION``,
    ``UNSUPPORTED_FEATURE``); the framework projects to the wire
    structured-error envelope.
    """

    capabilities: ClassVar[ProposalCapabilities]
    """What this ProposalManager can do — sales specialism + capability
    flags. Subclasses MUST override on the class body."""

    def get_products(
        self,
        req: GetProductsRequest,
        ctx: RequestContext[Any],
    ) -> MaybeAsync[GetProductsResponse]:
        """Initial product discovery from a buyer brief.

        Each returned :class:`~adcp.types.Product` SHOULD carry an
        ``implementation_config`` matching the bound
        :class:`DecisioningPlatform`'s recipe schema (see
        :class:`Recipe`). v1 treats ``implementation_config`` as
        opaque ``dict[str, Any]``; typed recipe validation lands in a
        subsequent PR.

        For non-guaranteed flows: typically a static catalogue,
        possibly filtered by buyer brief / signals.

        For guaranteed flows: typically a brief-driven assembly
        consulting rate cards + availability. v1 doesn't yet wire
        the ``finalize`` transition; adopters return draft proposals
        and rely on the buyer driving lifecycle via subsequent
        ``create_media_buy`` calls.
        """
        ...

    # NOTE: ``finalize_proposal`` is intentionally NOT a Protocol member.
    # Per Resolutions §7 of the v1.5 design doc, the framework detects
    # finalize support via ``hasattr(manager, "finalize_proposal")`` AND
    # ``manager.capabilities.finalize is True``. Putting the method on the
    # ``runtime_checkable`` Protocol body would break ``isinstance(...)``
    # for any v1 manager that doesn't declare finalize (every adopter who
    # ships catalog-mode without committing proposals). Mirrors v1's
    # ``refine_products`` posture — present on the Protocol surface only
    # because adopters declaring ``refine=True`` need a typed signature
    # to write against; absent from runtime conformance checks.
    #
    # Adopters declaring ``finalize=True`` who don't implement the method
    # get a clear error at ``serve()`` time; the boot-time validator walks
    # methods like ``_is_method_overridden`` from the dispatch design D3.

    # NOTE: ``derive_packages`` is also NOT a Protocol member — same
    # ``hasattr``-detection posture as ``finalize_proposal``. Adopters
    # opting into framework package derivation either flip
    # :attr:`ProposalCapabilities.derive_packages_from_allocations` (for
    # the built-in even-percentage helper) OR implement this method
    # (for custom math: auction min-bid, multi-currency, capability-
    # overlap filtering).
    #
    # Expected signature (keyword-only) when implementing the override:
    #
    #     def derive_packages(
    #         self,
    #         *,
    #         proposal_payload: Mapping[str, Any],
    #         total_budget: TotalBudget | None,
    #         recipes: Mapping[str, Recipe],
    #     ) -> list[PackageRequest]:
    #         ...
    #
    # Return the list the framework should mutate onto ``req.packages``;
    # raise :class:`adcp.decisioning.AdcpError` for buyer-fixable
    # rejections.

    # Optional refine surface — capability-gated.
    def refine_products(
        self,
        req: GetProductsRequest,
        ctx: RequestContext[Any],
    ) -> MaybeAsync[GetProductsResponse]:
        """Refine-mode iteration on a previous ``get_products`` response.

        Per the spec, refine is a ``buying_mode`` value on
        ``get_products`` — the wire envelope is the same. The framework
        routes refine requests to this method when:

        1. The wired ProposalManager declares
           :attr:`ProposalCapabilities.refine` = True, AND
        2. The request has ``buying_mode == 'refine'``, AND
        3. The manager subclass implements this method.

        Otherwise, refine requests fall through to :meth:`get_products`
        (the manager handles refinement itself) or surface
        ``UNSUPPORTED_FEATURE`` if neither path is wired.

        v1 does NOT handle the ``finalize`` action — that's a
        subsequent PR. Adopters implementing this method today should
        treat ``action='finalize'`` entries as
        ``UNSUPPORTED_FEATURE`` and return a structured error.
        """
        ...

Assembles proposals from buyer briefs.

Reads inventory, signals, rate cards, availability. Produces proposals where each :class:~adcp.types.Product carries a typed adcp.decisioning.implementation_config (a recipe; see :class:Recipe) the bound :class:DecisioningPlatform consumes at create_media_buy.

Methods may be sync or async; the dispatch adapter detects via :func:asyncio.iscoroutinefunction and runs sync methods on a thread pool. Same convention as the existing :class:SalesPlatform Protocol so a single thread pool serves both surfaces.

v1 surface (this PR):

  • :meth:get_products — initial product discovery from a buyer brief. Required.
  • :meth:refine_products — refine-mode iteration (capability- gated by :attr:ProposalCapabilities.refine). Optional; adopters who don't implement refine return non-refine products via get_products and the framework surfaces UNSUPPORTED_FEATURE on refine requests when this method is absent.

Future-state surfaces (deferred to subsequent PRs):

  • finalize transition handling (buying_mode='refine' + action='finalize' → committed proposal with locked pricing
  • expires_at)
  • Capability-overlap declaration on :class:Recipe + framework validation
  • Recipe lifecycle (session cache → persisted store → hydration at create_media_buy)

Throw :class:AdcpError for buyer-fixable rejection (BUDGET_TOO_LOW, POLICY_VIOLATION, UNSUPPORTED_FEATURE); the framework projects to the wire structured-error envelope.

Ancestors

  • typing.Protocol
  • typing.Generic

Class variables

var capabilities : ClassVar[ProposalCapabilities]

What this ProposalManager can do — sales specialism + capability flags. Subclasses MUST override on the class body.

Methods

def get_products(self,
req: GetProductsRequest,
ctx: RequestContext[Any]) ‑> MaybeAsync[GetProductsResponse]
Expand source code
def get_products(
    self,
    req: GetProductsRequest,
    ctx: RequestContext[Any],
) -> MaybeAsync[GetProductsResponse]:
    """Initial product discovery from a buyer brief.

    Each returned :class:`~adcp.types.Product` SHOULD carry an
    ``implementation_config`` matching the bound
    :class:`DecisioningPlatform`'s recipe schema (see
    :class:`Recipe`). v1 treats ``implementation_config`` as
    opaque ``dict[str, Any]``; typed recipe validation lands in a
    subsequent PR.

    For non-guaranteed flows: typically a static catalogue,
    possibly filtered by buyer brief / signals.

    For guaranteed flows: typically a brief-driven assembly
    consulting rate cards + availability. v1 doesn't yet wire
    the ``finalize`` transition; adopters return draft proposals
    and rely on the buyer driving lifecycle via subsequent
    ``create_media_buy`` calls.
    """
    ...

Initial product discovery from a buyer brief.

Each returned :class:~adcp.types.Product SHOULD carry an adcp.decisioning.implementation_config matching the bound :class:DecisioningPlatform's recipe schema (see :class:Recipe). v1 treats adcp.decisioning.implementation_config as opaque dict[str, Any]; typed recipe validation lands in a subsequent PR.

For non-guaranteed flows: typically a static catalogue, possibly filtered by buyer brief / signals.

For guaranteed flows: typically a brief-driven assembly consulting rate cards + availability. v1 doesn't yet wire the finalize transition; adopters return draft proposals and rely on the buyer driving lifecycle via subsequent create_media_buy calls.

def refine_products(self,
req: GetProductsRequest,
ctx: RequestContext[Any]) ‑> MaybeAsync[GetProductsResponse]
Expand source code
def refine_products(
    self,
    req: GetProductsRequest,
    ctx: RequestContext[Any],
) -> MaybeAsync[GetProductsResponse]:
    """Refine-mode iteration on a previous ``get_products`` response.

    Per the spec, refine is a ``buying_mode`` value on
    ``get_products`` — the wire envelope is the same. The framework
    routes refine requests to this method when:

    1. The wired ProposalManager declares
       :attr:`ProposalCapabilities.refine` = True, AND
    2. The request has ``buying_mode == 'refine'``, AND
    3. The manager subclass implements this method.

    Otherwise, refine requests fall through to :meth:`get_products`
    (the manager handles refinement itself) or surface
    ``UNSUPPORTED_FEATURE`` if neither path is wired.

    v1 does NOT handle the ``finalize`` action — that's a
    subsequent PR. Adopters implementing this method today should
    treat ``action='finalize'`` entries as
    ``UNSUPPORTED_FEATURE`` and return a structured error.
    """
    ...

Refine-mode iteration on a previous get_products response.

Per the spec, refine is a buying_mode value on get_products — the wire envelope is the same. The framework routes refine requests to this method when:

  1. The wired ProposalManager declares :attr:ProposalCapabilities.refine = True, AND
  2. The request has buying_mode == 'refine', AND
  3. The manager subclass implements this method.

Otherwise, refine requests fall through to :meth:get_products (the manager handles refinement itself) or surface UNSUPPORTED_FEATURE if neither path is wired.

v1 does NOT handle the finalize action — that's a subsequent PR. Adopters implementing this method today should treat action='finalize' entries as UNSUPPORTED_FEATURE and return a structured error.

class ProposalRecord (proposal_id: str,
account_id: str,
state: ProposalState,
recipes: Mapping[str, Recipe],
proposal_payload: Mapping[str, Any],
expires_at: datetime | None = None,
media_buy_id: str | None = None,
recipe_schema_version: int = 1)
Expand source code
@dataclass(frozen=True)
class ProposalRecord:
    """The framework's per-proposal storage row.

    :param proposal_id: Stable identifier the buyer receives in the
        ``proposals[]`` wire array.
    :param account_id: Account that owns the proposal. Drives the
        cross-tenant check in :meth:`ProposalStore.get`.
    :param state: Current lifecycle state.
    :param recipes: ``product_id -> Recipe`` mapping. The
        :class:`ProposalManager` returned these alongside products
        on get_products / refine_products; the framework persists
        them so :meth:`DecisioningPlatform.create_media_buy` can
        hydrate ``ctx.recipes`` from this same record.
    :param proposal_payload: The wire ``Proposal`` shape. Stored so
        the framework can re-emit it on refine iterations or replay
        it post-finalize without round-tripping through the manager
        again.
    :param expires_at: Set on :meth:`commit`. The inventory hold
        window; framework rejects ``create_media_buy`` calls past
        this deadline (plus the adopter's grace window).
    :param media_buy_id: Set on :meth:`mark_consumed`. The accepted
        proposal's terminal binding to a media buy; reverse-index
        lookups via :meth:`get_by_media_buy_id` use this.
    :param recipe_schema_version: Captured at :meth:`put_draft` time.
        Adopters whose Recipe subclasses add required fields later
        bump the schema and write a migration (or evict pre-bump
        records). Framework reads but does not enforce.
    """

    proposal_id: str
    account_id: str
    state: ProposalState
    recipes: Mapping[str, Recipe]
    proposal_payload: Mapping[str, Any]
    expires_at: datetime | None = None
    media_buy_id: str | None = None
    recipe_schema_version: int = 1

The framework's per-proposal storage row.

:param proposal_id: Stable identifier the buyer receives in the proposals[] wire array. :param account_id: Account that owns the proposal. Drives the cross-tenant check in :meth:ProposalStore.get(). :param state: Current lifecycle state. :param recipes: product_id -> Recipe mapping. The :class:ProposalManager returned these alongside products on get_products / refine_products; the framework persists them so :meth:DecisioningPlatform.create_media_buy can hydrate ctx.recipes from this same record. :param proposal_payload: The wire Proposal shape. Stored so the framework can re-emit it on refine iterations or replay it post-finalize without round-tripping through the manager again. :param expires_at: Set on :meth:commit. The inventory hold window; framework rejects create_media_buy calls past this deadline (plus the adopter's grace window). :param media_buy_id: Set on :meth:mark_consumed. The accepted proposal's terminal binding to a media buy; reverse-index lookups via :meth:get_by_media_buy_id use this. :param recipe_schema_version: Captured at :meth:put_draft time. Adopters whose Recipe subclasses add required fields later bump the schema and write a migration (or evict pre-bump records). Framework reads but does not enforce.

Instance variables

var account_id : str
var expires_at : datetime | None
var media_buy_id : str | None
var proposal_id : str
var proposal_payload : Mapping[str, Any]
var recipe_schema_version : int
var recipes : Mapping[str, Recipe]
var stateProposalState
class ProposalState (*args, **kwds)
Expand source code
class ProposalState(str, Enum):
    """Lifecycle states for a stored proposal.

    No ``EXPIRED`` member: the framework computes expiry from
    :attr:`ProposalRecord.expires_at` + the current clock + the
    adopter's grace window (see proposal_lifecycle.py D7). Storing
    expiry as a state would create a clock-driven write the framework
    doesn't actually need.
    """

    DRAFT = "draft"  # mutable; refine iterations overwrite
    COMMITTED = "committed"  # immutable + expires_at enforcement
    CONSUMING = "consuming"  # adapter dispatch in flight; reservation held
    CONSUMED = "consumed"  # post-create_media_buy terminal

Lifecycle states for a stored proposal.

No EXPIRED member: the framework computes expiry from :attr:ProposalRecord.expires_at + the current clock + the adopter's grace window (see proposal_lifecycle.py D7). Storing expiry as a state would create a clock-driven write the framework doesn't actually need.

Ancestors

  • builtins.str
  • enum.Enum

Class variables

var COMMITTED
var CONSUMED
var CONSUMING
var DRAFT
class ProposalStore (*args, **kwargs)
Expand source code
@runtime_checkable
class ProposalStore(Protocol):
    """Per-tenant proposal lifecycle persistence.

    Methods may be sync or async — the framework awaits at call time
    via :func:`_await_maybe` (mirrors
    :class:`adcp.decisioning.MediaBuyStore`).

    State machine the framework drives:

    .. code-block:: text

                                  ┌──── release_consumption ────┐
                                  ▼                             │
        put_draft ─► DRAFT ─► commit ─► COMMITTED ─► try_reserve_consumption ─► CONSUMING
                       ▲                                                          │
                       │                                                          │
                    (refine                                              finalize_consumption
                     iteration)                                                   │
                       │                                                          ▼
                       └─ put_draft (overwrite while DRAFT) ─┘                CONSUMED
                                                                              (terminal)

    The ``COMMITTED → CONSUMING → CONSUMED`` two-phase transition
    prevents the inventory double-spend race that a check-then-act
    sequence on ``COMMITTED`` would expose. Two parallel
    ``create_media_buy(proposal_id=X)`` calls cannot both reserve
    the proposal — the second :meth:`try_reserve_consumption` raises
    ``PROPOSAL_NOT_COMMITTED`` once the first transitioned the record.
    Adapter dispatch runs against the reservation; on success the
    framework calls :meth:`finalize_consumption` (records the
    ``media_buy_id``); on failure the framework calls
    :meth:`release_consumption` (rolls back to ``COMMITTED`` so the
    buyer can retry).

    Transitions outside this graph (commit-from-COMMITTED,
    finalize_consumption-from-DRAFT, etc.) raise :class:`AdcpError`
    with ``code='INTERNAL_ERROR'`` — those are framework / adopter
    bugs, not buyer-facing rejections.

    **``account_id`` is opaque.** The framework threads
    ``ctx.account.id`` (whatever the adopter's
    :class:`~adcp.decisioning.AccountStore.resolve` returned) into
    every method. The store MUST NOT parse it, split it, or re-derive
    tenant scope from it. Multi-tenant adopters encode their tenant
    scope into ``Account.id`` at the
    :class:`~adcp.decisioning.AccountStore` layer once, and the
    composite ``(account_id, proposal_id)`` then carries unique
    identity across the entire deployment without needing a separate
    ``tenant_id`` parameter on this Protocol. See the AccountStore
    docstring's "Multi-tenant deployments" section for the canonical
    encoding pattern, and :func:`~adcp.decisioning.create_tenant_store`
    for the typed helper.
    """

    is_durable: ClassVar[bool]
    """Drives the production-mode gate. ``False`` for
    :class:`InMemoryProposalStore`; ``True`` for adopter-supplied
    durable backings (Postgres / Redis / SQLAlchemy)."""

    def put_draft(
        self,
        *,
        proposal_id: str,
        account_id: str,
        recipes: Mapping[str, Recipe],
        proposal_payload: Mapping[str, Any],
    ) -> MaybeAsync[None]:
        """Store / replace a draft proposal.

        Refine iterations call this with the same ``proposal_id`` to
        overwrite. Calling :meth:`put_draft` on a record currently in
        :attr:`ProposalState.COMMITTED` or :attr:`ProposalState.CONSUMED`
        is rejected.
        """
        ...

    def get(
        self,
        proposal_id: str,
        *,
        expected_account_id: str | None = None,
    ) -> MaybeAsync[ProposalRecord | None]:
        """Look up a proposal record. Cross-tenant probes return ``None``.

        Mirrors :meth:`adcp.decisioning.TaskRegistry.get`'s posture:
        when ``expected_account_id`` is supplied, a mismatch returns
        ``None`` rather than the raw record. The dispatch path always
        passes the authenticated principal's account_id; adopter
        impls MUST honor this — returning a cross-tenant record
        enables principal-enumeration via proposal_id probing.
        """
        ...

    def commit(
        self,
        proposal_id: str,
        *,
        expires_at: datetime,
        proposal_payload: Mapping[str, Any],
        expected_account_id: str,
    ) -> MaybeAsync[None]:
        """Promote ``DRAFT`` → ``COMMITTED``.

        Idempotent on re-call with equal ``expires_at`` +
        ``proposal_payload``. A second commit with different values
        raises ``INTERNAL_ERROR`` — adopter bug.

        ``expected_account_id`` scopes the write to the calling
        principal's tenant. Durable backings whose primary key is
        ``(account_id, proposal_id)`` MUST use this in the SQL
        predicate — a write keyed only on ``proposal_id`` either
        misses (silently no-ops) or hits the wrong tenant's row.
        Required as of v1.5.1 (#727); the previous unscoped signature
        was a cross-tenant write surface.
        """
        ...

    def try_reserve_consumption(
        self,
        proposal_id: str,
        *,
        expected_account_id: str,
    ) -> MaybeAsync[ProposalRecord]:
        """Atomic CAS: ``COMMITTED`` → ``CONSUMING``.

        The framework calls this **before** dispatching
        :meth:`DecisioningPlatform.create_media_buy`. Holds the
        reservation until :meth:`finalize_consumption` (success) or
        :meth:`release_consumption` (rollback). Two parallel callers
        cannot both reserve — the loser raises ``PROPOSAL_NOT_COMMITTED``.

        :raises AdcpError: ``PROPOSAL_NOT_FOUND`` when no record exists,
            ``PROPOSAL_NOT_COMMITTED`` when state is not ``COMMITTED``
            (already CONSUMING / CONSUMED / DRAFT). Adopters backed by
            SQL implement this with ``SELECT … FOR UPDATE`` or an
            equivalent atomic CAS — the contract is that two
            concurrent calls produce exactly one success.

        :returns: The record on success, with ``state == CONSUMING``.
        """
        ...

    def finalize_consumption(
        self,
        proposal_id: str,
        *,
        media_buy_id: str,
        expected_account_id: str,
    ) -> MaybeAsync[None]:
        """Promote ``CONSUMING`` → ``CONSUMED`` and record the
        ``media_buy_id`` back-reference for
        :meth:`get_by_media_buy_id` reverse-index lookups.

        :raises AdcpError: ``INTERNAL_ERROR`` if the record is not in
            ``CONSUMING`` (framework called this without a prior
            successful :meth:`try_reserve_consumption`).
        """
        ...

    def release_consumption(
        self,
        proposal_id: str,
        *,
        expected_account_id: str,
    ) -> MaybeAsync[None]:
        """Rollback path: ``CONSUMING`` → ``COMMITTED``.

        Called by the framework when the adapter's
        :meth:`create_media_buy` raises (transient upstream error,
        validation, etc.) so the buyer can retry without
        ``PROPOSAL_NOT_COMMITTED``. Idempotent on a record already in
        ``COMMITTED`` (in case the adapter raised after a successful
        :meth:`finalize_consumption` — rare but harmless).
        """
        ...

    def mark_consumed(
        self,
        proposal_id: str,
        *,
        media_buy_id: str,
        expected_account_id: str,
    ) -> MaybeAsync[None]:
        """Direct ``COMMITTED`` → ``CONSUMED`` transition.

        New code uses :meth:`try_reserve_consumption` +
        :meth:`finalize_consumption` for the race-safe two-phase
        commit. This method is equivalent to a reserve-and-finalize
        against a single thread of writes; adopters MUST NOT call it
        from concurrent dispatch paths.

        ``expected_account_id`` scopes the transition to the calling
        principal's tenant — same rationale as :meth:`commit`.
        """
        ...

    def discard(
        self,
        proposal_id: str,
        *,
        expected_account_id: str,
    ) -> MaybeAsync[None]:
        """Rollback path. Idempotent — discarding an unknown id (or
        a cross-tenant probe) is a no-op (no raise). Symmetric with
        :meth:`adcp.decisioning.TaskRegistry.discard`.

        ``expected_account_id`` scopes the delete to the calling
        principal's tenant; a cross-tenant probe must not delete the
        other tenant's row.
        """
        ...

    def get_by_media_buy_id(
        self,
        media_buy_id: str,
        *,
        expected_account_id: str,
    ) -> MaybeAsync[ProposalRecord | None]:
        """Reverse-index lookup. Hydrate the (consumed) proposal that
        produced this ``media_buy_id`` for the given tenant.

        ``expected_account_id`` is required (no default) because
        ``media_buy_id`` is adopter-controlled and can collide across
        tenants — sequential IDs, deterministic test fixtures, etc.
        Indexing on the tenant-scoped tuple is the only safe shape.
        Adopters backed by SQL add a uniqueness constraint on
        ``(account_id, media_buy_id)`` where ``media_buy_id IS NOT NULL``.

        Returns ``None`` for legacy buys / non-proposal flows that
        never went through the proposal lifecycle.
        """
        ...

Per-tenant proposal lifecycle persistence.

Methods may be sync or async — the framework awaits at call time via :func:_await_maybe (mirrors :class:MediaBuyStore).

State machine the framework drives:

.. code-block:: text

                          ┌──── release_consumption ────┐
                          ▼                             │
put_draft ─► DRAFT ─► commit ─► COMMITTED ─► try_reserve_consumption ─► CONSUMING
               ▲                                                          │
               │                                                          │
            (refine                                              finalize_consumption
             iteration)                                                   │
               │                                                          ▼
               └─ put_draft (overwrite while DRAFT) ─┘                CONSUMED
                                                                      (terminal)

The COMMITTED → CONSUMING → CONSUMED two-phase transition prevents the inventory double-spend race that a check-then-act sequence on COMMITTED would expose. Two parallel create_media_buy(proposal_id=X) calls cannot both reserve the proposal — the second :meth:try_reserve_consumption raises PROPOSAL_NOT_COMMITTED once the first transitioned the record. Adapter dispatch runs against the reservation; on success the framework calls :meth:finalize_consumption (records the media_buy_id); on failure the framework calls :meth:release_consumption (rolls back to COMMITTED so the buyer can retry).

Transitions outside this graph (commit-from-COMMITTED, finalize_consumption-from-DRAFT, etc.) raise :class:AdcpError with code='INTERNAL_ERROR' — those are framework / adopter bugs, not buyer-facing rejections.

account_id is opaque. The framework threads ctx.account.id (whatever the adopter's :class:~adcp.decisioning.AccountStore.resolve returned) into every method. The store MUST NOT parse it, split it, or re-derive tenant scope from it. Multi-tenant adopters encode their tenant scope into Account.id at the :class:~adcp.decisioning.AccountStore layer once, and the composite (account_id, proposal_id) then carries unique identity across the entire deployment without needing a separate tenant_id parameter on this Protocol. See the AccountStore docstring's "Multi-tenant deployments" section for the canonical encoding pattern, and :func:~adcp.decisioning.create_tenant_store for the typed helper.

Ancestors

  • typing.Protocol
  • typing.Generic

Class variables

var is_durable : ClassVar[bool]

Drives the production-mode gate. False for :class:InMemoryProposalStore; True for adopter-supplied durable backings (Postgres / Redis / SQLAlchemy).

Methods

def commit(self,
proposal_id: str,
*,
expires_at: datetime,
proposal_payload: Mapping[str, Any],
expected_account_id: str) ‑> MaybeAsync[None]
Expand source code
def commit(
    self,
    proposal_id: str,
    *,
    expires_at: datetime,
    proposal_payload: Mapping[str, Any],
    expected_account_id: str,
) -> MaybeAsync[None]:
    """Promote ``DRAFT`` → ``COMMITTED``.

    Idempotent on re-call with equal ``expires_at`` +
    ``proposal_payload``. A second commit with different values
    raises ``INTERNAL_ERROR`` — adopter bug.

    ``expected_account_id`` scopes the write to the calling
    principal's tenant. Durable backings whose primary key is
    ``(account_id, proposal_id)`` MUST use this in the SQL
    predicate — a write keyed only on ``proposal_id`` either
    misses (silently no-ops) or hits the wrong tenant's row.
    Required as of v1.5.1 (#727); the previous unscoped signature
    was a cross-tenant write surface.
    """
    ...

Promote DRAFTCOMMITTED.

Idempotent on re-call with equal expires_at + proposal_payload. A second commit with different values raises INTERNAL_ERROR — adopter bug.

expected_account_id scopes the write to the calling principal's tenant. Durable backings whose primary key is (account_id, proposal_id) MUST use this in the SQL predicate — a write keyed only on proposal_id either misses (silently no-ops) or hits the wrong tenant's row. Required as of v1.5.1 (#727); the previous unscoped signature was a cross-tenant write surface.

def discard(self, proposal_id: str, *, expected_account_id: str) ‑> MaybeAsync[None]
Expand source code
def discard(
    self,
    proposal_id: str,
    *,
    expected_account_id: str,
) -> MaybeAsync[None]:
    """Rollback path. Idempotent — discarding an unknown id (or
    a cross-tenant probe) is a no-op (no raise). Symmetric with
    :meth:`adcp.decisioning.TaskRegistry.discard`.

    ``expected_account_id`` scopes the delete to the calling
    principal's tenant; a cross-tenant probe must not delete the
    other tenant's row.
    """
    ...

Rollback path. Idempotent — discarding an unknown id (or a cross-tenant probe) is a no-op (no raise). Symmetric with :meth:TaskRegistry.discard().

expected_account_id scopes the delete to the calling principal's tenant; a cross-tenant probe must not delete the other tenant's row.

def finalize_consumption(self, proposal_id: str, *, media_buy_id: str, expected_account_id: str) ‑> MaybeAsync[None]
Expand source code
def finalize_consumption(
    self,
    proposal_id: str,
    *,
    media_buy_id: str,
    expected_account_id: str,
) -> MaybeAsync[None]:
    """Promote ``CONSUMING`` → ``CONSUMED`` and record the
    ``media_buy_id`` back-reference for
    :meth:`get_by_media_buy_id` reverse-index lookups.

    :raises AdcpError: ``INTERNAL_ERROR`` if the record is not in
        ``CONSUMING`` (framework called this without a prior
        successful :meth:`try_reserve_consumption`).
    """
    ...

Promote CONSUMINGCONSUMED and record the media_buy_id back-reference for :meth:get_by_media_buy_id reverse-index lookups.

:raises AdcpError: INTERNAL_ERROR if the record is not in CONSUMING (framework called this without a prior successful :meth:try_reserve_consumption).

def get(self, proposal_id: str, *, expected_account_id: str | None = None) ‑> MaybeAsync[ProposalRecord | None]
Expand source code
def get(
    self,
    proposal_id: str,
    *,
    expected_account_id: str | None = None,
) -> MaybeAsync[ProposalRecord | None]:
    """Look up a proposal record. Cross-tenant probes return ``None``.

    Mirrors :meth:`adcp.decisioning.TaskRegistry.get`'s posture:
    when ``expected_account_id`` is supplied, a mismatch returns
    ``None`` rather than the raw record. The dispatch path always
    passes the authenticated principal's account_id; adopter
    impls MUST honor this — returning a cross-tenant record
    enables principal-enumeration via proposal_id probing.
    """
    ...

Look up a proposal record. Cross-tenant probes return None.

Mirrors :meth:TaskRegistry.get()'s posture: when expected_account_id is supplied, a mismatch returns None rather than the raw record. The dispatch path always passes the authenticated principal's account_id; adopter impls MUST honor this — returning a cross-tenant record enables principal-enumeration via proposal_id probing.

def get_by_media_buy_id(self, media_buy_id: str, *, expected_account_id: str) ‑> MaybeAsync[ProposalRecord | None]
Expand source code
def get_by_media_buy_id(
    self,
    media_buy_id: str,
    *,
    expected_account_id: str,
) -> MaybeAsync[ProposalRecord | None]:
    """Reverse-index lookup. Hydrate the (consumed) proposal that
    produced this ``media_buy_id`` for the given tenant.

    ``expected_account_id`` is required (no default) because
    ``media_buy_id`` is adopter-controlled and can collide across
    tenants — sequential IDs, deterministic test fixtures, etc.
    Indexing on the tenant-scoped tuple is the only safe shape.
    Adopters backed by SQL add a uniqueness constraint on
    ``(account_id, media_buy_id)`` where ``media_buy_id IS NOT NULL``.

    Returns ``None`` for legacy buys / non-proposal flows that
    never went through the proposal lifecycle.
    """
    ...

Reverse-index lookup. Hydrate the (consumed) proposal that produced this media_buy_id for the given tenant.

expected_account_id is required (no default) because media_buy_id is adopter-controlled and can collide across tenants — sequential IDs, deterministic test fixtures, etc. Indexing on the tenant-scoped tuple is the only safe shape. Adopters backed by SQL add a uniqueness constraint on (account_id, media_buy_id) where media_buy_id IS NOT NULL.

Returns None for legacy buys / non-proposal flows that never went through the proposal lifecycle.

def mark_consumed(self, proposal_id: str, *, media_buy_id: str, expected_account_id: str) ‑> MaybeAsync[None]
Expand source code
def mark_consumed(
    self,
    proposal_id: str,
    *,
    media_buy_id: str,
    expected_account_id: str,
) -> MaybeAsync[None]:
    """Direct ``COMMITTED`` → ``CONSUMED`` transition.

    New code uses :meth:`try_reserve_consumption` +
    :meth:`finalize_consumption` for the race-safe two-phase
    commit. This method is equivalent to a reserve-and-finalize
    against a single thread of writes; adopters MUST NOT call it
    from concurrent dispatch paths.

    ``expected_account_id`` scopes the transition to the calling
    principal's tenant — same rationale as :meth:`commit`.
    """
    ...

Direct COMMITTEDCONSUMED transition.

New code uses :meth:try_reserve_consumption + :meth:finalize_consumption for the race-safe two-phase commit. This method is equivalent to a reserve-and-finalize against a single thread of writes; adopters MUST NOT call it from concurrent dispatch paths.

expected_account_id scopes the transition to the calling principal's tenant — same rationale as :meth:commit.

def put_draft(self,
*,
proposal_id: str,
account_id: str,
recipes: Mapping[str, Recipe],
proposal_payload: Mapping[str, Any]) ‑> MaybeAsync[None]
Expand source code
def put_draft(
    self,
    *,
    proposal_id: str,
    account_id: str,
    recipes: Mapping[str, Recipe],
    proposal_payload: Mapping[str, Any],
) -> MaybeAsync[None]:
    """Store / replace a draft proposal.

    Refine iterations call this with the same ``proposal_id`` to
    overwrite. Calling :meth:`put_draft` on a record currently in
    :attr:`ProposalState.COMMITTED` or :attr:`ProposalState.CONSUMED`
    is rejected.
    """
    ...

Store / replace a draft proposal.

Refine iterations call this with the same proposal_id to overwrite. Calling :meth:put_draft on a record currently in :attr:ProposalState.COMMITTED or :attr:ProposalState.CONSUMED is rejected.

def release_consumption(self, proposal_id: str, *, expected_account_id: str) ‑> MaybeAsync[None]
Expand source code
def release_consumption(
    self,
    proposal_id: str,
    *,
    expected_account_id: str,
) -> MaybeAsync[None]:
    """Rollback path: ``CONSUMING`` → ``COMMITTED``.

    Called by the framework when the adapter's
    :meth:`create_media_buy` raises (transient upstream error,
    validation, etc.) so the buyer can retry without
    ``PROPOSAL_NOT_COMMITTED``. Idempotent on a record already in
    ``COMMITTED`` (in case the adapter raised after a successful
    :meth:`finalize_consumption` — rare but harmless).
    """
    ...

Rollback path: CONSUMINGCOMMITTED.

Called by the framework when the adapter's :meth:create_media_buy raises (transient upstream error, validation, etc.) so the buyer can retry without PROPOSAL_NOT_COMMITTED. Idempotent on a record already in COMMITTED (in case the adapter raised after a successful :meth:finalize_consumption — rare but harmless).

def try_reserve_consumption(self, proposal_id: str, *, expected_account_id: str) ‑> MaybeAsync[ProposalRecord]
Expand source code
def try_reserve_consumption(
    self,
    proposal_id: str,
    *,
    expected_account_id: str,
) -> MaybeAsync[ProposalRecord]:
    """Atomic CAS: ``COMMITTED`` → ``CONSUMING``.

    The framework calls this **before** dispatching
    :meth:`DecisioningPlatform.create_media_buy`. Holds the
    reservation until :meth:`finalize_consumption` (success) or
    :meth:`release_consumption` (rollback). Two parallel callers
    cannot both reserve — the loser raises ``PROPOSAL_NOT_COMMITTED``.

    :raises AdcpError: ``PROPOSAL_NOT_FOUND`` when no record exists,
        ``PROPOSAL_NOT_COMMITTED`` when state is not ``COMMITTED``
        (already CONSUMING / CONSUMED / DRAFT). Adopters backed by
        SQL implement this with ``SELECT … FOR UPDATE`` or an
        equivalent atomic CAS — the contract is that two
        concurrent calls produce exactly one success.

    :returns: The record on success, with ``state == CONSUMING``.
    """
    ...

Atomic CAS: COMMITTEDCONSUMING.

The framework calls this before dispatching :meth:DecisioningPlatform.create_media_buy. Holds the reservation until :meth:finalize_consumption (success) or :meth:release_consumption (rollback). Two parallel callers cannot both reserve — the loser raises PROPOSAL_NOT_COMMITTED.

:raises AdcpError: PROPOSAL_NOT_FOUND when no record exists, PROPOSAL_NOT_COMMITTED when state is not COMMITTED (already CONSUMING / CONSUMED / DRAFT). Adopters backed by SQL implement this with SELECT … FOR UPDATE or an equivalent atomic CAS — the contract is that two concurrent calls produce exactly one success.

:returns: The record on success, with state == CONSUMING.

class RateLimitedBuyerAgentRegistry (inner: BuyerAgentRegistry,
*,
rps_per_tenant: float = 100.0,
burst: float | None = None,
audit_sink: AuditSink | None = None,
sink_timeout_seconds: float = 5.0,
time_source: Callable[[], float] = <built-in function monotonic>)
Expand source code
class RateLimitedBuyerAgentRegistry:
    """Per-tenant token-bucket rate limiter wrapping a
    :class:`BuyerAgentRegistry`.

    Sized for the credential-stuffing oracle: the registry's
    :meth:`resolve_by_credential` is queryable with arbitrary
    ``key_id`` strings; without a rate limit, an attacker can
    enumerate the keyspace at line rate. The bucket sits between
    the request and the DB so probe traffic gets rejected before
    the SQL query runs.

    :param inner: The wrapped :class:`BuyerAgentRegistry`.
    :param rps_per_tenant: Steady-state requests per second per
        ``(tenant_id, lookup_key)`` bucket. Default 100 — high
        enough to absorb a real buyer's storyboard burst, low
        enough that an enumeration probe at line rate gets cut off.
    :param burst: Maximum bucket capacity (tokens). Default
        ``rps_per_tenant`` so a steady state can sustain
        ``rps_per_tenant`` calls/sec but bursts are capped at the
        same number. Adopters with bursty real traffic raise this.
    :param audit_sink: Optional audit sink — emits ``rate_limited``
        events when the bucket is exhausted. The most interesting
        event for security review (repeated rate-limit exhaustion
        is the credential-stuffing signal an attacker is actively
        probing).
    :param time_source: Override for tests — defaults to
        :func:`time.monotonic`.

    Failure mode
    ------------

    On bucket exhaustion, raises :class:`AdcpError`
    ``PERMISSION_DENIED`` with NO ``details`` and a generic message
    matching the registry-miss path. This is deliberate — a distinct
    ``RATE_LIMITED`` code or any populated ``details`` field would
    itself be an enumeration oracle (the attacker learns "this
    ``agent_url`` is interesting enough to be rate-limited"). The
    spec's omit-on-unestablished-identity rule from PR #393 applies.
    """

    def __init__(
        self,
        inner: BuyerAgentRegistry,
        *,
        rps_per_tenant: float = 100.0,
        burst: float | None = None,
        audit_sink: AuditSink | None = None,
        sink_timeout_seconds: float = 5.0,
        time_source: Callable[[], float] = time.monotonic,
    ) -> None:
        if rps_per_tenant <= 0:
            raise ValueError(f"rps_per_tenant must be > 0, got {rps_per_tenant!r}")
        if burst is not None and burst <= 0:
            raise ValueError(f"burst must be > 0, got {burst!r}")
        self._inner = inner
        self._rate = rps_per_tenant
        self._burst = burst if burst is not None else rps_per_tenant
        self._sink = audit_sink
        self._sink_timeout = sink_timeout_seconds
        self._now = time_source
        self._buckets: dict[tuple[str | None, str], _Bucket] = {}
        self._lock = asyncio.Lock()

    async def resolve_by_agent_url(self, agent_url: str) -> BuyerAgent | None:
        tenant_id = _current_tenant_id()
        lookup_key = f"agent_url:{agent_url}"
        await self._charge(
            (tenant_id, lookup_key),
            operation="buyer_agent_registry.resolve_by_agent_url",
            tenant_id=tenant_id,
        )
        return await self._inner.resolve_by_agent_url(agent_url)

    async def resolve_by_credential(self, credential: Credential) -> BuyerAgent | None:
        tenant_id = _current_tenant_id()
        lookup_key = _credential_key(credential)
        await self._charge(
            (tenant_id, lookup_key),
            operation="buyer_agent_registry.resolve_by_credential",
            tenant_id=tenant_id,
        )
        return await self._inner.resolve_by_credential(credential)

    async def _charge(
        self,
        key: tuple[str | None, str],
        *,
        operation: str,
        tenant_id: str | None,
    ) -> None:
        """Spend one token from ``key``'s bucket; raise + audit on
        exhaustion."""
        now = self._now()
        async with self._lock:
            bucket = self._buckets.get(key)
            if bucket is None:
                # New bucket — start full so a fresh tenant gets the
                # burst allowance immediately.
                bucket = _Bucket(tokens=self._burst, last_refill=now)
                self._buckets[key] = bucket
            else:
                # Refill at ``rate`` tokens/sec, capped at ``burst``.
                elapsed = now - bucket.last_refill
                bucket.tokens = min(self._burst, bucket.tokens + elapsed * self._rate)
                bucket.last_refill = now
            if bucket.tokens < 1.0:
                exhausted = True
            else:
                bucket.tokens -= 1.0
                exhausted = False
        if exhausted:
            # Audit emission OUTSIDE the lock — the sink may be slow.
            await _emit_audit(
                self._sink,
                operation=operation,
                outcome="rate_limited",
                lookup_key=key[1],
                tenant_id=tenant_id,
                sink_timeout_seconds=self._sink_timeout,
            )
            raise _denied_error()

Per-tenant token-bucket rate limiter wrapping a :class:BuyerAgentRegistry.

Sized for the credential-stuffing oracle: the registry's :meth:resolve_by_credential is queryable with arbitrary key_id strings; without a rate limit, an attacker can enumerate the keyspace at line rate. The bucket sits between the request and the DB so probe traffic gets rejected before the SQL query runs.

:param inner: The wrapped :class:BuyerAgentRegistry. :param rps_per_tenant: Steady-state requests per second per (tenant_id, lookup_key) bucket. Default 100 — high enough to absorb a real buyer's storyboard burst, low enough that an enumeration probe at line rate gets cut off. :param burst: Maximum bucket capacity (tokens). Default rps_per_tenant so a steady state can sustain rps_per_tenant calls/sec but bursts are capped at the same number. Adopters with bursty real traffic raise this. :param audit_sink: Optional audit sink — emits rate_limited events when the bucket is exhausted. The most interesting event for security review (repeated rate-limit exhaustion is the credential-stuffing signal an attacker is actively probing). :param time_source: Override for tests — defaults to :func:time.monotonic.

Failure Mode

On bucket exhaustion, raises :class:AdcpError PERMISSION_DENIED with NO details and a generic message matching the registry-miss path. This is deliberate — a distinct RATE_LIMITED code or any populated details field would itself be an enumeration oracle (the attacker learns "this agent_url is interesting enough to be rate-limited"). The spec's omit-on-unestablished-identity rule from PR #393 applies.

Methods

async def resolve_by_agent_url(self, agent_url: str) ‑> BuyerAgent | None
Expand source code
async def resolve_by_agent_url(self, agent_url: str) -> BuyerAgent | None:
    tenant_id = _current_tenant_id()
    lookup_key = f"agent_url:{agent_url}"
    await self._charge(
        (tenant_id, lookup_key),
        operation="buyer_agent_registry.resolve_by_agent_url",
        tenant_id=tenant_id,
    )
    return await self._inner.resolve_by_agent_url(agent_url)
async def resolve_by_credential(self, credential: Credential) ‑> BuyerAgent | None
Expand source code
async def resolve_by_credential(self, credential: Credential) -> BuyerAgent | None:
    tenant_id = _current_tenant_id()
    lookup_key = _credential_key(credential)
    await self._charge(
        (tenant_id, lookup_key),
        operation="buyer_agent_registry.resolve_by_credential",
        tenant_id=tenant_id,
    )
    return await self._inner.resolve_by_credential(credential)
class RateLimitedError (*, message: str | None = None, retry_after: int | None = None, **details: Any)
Expand source code
class RateLimitedError(AdcpError):
    """Spec ``RATE_LIMITED`` (``recovery='transient'``).

    Raised when the request rate exceeds the seller's threshold. The
    buyer retries after the ``retry_after`` interval.
    """

    def __init__(
        self,
        *,
        message: str | None = None,
        retry_after: int | None = None,
        **details: Any,
    ) -> None:
        super().__init__(
            "RATE_LIMITED",
            message=message or "Request rate exceeded.",
            recovery="transient",
            retry_after=retry_after,
            details=dict(details) or None,
        )

Spec RATE_LIMITED (recovery='transient').

Raised when the request rate exceeds the seller's threshold. The buyer retries after the retry_after interval.

Ancestors

  • AdcpError
  • builtins.Exception
  • builtins.BaseException

Inherited members

class Recipe (**data: Any)
Expand source code
class Recipe(BaseModel):
    """Base type for typed product implementation_config payloads.

    Subclasses declare a ``recipe_kind: Literal["<slug>"]`` field that
    identifies the adapter family (``"gam"``, ``"kevel"``, ``"meta"``,
    etc.). The base provides only the discriminator slot;
    adopters add the typed fields their adapter consumes at execute
    time, plus an optional :attr:`capability_overlap` for
    framework-gated buyer-request validation (per v1.5 § D4).

    Adopter subclasses are pure Pydantic — round-trip via
    ``model_dump()`` to land in ``Product.implementation_config`` on
    the wire response, and ``model_validate(d)`` to rehydrate when
    an adopter receives the dict back at ``create_media_buy`` time.

    The base intentionally does NOT declare ``recipe_kind`` itself;
    each subclass MUST declare it as a ``Literal["..."]`` so static
    type checkers narrow correctly when the adopter pattern-matches
    on the kind tag.

    :param capability_overlap: Optional typed declaration of which
        wire capabilities the buyer can configure on this product.
        ``None`` (default) means no framework gating — the v1
        behaviour. An explicit :class:`CapabilityOverlap` activates
        the v1.5 validation seam.
    """

    model_config = ConfigDict(
        # Allow subclasses to add fields without re-declaring config.
        # Strict on extras at the recipe level — adopters who add
        # ad-hoc fields should declare them on their subclass.
        # Allow ``frozenset`` / ``CapabilityOverlap`` to round-trip via
        # ``arbitrary_types_allowed``: Pydantic's stock JSON schema
        # generation doesn't have a frozenset codec but adopters use
        # ``model_dump(mode='python')`` to keep the typed shape, and
        # the field is opaque to the wire (rides on
        # ``implementation_config``).
        extra="forbid",
        frozen=False,
        arbitrary_types_allowed=True,
    )

    capability_overlap: CapabilityOverlap | None = None

Base type for typed product implementation_config payloads.

Subclasses declare a recipe_kind: Literal["<slug>"] field that identifies the adapter family ("gam", "kevel", "meta", etc.). The base provides only the discriminator slot; adopters add the typed fields their adapter consumes at execute time, plus an optional :attr:capability_overlap for framework-gated buyer-request validation (per v1.5 § D4).

Adopter subclasses are pure Pydantic — round-trip via model_dump() to land in Product.implementation_config on the wire response, and model_validate(d) to rehydrate when an adopter receives the dict back at create_media_buy time.

The base intentionally does NOT declare recipe_kind itself; each subclass MUST declare it as a Literal["..."] so static type checkers narrow correctly when the adopter pattern-matches on the kind tag.

:param capability_overlap: Optional typed declaration of which wire capabilities the buyer can configure on this product. None (default) means no framework gating — the v1 behaviour. An explicit :class:CapabilityOverlap activates the v1.5 validation seam.

Create a new model by parsing and validating input data from keyword arguments.

Raises [ValidationError][pydantic_core.ValidationError] if the input data cannot be validated to form a valid model.

self is explicitly positional-only to allow self as a field name.

Ancestors

  • pydantic.main.BaseModel

Class variables

var capability_overlapCapabilityOverlap | None
var model_config
class RefineResult (products: list[Any],
proposals: list[Any] | None,
per_refine_outcome: list[RefinementOutcome] = <factory>)
Expand source code
@dataclass(frozen=True)
class RefineResult:
    """Adopter-returned shape from ``refine_get_products``.

    Framework projects this into the wire :class:`GetProductsResponse`,
    constructing ``refinement_applied[]`` from
    :attr:`per_refine_outcome` + the request's ``refine[]`` array.

    :param products: Updated product list (per spec, refine returns a
        revised set — drop omitted, add ``more_like_this`` discoveries,
        update pricing on remaining).
    :param proposals: Updated proposal list.  ``None`` when the seller
        does not produce proposals (sales-non-guaranteed without proposal
        mode).  Empty list when proposals were all omitted.
    :param per_refine_outcome: Exactly ``len(request.refine)`` entries,
        in the same order.  Mismatched length is a developer-facing error
        (raised by the framework before the response is built).
    """

    products: list[Any]
    proposals: list[Any] | None
    per_refine_outcome: list[RefinementOutcome] = field(default_factory=list)

Adopter-returned shape from refine_get_products.

Framework projects this into the wire :class:GetProductsResponse, constructing refinement_applied[] from :attr:per_refine_outcome + the request's adcp.decisioning.refine[] array.

:param products: Updated product list (per spec, refine returns a revised set — drop omitted, add more_like_this discoveries, update pricing on remaining). :param proposals: Updated proposal list. None when the seller does not produce proposals (sales-non-guaranteed without proposal mode). Empty list when proposals were all omitted. :param per_refine_outcome: Exactly len(request.refine) entries, in the same order. Mismatched length is a developer-facing error (raised by the framework before the response is built).

Instance variables

var per_refine_outcome : list[RefinementOutcome]
var products : list[typing.Any]
var proposals : list[typing.Any] | None
class RefinementOutcome (status: RefinementStatus, notes: str | None = None)
Expand source code
@dataclass(frozen=True)
class RefinementOutcome:
    """Per-refine-entry decision returned by ``refine_get_products``.

    The adopter returns one outcome per entry in ``request.refine[]``, in
    the same order.  Framework constructs the wire ``refinement_applied[]``
    by zipping outcomes with the request's refine entries — adopter does
    NOT echo ``scope``, ``product_id``, or ``proposal_id`` manually.

    :param status: ``'applied'``, ``'partial'``, or ``'unable'`` per the
        wire enum (see :class:`adcp.types.generated_poc.bundled.media_buy`'s
        ``RefinementApplied.status``).
    :param notes: Adopter's explanation; recommended when status is
        ``'partial'`` or ``'unable'``.
    """

    status: RefinementStatus
    notes: str | None = None

Per-refine-entry decision returned by refine_get_products.

The adopter returns one outcome per entry in request.refine[], in the same order. Framework constructs the wire refinement_applied[] by zipping outcomes with the request's refine entries — adopter does NOT echo scope, product_id, or proposal_id manually.

:param status: 'applied', 'partial', or 'unable' per the wire enum (see :class:adcp.types.generated_poc.bundled.media_buy's RefinementApplied.status). :param notes: Adopter's explanation; recommended when status is 'partial' or 'unable'.

Instance variables

var notes : str | None
var status : Literal['applied', 'partial', 'unable']
class RequestContext (request_id: str | None = None,
caller_identity: str | None = None,
tenant_id: str | None = None,
metadata: dict[str, Any] = <factory>,
resolved_adcp_version: str | None = None,
account: Account[TMeta] = <factory>,
auth_info: AuthInfo | None = None,
auth_principal: str | None = None,
buyer_agent: BuyerAgent | None = None,
transport: "Literal['mcp', 'a2a'] | None" = None,
now: datetime = <factory>,
state: StateReader = <factory>,
resolve: ResourceResolver = <factory>,
recipes: Mapping[str, Recipe] = <factory>)
Expand source code
@dataclass
class RequestContext(ToolContext, Generic[TMeta]):
    """Per-request context passed to every Protocol method.

    Subclasses :class:`adcp.server.ToolContext` so the existing
    framework primitives (idempotency middleware, observability,
    A2A executor) consume it as a ``ToolContext`` while adopter
    Protocol methods read the typed :attr:`account` directly.

    **Framework-only construction.** Adopter code receives a
    ``RequestContext`` from the framework on every dispatch via the
    hydration helper in ``adcp.decisioning.dispatch``. Direct
    construction is supported for tests only — production code that
    builds a ``RequestContext`` from outside the dispatch seam is a
    bug. Adopters who need to modify the context (custom middleware,
    test doubles for ``state`` / ``resolve``) should use
    :func:`dataclasses.replace`, not raw construction. Mirrors the
    TS-side ``to-context.ts:buildRequestContext`` contract.

    :param account: The resolved account, with typed ``metadata: TMeta``.
        The framework's idempotency middleware reads
        ``ctx.caller_identity`` for cache scoping; the dispatch adapter
        sets ``caller_identity = account.id`` so caching scopes per
        resolved account, not per raw auth principal.
    :param auth_info: Optional verified principal info. On bearer-token
        flows the dispatch helper synthesizes
        ``AuthInfo(kind="bearer", principal=...)`` from the
        :data:`adcp.server.auth.current_principal` ContextVar so adopters
        can branch on ``ctx.auth_info.kind == "bearer"`` (the typed
        flow discriminator) without reaching into framework-private
        state. ``None`` when the request is unauthenticated (dev /
        ``'derived'`` fixtures).
    :param now: Monotonic timestamp for the request — adopters use
        this rather than ``datetime.now()`` directly so tests can
        inject deterministic clocks.

    Adopters call :meth:`handoff_to_task` to promote a method to the
    HITL background-task path. The framework dispatcher detects the
    returned :class:`TaskHandoff` via type-identity and projects it
    to the wire ``Submitted`` envelope.

    **Identifier disambiguation — when to use which:**

    The context carries four identifier-shaped fields. Each has a
    distinct role; mixing them up is the most common adopter bug.

    ``account.id`` — "whose data is this?"
        The resolved tenant / account that owns the call. Read it to
        route the request to the right adapter instance, scope your
        DB queries, and stamp audit logs.

    ``auth_principal`` — "who's calling?"
        The verified caller's identity label and the typed read for
        adopter authorization checks. Populated from two sources
        depending on the auth flow:

        * **Signed-request flows** — sourced from
          :class:`AuthInfo.principal` (``agent_url`` for AdCP v3
          signed-request agents per spec).
        * **Bearer-token flows** — sourced from the
          :data:`adcp.server.auth.current_principal` ContextVar that
          :class:`BearerTokenAuthMiddleware` populates
          (``Principal.caller_identity`` from the validator). The
          dispatch helper also synthesizes
          ``AuthInfo(kind="bearer", principal=...)`` so adopters can
          discriminate the flow via ``ctx.auth_info.kind == "bearer"``.

        Read it for per-principal ACLs *within* an account ("can
        principal X mutate this buy?"). ``None`` for unauthenticated
        dev fixtures.

    ``caller_identity`` — "what's the cache scope key?"
        Starts as the bare principal at the transport layer
        (:class:`ToolContext.caller_identity`), then the framework
        dispatch helper mutates it into the composite scope key
        (``<store_module>.<store_qualname>:<account_id>``) before
        the handler sees the :class:`RequestContext`. The
        idempotency middleware reads the composite form to scope
        the replay cache.

        **Do not read this for identity decisions** — by the time a
        handler observes the field it's a cache key, not a
        principal label. Use ``auth_principal`` for "who's calling?"
        and treat ``caller_identity`` as opaque (log / forward only;
        don't parse, compare, or rewrite). The composite format is
        framework-internal and any adopter assumption about its
        shape will break when the scope-key composition changes.

    ``tenant_id`` — "which transport tenant?"
        Inherited from :class:`ToolContext`; set by the transport
        layer before dispatch (typically from the host header or URL
        path on multi-tenant deployments). Usually equals
        ``account.id`` for ``'explicit'``-resolution adopters; can
        diverge for ``'derived'`` / ``'implicit'`` modes.

    Common patterns:

    * Routing to the right adapter? → ``ctx.account.metadata.adapter``
      (typed via the ``TMeta`` generic).
    * Authorization check? → ``ctx.auth_principal`` (who's calling)
      against ``ctx.account.id`` (whose data they're touching).
    * Idempotency scope? → don't touch; the framework owns this.
    * Logging request provenance? → log all four; they're cheap.

    :param transport: The wire protocol that dispatched this call —
        ``"mcp"`` or ``"a2a"``. ``None`` when ``RequestContext`` is
        constructed in tests without a transport-aware ``ToolContext``,
        or when a custom ``context_factory`` omits
        ``metadata["transport"]``. Production dispatch always populates
        this field. Note: even when the server is started with
        ``transport="both"``, individual requests always resolve to
        exactly one of ``"mcp"`` or ``"a2a"`` — this field never
        carries ``"both"``. For code running outside a handler call
        stack, read :data:`adcp.server.current_transport` instead.
    :param state: Sync reads of framework-owned in-flight workflow
        state. Default is :class:`adcp.decisioning.state._NotYetWiredStateReader`
        — returns empty values + emits one-time UserWarning per
        method on first call. v6.1 wires the backing store.
    :param resolve: Async framework-mediated fetches with cache +
        validation. Default is
        :class:`adcp.decisioning.resolve._NotYetWiredResolver` — raises
        ``NotImplementedError`` on every call. v6.1 wires the backing
        fetchers.
    :param auth_principal: Typed convenience field carrying the
        verified principal label. Sourced from
        :class:`AuthInfo.principal` on signed-request flows and from
        the :data:`adcp.server.auth.current_principal` ContextVar
        on bearer-token flows (the framework's
        :class:`BearerTokenAuthMiddleware` populates the
        ContextVar; the dispatch helper reads it when ``auth_info``
        is absent). The right read for "who's calling?" — distinct
        from ``caller_identity``, which the framework mutates into
        a composite cache scope key for idempotency.
    """

    # Default factories so ``RequestContext()`` works in tests; in
    # production the dispatch adapter populates every field.
    account: Account[TMeta] = field(default_factory=lambda: Account(id="<unset>"))
    auth_info: AuthInfo | None = None
    auth_principal: str | None = None
    buyer_agent: BuyerAgent | None = None
    transport: Literal["mcp", "a2a"] | None = None
    now: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
    state: StateReader = field(default_factory=_make_default_state_reader)
    resolve: ResourceResolver = field(default_factory=_make_default_resolver)
    # ``recipes`` — populated by the framework on dispatch paths that
    # hydrate a proposal (post-finalize ``create_media_buy`` /
    # ``update_media_buy`` / ``get_media_buy_delivery``). Empty mapping
    # by default so legacy / non-proposal flows see the v1 shape. See
    # ``docs/proposals/proposal-manager-v15-design.md`` § D3.
    recipes: Mapping[str, Recipe] = field(default_factory=dict)

    def handoff_to_task(
        self,
        fn: Callable[[Any], Awaitable[T] | T],
    ) -> TaskHandoff[T]:
        """Promote this call to a background task.

        The buyer sees ``{status: 'submitted', task_id}`` on the
        immediate response; the framework runs ``fn`` after returning,
        persists ``fn``'s terminal artifact to the task registry, and
        emits a push-notification webhook on terminal state.

        ``fn`` receives a ``TaskHandoffContext`` (defined in
        :mod:`adcp.decisioning.dispatch`) carrying:

        * ``id`` — framework-issued task UUID
        * ``update(progress)`` — write progress payload, transition
          ``'submitted'`` → ``'working'``
        * ``heartbeat()`` — liveness signal (v6.1 stub)

        Adopter code passes either a coroutine function (``async def
        review_async(task_ctx): ...``) or a sync callable; the
        dispatcher detects which and runs it appropriately.

        For external workflows that complete on their own schedule
        (human queue review, batch jobs, Airflow DAGs, ML pipelines)
        — use :meth:`handoff_to_workflow` instead. The split is purely
        about where the work runs (in-process / framework-managed vs.
        adopter-owned external system).
        """
        return TaskHandoff(fn)

    def handoff_to_workflow(
        self,
        fn: Callable[[Any], Awaitable[None] | None],
    ) -> WorkflowHandoff:
        """Promote this call to an externally-completed task.

        For workflows that run OUTSIDE the framework's process —
        human queue review (trafficker UI), nightly batch jobs,
        Airflow DAGs, ML pipelines, scheduled cron. The framework
        allocates a ``task_id``, calls ``fn`` ONCE synchronously
        (or awaits it if a coroutine) to register the work into the
        adopter's external system, persists ``submitted`` state, and
        returns the wire envelope. NO background coroutine runs in
        the framework.

        ``fn`` receives a :class:`TaskHandoffContext` carrying
        ``id`` (framework-allocated task_id) and ``_registry``
        (adopter can stash a reference for later completion). The
        adopter's external workflow later calls
        ``registry.complete(task_id, result)`` or
        ``registry.fail(task_id, error)`` directly when the work
        finishes — minutes, hours, or days later.

        Buyer experience is identical to :meth:`handoff_to_task` —
        same ``{task_id, status: 'submitted'}`` wire envelope, same
        ``tasks/get`` polling, same push-notification webhook on
        terminal state.

        **Rollback.** If ``fn`` raises during enqueue, the framework
        discards the just-allocated task_id from the registry and
        propagates the exception (wrapped to ``AdcpError`` per the
        dispatch contract). Adopter enqueue fns that need
        transactional persistence wrap their own DB write in their
        own transaction; the framework's rollback is registry-side
        only.

        Example::

            class TraffickerSeller(DecisioningPlatform):
                def __init__(self, review_queue, task_registry):
                    self.review_queue = review_queue
                    # Stash for later completion when human acts
                    self.task_registry = task_registry

                def create_media_buy(self, req, ctx):
                    if self._needs_human_approval(req):
                        return ctx.handoff_to_workflow(
                            lambda task_ctx: self._enqueue(task_ctx, req)
                        )
                    return CreateMediaBuySuccess(media_buy_id="mb_1", ...)

                def _enqueue(self, task_ctx, req):
                    self.review_queue.add(
                        task_id=task_ctx.id,
                        request_snapshot=req.model_dump(),
                    )

                # Elsewhere — Flask handler for the trafficker UI:
                async def on_decision(self, task_id, decision):
                    if decision.approved:
                        await self.task_registry.complete(
                            task_id,
                            CreateMediaBuySuccess(...).model_dump(),
                        )
                    else:
                        await self.task_registry.fail(
                            task_id, AdcpError(...).to_wire(),
                        )

        See :class:`adcp.decisioning.WorkflowHandoff` for the full
        semantics.
        """
        return WorkflowHandoff(fn)

Per-request context passed to every Protocol method.

Subclasses :class:ToolContext so the existing framework primitives (idempotency middleware, observability, A2A executor) consume it as a ToolContext while adopter Protocol methods read the typed :attr:account directly.

Framework-only construction. Adopter code receives a RequestContext from the framework on every dispatch via the hydration helper in adcp.decisioning.dispatch. Direct construction is supported for tests only — production code that builds a RequestContext from outside the dispatch seam is a bug. Adopters who need to modify the context (custom middleware, test doubles for adcp.decisioning.state / adcp.decisioning.resolve) should use :func:dataclasses.replace, not raw construction. Mirrors the TS-side to-context.ts:buildRequestContext contract.

:param account: The resolved account, with typed metadata: TMeta. The framework's idempotency middleware reads ctx.caller_identity for cache scoping; the dispatch adapter sets caller_identity = account.id so caching scopes per resolved account, not per raw auth principal. :param auth_info: Optional verified principal info. On bearer-token flows the dispatch helper synthesizes AuthInfo(kind="bearer", principal=...) from the :data:adcp.server.auth.current_principal ContextVar so adopters can branch on ctx.auth_info.kind == "bearer" (the typed flow discriminator) without reaching into framework-private state. None when the request is unauthenticated (dev / 'derived' fixtures). :param now: Monotonic timestamp for the request — adopters use this rather than datetime.now() directly so tests can inject deterministic clocks.

Adopters call :meth:handoff_to_task to promote a method to the HITL background-task path. The framework dispatcher detects the returned :class:TaskHandoff via type-identity and projects it to the wire Submitted envelope.

Identifier disambiguation — when to use which:

The context carries four identifier-shaped fields. Each has a distinct role; mixing them up is the most common adopter bug.

account.id — "whose data is this?" The resolved tenant / account that owns the call. Read it to route the request to the right adapter instance, scope your DB queries, and stamp audit logs.

auth_principal — "who's calling?" The verified caller's identity label and the typed read for adopter authorization checks. Populated from two sources depending on the auth flow:

* **Signed-request flows** — sourced from
  :class:<code><a title="adcp.decisioning.AuthInfo.principal" href="#adcp.decisioning.AuthInfo.principal">AuthInfo.principal</a></code> (<code>agent\_url</code> for AdCP v3
  signed-request agents per spec).
* **Bearer-token flows** — sourced from the
  :data:<code>adcp.server.auth.current\_principal</code> ContextVar that
  :class:<code>BearerTokenAuthMiddleware</code> populates
  (<code>Principal.caller\_identity</code> from the validator). The
  dispatch helper also synthesizes
  ``AuthInfo(kind="bearer", principal=...)`` so adopters can
  discriminate the flow via ``ctx.auth_info.kind == "bearer"``.

Read it for per-principal ACLs *within* an account ("can
principal X mutate this buy?"). <code>None</code> for unauthenticated
dev fixtures.

caller_identity — "what's the cache scope key?" Starts as the bare principal at the transport layer (:class:ToolContext.caller_identity), then the framework dispatch helper mutates it into the composite scope key (<store_module>.<store_qualname>:<account_id>) before the handler sees the :class:RequestContext. The idempotency middleware reads the composite form to scope the replay cache.

**Do not read this for identity decisions** — by the time a
handler observes the field it's a cache key, not a
principal label. Use <code>auth\_principal</code> for "who's calling?"
and treat <code>caller\_identity</code> as opaque (log / forward only;
don't parse, compare, or rewrite). The composite format is
framework-internal and any adopter assumption about its
shape will break when the scope-key composition changes.

tenant_id — "which transport tenant?" Inherited from :class:ToolContext; set by the transport layer before dispatch (typically from the host header or URL path on multi-tenant deployments). Usually equals account.id for 'explicit'-resolution adopters; can diverge for 'derived' / 'implicit' modes.

Common patterns:

  • Routing to the right adapter? → ctx.account.metadata.adapter (typed via the TMeta generic).
  • Authorization check? → ctx.auth_principal (who's calling) against ctx.account.id (whose data they're touching).
  • Idempotency scope? → don't touch; the framework owns this.
  • Logging request provenance? → log all four; they're cheap.

:param transport: The wire protocol that dispatched this call — "mcp" or "a2a". None when RequestContext is constructed in tests without a transport-aware ToolContext, or when a custom context_factory omits metadata["transport"]. Production dispatch always populates this field. Note: even when the server is started with transport="both", individual requests always resolve to exactly one of "mcp" or "a2a" — this field never carries "both". For code running outside a handler call stack, read :data:adcp.server.current_transport instead. :param state: Sync reads of framework-owned in-flight workflow state. Default is :class:adcp.decisioning.state._NotYetWiredStateReader — returns empty values + emits one-time UserWarning per method on first call. v6.1 wires the backing store. :param resolve: Async framework-mediated fetches with cache + validation. Default is :class:adcp.decisioning.resolve._NotYetWiredResolver — raises NotImplementedError on every call. v6.1 wires the backing fetchers. :param auth_principal: Typed convenience field carrying the verified principal label. Sourced from :class:AuthInfo.principal on signed-request flows and from the :data:adcp.server.auth.current_principal ContextVar on bearer-token flows (the framework's :class:BearerTokenAuthMiddleware populates the ContextVar; the dispatch helper reads it when auth_info is absent). The right read for "who's calling?" — distinct from caller_identity, which the framework mutates into a composite cache scope key for idempotency.

Ancestors

Instance variables

var accountAccount[TMeta]
var auth_infoAuthInfo | None
var auth_principal : str | None
var buyer_agentBuyerAgent | None
var now : datetime
var recipes : Mapping[str, Recipe]
var resolveResourceResolver
var stateStateReader
var transport : Literal['mcp', 'a2a'] | None

Methods

def handoff_to_task(self, fn: Callable[[Any], Awaitable[T] | T]) ‑> TaskHandoff[~T]
Expand source code
def handoff_to_task(
    self,
    fn: Callable[[Any], Awaitable[T] | T],
) -> TaskHandoff[T]:
    """Promote this call to a background task.

    The buyer sees ``{status: 'submitted', task_id}`` on the
    immediate response; the framework runs ``fn`` after returning,
    persists ``fn``'s terminal artifact to the task registry, and
    emits a push-notification webhook on terminal state.

    ``fn`` receives a ``TaskHandoffContext`` (defined in
    :mod:`adcp.decisioning.dispatch`) carrying:

    * ``id`` — framework-issued task UUID
    * ``update(progress)`` — write progress payload, transition
      ``'submitted'`` → ``'working'``
    * ``heartbeat()`` — liveness signal (v6.1 stub)

    Adopter code passes either a coroutine function (``async def
    review_async(task_ctx): ...``) or a sync callable; the
    dispatcher detects which and runs it appropriately.

    For external workflows that complete on their own schedule
    (human queue review, batch jobs, Airflow DAGs, ML pipelines)
    — use :meth:`handoff_to_workflow` instead. The split is purely
    about where the work runs (in-process / framework-managed vs.
    adopter-owned external system).
    """
    return TaskHandoff(fn)

Promote this call to a background task.

The buyer sees {status: 'submitted', task_id} on the immediate response; the framework runs fn after returning, persists fn's terminal artifact to the task registry, and emits a push-notification webhook on terminal state.

fn receives a TaskHandoffContext (defined in :mod:adcp.decisioning.dispatch) carrying:

  • id — framework-issued task UUID
  • update(progress) — write progress payload, transition 'submitted''working'
  • heartbeat() — liveness signal (v6.1 stub)

Adopter code passes either a coroutine function (async def review_async(task_ctx): ...) or a sync callable; the dispatcher detects which and runs it appropriately.

For external workflows that complete on their own schedule (human queue review, batch jobs, Airflow DAGs, ML pipelines) — use :meth:handoff_to_workflow instead. The split is purely about where the work runs (in-process / framework-managed vs. adopter-owned external system).

def handoff_to_workflow(self, fn: Callable[[Any], Awaitable[None] | None]) ‑> WorkflowHandoff
Expand source code
def handoff_to_workflow(
    self,
    fn: Callable[[Any], Awaitable[None] | None],
) -> WorkflowHandoff:
    """Promote this call to an externally-completed task.

    For workflows that run OUTSIDE the framework's process —
    human queue review (trafficker UI), nightly batch jobs,
    Airflow DAGs, ML pipelines, scheduled cron. The framework
    allocates a ``task_id``, calls ``fn`` ONCE synchronously
    (or awaits it if a coroutine) to register the work into the
    adopter's external system, persists ``submitted`` state, and
    returns the wire envelope. NO background coroutine runs in
    the framework.

    ``fn`` receives a :class:`TaskHandoffContext` carrying
    ``id`` (framework-allocated task_id) and ``_registry``
    (adopter can stash a reference for later completion). The
    adopter's external workflow later calls
    ``registry.complete(task_id, result)`` or
    ``registry.fail(task_id, error)`` directly when the work
    finishes — minutes, hours, or days later.

    Buyer experience is identical to :meth:`handoff_to_task` —
    same ``{task_id, status: 'submitted'}`` wire envelope, same
    ``tasks/get`` polling, same push-notification webhook on
    terminal state.

    **Rollback.** If ``fn`` raises during enqueue, the framework
    discards the just-allocated task_id from the registry and
    propagates the exception (wrapped to ``AdcpError`` per the
    dispatch contract). Adopter enqueue fns that need
    transactional persistence wrap their own DB write in their
    own transaction; the framework's rollback is registry-side
    only.

    Example::

        class TraffickerSeller(DecisioningPlatform):
            def __init__(self, review_queue, task_registry):
                self.review_queue = review_queue
                # Stash for later completion when human acts
                self.task_registry = task_registry

            def create_media_buy(self, req, ctx):
                if self._needs_human_approval(req):
                    return ctx.handoff_to_workflow(
                        lambda task_ctx: self._enqueue(task_ctx, req)
                    )
                return CreateMediaBuySuccess(media_buy_id="mb_1", ...)

            def _enqueue(self, task_ctx, req):
                self.review_queue.add(
                    task_id=task_ctx.id,
                    request_snapshot=req.model_dump(),
                )

            # Elsewhere — Flask handler for the trafficker UI:
            async def on_decision(self, task_id, decision):
                if decision.approved:
                    await self.task_registry.complete(
                        task_id,
                        CreateMediaBuySuccess(...).model_dump(),
                    )
                else:
                    await self.task_registry.fail(
                        task_id, AdcpError(...).to_wire(),
                    )

    See :class:`adcp.decisioning.WorkflowHandoff` for the full
    semantics.
    """
    return WorkflowHandoff(fn)

Promote this call to an externally-completed task.

For workflows that run OUTSIDE the framework's process — human queue review (trafficker UI), nightly batch jobs, Airflow DAGs, ML pipelines, scheduled cron. The framework allocates a task_id, calls fn ONCE synchronously (or awaits it if a coroutine) to register the work into the adopter's external system, persists submitted state, and returns the wire envelope. NO background coroutine runs in the framework.

fn receives a :class:TaskHandoffContext carrying id (framework-allocated task_id) and _registry (adopter can stash a reference for later completion). The adopter's external workflow later calls registry.complete(task_id, result) or registry.fail(task_id, error) directly when the work finishes — minutes, hours, or days later.

Buyer experience is identical to :meth:handoff_to_task — same {task_id, status: 'submitted'} wire envelope, same tasks/get polling, same push-notification webhook on terminal state.

Rollback. If fn raises during enqueue, the framework discards the just-allocated task_id from the registry and propagates the exception (wrapped to AdcpError per the dispatch contract). Adopter enqueue fns that need transactional persistence wrap their own DB write in their own transaction; the framework's rollback is registry-side only.

Example::

class TraffickerSeller(DecisioningPlatform):
    def __init__(self, review_queue, task_registry):
        self.review_queue = review_queue
        # Stash for later completion when human acts
        self.task_registry = task_registry

    def create_media_buy(self, req, ctx):
        if self._needs_human_approval(req):
            return ctx.handoff_to_workflow(
                lambda task_ctx: self._enqueue(task_ctx, req)
            )
        return CreateMediaBuySuccess(media_buy_id="mb_1", ...)

    def _enqueue(self, task_ctx, req):
        self.review_queue.add(
            task_id=task_ctx.id,
            request_snapshot=req.model_dump(),
        )

    # Elsewhere — Flask handler for the trafficker UI:
    async def on_decision(self, task_id, decision):
        if decision.approved:
            await self.task_registry.complete(
                task_id,
                CreateMediaBuySuccess(...).model_dump(),
            )
        else:
            await self.task_registry.fail(
                task_id, AdcpError(...).to_wire(),
            )

See :class:WorkflowHandoff for the full semantics.

class ResolveContext (auth_info: AuthInfo | None = None,
tool_name: str | None = None,
agent: BuyerAgent | None = None,
extra: dict[str, Any] = <factory>)
Expand source code
@dataclass
class ResolveContext:
    """Per-request context threaded into :class:`AccountStore` methods
    that need the caller's principal but don't get a full
    :class:`RequestContext` (because the resolved account isn't yet
    available, or the surface operates on multiple accounts at once).

    Mirrors the JS-side ``ResolveContext`` shape: ``auth_info``,
    ``tool_name``, ``agent``. Adopters read these to implement
    principal-keyed gates on ``sync_accounts`` / ``list_accounts`` /
    ``sync_governance`` (e.g., the spec's
    ``BILLING_NOT_PERMITTED_FOR_AGENT`` per-buyer-agent gate from
    adcontextprotocol/adcp#3851) without re-deriving identity from
    the request.

    **Prefer ``agent`` over ``auth_info`` for commercial-relationship
    decisions.** ``agent`` is the registry-resolved durable identity
    (status, billing capabilities, default account terms);
    ``auth_info`` is the raw transport-level credential. For billing
    gates the registry-resolved identity is canonical.

    :param auth_info: Verified principal info. ``None`` for
        unauthenticated requests (dev / ``'derived'`` fixtures).
    :param tool_name: Wire verb that triggered the call
        (e.g. ``'sync_accounts'``, ``'list_accounts'``,
        ``'sync_governance'``). Adopters use it for audit logs; the
        framework doesn't dispatch on it.
    :param agent: Resolved :class:`BuyerAgent` when a
        :class:`BuyerAgentRegistry` is wired. ``None`` otherwise.
    """

    auth_info: AuthInfo | None = None
    tool_name: str | None = None
    agent: BuyerAgent | None = None
    #: Adopter passthrough for additional context the framework
    #: doesn't model. Reserved for forward compatibility.
    extra: dict[str, Any] = field(default_factory=dict)

Per-request context threaded into :class:AccountStore methods that need the caller's principal but don't get a full :class:RequestContext (because the resolved account isn't yet available, or the surface operates on multiple accounts at once).

Mirrors the JS-side ResolveContext shape: auth_info, tool_name, agent. Adopters read these to implement principal-keyed gates on sync_accounts / list_accounts / sync_governance (e.g., the spec's BILLING_NOT_PERMITTED_FOR_AGENT per-buyer-agent gate from adcontextprotocol/adcp#3851) without re-deriving identity from the request.

Prefer agent over auth_info for commercial-relationship decisions. agent is the registry-resolved durable identity (status, billing capabilities, default account terms); auth_info is the raw transport-level credential. For billing gates the registry-resolved identity is canonical.

:param auth_info: Verified principal info. None for unauthenticated requests (dev / 'derived' fixtures). :param tool_name: Wire verb that triggered the call (e.g. 'sync_accounts', 'list_accounts', 'sync_governance'). Adopters use it for audit logs; the framework doesn't dispatch on it. :param agent: Resolved :class:BuyerAgent when a :class:BuyerAgentRegistry is wired. None otherwise.

Instance variables

var agentBuyerAgent | None
var auth_infoAuthInfo | None
var extra : dict[str, Any]

Adopter passthrough for additional context the framework doesn't model. Reserved for forward compatibility.

var tool_name : str | None
class ResourceResolver (*args, **kwargs)
Expand source code
@runtime_checkable
class ResourceResolver(Protocol):
    """Async fetches of framework-mediated resources.

    Platforms call ``ctx.resolve.property_list(list_id)`` instead of
    fetching from their own DB; the framework returns a validated
    typed result. The resolver routes through
    ``capabilities.creative_agents`` for creative-format reads, hits
    the framework's local ``CreativePlatform.list_formats`` for
    self-hosted formats, and reads the seller's declared property /
    collection lists with id-validation built in.

    Framework-supplied; never constructed by adopter code. The
    ``RequestContext.resolve`` field is populated by the dispatch
    hydration helper. Adopters substituting test doubles use
    :func:`dataclasses.replace` on the context, not direct
    construction.

    Mirrors the TS-side ``ResourceResolver`` interface in
    ``src/lib/server/decisioning/context.ts``. v6.0 ships the contract
    + the no-op stub (raises ``NotImplementedError`` on every call);
    v6.1 lands the backing fetchers.

    .. note::
       :class:`runtime_checkable` Protocols only check attribute
       *presence*. Whether a method is ``async def`` is irrelevant to
       the runtime ``isinstance`` check — a sync method named
       ``property_list`` would pass the structural check but fail at
       ``await`` time. Use mypy to enforce ``async def`` signatures
       across adopter impls.
    """

    async def property_list(self, list_id: str) -> PropertyList:
        """Fetch a property list by id. Framework validates the id
        exists in the seller's declared lists before returning;
        consumers can trust the result."""
        ...

    async def collection_list(self, list_id: str) -> CollectionList:
        """Fetch a collection list by id. Same id-validation
        guarantee as :meth:`property_list`."""
        ...

    async def creative_format(
        self,
        format_id: FormatReferenceStructuredObject,
        *,
        revalidate: bool = False,
    ) -> Format:
        """Fetch a creative format definition.

        Routes through ``capabilities.creative_agents`` declaration
        with a framework-managed cache; self-hosted formats hit the
        local ``CreativePlatform.list_formats``. Returns the resolved
        :class:`Format` with full asset slot definitions.

        :param revalidate: When ``True``, bypasses the framework cache
            and re-fetches from the upstream creative-agent. Adopters
            with freshness needs (e.g., creative submission validating
            against the latest format spec) pass ``revalidate=True``;
            most reads use the default (``False``) to amortize the
            agent round-trip.

        Cache TTL is implementation detail (defaults to 1h on the
        reference impl); adopters who need stricter freshness use
        ``revalidate=True`` rather than depending on the TTL value.
        """
        ...

Async fetches of framework-mediated resources.

Platforms call ctx.resolve.property_list(list_id) instead of fetching from their own DB; the framework returns a validated typed result. The resolver routes through capabilities.creative_agents for creative-format reads, hits the framework's local CreativePlatform.list_formats for self-hosted formats, and reads the seller's declared property / collection lists with id-validation built in.

Framework-supplied; never constructed by adopter code. The RequestContext.resolve field is populated by the dispatch hydration helper. Adopters substituting test doubles use :func:dataclasses.replace on the context, not direct construction.

Mirrors the TS-side ResourceResolver interface in src/lib/server/decisioning/context.ts. v6.0 ships the contract + the no-op stub (raises NotImplementedError on every call); v6.1 lands the backing fetchers.

Note

:class:runtime_checkable Protocols only check attribute presence. Whether a method is async def is irrelevant to the runtime isinstance check — a sync method named adcp.decisioning.property_list would pass the structural check but fail at await time. Use mypy to enforce async def signatures across adopter impls.

Ancestors

  • typing.Protocol
  • typing.Generic

Methods

async def collection_list(self, list_id: str) ‑> adcp.types.generated_poc.collection.collection_list.CollectionList
Expand source code
async def collection_list(self, list_id: str) -> CollectionList:
    """Fetch a collection list by id. Same id-validation
    guarantee as :meth:`property_list`."""
    ...

Fetch a collection list by id. Same id-validation guarantee as :meth:adcp.decisioning.property_list.

async def creative_format(self,
format_id: FormatReferenceStructuredObject,
*,
revalidate: bool = False) ‑> adcp.types.generated_poc.core.format.Format
Expand source code
async def creative_format(
    self,
    format_id: FormatReferenceStructuredObject,
    *,
    revalidate: bool = False,
) -> Format:
    """Fetch a creative format definition.

    Routes through ``capabilities.creative_agents`` declaration
    with a framework-managed cache; self-hosted formats hit the
    local ``CreativePlatform.list_formats``. Returns the resolved
    :class:`Format` with full asset slot definitions.

    :param revalidate: When ``True``, bypasses the framework cache
        and re-fetches from the upstream creative-agent. Adopters
        with freshness needs (e.g., creative submission validating
        against the latest format spec) pass ``revalidate=True``;
        most reads use the default (``False``) to amortize the
        agent round-trip.

    Cache TTL is implementation detail (defaults to 1h on the
    reference impl); adopters who need stricter freshness use
    ``revalidate=True`` rather than depending on the TTL value.
    """
    ...

Fetch a creative format definition.

Routes through capabilities.creative_agents declaration with a framework-managed cache; self-hosted formats hit the local CreativePlatform.list_formats. Returns the resolved :class:Format with full asset slot definitions.

:param revalidate: When True, bypasses the framework cache and re-fetches from the upstream creative-agent. Adopters with freshness needs (e.g., creative submission validating against the latest format spec) pass revalidate=True; most reads use the default (False) to amortize the agent round-trip.

Cache TTL is implementation detail (defaults to 1h on the reference impl); adopters who need stricter freshness use revalidate=True rather than depending on the TTL value.

async def property_list(self, list_id: str) ‑> adcp.types.generated_poc.core.property_list_ref.PropertyListReference
Expand source code
async def property_list(self, list_id: str) -> PropertyList:
    """Fetch a property list by id. Framework validates the id
    exists in the seller's declared lists before returning;
    consumers can trust the result."""
    ...

Fetch a property list by id. Framework validates the id exists in the seller's declared lists before returning; consumers can trust the result.

class SalesPlatform (*args, **kwargs)
Expand source code
@runtime_checkable
class SalesPlatform(Protocol, Generic[TMeta]):
    """Unified hybrid interface for every ``sales-*`` specialism.

    Methods may be sync (return ``T`` directly) or async (return
    ``Awaitable[T]``); the dispatch adapter detects via
    :func:`inspect.iscoroutinefunction` and runs sync methods on a
    thread pool via :func:`asyncio.to_thread` so a blocking sync
    handler doesn't serialize the event loop.

    Hybrid sellers (programmatic remnant + guaranteed inventory in
    one tenant) branch per call: return the Success directly for the
    sync fast path, return ``ctx.handoff_to_task(fn)`` for the HITL
    slow path. The framework dispatcher detects the
    :class:`TaskHandoff` via type-identity and projects to the wire
    ``Submitted`` envelope.

    Throw :class:`adcp.decisioning.AdcpError` for buyer-fixable
    rejection (``BUDGET_TOO_LOW``, ``POLICY_VIOLATION``, etc.); the
    framework projects to the wire structured-error envelope with
    code, recovery, field, suggestion, retry_after, details.
    """

    # ---- Required for every sales-* specialism ----

    def get_products(
        self,
        req: GetProductsRequest,
        ctx: RequestContext[TMeta],
    ) -> DiscoveryResult[GetProductsResponse]:
        """Catalog discovery — synchronous by default, MAY hand off.

        Return :class:`GetProductsResponse` directly for the sync fast
        path. Brief / refine discovery MAY hand off via
        ``ctx.handoff_to_task(fn)`` when composing the catalog needs
        background work (custom curation queue, slow proposal
        generation); the framework projects the handoff to the wire
        ``submitted`` envelope. The buyer reaches the terminal
        :class:`GetProductsResponse` via ``tasks/get`` polling, and — when
        the request carried ``push_notification_config`` — the framework
        also delivers the terminal completion / failure webhook to the
        buyer's URL from the background completion path (exactly once).
        ``get_products`` exposes the ``submitted`` / ``working`` /
        ``input_required`` async arms.

        Wholesale (``buying_mode='wholesale'``) MUST return synchronously
        — it is a raw rate-card read with no seller-side composition to
        background. A wholesale call that cannot finish within the
        buyer's ``time_budget`` declares the gap via ``incomplete[]`` on
        a sync response rather than handing off; returning a handoff from
        a wholesale call is rejected at the framework layer with
        ``AdcpError(INVALID_REQUEST, field='buying_mode')``.

        Brief-based proposal generation rides on a separate verb
        (``request_proposal``, adcp#3407); proposal-mode adopters
        surface the eventual products via
        ``ctx.publish_status_change(resource_type='proposal', ...)``
        rather than blocking ``get_products`` waiting for trafficker
        approval.

        **Buying mode dispatch:** when ``req.buying_mode == 'refine'`` and
        the platform implements :meth:`refine_get_products`, the framework
        dispatches there instead of this method.  Platforms that do not
        implement ``refine_get_products`` reject ``buying_mode='refine'``
        with ``AdcpError(INVALID_REQUEST, field='buying_mode')`` at the
        framework layer — the platform method is not called.
        """
        ...

    # Truly optional — adopters who don't implement refine_get_products
    # remain structurally conformant.  The framework uses
    # :func:`adcp.decisioning.has_refine_support` (a ``hasattr`` check) at
    # dispatch time and rejects ``buying_mode='refine'`` with
    # ``AdcpError(INVALID_REQUEST, field='buying_mode')`` when absent.
    #
    # Implementations match this signature::
    #
    #     def refine_get_products(
    #         self,
    #         req: GetProductsRequest,
    #         ctx: RequestContext[TMeta],
    #     ) -> MaybeAsync[RefineResult]: ...
    #
    # Return a :class:`adcp.decisioning.RefineResult` with ``products``,
    # ``proposals``, and exactly ``len(req.refine)`` outcomes in
    # ``per_refine_outcome``.  The framework constructs the wire
    # ``refinement_applied[]`` by zipping outcomes with ``req.refine`` —
    # adopters do NOT echo ``scope`` / ``product_id`` / ``proposal_id``
    # manually.

    def create_media_buy(
        self,
        req: CreateMediaBuyRequest,
        ctx: RequestContext[TMeta],
    ) -> SalesResult[CreateMediaBuySuccessResponse]:
        """Unified hybrid. Return :class:`CreateMediaBuySuccessResponse` directly
        for sync fast path; return :meth:`RequestContext.handoff_to_task`
        for HITL slow path.

        Pre-flight runs sync regardless of path so bad budgets reject
        before allocating a task id — call ``preflight()`` at the top,
        ``raise AdcpError(...)`` on rejection.

        Buyer pattern-matches on the response shape:

        * ``media_buy_id`` field present → sync success
        * ``task_id`` + ``status='submitted'`` → poll ``tasks_get`` or
          receive webhook

        **Framework injection:** when a :class:`~adcp.decisioning.ProductConfigStore`
        is wired via ``config_store=`` in
        :func:`adcp.decisioning.serve.create_adcp_server_from_platform`,
        the framework calls the store before invoking this method and
        injects the result as ``configs: dict[str, dict[str, Any]]``.
        Declare ``configs`` in your method signature to receive it::

            def create_media_buy(self, req, ctx, configs=None):
                configs = configs or {}
                line_item_id = configs.get(pkg.product_id, {}).get("line_item_id")

        When the store is not wired, or when ``req.packages`` is
        ``None`` (proposal-id flow), ``configs`` arrives as an empty
        dict ``{}``.
        """
        ...

    def update_media_buy(
        self,
        media_buy_id: str,
        patch: UpdateMediaBuyRequest,
        ctx: RequestContext[TMeta],
    ) -> MaybeAsync[UpdateMediaBuySuccessResponse]:
        """Mutate an in-flight media buy.

        v6.0 returns sync only — the per-tool response schema doesn't
        carry the ``Submitted`` arm yet (adcp#3392). Re-approval flows
        return the success with the ``status`` field omitted (in-spec
        per the schema description) and drive lifecycle via
        ``ctx.publish_status_change``. v6.1 + adcp#3392 expand this
        signature to :data:`SalesResult` so re-approval flows can
        hand off cleanly.
        """
        ...

    def sync_creatives(
        self,
        req: SyncCreativesRequest,
        ctx: RequestContext[TMeta],
    ) -> SalesResult[SyncCreativesSuccessResponse]:
        """Unified hybrid for creative review.

        Mixed approved/pending rows in a single sync response, OR
        hand off the whole batch to background standards-and-practices
        review. Adopters with pre-approved buyer pools fast-path; new
        buyers' creatives go to review.
        """
        ...

    def get_media_buy_delivery(
        self,
        req: GetMediaBuyDeliveryRequest,
        ctx: RequestContext[TMeta],
    ) -> MaybeAsync[GetMediaBuyDeliveryResponse]:
        """Sync delivery read — pacing, spend, impressions per package."""
        ...

    # ---- Optional (gated by specialism — present-or-absent) ----

    def get_media_buys(
        self,
        req: GetMediaBuysRequest,
        ctx: RequestContext[TMeta],
    ) -> MaybeAsync[GetMediaBuysResponse]:
        """List media buys for the resolved account.

        Required when claiming any ``sales-*`` specialism in v6.0 rc.1+.
        ``validate_platform`` fails server boot if a sales-claiming
        platform doesn't implement this.
        """
        ...

    def provide_performance_feedback(
        self,
        req: ProvidePerformanceFeedbackRequest,
        ctx: RequestContext[TMeta],
    ) -> MaybeAsync[ProvidePerformanceFeedbackResponse]:
        """Buyer-supplied performance signal back to the seller.

        Required when claiming any ``sales-*`` specialism in v6.0 rc.1+.
        """
        ...

    def list_creative_formats(
        self,
        req: ListCreativeFormatsRequest,
        ctx: RequestContext[TMeta],
    ) -> MaybeAsync[ListCreativeFormatsResponse]:
        """Catalog of accepted creative formats.

        Required when claiming any ``sales-*`` specialism in v6.0 rc.1+.
        """
        ...

    def list_creatives(
        self,
        req: ListCreativesRequest,
        ctx: RequestContext[TMeta],
    ) -> MaybeAsync[ListCreativesResponse]:
        """List the seller's view of buyer-uploaded creatives.

        Required when claiming any ``sales-*`` specialism in v6.0 rc.1+.
        """
        ...

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

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

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

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

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

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

        Return a list of :class:`~adcp.types.SyncCatalogResult` rows
        (ergonomic form) or a fully-shaped
        :class:`~adcp.types.SyncCatalogsSuccessResponse`.
        """
        raise NotImplementedError

Unified hybrid interface for every sales-* specialism.

Methods may be sync (return T directly) or async (return Awaitable[T]); the dispatch adapter detects via :func:inspect.iscoroutinefunction and runs sync methods on a thread pool via :func:asyncio.to_thread so a blocking sync handler doesn't serialize the event loop.

Hybrid sellers (programmatic remnant + guaranteed inventory in one tenant) branch per call: return the Success directly for the sync fast path, return ctx.handoff_to_task(fn) for the HITL slow path. The framework dispatcher detects the :class:TaskHandoff via type-identity and projects to the wire Submitted envelope.

Throw :class:AdcpError for buyer-fixable rejection (BUDGET_TOO_LOW, POLICY_VIOLATION, etc.); the framework projects to the wire structured-error envelope with code, recovery, field, suggestion, retry_after, details.

Ancestors

  • typing.Protocol
  • typing.Generic

Methods

def create_media_buy(self,
req: CreateMediaBuyRequest,
ctx: RequestContext[TMeta]) ‑> SalesResult[CreateMediaBuySuccessResponse]
Expand source code
def create_media_buy(
    self,
    req: CreateMediaBuyRequest,
    ctx: RequestContext[TMeta],
) -> SalesResult[CreateMediaBuySuccessResponse]:
    """Unified hybrid. Return :class:`CreateMediaBuySuccessResponse` directly
    for sync fast path; return :meth:`RequestContext.handoff_to_task`
    for HITL slow path.

    Pre-flight runs sync regardless of path so bad budgets reject
    before allocating a task id — call ``preflight()`` at the top,
    ``raise AdcpError(...)`` on rejection.

    Buyer pattern-matches on the response shape:

    * ``media_buy_id`` field present → sync success
    * ``task_id`` + ``status='submitted'`` → poll ``tasks_get`` or
      receive webhook

    **Framework injection:** when a :class:`~adcp.decisioning.ProductConfigStore`
    is wired via ``config_store=`` in
    :func:`adcp.decisioning.serve.create_adcp_server_from_platform`,
    the framework calls the store before invoking this method and
    injects the result as ``configs: dict[str, dict[str, Any]]``.
    Declare ``configs`` in your method signature to receive it::

        def create_media_buy(self, req, ctx, configs=None):
            configs = configs or {}
            line_item_id = configs.get(pkg.product_id, {}).get("line_item_id")

    When the store is not wired, or when ``req.packages`` is
    ``None`` (proposal-id flow), ``configs`` arrives as an empty
    dict ``{}``.
    """
    ...

Unified hybrid. Return :class:CreateMediaBuySuccessResponse directly for sync fast path; return :meth:RequestContext.handoff_to_task() for HITL slow path.

Pre-flight runs sync regardless of path so bad budgets reject before allocating a task id — call preflight() at the top, raise AdcpError(…) on rejection.

Buyer pattern-matches on the response shape:

  • media_buy_id field present → sync success
  • task_id + status='submitted' → poll tasks_get or receive webhook

Framework injection: when a :class:~adcp.decisioning.ProductConfigStore is wired via config_store= in :func:adcp.decisioning.serve.create_adcp_server_from_platform, the framework calls the store before invoking this method and injects the result as configs: dict[str, dict[str, Any]]. Declare configs in your method signature to receive it::

def create_media_buy(self, req, ctx, configs=None):
    configs = configs or {}
    line_item_id = configs.get(pkg.product_id, {}).get("line_item_id")

When the store is not wired, or when req.packages is None (proposal-id flow), configs arrives as an empty dict {}.

def get_media_buy_delivery(self,
req: GetMediaBuyDeliveryRequest,
ctx: RequestContext[TMeta]) ‑> MaybeAsync[GetMediaBuyDeliveryResponse]
Expand source code
def get_media_buy_delivery(
    self,
    req: GetMediaBuyDeliveryRequest,
    ctx: RequestContext[TMeta],
) -> MaybeAsync[GetMediaBuyDeliveryResponse]:
    """Sync delivery read — pacing, spend, impressions per package."""
    ...

Sync delivery read — pacing, spend, impressions per package.

def get_media_buys(self,
req: GetMediaBuysRequest,
ctx: RequestContext[TMeta]) ‑> MaybeAsync[GetMediaBuysResponse]
Expand source code
def get_media_buys(
    self,
    req: GetMediaBuysRequest,
    ctx: RequestContext[TMeta],
) -> MaybeAsync[GetMediaBuysResponse]:
    """List media buys for the resolved account.

    Required when claiming any ``sales-*`` specialism in v6.0 rc.1+.
    ``validate_platform`` fails server boot if a sales-claiming
    platform doesn't implement this.
    """
    ...

List media buys for the resolved account.

Required when claiming any sales-* specialism in v6.0 rc.1+. validate_platform() fails server boot if a sales-claiming platform doesn't implement this.

def get_products(self,
req: GetProductsRequest,
ctx: RequestContext[TMeta]) ‑> DiscoveryResult[GetProductsResponse]
Expand source code
def get_products(
    self,
    req: GetProductsRequest,
    ctx: RequestContext[TMeta],
) -> DiscoveryResult[GetProductsResponse]:
    """Catalog discovery — synchronous by default, MAY hand off.

    Return :class:`GetProductsResponse` directly for the sync fast
    path. Brief / refine discovery MAY hand off via
    ``ctx.handoff_to_task(fn)`` when composing the catalog needs
    background work (custom curation queue, slow proposal
    generation); the framework projects the handoff to the wire
    ``submitted`` envelope. The buyer reaches the terminal
    :class:`GetProductsResponse` via ``tasks/get`` polling, and — when
    the request carried ``push_notification_config`` — the framework
    also delivers the terminal completion / failure webhook to the
    buyer's URL from the background completion path (exactly once).
    ``get_products`` exposes the ``submitted`` / ``working`` /
    ``input_required`` async arms.

    Wholesale (``buying_mode='wholesale'``) MUST return synchronously
    — it is a raw rate-card read with no seller-side composition to
    background. A wholesale call that cannot finish within the
    buyer's ``time_budget`` declares the gap via ``incomplete[]`` on
    a sync response rather than handing off; returning a handoff from
    a wholesale call is rejected at the framework layer with
    ``AdcpError(INVALID_REQUEST, field='buying_mode')``.

    Brief-based proposal generation rides on a separate verb
    (``request_proposal``, adcp#3407); proposal-mode adopters
    surface the eventual products via
    ``ctx.publish_status_change(resource_type='proposal', ...)``
    rather than blocking ``get_products`` waiting for trafficker
    approval.

    **Buying mode dispatch:** when ``req.buying_mode == 'refine'`` and
    the platform implements :meth:`refine_get_products`, the framework
    dispatches there instead of this method.  Platforms that do not
    implement ``refine_get_products`` reject ``buying_mode='refine'``
    with ``AdcpError(INVALID_REQUEST, field='buying_mode')`` at the
    framework layer — the platform method is not called.
    """
    ...

Catalog discovery — synchronous by default, MAY hand off.

Return :class:GetProductsResponse directly for the sync fast path. Brief / refine discovery MAY hand off via ctx.handoff_to_task(fn) when composing the catalog needs background work (custom curation queue, slow proposal generation); the framework projects the handoff to the wire submitted envelope. The buyer reaches the terminal :class:GetProductsResponse via tasks/get polling, and — when the request carried push_notification_config — the framework also delivers the terminal completion / failure webhook to the buyer's URL from the background completion path (exactly once). get_products exposes the submitted / working / input_required async arms.

Wholesale (buying_mode='wholesale') MUST return synchronously — it is a raw rate-card read with no seller-side composition to background. A wholesale call that cannot finish within the buyer's adcp.decisioning.time_budget declares the gap via incomplete[] on a sync response rather than handing off; returning a handoff from a wholesale call is rejected at the framework layer with AdcpError(INVALID_REQUEST, field='buying_mode').

Brief-based proposal generation rides on a separate verb (request_proposal, adcp#3407); proposal-mode adopters surface the eventual products via ctx.publish_status_change(resource_type='proposal', ...) rather than blocking get_products waiting for trafficker approval.

Buying mode dispatch: when req.buying_mode == 'refine' and the platform implements :meth:refine_get_products, the framework dispatches there instead of this method. Platforms that do not implement refine_get_products reject buying_mode='refine' with AdcpError(INVALID_REQUEST, field='buying_mode') at the framework layer — the platform method is not called.

def list_creative_formats(self,
req: ListCreativeFormatsRequest,
ctx: RequestContext[TMeta]) ‑> MaybeAsync[ListCreativeFormatsResponse]
Expand source code
def list_creative_formats(
    self,
    req: ListCreativeFormatsRequest,
    ctx: RequestContext[TMeta],
) -> MaybeAsync[ListCreativeFormatsResponse]:
    """Catalog of accepted creative formats.

    Required when claiming any ``sales-*`` specialism in v6.0 rc.1+.
    """
    ...

Catalog of accepted creative formats.

Required when claiming any sales-* specialism in v6.0 rc.1+.

def list_creatives(self,
req: ListCreativesRequest,
ctx: RequestContext[TMeta]) ‑> MaybeAsync[ListCreativesResponse]
Expand source code
def list_creatives(
    self,
    req: ListCreativesRequest,
    ctx: RequestContext[TMeta],
) -> MaybeAsync[ListCreativesResponse]:
    """List the seller's view of buyer-uploaded creatives.

    Required when claiming any ``sales-*`` specialism in v6.0 rc.1+.
    """
    ...

List the seller's view of buyer-uploaded creatives.

Required when claiming any sales-* specialism in v6.0 rc.1+.

def provide_performance_feedback(self,
req: ProvidePerformanceFeedbackRequest,
ctx: RequestContext[TMeta]) ‑> MaybeAsync[ProvidePerformanceFeedbackResponse]
Expand source code
def provide_performance_feedback(
    self,
    req: ProvidePerformanceFeedbackRequest,
    ctx: RequestContext[TMeta],
) -> MaybeAsync[ProvidePerformanceFeedbackResponse]:
    """Buyer-supplied performance signal back to the seller.

    Required when claiming any ``sales-*`` specialism in v6.0 rc.1+.
    """
    ...

Buyer-supplied performance signal back to the seller.

Required when claiming any sales-* specialism in v6.0 rc.1+.

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

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

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

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

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

    Return a list of :class:`~adcp.types.SyncCatalogResult` rows
    (ergonomic form) or a fully-shaped
    :class:`~adcp.types.SyncCatalogsSuccessResponse`.
    """
    raise NotImplementedError

Sync product catalogs with the platform.

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

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

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

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

Return a list of :class:~adcp.types.SyncCatalogResult rows (ergonomic form) or a fully-shaped :class:~adcp.types.SyncCatalogsSuccessResponse.

def sync_creatives(self,
req: SyncCreativesRequest,
ctx: RequestContext[TMeta]) ‑> SalesResult[SyncCreativesSuccessResponse]
Expand source code
def sync_creatives(
    self,
    req: SyncCreativesRequest,
    ctx: RequestContext[TMeta],
) -> SalesResult[SyncCreativesSuccessResponse]:
    """Unified hybrid for creative review.

    Mixed approved/pending rows in a single sync response, OR
    hand off the whole batch to background standards-and-practices
    review. Adopters with pre-approved buyer pools fast-path; new
    buyers' creatives go to review.
    """
    ...

Unified hybrid for creative review.

Mixed approved/pending rows in a single sync response, OR hand off the whole batch to background standards-and-practices review. Adopters with pre-approved buyer pools fast-path; new buyers' creatives go to review.

def update_media_buy(self,
media_buy_id: str,
patch: UpdateMediaBuyRequest,
ctx: RequestContext[TMeta]) ‑> MaybeAsync[UpdateMediaBuySuccessResponse]
Expand source code
def update_media_buy(
    self,
    media_buy_id: str,
    patch: UpdateMediaBuyRequest,
    ctx: RequestContext[TMeta],
) -> MaybeAsync[UpdateMediaBuySuccessResponse]:
    """Mutate an in-flight media buy.

    v6.0 returns sync only — the per-tool response schema doesn't
    carry the ``Submitted`` arm yet (adcp#3392). Re-approval flows
    return the success with the ``status`` field omitted (in-spec
    per the schema description) and drive lifecycle via
    ``ctx.publish_status_change``. v6.1 + adcp#3392 expand this
    signature to :data:`SalesResult` so re-approval flows can
    hand off cleanly.
    """
    ...

Mutate an in-flight media buy.

v6.0 returns sync only — the per-tool response schema doesn't carry the Submitted arm yet (adcp#3392). Re-approval flows return the success with the status field omitted (in-spec per the schema description) and drive lifecycle via ctx.publish_status_change. v6.1 + adcp#3392 expand this signature to :data:SalesResult so re-approval flows can hand off cleanly.

class ServiceUnavailableError (*, message: str | None = None, retry_after: int | None = None, **details: Any)
Expand source code
class ServiceUnavailableError(AdcpError):
    """Spec ``SERVICE_UNAVAILABLE`` (``recovery='transient'``).

    Raised when the seller service is temporarily unavailable. The
    buyer retries with exponential backoff; ``retry_after`` MAY be set
    to hint a minimum delay.
    """

    def __init__(
        self,
        *,
        message: str | None = None,
        retry_after: int | None = None,
        **details: Any,
    ) -> None:
        super().__init__(
            "SERVICE_UNAVAILABLE",
            message=message or "Service is temporarily unavailable.",
            recovery="transient",
            retry_after=retry_after,
            details=dict(details) or None,
        )

Spec SERVICE_UNAVAILABLE (recovery='transient').

Raised when the seller service is temporarily unavailable. The buyer retries with exponential backoff; retry_after MAY be set to hint a minimum delay.

Ancestors

  • AdcpError
  • builtins.Exception
  • builtins.BaseException

Inherited members

class ShortCircuit (value: T)
Expand source code
@dataclass(frozen=True)
class ShortCircuit(Generic[T]):
    """Wrapper a ``before`` hook returns to skip the inner method.

    Returning ``ShortCircuit(value=...)`` from a
    :data:`BeforeHook` skips the wrapped inner method and feeds the
    wrapped value through ``after`` (if any) back to the caller.
    Returning ``None`` falls through to the inner method.

    Discriminated wrapper rather than a sentinel ``None``: adopters
    who omit the wrapper and return a bare value get a
    :class:`TypeError` at runtime rather than silent
    short-circuit-with-``None`` behavior — the most common footgun
    when porting middleware between languages.

    :param value: The result to return in place of calling the inner
        method. Must satisfy the wire schema (any decoration via
        ``after`` runs before response-schema validation).
    """

    value: T

Wrapper a before hook returns to skip the inner method.

Returning ShortCircuit(value=...) from a :data:BeforeHook skips the wrapped inner method and feeds the wrapped value through after (if any) back to the caller. Returning None falls through to the inner method.

Discriminated wrapper rather than a sentinel None: adopters who omit the wrapper and return a bare value get a :class:TypeError at runtime rather than silent short-circuit-with-None behavior — the most common footgun when porting middleware between languages.

:param value: The result to return in place of calling the inner method. Must satisfy the wire schema (any decoration via after runs before response-schema validation).

Ancestors

  • typing.Generic

Instance variables

var value : ~T
class SignalsPlatform (*args, **kwargs)
Expand source code
@runtime_checkable
class SignalsPlatform(OwnedSignalsPlatform[TMeta], Protocol, Generic[TMeta]):
    """Catalog discovery + activation for marketplace/provisioned signals.

    Use this Protocol for ``signal-marketplace``. Use
    :class:`OwnedSignalsPlatform` for ``signal-owned`` platforms where
    returned signals are already usable in later media-buy targeting and
    there is no buyer-triggered destination provisioning step.
    """

    def activate_signal(
        self,
        req: ActivateSignalRequest,
        ctx: RequestContext[TMeta],
    ) -> MaybeAsync[ActivateSignalSuccessResponse]:
        """Provision a signal onto one or more destination platforms
        (Snap, Meta, TikTok, etc.).

        Returns the success-arm shape immediately with ``deployments``
        rows in their current state — ``'pending'`` is a valid sync
        return for slow activation pipelines.

        Subsequent state changes (per-deployment ``activating`` /
        ``deployed`` / ``failed``) flow via
        ``ctx.publish_status_change(resource_type='signal',
        resource_id=signal_agent_segment_id, payload=...)`` as each
        destination's identity-graph match completes.

        Use ``req.action='deactivate'`` for GDPR/CCPA-compliant
        teardown when campaigns end.

        :raises adcp.decisioning.AdcpError: ``code='SIGNAL_NOT_FOUND'``
            (unknown ``signal_agent_segment_id``),
            ``code='POLICY_VIOLATION'`` (buyer lacks rights to activate
            this data), or ``code='INVALID_REQUEST'`` (missing or
            unrecognized destination).
        """
        ...

Catalog discovery + activation for marketplace/provisioned signals.

Use this Protocol for signal-marketplace. Use :class:OwnedSignalsPlatform for signal-owned platforms where returned signals are already usable in later media-buy targeting and there is no buyer-triggered destination provisioning step.

Ancestors

Methods

def activate_signal(self,
req: ActivateSignalRequest,
ctx: RequestContext[TMeta]) ‑> MaybeAsync[ActivateSignalSuccessResponse]
Expand source code
def activate_signal(
    self,
    req: ActivateSignalRequest,
    ctx: RequestContext[TMeta],
) -> MaybeAsync[ActivateSignalSuccessResponse]:
    """Provision a signal onto one or more destination platforms
    (Snap, Meta, TikTok, etc.).

    Returns the success-arm shape immediately with ``deployments``
    rows in their current state — ``'pending'`` is a valid sync
    return for slow activation pipelines.

    Subsequent state changes (per-deployment ``activating`` /
    ``deployed`` / ``failed``) flow via
    ``ctx.publish_status_change(resource_type='signal',
    resource_id=signal_agent_segment_id, payload=...)`` as each
    destination's identity-graph match completes.

    Use ``req.action='deactivate'`` for GDPR/CCPA-compliant
    teardown when campaigns end.

    :raises adcp.decisioning.AdcpError: ``code='SIGNAL_NOT_FOUND'``
        (unknown ``signal_agent_segment_id``),
        ``code='POLICY_VIOLATION'`` (buyer lacks rights to activate
        this data), or ``code='INVALID_REQUEST'`` (missing or
        unrecognized destination).
    """
    ...

Provision a signal onto one or more destination platforms (Snap, Meta, TikTok, etc.).

Returns the success-arm shape immediately with deployments rows in their current state — 'pending' is a valid sync return for slow activation pipelines.

Subsequent state changes (per-deployment activating / deployed / failed) flow via ctx.publish_status_change(resource_type='signal', resource_id=signal_agent_segment_id, payload=...) as each destination's identity-graph match completes.

Use req.action='deactivate' for GDPR/CCPA-compliant teardown when campaigns end.

:raises adcp.decisioning.AdcpError: code='SIGNAL_NOT_FOUND' (unknown signal_agent_segment_id), code='POLICY_VIOLATION' (buyer lacks rights to activate this data), or code='INVALID_REQUEST' (missing or unrecognized destination).

Inherited members

class SingletonAccounts (account_id: str,
*,
name: str = '',
metadata_factory: Callable[[], TMeta] | None = None,
mode: "Literal['live', 'sandbox', 'mock'] | None" = None)
Expand source code
class SingletonAccounts(Generic[TMeta]):
    """Single-platform deployment with per-principal idempotency scoping.

    Use for: Innovid training-agent class, single-publisher proof-of-
    concepts, dev/staging environments.

    Synthesizes ``account.id`` from the verified principal:
    ``f"{base_account_id}:{principal}"``. Without this, every caller
    across the entire deployment would share one idempotency cache —
    UUID collision (random or engineered) returns another caller's
    ``response_payload``, which is a buyer-to-buyer data leak.
    Per-principal synthesis closes this while keeping the "one platform,
    no per-tenant lookup" ergonomic.

    For unauthenticated dev fixtures (``ctx.auth_info is None``),
    the synthesized id is ``f"{base_account_id}:anonymous"`` — adopters
    relying on this MUST ensure their dev/CI pipeline authenticates
    before any cross-test isolation matters.

    Example::

        class TrainingAgentSeller(DecisioningPlatform):
            accounts = SingletonAccounts(account_id="training-agent")

    :param account_id: Base account id used in the synthesized
        per-principal id. Must be stable across process restarts so
        idempotency cache hits work across deploys.
    :param name: Human-readable name copied to ``Account.name``.
    :param metadata_factory: Optional factory for ``Account.metadata``
        — adopters with typed metadata pass a closure that returns the
        right TypedDict / dataclass instance.
    :param mode: Optional account-mode flag. When set, every resolved
        :class:`Account` carries ``mode`` and is stamped explicit so
        the framework's observed-modes tracker counts the resolution.
        Default ``None`` — leaves accounts at the implicit-default
        ``'live'`` (pre-mode behavior). Pass ``'sandbox'`` for a
        single-platform conformance / dev deployment that should admit
        ``comply_test_controller``; pass ``'live'`` to deliberately mark
        the singleton as production (the env-fallback guard then trips
        loudly if ``ADCP_SANDBOX=1`` is also set).
    """

    resolution: ClassVar[str] = "derived"

    def __init__(
        self,
        account_id: str,
        *,
        name: str = "",
        metadata_factory: Callable[[], TMeta] | None = None,
        mode: Literal["live", "sandbox", "mock"] | None = None,
    ) -> None:
        if not account_id or not isinstance(account_id, str):
            raise ValueError(
                f"SingletonAccounts requires a non-empty account_id; got {account_id!r}"
            )
        self._account_id = account_id
        self._name = name or account_id
        self._metadata_factory = metadata_factory
        self._mode = mode

    def resolve(
        self,
        ref: dict[str, Any] | None = None,
        auth_info: AuthInfo | None = None,
    ) -> Account[TMeta]:
        del ref  # singleton ignores wire refs
        principal = auth_info.principal if auth_info and auth_info.principal else "anonymous"
        scoped_id = f"{self._account_id}:{principal}"
        metadata: TMeta = (
            self._metadata_factory() if self._metadata_factory else {}  # type: ignore[assignment]
        )
        kwargs: dict[str, Any] = {}
        if self._mode is not None:
            # Adopter passed explicit mode at construction — stamp it on
            # the Account AND mark explicit so the observed-modes tracker
            # counts this resolution for the env-fallback fail-closed
            # guard.
            kwargs["mode"] = self._mode
            kwargs["_mode_explicit"] = True
        return Account(
            id=scoped_id,
            name=f"{self._name} ({principal})" if principal != "anonymous" else self._name,
            status="active",
            metadata=metadata,
            auth_info=_auth_info_to_dict(auth_info),
            **kwargs,
        )

Single-platform deployment with per-principal idempotency scoping.

Use for: Innovid training-agent class, single-publisher proof-of- concepts, dev/staging environments.

Synthesizes account.id from the verified principal: f"{base_account_id}:{principal}". Without this, every caller across the entire deployment would share one idempotency cache — UUID collision (random or engineered) returns another caller's response_payload, which is a buyer-to-buyer data leak. Per-principal synthesis closes this while keeping the "one platform, no per-tenant lookup" ergonomic.

For unauthenticated dev fixtures (ctx.auth_info is None), the synthesized id is f"{base_account_id}:anonymous" — adopters relying on this MUST ensure their dev/CI pipeline authenticates before any cross-test isolation matters.

Example::

class TrainingAgentSeller(DecisioningPlatform):
    accounts = SingletonAccounts(account_id="training-agent")

:param account_id: Base account id used in the synthesized per-principal id. Must be stable across process restarts so idempotency cache hits work across deploys. :param name: Human-readable name copied to Account.name. :param metadata_factory: Optional factory for Account.metadata — adopters with typed metadata pass a closure that returns the right TypedDict / dataclass instance. :param mode: Optional account-mode flag. When set, every resolved :class:Account carries mode and is stamped explicit so the framework's observed-modes tracker counts the resolution. Default None — leaves accounts at the implicit-default 'live' (pre-mode behavior). Pass 'sandbox' for a single-platform conformance / dev deployment that should admit comply_test_controller; pass 'live' to deliberately mark the singleton as production (the env-fallback guard then trips loudly if ADCP_SANDBOX=1 is also set).

Ancestors

  • typing.Generic

Class variables

var resolution : ClassVar[str]

Methods

def resolve(self,
ref: dict[str, Any] | None = None,
auth_info: AuthInfo | None = None) ‑> Account[~TMeta]
Expand source code
def resolve(
    self,
    ref: dict[str, Any] | None = None,
    auth_info: AuthInfo | None = None,
) -> Account[TMeta]:
    del ref  # singleton ignores wire refs
    principal = auth_info.principal if auth_info and auth_info.principal else "anonymous"
    scoped_id = f"{self._account_id}:{principal}"
    metadata: TMeta = (
        self._metadata_factory() if self._metadata_factory else {}  # type: ignore[assignment]
    )
    kwargs: dict[str, Any] = {}
    if self._mode is not None:
        # Adopter passed explicit mode at construction — stamp it on
        # the Account AND mark explicit so the observed-modes tracker
        # counts this resolution for the env-fallback fail-closed
        # guard.
        kwargs["mode"] = self._mode
        kwargs["_mode_explicit"] = True
    return Account(
        id=scoped_id,
        name=f"{self._name} ({principal})" if principal != "anonymous" else self._name,
        status="active",
        metadata=metadata,
        auth_info=_auth_info_to_dict(auth_info),
        **kwargs,
    )
class StateReader (*args, **kwargs)
Expand source code
@runtime_checkable
class StateReader(Protocol):
    """Sync reads of framework-owned in-flight workflow state.

    Platform methods read prior workflow context (recent media-buy
    transitions, related proposals, in-flight governance bindings)
    without re-querying their own DB. The framework owns the cache; the
    Protocol surface is purely read.

    Framework-supplied; never constructed by adopter code. The
    ``RequestContext.state`` field is populated by the dispatch
    hydration helper. Adopters substituting test doubles use
    :func:`dataclasses.replace` on the context, not direct
    construction.

    Mirrors the TS-side ``WorkflowStateReader`` interface in
    ``src/lib/server/decisioning/context.ts``. v6.0 ships the contract
    + the no-op stub; v6.1 lands the backing store.

    .. note::
       :class:`runtime_checkable` Protocols match by attribute *name*
       only — return types (including :data:`GovernanceContextJWS`,
       which is a :func:`typing.NewType` invisible at runtime) and
       method signatures are NOT enforced by ``isinstance``. A custom
       impl that returns ``int`` from ``governance_context()`` will
       pass the structural check; mypy is the only enforcement for
       return-type contracts. Coverage gap is acceptable for v6.0.
    """

    def find_by_object(
        self,
        object_type: WorkflowObjectType,
        object_id: str,
    ) -> Sequence[WorkflowStep]:
        """Return workflow steps that touched the given object,
        chronological. Used for "what's happened to this buy?" reads
        without a platform-side fetch."""
        ...

    def find_proposal_by_id(self, proposal_id: str) -> Proposal | None:
        """Resolve a ``proposal_id`` threaded across
        ``get_products → refine → create_media_buy`` without platform
        code. Returns ``None`` if the framework doesn't recognize the
        id."""
        ...

    def governance_context(self) -> GovernanceContextJWS | None:
        """Currently in-flight verified governance context (the JWS
        token). ``None`` for non-governance flows. Framework verifies
        before exposure; platform code can trust the value.

        Adopters claiming ``governance-*`` specialisms in
        ``capabilities.specialisms`` MUST set
        ``capabilities.governance_aware=True`` and wire a real
        ``StateReader`` that returns real JWS tokens. The default stub
        returns ``None``, which would silently skip the gate — server
        boot fails fast if a governance specialism is claimed without
        the opt-in. See
        ``docs/proposals/decisioning-platform-dispatch-design.md#d15``.
        """
        ...

    def workflow_steps(self) -> Sequence[WorkflowStep]:
        """All chronological steps for this request's account.
        Audit-read shape."""
        ...

Sync reads of framework-owned in-flight workflow state.

Platform methods read prior workflow context (recent media-buy transitions, related proposals, in-flight governance bindings) without re-querying their own DB. The framework owns the cache; the Protocol surface is purely read.

Framework-supplied; never constructed by adopter code. The RequestContext.state field is populated by the dispatch hydration helper. Adopters substituting test doubles use :func:dataclasses.replace on the context, not direct construction.

Mirrors the TS-side WorkflowStateReader interface in src/lib/server/decisioning/context.ts. v6.0 ships the contract + the no-op stub; v6.1 lands the backing store.

Note

:class:runtime_checkable Protocols match by attribute name only — return types (including :data:GovernanceContextJWS, which is a :func:typing.NewType invisible at runtime) and method signatures are NOT enforced by isinstance. A custom impl that returns int from governance_context() will pass the structural check; mypy is the only enforcement for return-type contracts. Coverage gap is acceptable for v6.0.

Ancestors

  • typing.Protocol
  • typing.Generic

Methods

def find_by_object(self, object_type: WorkflowObjectType, object_id: str) ‑> Sequence[WorkflowStep]
Expand source code
def find_by_object(
    self,
    object_type: WorkflowObjectType,
    object_id: str,
) -> Sequence[WorkflowStep]:
    """Return workflow steps that touched the given object,
    chronological. Used for "what's happened to this buy?" reads
    without a platform-side fetch."""
    ...

Return workflow steps that touched the given object, chronological. Used for "what's happened to this buy?" reads without a platform-side fetch.

def find_proposal_by_id(self, proposal_id: str) ‑> adcp.types.generated_poc.core.proposal.Proposal | None
Expand source code
def find_proposal_by_id(self, proposal_id: str) -> Proposal | None:
    """Resolve a ``proposal_id`` threaded across
    ``get_products → refine → create_media_buy`` without platform
    code. Returns ``None`` if the framework doesn't recognize the
    id."""
    ...

Resolve a proposal_id threaded across get_products → refine → create_media_buy without platform code. Returns None if the framework doesn't recognize the id.

def governance_context(self) ‑> GovernanceContextJWS | None
Expand source code
def governance_context(self) -> GovernanceContextJWS | None:
    """Currently in-flight verified governance context (the JWS
    token). ``None`` for non-governance flows. Framework verifies
    before exposure; platform code can trust the value.

    Adopters claiming ``governance-*`` specialisms in
    ``capabilities.specialisms`` MUST set
    ``capabilities.governance_aware=True`` and wire a real
    ``StateReader`` that returns real JWS tokens. The default stub
    returns ``None``, which would silently skip the gate — server
    boot fails fast if a governance specialism is claimed without
    the opt-in. See
    ``docs/proposals/decisioning-platform-dispatch-design.md#d15``.
    """
    ...

Currently in-flight verified governance context (the JWS token). None for non-governance flows. Framework verifies before exposure; platform code can trust the value.

Adopters claiming governance-* specialisms in capabilities.specialisms MUST set capabilities.governance_aware=True and wire a real StateReader that returns real JWS tokens. The default stub returns None, which would silently skip the gate — server boot fails fast if a governance specialism is claimed without the opt-in. See docs/proposals/decisioning-platform-dispatch-design.md#d15.

def workflow_steps(self) ‑> Sequence[WorkflowStep]
Expand source code
def workflow_steps(self) -> Sequence[WorkflowStep]:
    """All chronological steps for this request's account.
    Audit-read shape."""
    ...

All chronological steps for this request's account. Audit-read shape.

class StaticBearer (token: str, kind: "Literal['static_bearer']" = 'static_bearer')
Expand source code
@dataclass(frozen=True)
class StaticBearer:
    """Fixed Bearer token injected into every request."""

    token: str
    kind: Literal["static_bearer"] = "static_bearer"

Fixed Bearer token injected into every request.

Instance variables

var kind : Literal['static_bearer']
var token : str
class SyncAccountsResultRow (brand: dict[str, Any],
operator: str,
action: "Literal['created', 'updated', 'unchanged', 'failed'] | str",
status: str,
account_id: str | None = None,
name: str | None = None,
billing: "Literal['operator', 'agent', 'advertiser'] | None" = None,
billing_entity: BusinessEntity | None = None,
setup: AccountSetup | None = None,
account_scope: AccountScope | None = None,
rate_card: str | None = None,
payment_terms: PaymentTerms | None = None,
credit_limit: CreditLimit | None = None,
errors: list[dict[str, Any]] | None = None,
warnings: list[str] | None = None,
sandbox: bool | None = None)
Expand source code
@dataclass
class SyncAccountsResultRow:
    """Per-account result row returned by an adopter's ``accounts.upsert``
    implementation. Maps to one element of the wire ``sync_accounts``
    response's ``accounts[]`` array.

    Carries the same optional commercial / lifecycle fields as the wire
    shape so adopters can echo ``setup`` (for ``pending_approval``
    accounts), ``billing_entity``, ``payment_terms``, etc. on creation.
    The framework projects through :func:`to_wire_sync_accounts_row`
    before emit, applying the same ``billing_entity.bank`` strip as
    :func:`to_wire_account` (write-only contract).

    **MUST NOT carry auth-derived fields.** This shape is emitted on
    the ``sync_accounts`` response wire. Adopters MUST NOT add an
    ``auth_info`` key on returned rows — same MUST-NOT-LEAK rule the
    framework enforces on :attr:`Account.auth_info`.

    :param brand: Required. Echoed from the request's ``account.brand``.
    :param operator: Required. Echoed from the request's
        ``account.operator``.
    :param action: Required. ``created`` / ``updated`` / ``unchanged``
        / ``failed``.
    :param status: Required. AdCP account-status enum value.
    :param account_id: Seller-assigned account identifier (when
        ``action`` is ``created``).
    :param name: Human-readable account name assigned by the seller.
    :param billing: Invoiced-to party
        (``operator`` / ``agent`` / ``advertiser``).
    :param billing_entity: Business entity invoiced.
        ``bank`` is stripped on emit (write-only).
    :param setup: Setup payload for ``pending_approval`` accounts.
    :param account_scope: Account scope.
    :param rate_card: Rate card applied to this account.
    :param payment_terms: Payment terms.
    :param credit_limit: Credit limit.
    :param errors: Per-account errors (only when action is ``failed``).
    :param warnings: Non-fatal warnings about this account.
    :param sandbox: Sandbox-account marker, echoed from the request.
    """

    brand: dict[str, Any]
    operator: str
    # The wire schema uses an Enum on the response side; adopters can
    # pass either the Enum value or the literal string. Typed as the
    # literal union (Pydantic enum-or-string coercion handles the
    # rest); the framework projects via ``_enum_value`` on emit.
    action: Literal["created", "updated", "unchanged", "failed"] | str
    status: str
    account_id: str | None = None
    name: str | None = None
    billing: Literal["operator", "agent", "advertiser"] | None = None
    billing_entity: BusinessEntity | None = None
    setup: AccountSetup | None = None
    account_scope: AccountScope | None = None
    rate_card: str | None = None
    payment_terms: PaymentTerms | None = None
    credit_limit: CreditLimit | None = None
    errors: list[dict[str, Any]] | None = None
    warnings: list[str] | None = None
    sandbox: bool | None = None

Per-account result row returned by an adopter's accounts.upsert implementation. Maps to one element of the wire sync_accounts response's adcp.decisioning.accounts[] array.

Carries the same optional commercial / lifecycle fields as the wire shape so adopters can echo setup (for pending_approval accounts), billing_entity, payment_terms, etc. on creation. The framework projects through :func:to_wire_sync_accounts_row() before emit, applying the same billing_entity.bank strip as :func:to_wire_account() (write-only contract).

MUST NOT carry auth-derived fields. This shape is emitted on the sync_accounts response wire. Adopters MUST NOT add an auth_info key on returned rows — same MUST-NOT-LEAK rule the framework enforces on :attr:Account.auth_info.

:param brand: Required. Echoed from the request's account.brand. :param operator: Required. Echoed from the request's account.operator. :param action: Required. created / updated / unchanged / failed. :param status: Required. AdCP account-status enum value. :param account_id: Seller-assigned account identifier (when action is created). :param name: Human-readable account name assigned by the seller. :param billing: Invoiced-to party (operator / agent / advertiser). :param billing_entity: Business entity invoiced. bank is stripped on emit (write-only). :param setup: Setup payload for pending_approval accounts. :param account_scope: Account scope. :param rate_card: Rate card applied to this account. :param payment_terms: Payment terms. :param credit_limit: Credit limit. :param errors: Per-account errors (only when action is failed). :param warnings: Non-fatal warnings about this account. :param sandbox: Sandbox-account marker, echoed from the request.

Instance variables

var account_id : str | None
var account_scope : AccountScope | None
var action : Literal['created', 'updated', 'unchanged', 'failed'] | str
var billing : Literal['operator', 'agent', 'advertiser'] | None
var billing_entity : BusinessEntity | None
var brand : dict[str, Any]
var credit_limit : CreditLimit | None
var errors : list[dict[str, Any]] | None
var name : str | None
var operator : str
var payment_terms : PaymentTerms | None
var rate_card : str | None
var sandbox : bool | None
var setup : AccountSetup | None
var status : str
var warnings : list[str] | None
class SyncGovernanceEntry (account: AccountReference, governance_agents: list[dict[str, Any]])
Expand source code
@dataclass
class SyncGovernanceEntry:
    """One entry from the wire ``sync_governance`` request's ``accounts[]``.

    The framework strips wire metadata (``idempotency_key``,
    ``adcp_major_version``, ``context``, ``ext``) before invoking
    :meth:`AccountStore.sync_governance`. Each entry pairs an
    :class:`AccountReference` with its ``governance_agents[]``.

    The ``governance_agents`` list carries ``authentication.credentials``
    on the input — adopters persist these for outbound
    ``check_governance`` calls. The framework strips ``authentication``
    on emit (see :func:`to_wire_sync_governance_row`); the input shape
    here keeps credentials present for the adopter's persistence step.

    :param account: AccountReference for the account being synced.
    :param governance_agents: Wire ``governance_agents[]``, including
        ``authentication`` (which carries the write-only credentials).
        Pass-through from the request — the framework does not strip
        credentials before this point so adopters can persist them.
    """

    account: AccountReference
    governance_agents: list[dict[str, Any]]

One entry from the wire sync_governance request's adcp.decisioning.accounts[].

The framework strips wire metadata (idempotency_key, adcp_major_version, adcp.decisioning.context, ext) before invoking :meth:AccountStore.sync_governance. Each entry pairs an :class:AccountReference with its governance_agents[].

The governance_agents list carries authentication.credentials on the input — adopters persist these for outbound check_governance calls. The framework strips authentication on emit (see :func:to_wire_sync_governance_row()); the input shape here keeps credentials present for the adopter's persistence step.

:param account: AccountReference for the account being synced. :param governance_agents: Wire governance_agents[], including authentication (which carries the write-only credentials). Pass-through from the request — the framework does not strip credentials before this point so adopters can persist them.

Instance variables

var account : AccountReference
var governance_agents : list[dict[str, Any]]
class SyncGovernanceResultRow (account: AccountReference,
status: "Literal['synced', 'failed'] | str",
governance_agents: list[dict[str, Any]] | None = None,
errors: list[dict[str, Any]] | None = None)
Expand source code
@dataclass
class SyncGovernanceResultRow:
    """Per-entry result row returned by ``AccountStore.sync_governance``.

    Maps to one element of the wire ``sync_governance`` response's
    ``accounts[]`` array. The framework projects through
    :func:`to_wire_sync_governance_row` before emit, stripping
    ``authentication`` (write-only) from every governance agent.

    **Replace semantics, per spec.** Each ``sync_governance`` call
    REPLACES the previously synced governance agents for the
    referenced account. An entry whose ``governance_agents`` is empty
    clears the binding for that account.

    Per-entry rejection (vs. operation-level throw) so a single bad
    entry doesn't fail the whole batch — return a row with
    ``status='failed'`` and ``errors=[{code: 'PERMISSION_DENIED', ...}]``
    for the rejected entry.

    :param account: AccountReference, echoed from the request.
    :param status: ``synced`` (governance agents persisted) or
        ``failed`` (could not complete; see ``errors``).
    :param governance_agents: Governance agents now synced on this
        account. Reflects the persisted state after sync.
    :param errors: Per-account errors (only when status is ``failed``).
    """

    account: AccountReference
    status: Literal["synced", "failed"] | str
    governance_agents: list[dict[str, Any]] | None = None
    errors: list[dict[str, Any]] | None = None

Per-entry result row returned by AccountStore.sync_governance.

Maps to one element of the wire sync_governance response's adcp.decisioning.accounts[] array. The framework projects through :func:to_wire_sync_governance_row() before emit, stripping authentication (write-only) from every governance agent.

Replace semantics, per spec. Each sync_governance call REPLACES the previously synced governance agents for the referenced account. An entry whose governance_agents is empty clears the binding for that account.

Per-entry rejection (vs. operation-level throw) so a single bad entry doesn't fail the whole batch — return a row with status='failed' and errors=[{code: 'PERMISSION_DENIED', ...}] for the rejected entry.

:param account: AccountReference, echoed from the request. :param status: synced (governance agents persisted) or failed (could not complete; see adcp.decisioning.errors). :param governance_agents: Governance agents now synced on this account. Reflects the persisted state after sync. :param errors: Per-account errors (only when status is failed).

Instance variables

var account : AccountReference
var errors : list[dict[str, Any]] | None
var governance_agents : list[dict[str, Any]] | None
var status : Literal['synced', 'failed'] | str
class TaskHandoff (fn: Callable[[Any], Awaitable[T] | T])
Expand source code
class TaskHandoff(Generic[T]):
    """Marker the framework recognizes as 'promote this call to a task.'

    Adopters obtain instances via :meth:`RequestContext.handoff_to_task`;
    the framework dispatches based on type-identity (``type(obj) is
    TaskHandoff``) so a buyer-supplied request body can never become a
    handoff (it would never have the right ``type``), and adopter
    subclasses don't accidentally trigger the handoff path.

    The Python implementation deliberately omits the JS-side
    ``Symbol.for(...)``-keyed brand. JS needs the brand to defend against
    untrusted code in the same realm forging markers; Python adopter code
    is trusted, and a buyer-supplied wire body cannot reach this type
    because :class:`TaskHandoff` is a return type — never deserialized
    from JSON. The adversary doesn't exist; the ceremony to defend
    against them shouldn't either.

    Example::

        def create_media_buy(self, req, ctx):
            if self._is_pre_approved(req, ctx.account):
                # Sync fast path — return Success directly
                return CreateMediaBuySuccess(media_buy_id="mb_1", ...)
            # Framework-async slow path — hand off to background work
            return ctx.handoff_to_task(self._review_async)

    **What TaskHandoff is for** — short, framework-mediated async work
    where the adopter awaits an external system (DSP API call,
    classifier inference, third-party brand-safety scan, generative
    creative render) inside a coroutine. The handoff fn runs in the
    same process, the framework awaits it, persists the terminal
    artifact, and emits a webhook on completion. Typical wall-clock:
    seconds to minutes.

    **What TaskHandoff is NOT for** — external workflows that complete
    on their own schedule (human queue review, nightly batch jobs,
    Airflow DAGs, ML pipelines that run hours later). The handoff fn
    would either block the framework's background runner indefinitely
    (until the external system acts), or poll an external queue (which
    doesn't fit the "fn returns terminal artifact" contract). Use
    :class:`WorkflowHandoff` instead — obtained via
    :meth:`RequestContext.handoff_to_workflow`. The framework allocates
    a ``task_id``, persists ``submitted`` state, and returns the wire
    envelope; the adopter's external system later calls
    ``registry.complete(task_id, result)`` or
    ``registry.fail(task_id, error)`` directly.

    Buyer experience is identical across the three paths — sync return,
    TaskHandoff, WorkflowHandoff — they all surface as polled-or-webhook
    completion against the same wire shape. The split is purely about
    where the work runs (in-process / framework-managed / adopter-owned).
    """

    __slots__ = ("_fn",)

    def __init__(self, fn: Callable[[Any], Awaitable[T] | T]) -> None:
        # ``fn`` is ``Callable[[TaskHandoffContext], Awaitable[T] | T]``
        # but TaskHandoffContext lives in dispatch.py to avoid a cycle.
        # The framework calls ``handoff._fn(task_ctx)`` at dispatch time;
        # adopters pass either a coroutine function or a sync callable
        # and the dispatcher detects via ``inspect.iscoroutine``.
        self._fn = fn

    def __repr__(self) -> str:
        return "TaskHandoff(<sealed>)"

Marker the framework recognizes as 'promote this call to a task.'

Adopters obtain instances via :meth:RequestContext.handoff_to_task(); the framework dispatches based on type-identity (type(obj) is TaskHandoff) so a buyer-supplied request body can never become a handoff (it would never have the right type), and adopter subclasses don't accidentally trigger the handoff path.

The Python implementation deliberately omits the JS-side Symbol.for(…)-keyed brand. JS needs the brand to defend against untrusted code in the same realm forging markers; Python adopter code is trusted, and a buyer-supplied wire body cannot reach this type because :class:TaskHandoff is a return type — never deserialized from JSON. The adversary doesn't exist; the ceremony to defend against them shouldn't either.

Example::

def create_media_buy(self, req, ctx):
    if self._is_pre_approved(req, ctx.account):
        # Sync fast path — return Success directly
        return CreateMediaBuySuccess(media_buy_id="mb_1", ...)
    # Framework-async slow path — hand off to background work
    return ctx.handoff_to_task(self._review_async)

What TaskHandoff is for — short, framework-mediated async work where the adopter awaits an external system (DSP API call, classifier inference, third-party brand-safety scan, generative creative render) inside a coroutine. The handoff fn runs in the same process, the framework awaits it, persists the terminal artifact, and emits a webhook on completion. Typical wall-clock: seconds to minutes.

What TaskHandoff is NOT for — external workflows that complete on their own schedule (human queue review, nightly batch jobs, Airflow DAGs, ML pipelines that run hours later). The handoff fn would either block the framework's background runner indefinitely (until the external system acts), or poll an external queue (which doesn't fit the "fn returns terminal artifact" contract). Use :class:WorkflowHandoff instead — obtained via :meth:RequestContext.handoff_to_workflow(). The framework allocates a task_id, persists submitted state, and returns the wire envelope; the adopter's external system later calls registry.complete(task_id, result) or registry.fail(task_id, error) directly.

Buyer experience is identical across the three paths — sync return, TaskHandoff, WorkflowHandoff — they all surface as polled-or-webhook completion against the same wire shape. The split is purely about where the work runs (in-process / framework-managed / adopter-owned).

Ancestors

  • typing.Generic
class TaskHandoffContext (id: str,
_registry: TaskRegistry)
Expand source code
@dataclass
class TaskHandoffContext:
    """Per-task context passed to the handoff fn registered via
    :meth:`adcp.decisioning.RequestContext.handoff_to_task`.

    Adopter pattern::

        def create_media_buy(self, req, ctx):
            if self._needs_review(req):
                return ctx.handoff_to_task(self._async_review)

            return CreateMediaBuySuccess(media_buy_id="mb_1", ...)

        async def _async_review(self, task_ctx: TaskHandoffContext):
            await task_ctx.update({"message": "Trafficker reviewing"})
            decision = await self._wait_for_trafficker(task_ctx.id)
            return CreateMediaBuySuccess(media_buy_id=decision.id, ...)

    The framework allocates ``task_ctx.id`` BEFORE invoking the
    handoff fn so the adopter can persist the id to its own backend
    (storyboard runner row, Slack thread reference, etc.) before
    kicking off slow work. This fixes a documented v1 ergonomics bug
    where adopters could only learn the task_id AFTER returning.

    Constructed by :func:`adcp.decisioning.dispatch._build_handoff_context`;
    never instantiated by adopter code.
    """

    id: str
    _registry: TaskRegistry
    _heartbeat_impl: Callable[[], Awaitable[None]] = field(default_factory=lambda: _noop_heartbeat)

    async def update(self, progress: dict[str, Any]) -> None:
        """Write a progress payload. Transitions ``submitted`` →
        ``working`` on first call.

        Errors are swallowed (logged at WARNING with traceback):
        a transient registry write failure must not abort the handoff.
        Buyer-facing impact is a missed progress event, not a failed
        task. Adopters who need delivery guarantees plug a durable
        registry; the warning surfaces the transient via existing
        observability hooks so silent loss isn't truly invisible.
        """
        try:
            await self._registry.update_progress(self.id, progress)
        except Exception:
            logger.warning(
                "TaskHandoffContext.update(task_id=%s) suppressed "
                "registry transient — progress event lost; handoff "
                "continues",
                self.id,
                exc_info=True,
            )
            return

    async def heartbeat(self) -> None:
        """Liveness signal for operator infrastructure. v6.1 stub.

        v6.0 ships as a no-op so adopter code calling
        ``await task_ctx.heartbeat()`` future-proofs against the
        eventual implementation. Operator-side TTL-reset wiring lands
        with the durable registry impl.
        """
        await self._heartbeat_impl()

Per-task context passed to the handoff fn registered via :meth:RequestContext.handoff_to_task().

Adopter pattern::

def create_media_buy(self, req, ctx):
    if self._needs_review(req):
        return ctx.handoff_to_task(self._async_review)

    return CreateMediaBuySuccess(media_buy_id="mb_1", ...)

async def _async_review(self, task_ctx: TaskHandoffContext):
    await task_ctx.update({"message": "Trafficker reviewing"})
    decision = await self._wait_for_trafficker(task_ctx.id)
    return CreateMediaBuySuccess(media_buy_id=decision.id, ...)

The framework allocates task_ctx.id BEFORE invoking the handoff fn so the adopter can persist the id to its own backend (storyboard runner row, Slack thread reference, etc.) before kicking off slow work. This fixes a documented v1 ergonomics bug where adopters could only learn the task_id AFTER returning.

Constructed by :func:adcp.decisioning.dispatch._build_handoff_context; never instantiated by adopter code.

Instance variables

var id : str

Methods

async def heartbeat(self) ‑> None
Expand source code
async def heartbeat(self) -> None:
    """Liveness signal for operator infrastructure. v6.1 stub.

    v6.0 ships as a no-op so adopter code calling
    ``await task_ctx.heartbeat()`` future-proofs against the
    eventual implementation. Operator-side TTL-reset wiring lands
    with the durable registry impl.
    """
    await self._heartbeat_impl()

Liveness signal for operator infrastructure. v6.1 stub.

v6.0 ships as a no-op so adopter code calling await task_ctx.heartbeat() future-proofs against the eventual implementation. Operator-side TTL-reset wiring lands with the durable registry impl.

async def update(self, progress: dict[str, Any]) ‑> None
Expand source code
async def update(self, progress: dict[str, Any]) -> None:
    """Write a progress payload. Transitions ``submitted`` →
    ``working`` on first call.

    Errors are swallowed (logged at WARNING with traceback):
    a transient registry write failure must not abort the handoff.
    Buyer-facing impact is a missed progress event, not a failed
    task. Adopters who need delivery guarantees plug a durable
    registry; the warning surfaces the transient via existing
    observability hooks so silent loss isn't truly invisible.
    """
    try:
        await self._registry.update_progress(self.id, progress)
    except Exception:
        logger.warning(
            "TaskHandoffContext.update(task_id=%s) suppressed "
            "registry transient — progress event lost; handoff "
            "continues",
            self.id,
            exc_info=True,
        )
        return

Write a progress payload. Transitions submittedworking on first call.

Errors are swallowed (logged at WARNING with traceback): a transient registry write failure must not abort the handoff. Buyer-facing impact is a missed progress event, not a failed task. Adopters who need delivery guarantees plug a durable registry; the warning surfaces the transient via existing observability hooks so silent loss isn't truly invisible.

class TaskRegistry (*args, **kwargs)
Expand source code
@runtime_checkable
class TaskRegistry(Protocol):
    """Per-account task store — the seam adopters substitute for a
    durable backing implementation.

    **Durability marker** (``is_durable: ClassVar[bool]``):

    Production deployments running ``sales-broadcast-tv`` or any HITL
    flow refuse to start with a non-durable registry unless the
    operator explicitly opts in via
    ``ADCP_DECISIONING_ALLOW_INMEMORY_TASKS=1``. The framework reads
    ``registry.is_durable`` to make this decision; subclassing
    :class:`InMemoryTaskRegistry` for instrumentation does NOT bypass
    the gate (the subclass inherits ``is_durable = False``). Custom
    durable impls MUST set ``is_durable = True`` explicitly. The
    Protocol declares this as a class-level ``bool``.

    Lifecycle (framework-driven; adopters call only :meth:`TaskHandoffContext`
    methods, not these directly):

    1. Dispatch detects ``ctx.handoff_to_task(fn)`` returned from a
       platform method. Allocates a task_id and calls :meth:`issue` to
       persist the ``submitted`` row.
    2. Dispatch projects the wire ``Submitted`` envelope to the buyer.
    3. Dispatch runs ``fn(task_handoff_ctx)`` in the background. The
       adopter calls ``task_handoff_ctx.update(progress)`` zero or
       more times; the framework routes each to :meth:`update_progress`
       (also transitions ``submitted`` → ``working`` on first update).
    4. When ``fn`` returns, dispatch calls :meth:`complete` with the
       terminal artifact (a JSON-serialized spec response).
    5. When ``fn`` raises :class:`adcp.decisioning.AdcpError` (or any
       exception, wrapped to ``INTERNAL_ERROR``), dispatch calls
       :meth:`fail` with the wire-shaped error payload.

    All write paths set ``updated_at = now``. The registry is
    expected to be safe for concurrent reads; concurrent writes to
    the same task are serialized by the dispatcher (one ``fn`` per
    handoff, no concurrent `update_progress`/`complete` against the
    same task_id).

    Cross-tenant safety: every read MUST be account-scoped. The
    :meth:`get` method takes an optional ``expected_account_id`` —
    when supplied (the wire ``tasks/get`` path always supplies it),
    a mismatch returns ``None``, NOT the raw record. Adopters
    implementing custom registries MUST honor this: returning a
    cross-tenant record on probe enables principal-enumeration via
    task_id guessing. See
    ``tests/test_decisioning_task_registry_cross_tenant.py`` for
    the regression suite.

    **``account_id`` is opaque.** The framework threads
    ``ctx.account.id`` (whatever the adopter's
    :class:`~adcp.decisioning.AccountStore.resolve` returned) into
    every method. The registry MUST NOT parse it or re-derive tenant
    scope from it. Multi-tenant adopters encode their tenant scope
    into ``Account.id`` once at the
    :class:`~adcp.decisioning.AccountStore` layer; the registry then
    gets a globally-unique scope key and the cross-tenant probe
    check above degenerates to a simple equality. See the
    AccountStore docstring's "Multi-tenant deployments" section for
    the canonical encoding pattern.
    """

    #: Whether this registry persists tasks across process restarts.
    #: ``False`` for in-memory / lossy impls; ``True`` for durable
    #: backings (PostgreSQL, Redis, etc.). The framework's
    #: production-mode gate refuses non-durable registries unless
    #: the operator explicitly opts in via
    #: ``ADCP_DECISIONING_ALLOW_INMEMORY_TASKS=1``.
    is_durable: ClassVar[bool]

    async def issue(
        self,
        *,
        account_id: str,
        task_type: str,
        request_context: dict[str, Any] | None = None,
        **_extra: Any,
    ) -> str:
        """Allocate a fresh task_id, persist a ``submitted`` row, and
        return the id.

        :param account_id: Account that owns the task. Drives the
            cross-tenant access check on subsequent reads.
        :param task_type: Wire-spec task type (``'create_media_buy'``,
            etc.). Persisted on the row and surfaced on ``tasks/get``
            reads; NOT included in the synchronous Submitted envelope
            (per ``schemas/cache/core/protocol-envelope.json``).
        :param request_context: Buyer-supplied ``context`` extension
            from the request that issued this task. Persisted on the
            row and surfaced at the top level of ``tasks/get``
            responses (sibling of ``result`` / ``error``) so buyers
            can correlate polled task state with the kick-off
            request. ``None`` when the request carried no context
            field; the framework supplies it from the original
            request params. Adopters writing custom registries SHOULD
            store and surface this field; older registry impls that
            ignore it are functionally compatible (no echo on
            ``tasks/get`` reads, identical to pre-#563 behavior).
        :param _extra: Forward-compat slot for kwargs added by future
            framework versions. Custom registry impls MUST include
            ``**_extra: Any`` on their ``issue()`` signature so the
            framework can introduce new optional kwargs without
            breaking adopters who haven't yet adopted the new field.
            Implementations that don't recognize an extra kwarg
            should silently ignore it (the framework only relies on
            kwargs the Protocol explicitly declares). Logging the
            unrecognized keys at DEBUG level is encouraged so
            adopters notice when they've fallen behind.
        :returns: The framework-allocated task_id (string UUID).
        """
        ...

    async def update_progress(
        self,
        task_id: str,
        progress: dict[str, Any],
    ) -> None:
        """Write a progress payload and transition ``submitted`` →
        ``working`` on first call. No-op transition on subsequent
        calls (already in ``working``).

        Errors here are swallowed by the dispatch wrapper — a transient
        registry write failure must NOT abort the adopter's background
        handoff. Buyer-facing impact is a missed progress event, not a
        failed task. Adopter impls of this method that need durability
        guarantees should buffer + retry internally.
        """
        ...

    async def complete(
        self,
        task_id: str,
        result: dict[str, Any],
    ) -> None:
        """Mark the task ``completed`` with ``result`` as the terminal
        artifact.

        ``result`` MUST be the JSON-serialized spec response shape
        (e.g. ``CreateMediaBuySuccessResponse`` via ``model_dump()``).
        Idempotent on repeated calls with equal ``result``;
        non-idempotent re-completion with different result raises
        ``ValueError``.
        """
        ...

    async def fail(
        self,
        task_id: str,
        error: dict[str, Any],
    ) -> None:
        """Mark the task ``failed`` with ``error`` as the terminal
        wire-shaped error payload.

        ``error`` MUST be the :meth:`AdcpError.to_wire` shape so
        ``tasks/get`` round-trips the spec ``adcp_error`` envelope
        verbatim. Idempotent on repeated calls with equal ``error``.
        """
        ...

    async def get(
        self,
        task_id: str,
        *,
        expected_account_id: str | None = None,
    ) -> dict[str, Any] | None:
        """Look up a task record. Cross-tenant probes return ``None``.

        :param task_id: Framework-allocated id from a prior :meth:`issue`.
        :param expected_account_id: When supplied, the registry MUST
            return ``None`` if the stored record's ``account_id`` does
            not match. The wire ``tasks/get`` path always supplies the
            authenticated principal's account_id so adopters can't
            probe across tenants.
        :returns: The record dict (per :meth:`TaskRecord.to_dict`) or
            ``None`` if the id is unknown OR a cross-tenant mismatch.
        """
        ...

    async def discard(self, task_id: str) -> None:
        """Remove a task_id from the registry — rollback path.

        Used by the WorkflowHandoff dispatch projection
        (:func:`adcp.decisioning.dispatch._project_workflow_handoff`)
        when the adopter's enqueue fn raises after the task_id has
        been allocated. Without rollback, the buyer would receive a
        Submitted envelope referencing an orphan task_id their
        external workflow never registered.

        Idempotent: discarding an unknown task_id is a no-op (no
        raise). The discard window is tightly scoped — between
        ``issue()`` and the framework's projection step, with the
        adopter's enqueue fn in between. In practice this is a few
        milliseconds.

        Adopters MUST NOT call ``discard`` on a task that has
        progressed past ``submitted`` — that's the wrong recovery
        path; use ``fail()`` instead.
        """
        ...

Per-account task store — the seam adopters substitute for a durable backing implementation.

Durability marker (is_durable: ClassVar[bool]):

Production deployments running sales-broadcast-tv or any HITL flow refuse to start with a non-durable registry unless the operator explicitly opts in via ADCP_DECISIONING_ALLOW_INMEMORY_TASKS=1. The framework reads registry.is_durable to make this decision; subclassing :class:InMemoryTaskRegistry for instrumentation does NOT bypass the gate (the subclass inherits is_durable = False). Custom durable impls MUST set is_durable = True explicitly. The Protocol declares this as a class-level bool.

Lifecycle (framework-driven; adopters call only :meth:TaskHandoffContext methods, not these directly):

  1. Dispatch detects ctx.handoff_to_task(fn) returned from a platform method. Allocates a task_id and calls :meth:issue to persist the submitted row.
  2. Dispatch projects the wire Submitted envelope to the buyer.
  3. Dispatch runs fn(task_handoff_ctx) in the background. The adopter calls task_handoff_ctx.update(progress) zero or more times; the framework routes each to :meth:update_progress (also transitions submittedworking on first update).
  4. When fn returns, dispatch calls :meth:complete with the terminal artifact (a JSON-serialized spec response).
  5. When fn raises :class:AdcpError (or any exception, wrapped to INTERNAL_ERROR), dispatch calls :meth:fail with the wire-shaped error payload.

All write paths set updated_at = now. The registry is expected to be safe for concurrent reads; concurrent writes to the same task are serialized by the dispatcher (one fn per handoff, no concurrent update_progress/complete against the same task_id).

Cross-tenant safety: every read MUST be account-scoped. The :meth:get method takes an optional expected_account_id — when supplied (the wire tasks/get path always supplies it), a mismatch returns None, NOT the raw record. Adopters implementing custom registries MUST honor this: returning a cross-tenant record on probe enables principal-enumeration via task_id guessing. See tests/test_decisioning_task_registry_cross_tenant.py for the regression suite.

account_id is opaque. The framework threads ctx.account.id (whatever the adopter's :class:~adcp.decisioning.AccountStore.resolve returned) into every method. The registry MUST NOT parse it or re-derive tenant scope from it. Multi-tenant adopters encode their tenant scope into Account.id once at the :class:~adcp.decisioning.AccountStore layer; the registry then gets a globally-unique scope key and the cross-tenant probe check above degenerates to a simple equality. See the AccountStore docstring's "Multi-tenant deployments" section for the canonical encoding pattern.

Ancestors

  • typing.Protocol
  • typing.Generic

Class variables

var is_durable : ClassVar[bool]

Whether this registry persists tasks across process restarts. False for in-memory / lossy impls; True for durable backings (PostgreSQL, Redis, etc.). The framework's production-mode gate refuses non-durable registries unless the operator explicitly opts in via ADCP_DECISIONING_ALLOW_INMEMORY_TASKS=1.

Methods

async def complete(self, task_id: str, result: dict[str, Any]) ‑> None
Expand source code
async def complete(
    self,
    task_id: str,
    result: dict[str, Any],
) -> None:
    """Mark the task ``completed`` with ``result`` as the terminal
    artifact.

    ``result`` MUST be the JSON-serialized spec response shape
    (e.g. ``CreateMediaBuySuccessResponse`` via ``model_dump()``).
    Idempotent on repeated calls with equal ``result``;
    non-idempotent re-completion with different result raises
    ``ValueError``.
    """
    ...

Mark the task completed with result as the terminal artifact.

result MUST be the JSON-serialized spec response shape (e.g. CreateMediaBuySuccessResponse via model_dump()). Idempotent on repeated calls with equal result; non-idempotent re-completion with different result raises ValueError.

async def discard(self, task_id: str) ‑> None
Expand source code
async def discard(self, task_id: str) -> None:
    """Remove a task_id from the registry — rollback path.

    Used by the WorkflowHandoff dispatch projection
    (:func:`adcp.decisioning.dispatch._project_workflow_handoff`)
    when the adopter's enqueue fn raises after the task_id has
    been allocated. Without rollback, the buyer would receive a
    Submitted envelope referencing an orphan task_id their
    external workflow never registered.

    Idempotent: discarding an unknown task_id is a no-op (no
    raise). The discard window is tightly scoped — between
    ``issue()`` and the framework's projection step, with the
    adopter's enqueue fn in between. In practice this is a few
    milliseconds.

    Adopters MUST NOT call ``discard`` on a task that has
    progressed past ``submitted`` — that's the wrong recovery
    path; use ``fail()`` instead.
    """
    ...

Remove a task_id from the registry — rollback path.

Used by the WorkflowHandoff dispatch projection (:func:adcp.decisioning.dispatch._project_workflow_handoff) when the adopter's enqueue fn raises after the task_id has been allocated. Without rollback, the buyer would receive a Submitted envelope referencing an orphan task_id their external workflow never registered.

Idempotent: discarding an unknown task_id is a no-op (no raise). The discard window is tightly scoped — between issue() and the framework's projection step, with the adopter's enqueue fn in between. In practice this is a few milliseconds.

Adopters MUST NOT call discard on a task that has progressed past submitted — that's the wrong recovery path; use fail() instead.

async def fail(self, task_id: str, error: dict[str, Any]) ‑> None
Expand source code
async def fail(
    self,
    task_id: str,
    error: dict[str, Any],
) -> None:
    """Mark the task ``failed`` with ``error`` as the terminal
    wire-shaped error payload.

    ``error`` MUST be the :meth:`AdcpError.to_wire` shape so
    ``tasks/get`` round-trips the spec ``adcp_error`` envelope
    verbatim. Idempotent on repeated calls with equal ``error``.
    """
    ...

Mark the task failed with error as the terminal wire-shaped error payload.

error MUST be the :meth:AdcpError.to_wire() shape so tasks/get round-trips the spec adcp_error envelope verbatim. Idempotent on repeated calls with equal error.

async def get(self, task_id: str, *, expected_account_id: str | None = None) ‑> dict[str, typing.Any] | None
Expand source code
async def get(
    self,
    task_id: str,
    *,
    expected_account_id: str | None = None,
) -> dict[str, Any] | None:
    """Look up a task record. Cross-tenant probes return ``None``.

    :param task_id: Framework-allocated id from a prior :meth:`issue`.
    :param expected_account_id: When supplied, the registry MUST
        return ``None`` if the stored record's ``account_id`` does
        not match. The wire ``tasks/get`` path always supplies the
        authenticated principal's account_id so adopters can't
        probe across tenants.
    :returns: The record dict (per :meth:`TaskRecord.to_dict`) or
        ``None`` if the id is unknown OR a cross-tenant mismatch.
    """
    ...

Look up a task record. Cross-tenant probes return None.

:param task_id: Framework-allocated id from a prior :meth:issue. :param expected_account_id: When supplied, the registry MUST return None if the stored record's account_id does not match. The wire tasks/get path always supplies the authenticated principal's account_id so adopters can't probe across tenants. :returns: The record dict (per :meth:TaskRecord.to_dict) or None if the id is unknown OR a cross-tenant mismatch.

async def issue(self,
*,
account_id: str,
task_type: str,
request_context: dict[str, Any] | None = None,
**_extra: Any) ‑> str
Expand source code
async def issue(
    self,
    *,
    account_id: str,
    task_type: str,
    request_context: dict[str, Any] | None = None,
    **_extra: Any,
) -> str:
    """Allocate a fresh task_id, persist a ``submitted`` row, and
    return the id.

    :param account_id: Account that owns the task. Drives the
        cross-tenant access check on subsequent reads.
    :param task_type: Wire-spec task type (``'create_media_buy'``,
        etc.). Persisted on the row and surfaced on ``tasks/get``
        reads; NOT included in the synchronous Submitted envelope
        (per ``schemas/cache/core/protocol-envelope.json``).
    :param request_context: Buyer-supplied ``context`` extension
        from the request that issued this task. Persisted on the
        row and surfaced at the top level of ``tasks/get``
        responses (sibling of ``result`` / ``error``) so buyers
        can correlate polled task state with the kick-off
        request. ``None`` when the request carried no context
        field; the framework supplies it from the original
        request params. Adopters writing custom registries SHOULD
        store and surface this field; older registry impls that
        ignore it are functionally compatible (no echo on
        ``tasks/get`` reads, identical to pre-#563 behavior).
    :param _extra: Forward-compat slot for kwargs added by future
        framework versions. Custom registry impls MUST include
        ``**_extra: Any`` on their ``issue()`` signature so the
        framework can introduce new optional kwargs without
        breaking adopters who haven't yet adopted the new field.
        Implementations that don't recognize an extra kwarg
        should silently ignore it (the framework only relies on
        kwargs the Protocol explicitly declares). Logging the
        unrecognized keys at DEBUG level is encouraged so
        adopters notice when they've fallen behind.
    :returns: The framework-allocated task_id (string UUID).
    """
    ...

Allocate a fresh task_id, persist a submitted row, and return the id.

:param account_id: Account that owns the task. Drives the cross-tenant access check on subsequent reads. :param task_type: Wire-spec task type ('create_media_buy', etc.). Persisted on the row and surfaced on tasks/get reads; NOT included in the synchronous Submitted envelope (per schemas/cache/core/protocol-envelope.json). :param request_context: Buyer-supplied adcp.decisioning.context extension from the request that issued this task. Persisted on the row and surfaced at the top level of tasks/get responses (sibling of result / error) so buyers can correlate polled task state with the kick-off request. None when the request carried no context field; the framework supplies it from the original request params. Adopters writing custom registries SHOULD store and surface this field; older registry impls that ignore it are functionally compatible (no echo on tasks/get reads, identical to pre-#563 behavior). :param _extra: Forward-compat slot for kwargs added by future framework versions. Custom registry impls MUST include **_extra: Any on their issue() signature so the framework can introduce new optional kwargs without breaking adopters who haven't yet adopted the new field. Implementations that don't recognize an extra kwarg should silently ignore it (the framework only relies on kwargs the Protocol explicitly declares). Logging the unrecognized keys at DEBUG level is encouraged so adopters notice when they've fallen behind. :returns: The framework-allocated task_id (string UUID).

async def update_progress(self, task_id: str, progress: dict[str, Any]) ‑> None
Expand source code
async def update_progress(
    self,
    task_id: str,
    progress: dict[str, Any],
) -> None:
    """Write a progress payload and transition ``submitted`` →
    ``working`` on first call. No-op transition on subsequent
    calls (already in ``working``).

    Errors here are swallowed by the dispatch wrapper — a transient
    registry write failure must NOT abort the adopter's background
    handoff. Buyer-facing impact is a missed progress event, not a
    failed task. Adopter impls of this method that need durability
    guarantees should buffer + retry internally.
    """
    ...

Write a progress payload and transition submittedworking on first call. No-op transition on subsequent calls (already in working).

Errors here are swallowed by the dispatch wrapper — a transient registry write failure must NOT abort the adopter's background handoff. Buyer-facing impact is a missed progress event, not a failed task. Adopter impls of this method that need durability guarantees should buffer + retry internally.

class TranslationMap (adcp_to_upstream: Mapping[A, U],
*,
default_adcp: A | None = None,
default_upstream: U | None = None)
Expand source code
class TranslationMap(Generic[A, U]):
    """Reversible mapping between AdCP wire values and upstream platform values.

    Construct via :func:`create_translation_map`. Both lookup methods
    raise :class:`KeyError` for unknown keys unless a default is
    configured. Use the ``has_*`` predicates to guard a lookup, or
    pass ``default_adcp`` / ``default_upstream`` for graceful
    fallback.

    Example::

        channel_map = create_translation_map({
            "olv": "video",
            "ctv": "ctv",
            "display": "display",
            "streaming_audio": "audio",
        })
        channel_map.to_upstream("olv")     # "video"
        channel_map.to_adcp("video")       # "olv"
        channel_map.has_adcp("olv")        # True
        channel_map.has_upstream("audio")  # True
    """

    __slots__ = ("_forward", "_reverse", "_default_adcp", "_default_upstream")

    def __init__(
        self,
        adcp_to_upstream: Mapping[A, U],
        *,
        default_adcp: A | None = None,
        default_upstream: U | None = None,
    ) -> None:
        self._forward: dict[A, U] = dict(adcp_to_upstream)
        self._default_adcp = default_adcp
        self._default_upstream = default_upstream

        # Build reverse map and detect collisions in the same pass. Two
        # AdCP keys mapping to the same upstream value would silently
        # overwrite without this check — fail loud at construction so
        # the bug is caught at boot, not on a request.
        reverse: dict[U, A] = {}
        for adcp_key, upstream_key in self._forward.items():
            if upstream_key in reverse:
                raise ValueError(
                    f"translation collision: AdCP keys "
                    f"{reverse[upstream_key]!r} and {adcp_key!r} both map to "
                    f"upstream value {upstream_key!r}"
                )
            reverse[upstream_key] = adcp_key
        self._reverse: dict[U, A] = reverse

    def to_upstream(self, adcp_key: A) -> U:
        """Translate an AdCP wire value to the upstream platform value.

        :raises KeyError: when ``adcp_key`` isn't in the map and no
            ``default_upstream`` was provided.
        """
        if adcp_key in self._forward:
            return self._forward[adcp_key]
        if self._default_upstream is not None:
            return self._default_upstream
        known = sorted(self._forward.keys(), key=repr)
        raise KeyError(f"unknown AdCP key {adcp_key!r}; known keys: {known!r}")

    def to_adcp(self, upstream_key: U) -> A:
        """Translate an upstream platform value back to the AdCP wire value.

        :raises KeyError: when ``upstream_key`` isn't in the map and
            no ``default_adcp`` was provided.
        """
        if upstream_key in self._reverse:
            return self._reverse[upstream_key]
        if self._default_adcp is not None:
            return self._default_adcp
        known = sorted(self._reverse.keys(), key=repr)
        raise KeyError(f"unknown upstream key {upstream_key!r}; known keys: {known!r}")

    def has_adcp(self, value: object) -> bool:
        """True when ``value`` is a known AdCP-side key."""
        return value in self._forward

    def has_upstream(self, value: object) -> bool:
        """True when ``value`` is a known upstream-side key."""
        return value in self._reverse

Reversible mapping between AdCP wire values and upstream platform values.

Construct via :func:create_translation_map(). Both lookup methods raise :class:KeyError for unknown keys unless a default is configured. Use the has_* predicates to guard a lookup, or pass default_adcp / default_upstream for graceful fallback.

Example::

channel_map = create_translation_map({
    "olv": "video",
    "ctv": "ctv",
    "display": "display",
    "streaming_audio": "audio",
})
channel_map.to_upstream("olv")     # "video"
channel_map.to_adcp("video")       # "olv"
channel_map.has_adcp("olv")        # True
channel_map.has_upstream("audio")  # True

Ancestors

  • typing.Generic

Methods

def has_adcp(self, value: object) ‑> bool
Expand source code
def has_adcp(self, value: object) -> bool:
    """True when ``value`` is a known AdCP-side key."""
    return value in self._forward

True when value is a known AdCP-side key.

def has_upstream(self, value: object) ‑> bool
Expand source code
def has_upstream(self, value: object) -> bool:
    """True when ``value`` is a known upstream-side key."""
    return value in self._reverse

True when value is a known upstream-side key.

def to_adcp(self, upstream_key: U) ‑> ~A
Expand source code
def to_adcp(self, upstream_key: U) -> A:
    """Translate an upstream platform value back to the AdCP wire value.

    :raises KeyError: when ``upstream_key`` isn't in the map and
        no ``default_adcp`` was provided.
    """
    if upstream_key in self._reverse:
        return self._reverse[upstream_key]
    if self._default_adcp is not None:
        return self._default_adcp
    known = sorted(self._reverse.keys(), key=repr)
    raise KeyError(f"unknown upstream key {upstream_key!r}; known keys: {known!r}")

Translate an upstream platform value back to the AdCP wire value.

:raises KeyError: when upstream_key isn't in the map and no default_adcp was provided.

def to_upstream(self, adcp_key: A) ‑> ~U
Expand source code
def to_upstream(self, adcp_key: A) -> U:
    """Translate an AdCP wire value to the upstream platform value.

    :raises KeyError: when ``adcp_key`` isn't in the map and no
        ``default_upstream`` was provided.
    """
    if adcp_key in self._forward:
        return self._forward[adcp_key]
    if self._default_upstream is not None:
        return self._default_upstream
    known = sorted(self._forward.keys(), key=repr)
    raise KeyError(f"unknown AdCP key {adcp_key!r}; known keys: {known!r}")

Translate an AdCP wire value to the upstream platform value.

:raises KeyError: when adcp_key isn't in the map and no default_upstream was provided.

class UnsupportedFeatureError (*, message: str | None = None, field: str | None = None, **details: Any)
Expand source code
class UnsupportedFeatureError(AdcpError):
    """Spec ``UNSUPPORTED_FEATURE`` (``recovery='correctable'``).

    Raised when a requested feature or field is not supported by this
    seller. The buyer checks ``get_adcp_capabilities`` and removes the
    unsupported field.
    """

    def __init__(
        self,
        *,
        message: str | None = None,
        field: str | None = None,
        **details: Any,
    ) -> None:
        super().__init__(
            "UNSUPPORTED_FEATURE",
            message=message or "Requested feature is not supported by this seller.",
            recovery="correctable",
            field=field,
            details=dict(details) or None,
        )

Spec UNSUPPORTED_FEATURE (recovery='correctable').

Raised when a requested feature or field is not supported by this seller. The buyer checks get_adcp_capabilities and removes the unsupported field.

Ancestors

  • AdcpError
  • builtins.Exception
  • builtins.BaseException

Inherited members

class UpdateMediaBuyMutation (action: str,
field_paths: tuple[str, ...],
package_id: str | None = None,
before: Any = None,
after: Any = None,
raw: Any = None,
resolution: UpdateMutationResolution = 'fine',
allowed_action_candidates: tuple[str, ...] = <factory>)
Expand source code
@dataclass(frozen=True, slots=True)
class UpdateMediaBuyMutation:
    """A single logical mutation requested by an ``update_media_buy`` patch.

    ``action`` is the most specific action inferred from the patch. When the
    patch does not carry enough information to choose a fine-grained action,
    the helper emits the closest coarse action and marks ``resolution`` as
    ``"coarse"``.

    ``allowed_action_candidates`` are ordered from specific to broad. A caller
    can use them to match a mutation against either fine-grained capabilities
    such as ``increase_budget`` or coarse capabilities such as
    ``update_budget`` / ``update_packages``.
    """

    action: str
    field_paths: tuple[str, ...]
    package_id: str | None = None
    before: Any = None
    after: Any = None
    raw: Any = None
    resolution: UpdateMutationResolution = "fine"
    allowed_action_candidates: tuple[str, ...] = field(default_factory=tuple)

    def is_allowed_by(
        self,
        allowed_actions: Iterable[Any] | None,
        *,
        allowed_modes: Iterable[str] | None = SELF_SERVE_UPDATE_ACTION_MODES,
    ) -> bool:
        """Return whether ``allowed_actions`` covers this mutation.

        ``allowed_actions`` may be a simple string list or the wire
        ``available_actions[]`` shape returned by ``get_media_buys``. By
        default, wire entries must be immediately executable
        (``self_serve`` or ``conditional_self_serve``); pass
        ``allowed_modes=None`` to ignore mode and check only action presence.
        """

        allowed = set(
            normalize_update_media_buy_allowed_actions(
                allowed_actions,
                allowed_modes=allowed_modes,
            )
        )
        return any(candidate in allowed for candidate in self.allowed_action_candidates)

A single logical mutation requested by an adcp.decisioning.update_media_buy patch.

action is the most specific action inferred from the patch. When the patch does not carry enough information to choose a fine-grained action, the helper emits the closest coarse action and marks resolution as "coarse".

allowed_action_candidates are ordered from specific to broad. A caller can use them to match a mutation against either fine-grained capabilities such as increase_budget or coarse capabilities such as update_budget / update_packages.

Instance variables

var action : str
Expand source code
@dataclass(frozen=True, slots=True)
class UpdateMediaBuyMutation:
    """A single logical mutation requested by an ``update_media_buy`` patch.

    ``action`` is the most specific action inferred from the patch. When the
    patch does not carry enough information to choose a fine-grained action,
    the helper emits the closest coarse action and marks ``resolution`` as
    ``"coarse"``.

    ``allowed_action_candidates`` are ordered from specific to broad. A caller
    can use them to match a mutation against either fine-grained capabilities
    such as ``increase_budget`` or coarse capabilities such as
    ``update_budget`` / ``update_packages``.
    """

    action: str
    field_paths: tuple[str, ...]
    package_id: str | None = None
    before: Any = None
    after: Any = None
    raw: Any = None
    resolution: UpdateMutationResolution = "fine"
    allowed_action_candidates: tuple[str, ...] = field(default_factory=tuple)

    def is_allowed_by(
        self,
        allowed_actions: Iterable[Any] | None,
        *,
        allowed_modes: Iterable[str] | None = SELF_SERVE_UPDATE_ACTION_MODES,
    ) -> bool:
        """Return whether ``allowed_actions`` covers this mutation.

        ``allowed_actions`` may be a simple string list or the wire
        ``available_actions[]`` shape returned by ``get_media_buys``. By
        default, wire entries must be immediately executable
        (``self_serve`` or ``conditional_self_serve``); pass
        ``allowed_modes=None`` to ignore mode and check only action presence.
        """

        allowed = set(
            normalize_update_media_buy_allowed_actions(
                allowed_actions,
                allowed_modes=allowed_modes,
            )
        )
        return any(candidate in allowed for candidate in self.allowed_action_candidates)
var after : Any
Expand source code
@dataclass(frozen=True, slots=True)
class UpdateMediaBuyMutation:
    """A single logical mutation requested by an ``update_media_buy`` patch.

    ``action`` is the most specific action inferred from the patch. When the
    patch does not carry enough information to choose a fine-grained action,
    the helper emits the closest coarse action and marks ``resolution`` as
    ``"coarse"``.

    ``allowed_action_candidates`` are ordered from specific to broad. A caller
    can use them to match a mutation against either fine-grained capabilities
    such as ``increase_budget`` or coarse capabilities such as
    ``update_budget`` / ``update_packages``.
    """

    action: str
    field_paths: tuple[str, ...]
    package_id: str | None = None
    before: Any = None
    after: Any = None
    raw: Any = None
    resolution: UpdateMutationResolution = "fine"
    allowed_action_candidates: tuple[str, ...] = field(default_factory=tuple)

    def is_allowed_by(
        self,
        allowed_actions: Iterable[Any] | None,
        *,
        allowed_modes: Iterable[str] | None = SELF_SERVE_UPDATE_ACTION_MODES,
    ) -> bool:
        """Return whether ``allowed_actions`` covers this mutation.

        ``allowed_actions`` may be a simple string list or the wire
        ``available_actions[]`` shape returned by ``get_media_buys``. By
        default, wire entries must be immediately executable
        (``self_serve`` or ``conditional_self_serve``); pass
        ``allowed_modes=None`` to ignore mode and check only action presence.
        """

        allowed = set(
            normalize_update_media_buy_allowed_actions(
                allowed_actions,
                allowed_modes=allowed_modes,
            )
        )
        return any(candidate in allowed for candidate in self.allowed_action_candidates)
var allowed_action_candidates : tuple[str, ...]
Expand source code
@dataclass(frozen=True, slots=True)
class UpdateMediaBuyMutation:
    """A single logical mutation requested by an ``update_media_buy`` patch.

    ``action`` is the most specific action inferred from the patch. When the
    patch does not carry enough information to choose a fine-grained action,
    the helper emits the closest coarse action and marks ``resolution`` as
    ``"coarse"``.

    ``allowed_action_candidates`` are ordered from specific to broad. A caller
    can use them to match a mutation against either fine-grained capabilities
    such as ``increase_budget`` or coarse capabilities such as
    ``update_budget`` / ``update_packages``.
    """

    action: str
    field_paths: tuple[str, ...]
    package_id: str | None = None
    before: Any = None
    after: Any = None
    raw: Any = None
    resolution: UpdateMutationResolution = "fine"
    allowed_action_candidates: tuple[str, ...] = field(default_factory=tuple)

    def is_allowed_by(
        self,
        allowed_actions: Iterable[Any] | None,
        *,
        allowed_modes: Iterable[str] | None = SELF_SERVE_UPDATE_ACTION_MODES,
    ) -> bool:
        """Return whether ``allowed_actions`` covers this mutation.

        ``allowed_actions`` may be a simple string list or the wire
        ``available_actions[]`` shape returned by ``get_media_buys``. By
        default, wire entries must be immediately executable
        (``self_serve`` or ``conditional_self_serve``); pass
        ``allowed_modes=None`` to ignore mode and check only action presence.
        """

        allowed = set(
            normalize_update_media_buy_allowed_actions(
                allowed_actions,
                allowed_modes=allowed_modes,
            )
        )
        return any(candidate in allowed for candidate in self.allowed_action_candidates)
var before : Any
Expand source code
@dataclass(frozen=True, slots=True)
class UpdateMediaBuyMutation:
    """A single logical mutation requested by an ``update_media_buy`` patch.

    ``action`` is the most specific action inferred from the patch. When the
    patch does not carry enough information to choose a fine-grained action,
    the helper emits the closest coarse action and marks ``resolution`` as
    ``"coarse"``.

    ``allowed_action_candidates`` are ordered from specific to broad. A caller
    can use them to match a mutation against either fine-grained capabilities
    such as ``increase_budget`` or coarse capabilities such as
    ``update_budget`` / ``update_packages``.
    """

    action: str
    field_paths: tuple[str, ...]
    package_id: str | None = None
    before: Any = None
    after: Any = None
    raw: Any = None
    resolution: UpdateMutationResolution = "fine"
    allowed_action_candidates: tuple[str, ...] = field(default_factory=tuple)

    def is_allowed_by(
        self,
        allowed_actions: Iterable[Any] | None,
        *,
        allowed_modes: Iterable[str] | None = SELF_SERVE_UPDATE_ACTION_MODES,
    ) -> bool:
        """Return whether ``allowed_actions`` covers this mutation.

        ``allowed_actions`` may be a simple string list or the wire
        ``available_actions[]`` shape returned by ``get_media_buys``. By
        default, wire entries must be immediately executable
        (``self_serve`` or ``conditional_self_serve``); pass
        ``allowed_modes=None`` to ignore mode and check only action presence.
        """

        allowed = set(
            normalize_update_media_buy_allowed_actions(
                allowed_actions,
                allowed_modes=allowed_modes,
            )
        )
        return any(candidate in allowed for candidate in self.allowed_action_candidates)
var field_paths : tuple[str, ...]
Expand source code
@dataclass(frozen=True, slots=True)
class UpdateMediaBuyMutation:
    """A single logical mutation requested by an ``update_media_buy`` patch.

    ``action`` is the most specific action inferred from the patch. When the
    patch does not carry enough information to choose a fine-grained action,
    the helper emits the closest coarse action and marks ``resolution`` as
    ``"coarse"``.

    ``allowed_action_candidates`` are ordered from specific to broad. A caller
    can use them to match a mutation against either fine-grained capabilities
    such as ``increase_budget`` or coarse capabilities such as
    ``update_budget`` / ``update_packages``.
    """

    action: str
    field_paths: tuple[str, ...]
    package_id: str | None = None
    before: Any = None
    after: Any = None
    raw: Any = None
    resolution: UpdateMutationResolution = "fine"
    allowed_action_candidates: tuple[str, ...] = field(default_factory=tuple)

    def is_allowed_by(
        self,
        allowed_actions: Iterable[Any] | None,
        *,
        allowed_modes: Iterable[str] | None = SELF_SERVE_UPDATE_ACTION_MODES,
    ) -> bool:
        """Return whether ``allowed_actions`` covers this mutation.

        ``allowed_actions`` may be a simple string list or the wire
        ``available_actions[]`` shape returned by ``get_media_buys``. By
        default, wire entries must be immediately executable
        (``self_serve`` or ``conditional_self_serve``); pass
        ``allowed_modes=None`` to ignore mode and check only action presence.
        """

        allowed = set(
            normalize_update_media_buy_allowed_actions(
                allowed_actions,
                allowed_modes=allowed_modes,
            )
        )
        return any(candidate in allowed for candidate in self.allowed_action_candidates)
var package_id : str | None
Expand source code
@dataclass(frozen=True, slots=True)
class UpdateMediaBuyMutation:
    """A single logical mutation requested by an ``update_media_buy`` patch.

    ``action`` is the most specific action inferred from the patch. When the
    patch does not carry enough information to choose a fine-grained action,
    the helper emits the closest coarse action and marks ``resolution`` as
    ``"coarse"``.

    ``allowed_action_candidates`` are ordered from specific to broad. A caller
    can use them to match a mutation against either fine-grained capabilities
    such as ``increase_budget`` or coarse capabilities such as
    ``update_budget`` / ``update_packages``.
    """

    action: str
    field_paths: tuple[str, ...]
    package_id: str | None = None
    before: Any = None
    after: Any = None
    raw: Any = None
    resolution: UpdateMutationResolution = "fine"
    allowed_action_candidates: tuple[str, ...] = field(default_factory=tuple)

    def is_allowed_by(
        self,
        allowed_actions: Iterable[Any] | None,
        *,
        allowed_modes: Iterable[str] | None = SELF_SERVE_UPDATE_ACTION_MODES,
    ) -> bool:
        """Return whether ``allowed_actions`` covers this mutation.

        ``allowed_actions`` may be a simple string list or the wire
        ``available_actions[]`` shape returned by ``get_media_buys``. By
        default, wire entries must be immediately executable
        (``self_serve`` or ``conditional_self_serve``); pass
        ``allowed_modes=None`` to ignore mode and check only action presence.
        """

        allowed = set(
            normalize_update_media_buy_allowed_actions(
                allowed_actions,
                allowed_modes=allowed_modes,
            )
        )
        return any(candidate in allowed for candidate in self.allowed_action_candidates)
var raw : Any
Expand source code
@dataclass(frozen=True, slots=True)
class UpdateMediaBuyMutation:
    """A single logical mutation requested by an ``update_media_buy`` patch.

    ``action`` is the most specific action inferred from the patch. When the
    patch does not carry enough information to choose a fine-grained action,
    the helper emits the closest coarse action and marks ``resolution`` as
    ``"coarse"``.

    ``allowed_action_candidates`` are ordered from specific to broad. A caller
    can use them to match a mutation against either fine-grained capabilities
    such as ``increase_budget`` or coarse capabilities such as
    ``update_budget`` / ``update_packages``.
    """

    action: str
    field_paths: tuple[str, ...]
    package_id: str | None = None
    before: Any = None
    after: Any = None
    raw: Any = None
    resolution: UpdateMutationResolution = "fine"
    allowed_action_candidates: tuple[str, ...] = field(default_factory=tuple)

    def is_allowed_by(
        self,
        allowed_actions: Iterable[Any] | None,
        *,
        allowed_modes: Iterable[str] | None = SELF_SERVE_UPDATE_ACTION_MODES,
    ) -> bool:
        """Return whether ``allowed_actions`` covers this mutation.

        ``allowed_actions`` may be a simple string list or the wire
        ``available_actions[]`` shape returned by ``get_media_buys``. By
        default, wire entries must be immediately executable
        (``self_serve`` or ``conditional_self_serve``); pass
        ``allowed_modes=None`` to ignore mode and check only action presence.
        """

        allowed = set(
            normalize_update_media_buy_allowed_actions(
                allowed_actions,
                allowed_modes=allowed_modes,
            )
        )
        return any(candidate in allowed for candidate in self.allowed_action_candidates)
var resolution : Literal['fine', 'coarse', 'unknown']
Expand source code
@dataclass(frozen=True, slots=True)
class UpdateMediaBuyMutation:
    """A single logical mutation requested by an ``update_media_buy`` patch.

    ``action`` is the most specific action inferred from the patch. When the
    patch does not carry enough information to choose a fine-grained action,
    the helper emits the closest coarse action and marks ``resolution`` as
    ``"coarse"``.

    ``allowed_action_candidates`` are ordered from specific to broad. A caller
    can use them to match a mutation against either fine-grained capabilities
    such as ``increase_budget`` or coarse capabilities such as
    ``update_budget`` / ``update_packages``.
    """

    action: str
    field_paths: tuple[str, ...]
    package_id: str | None = None
    before: Any = None
    after: Any = None
    raw: Any = None
    resolution: UpdateMutationResolution = "fine"
    allowed_action_candidates: tuple[str, ...] = field(default_factory=tuple)

    def is_allowed_by(
        self,
        allowed_actions: Iterable[Any] | None,
        *,
        allowed_modes: Iterable[str] | None = SELF_SERVE_UPDATE_ACTION_MODES,
    ) -> bool:
        """Return whether ``allowed_actions`` covers this mutation.

        ``allowed_actions`` may be a simple string list or the wire
        ``available_actions[]`` shape returned by ``get_media_buys``. By
        default, wire entries must be immediately executable
        (``self_serve`` or ``conditional_self_serve``); pass
        ``allowed_modes=None`` to ignore mode and check only action presence.
        """

        allowed = set(
            normalize_update_media_buy_allowed_actions(
                allowed_actions,
                allowed_modes=allowed_modes,
            )
        )
        return any(candidate in allowed for candidate in self.allowed_action_candidates)

Methods

def is_allowed_by(self,
allowed_actions: Iterable[Any] | None,
*,
allowed_modes: Iterable[str] | None = ('self_serve', 'conditional_self_serve')) ‑> bool
Expand source code
def is_allowed_by(
    self,
    allowed_actions: Iterable[Any] | None,
    *,
    allowed_modes: Iterable[str] | None = SELF_SERVE_UPDATE_ACTION_MODES,
) -> bool:
    """Return whether ``allowed_actions`` covers this mutation.

    ``allowed_actions`` may be a simple string list or the wire
    ``available_actions[]`` shape returned by ``get_media_buys``. By
    default, wire entries must be immediately executable
    (``self_serve`` or ``conditional_self_serve``); pass
    ``allowed_modes=None`` to ignore mode and check only action presence.
    """

    allowed = set(
        normalize_update_media_buy_allowed_actions(
            allowed_actions,
            allowed_modes=allowed_modes,
        )
    )
    return any(candidate in allowed for candidate in self.allowed_action_candidates)

Return whether allowed_actions covers this mutation.

allowed_actions may be a simple string list or the wire available_actions[] shape returned by get_media_buys. By default, wire entries must be immediately executable (self_serve or conditional_self_serve); pass allowed_modes=None to ignore mode and check only action presence.

class UpstreamHttpClient (*,
base_url: str,
auth: UpstreamAuth,
default_headers: Mapping[str, str] | None = None,
timeout: float = 30.0,
treat_404_as_none: bool = True)
Expand source code
class UpstreamHttpClient:
    """Thin typed HTTP client over ``httpx.AsyncClient``.

    Construct via :func:`create_upstream_http_client`. Connection
    pooling is automatic (``max_keepalive_connections=10,
    max_connections=20``) and the client reuses a single
    ``httpx.AsyncClient`` across calls. Call :meth:`aclose` on shutdown
    to release the pool, or use ``async with`` if your wiring supports
    it.

    Method signatures:

    * :meth:`get` — returns parsed JSON ``dict``, or ``None`` on 404
      when ``treat_404_as_none=True``.
    * :meth:`post` / :meth:`put` — returns parsed JSON ``dict``.
    * :meth:`delete` — returns parsed JSON ``dict``, or ``None`` on
      404 when ``treat_404_as_none=True``.

    All non-2xx responses raise :class:`AdcpError` with a
    spec-conformant code. The 404 code is configurable per call via
    ``not_found_code`` for endpoints whose missing-resource semantics
    aren't a media buy.
    """

    def __init__(
        self,
        *,
        base_url: str,
        auth: UpstreamAuth,
        default_headers: Mapping[str, str] | None = None,
        timeout: float = 30.0,
        treat_404_as_none: bool = True,
    ) -> None:
        self._base_url = base_url.rstrip("/")
        self._auth = auth
        self._default_headers: dict[str, str] = dict(default_headers or {})
        self._timeout = timeout
        self._treat_404_as_none = treat_404_as_none
        self._client: httpx.AsyncClient | None = None

    async def _get_client(self) -> httpx.AsyncClient:
        if self._client is None:
            self._client = httpx.AsyncClient(
                base_url=self._base_url,
                timeout=self._timeout,
                limits=httpx.Limits(
                    max_keepalive_connections=10,
                    max_connections=20,
                ),
            )
        return self._client

    async def aclose(self) -> None:
        """Release the underlying ``httpx.AsyncClient`` connection pool."""
        if self._client is not None:
            await self._client.aclose()
            self._client = None

    async def __aenter__(self) -> UpstreamHttpClient:
        await self._get_client()
        return self

    async def __aexit__(self, *_: object) -> None:
        await self.aclose()

    async def _request(
        self,
        method: str,
        path: str,
        *,
        params: Mapping[str, Any] | None,
        json: Any,
        headers: Mapping[str, str] | None,
        auth_context: AuthContext | None,
        not_found_code: str,
    ) -> Any:
        auth_header = await _resolve_auth_header(self._auth, auth_context)
        merged: dict[str, str] = {**self._default_headers, **auth_header}
        if headers:
            merged.update(headers)
        if json is not None:
            # httpx adds Content-Type itself when ``json=`` is used, but
            # set it explicitly so adopter middleware sees it pre-flight.
            merged.setdefault("Content-Type", "application/json")

        # Drop None query params — caller-friendly: pass kwargs through
        # without filtering, matches the JS helper's behavior.
        cleaned_params: dict[str, Any] | None = None
        if params is not None:
            cleaned_params = {k: v for k, v in params.items() if v is not None}

        client = await self._get_client()
        response = await client.request(
            method,
            path,
            params=cleaned_params,
            json=json,
            headers=merged,
        )
        if response.status_code == 404 and self._treat_404_as_none:
            return None
        if response.status_code >= 300:
            try:
                body_text = response.text
            except Exception:  # pragma: no cover — defensive
                body_text = ""
            raise _project_status(
                response.status_code,
                not_found_code=not_found_code,
                method=method,
                path=path,
                body_text=body_text,
            )
        if response.status_code == 204 or not response.content:
            return {}
        try:
            parsed: Any = response.json()
        except ValueError as exc:
            # Server returned a successful status with a non-JSON body (e.g.
            # a proxy/CDN HTML error page). Project to SERVICE_UNAVAILABLE so
            # adopters get a typed AdcpError rather than a raw JSONDecodeError.
            raise AdcpError(
                "SERVICE_UNAVAILABLE",
                message=(
                    f"upstream {method} {path} returned non-JSON body "
                    f"(status {response.status_code}): {exc}"
                ),
                recovery="transient",
            ) from exc
        return parsed

    async def get(
        self,
        path: str,
        *,
        params: Mapping[str, Any] | None = None,
        headers: Mapping[str, str] | None = None,
        auth_context: AuthContext | None = None,
        not_found_code: str = _DEFAULT_NOT_FOUND_CODE,
    ) -> Any:
        """``GET path``. Returns parsed JSON, or ``None`` on 404 when ``treat_404_as_none``."""
        return await self._request(
            "GET",
            path,
            params=params,
            json=None,
            headers=headers,
            auth_context=auth_context,
            not_found_code=not_found_code,
        )

    async def post(
        self,
        path: str,
        *,
        json: Any = None,
        params: Mapping[str, Any] | None = None,
        headers: Mapping[str, str] | None = None,
        auth_context: AuthContext | None = None,
        not_found_code: str = _DEFAULT_NOT_FOUND_CODE,
    ) -> Any:
        """``POST path`` with JSON body. Returns parsed JSON."""
        result = await self._request(
            "POST",
            path,
            params=params,
            json=json,
            headers=headers,
            auth_context=auth_context,
            not_found_code=not_found_code,
        )
        # POST 404 is unusual; treat_404_as_none still applies but
        # callers don't expect None — surface as MEDIA_BUY_NOT_FOUND.
        if result is None:
            raise AdcpError(
                not_found_code,
                message=f"upstream POST {path} returned 404",
                recovery="terminal",
            )
        return result

    async def put(
        self,
        path: str,
        *,
        json: Any = None,
        params: Mapping[str, Any] | None = None,
        headers: Mapping[str, str] | None = None,
        auth_context: AuthContext | None = None,
        not_found_code: str = _DEFAULT_NOT_FOUND_CODE,
    ) -> Any:
        """``PUT path`` with JSON body. Returns parsed JSON."""
        result = await self._request(
            "PUT",
            path,
            params=params,
            json=json,
            headers=headers,
            auth_context=auth_context,
            not_found_code=not_found_code,
        )
        if result is None:
            raise AdcpError(
                not_found_code,
                message=f"upstream PUT {path} returned 404",
                recovery="terminal",
            )
        return result

    async def delete(
        self,
        path: str,
        *,
        params: Mapping[str, Any] | None = None,
        headers: Mapping[str, str] | None = None,
        auth_context: AuthContext | None = None,
        not_found_code: str = _DEFAULT_NOT_FOUND_CODE,
    ) -> Any:
        """``DELETE path``. Returns parsed JSON, or ``None`` on 404 when ``treat_404_as_none``."""
        return await self._request(
            "DELETE",
            path,
            params=params,
            json=None,
            headers=headers,
            auth_context=auth_context,
            not_found_code=not_found_code,
        )

Thin typed HTTP client over httpx.AsyncClient.

Construct via :func:create_upstream_http_client(). Connection pooling is automatic (max_keepalive_connections=10, max_connections=20) and the client reuses a single httpx.AsyncClient across calls. Call :meth:aclose on shutdown to release the pool, or use async with if your wiring supports it.

Method signatures:

  • :meth:get — returns parsed JSON dict, or None on 404 when treat_404_as_none=True.
  • :meth:post / :meth:put — returns parsed JSON dict.
  • :meth:delete — returns parsed JSON dict, or None on 404 when treat_404_as_none=True.

All non-2xx responses raise :class:AdcpError with a spec-conformant code. The 404 code is configurable per call via not_found_code for endpoints whose missing-resource semantics aren't a media buy.

Methods

async def aclose(self) ‑> None
Expand source code
async def aclose(self) -> None:
    """Release the underlying ``httpx.AsyncClient`` connection pool."""
    if self._client is not None:
        await self._client.aclose()
        self._client = None

Release the underlying httpx.AsyncClient connection pool.

async def delete(self,
path: str,
*,
params: Mapping[str, Any] | None = None,
headers: Mapping[str, str] | None = None,
auth_context: AuthContext | None = None,
not_found_code: str = 'MEDIA_BUY_NOT_FOUND') ‑> Any
Expand source code
async def delete(
    self,
    path: str,
    *,
    params: Mapping[str, Any] | None = None,
    headers: Mapping[str, str] | None = None,
    auth_context: AuthContext | None = None,
    not_found_code: str = _DEFAULT_NOT_FOUND_CODE,
) -> Any:
    """``DELETE path``. Returns parsed JSON, or ``None`` on 404 when ``treat_404_as_none``."""
    return await self._request(
        "DELETE",
        path,
        params=params,
        json=None,
        headers=headers,
        auth_context=auth_context,
        not_found_code=not_found_code,
    )

DELETE path. Returns parsed JSON, or None on 404 when treat_404_as_none.

async def get(self,
path: str,
*,
params: Mapping[str, Any] | None = None,
headers: Mapping[str, str] | None = None,
auth_context: AuthContext | None = None,
not_found_code: str = 'MEDIA_BUY_NOT_FOUND') ‑> Any
Expand source code
async def get(
    self,
    path: str,
    *,
    params: Mapping[str, Any] | None = None,
    headers: Mapping[str, str] | None = None,
    auth_context: AuthContext | None = None,
    not_found_code: str = _DEFAULT_NOT_FOUND_CODE,
) -> Any:
    """``GET path``. Returns parsed JSON, or ``None`` on 404 when ``treat_404_as_none``."""
    return await self._request(
        "GET",
        path,
        params=params,
        json=None,
        headers=headers,
        auth_context=auth_context,
        not_found_code=not_found_code,
    )

GET path. Returns parsed JSON, or None on 404 when treat_404_as_none.

async def post(self,
path: str,
*,
json: Any = None,
params: Mapping[str, Any] | None = None,
headers: Mapping[str, str] | None = None,
auth_context: AuthContext | None = None,
not_found_code: str = 'MEDIA_BUY_NOT_FOUND') ‑> Any
Expand source code
async def post(
    self,
    path: str,
    *,
    json: Any = None,
    params: Mapping[str, Any] | None = None,
    headers: Mapping[str, str] | None = None,
    auth_context: AuthContext | None = None,
    not_found_code: str = _DEFAULT_NOT_FOUND_CODE,
) -> Any:
    """``POST path`` with JSON body. Returns parsed JSON."""
    result = await self._request(
        "POST",
        path,
        params=params,
        json=json,
        headers=headers,
        auth_context=auth_context,
        not_found_code=not_found_code,
    )
    # POST 404 is unusual; treat_404_as_none still applies but
    # callers don't expect None — surface as MEDIA_BUY_NOT_FOUND.
    if result is None:
        raise AdcpError(
            not_found_code,
            message=f"upstream POST {path} returned 404",
            recovery="terminal",
        )
    return result

POST path with JSON body. Returns parsed JSON.

async def put(self,
path: str,
*,
json: Any = None,
params: Mapping[str, Any] | None = None,
headers: Mapping[str, str] | None = None,
auth_context: AuthContext | None = None,
not_found_code: str = 'MEDIA_BUY_NOT_FOUND') ‑> Any
Expand source code
async def put(
    self,
    path: str,
    *,
    json: Any = None,
    params: Mapping[str, Any] | None = None,
    headers: Mapping[str, str] | None = None,
    auth_context: AuthContext | None = None,
    not_found_code: str = _DEFAULT_NOT_FOUND_CODE,
) -> Any:
    """``PUT path`` with JSON body. Returns parsed JSON."""
    result = await self._request(
        "PUT",
        path,
        params=params,
        json=json,
        headers=headers,
        auth_context=auth_context,
        not_found_code=not_found_code,
    )
    if result is None:
        raise AdcpError(
            not_found_code,
            message=f"upstream PUT {path} returned 404",
            recovery="terminal",
        )
    return result

PUT path with JSON body. Returns parsed JSON.

class ValidationError (*,
message: str | None = None,
field: str | None = None,
suggestion: str | None = None,
**details: Any)
Expand source code
class ValidationError(AdcpError):
    """Spec ``VALIDATION_ERROR`` (``recovery='correctable'``).

    Raised when a request contains invalid field values or violates
    business rules beyond schema validation. ``field`` SHOULD identify
    the offending path so buyers can highlight the input.
    """

    def __init__(
        self,
        *,
        message: str | None = None,
        field: str | None = None,
        suggestion: str | None = None,
        **details: Any,
    ) -> None:
        super().__init__(
            "VALIDATION_ERROR",
            message=message or "Request failed validation.",
            recovery="correctable",
            field=field,
            suggestion=suggestion,
            details=dict(details) or None,
        )

Spec VALIDATION_ERROR (recovery='correctable').

Raised when a request contains invalid field values or violates business rules beyond schema validation. field SHOULD identify the offending path so buyers can highlight the input.

Ancestors

  • AdcpError
  • builtins.Exception
  • builtins.BaseException

Inherited members

class WorkflowHandoff (fn: Callable[[Any], Awaitable[None] | None])
Expand source code
class WorkflowHandoff:
    """Marker the framework recognizes as 'register this call as a task
    completed externally.'

    Adopters obtain instances via :meth:`RequestContext.handoff_to_workflow`;
    the framework dispatches based on type-identity (``type(obj) is
    WorkflowHandoff``) — same posture as :class:`TaskHandoff`.

    **Distinct from :class:`TaskHandoff`.** TaskHandoff is for
    framework-managed in-process async work — the adopter's coroutine
    runs in the background and returns a terminal artifact within
    seconds-to-minutes. WorkflowHandoff is for adopter-owned external
    workflows that complete on their own schedule (human queue review,
    nightly batch jobs, Airflow DAGs, ML pipelines, scheduled cron).
    The framework allocates a ``task_id``, calls the adopter's enqueue
    fn ONCE synchronously to register the work into the adopter's
    external system, persists ``submitted`` state, and returns the
    wire envelope. NO background coroutine runs.

    The adopter's external workflow later calls
    ``registry.complete(task_id, result)`` or
    ``registry.fail(task_id, error)`` directly — the registry handle
    is plumbed through the platform's own DI / app-level config.

    Example::

        class TraffickerSeller(DecisioningPlatform):
            def __init__(self, review_queue, task_registry):
                self.review_queue = review_queue
                # Stash the registry so the trafficker UI can call
                # registry.complete(task_id, result) when the human acts.
                self.task_registry = task_registry

            def create_media_buy(self, req, ctx):
                if self._needs_human_approval(req):
                    # Framework allocates task_id, calls _enqueue with
                    # task_ctx, persists 'submitted', returns Submitted.
                    # No background work runs in the framework.
                    return ctx.handoff_to_workflow(
                        lambda task_ctx: self._enqueue(task_ctx, req)
                    )
                return CreateMediaBuySuccess(media_buy_id="mb_1", ...)

            def _enqueue(self, task_ctx, req):
                # Persist for the trafficker UI. ``task_ctx.id`` is the
                # framework-allocated task_id; the buyer polls/webhooks
                # on this id.
                self.review_queue.add(
                    task_id=task_ctx.id,
                    request_snapshot=req.model_dump(),
                )
                # Return — no work done here. Trafficker UI completes
                # via self.task_registry.complete() when they decide.

    **Wire-shape parity.** The buyer cannot tell whether the seller
    used sync, TaskHandoff, or WorkflowHandoff. All three project to
    the same Submitted envelope (``{task_id, status: 'submitted'}``);
    completion (whenever it happens, by whatever path) flows via
    ``tasks/get`` or push-notification webhook with the same payload
    shape.

    **Rollback.** If the enqueue fn raises, the framework discards
    the just-allocated task_id from the registry and propagates the
    exception (wrapped to ``AdcpError`` per the dispatch contract).
    The buyer never sees an orphan task_id they can't reach. Adopter
    enqueue fns that need transactional persistence wrap their own DB
    write in their own transaction; the framework's rollback is
    registry-side only.
    """

    __slots__ = ("_fn",)

    def __init__(self, fn: Callable[[Any], Awaitable[None] | None]) -> None:
        # ``fn`` is ``Callable[[TaskHandoffContext], Awaitable[None] |
        # None]`` — the framework calls it once synchronously (or
        # awaits it if a coroutine) at handoff time. Return value
        # unused; the adopter's external workflow completes via
        # ``registry.complete()`` later, NOT via fn return.
        # TaskHandoffContext lives in task_registry.py to avoid a cycle.
        self._fn = fn

    def __repr__(self) -> str:
        return "WorkflowHandoff(<sealed>)"

Marker the framework recognizes as 'register this call as a task completed externally.'

Adopters obtain instances via :meth:RequestContext.handoff_to_workflow(); the framework dispatches based on type-identity (type(obj) is WorkflowHandoff) — same posture as :class:TaskHandoff.

Distinct from :class:TaskHandoff. TaskHandoff is for framework-managed in-process async work — the adopter's coroutine runs in the background and returns a terminal artifact within seconds-to-minutes. WorkflowHandoff is for adopter-owned external workflows that complete on their own schedule (human queue review, nightly batch jobs, Airflow DAGs, ML pipelines, scheduled cron). The framework allocates a task_id, calls the adopter's enqueue fn ONCE synchronously to register the work into the adopter's external system, persists submitted state, and returns the wire envelope. NO background coroutine runs.

The adopter's external workflow later calls registry.complete(task_id, result) or registry.fail(task_id, error) directly — the registry handle is plumbed through the platform's own DI / app-level config.

Example::

class TraffickerSeller(DecisioningPlatform):
    def __init__(self, review_queue, task_registry):
        self.review_queue = review_queue
        # Stash the registry so the trafficker UI can call
        # registry.complete(task_id, result) when the human acts.
        self.task_registry = task_registry

    def create_media_buy(self, req, ctx):
        if self._needs_human_approval(req):
            # Framework allocates task_id, calls _enqueue with
            # task_ctx, persists 'submitted', returns Submitted.
            # No background work runs in the framework.
            return ctx.handoff_to_workflow(
                lambda task_ctx: self._enqueue(task_ctx, req)
            )
        return CreateMediaBuySuccess(media_buy_id="mb_1", ...)

    def _enqueue(self, task_ctx, req):
        # Persist for the trafficker UI. <code>task\_ctx.id</code> is the
        # framework-allocated task_id; the buyer polls/webhooks
        # on this id.
        self.review_queue.add(
            task_id=task_ctx.id,
            request_snapshot=req.model_dump(),
        )
        # Return — no work done here. Trafficker UI completes
        # via self.task_registry.complete() when they decide.

Wire-shape parity. The buyer cannot tell whether the seller used sync, TaskHandoff, or WorkflowHandoff. All three project to the same Submitted envelope ({task_id, status: 'submitted'}); completion (whenever it happens, by whatever path) flows via tasks/get or push-notification webhook with the same payload shape.

Rollback. If the enqueue fn raises, the framework discards the just-allocated task_id from the registry and propagates the exception (wrapped to AdcpError per the dispatch contract). The buyer never sees an orphan task_id they can't reach. Adopter enqueue fns that need transactional persistence wrap their own DB write in their own transaction; the framework's rollback is registry-side only.

class WorkflowStep (id: str,
object_type: WorkflowObjectType,
object_id: str,
tool: str,
at: str,
actor: dict[str, str],
status: "Literal['submitted', 'completed', 'failed', 'progress']")
Expand source code
@dataclass(frozen=True)
class WorkflowStep:
    """A chronological event the framework recorded against an object.

    Frozen because the framework writes the step record once at the
    transition; platform code reads but does not mutate. The shape
    mirrors the TS-side ``WorkflowStep`` interface so cross-language
    adopters get the same fields.

    :param id: Stable step identifier (framework-allocated UUID).
    :param object_type: The object this step touched.
    :param object_id: Stable id of the touched object within
        :attr:`object_type`.
    :param tool: Wire verb that ran the step
        (e.g. ``'create_media_buy'``, ``'sync_creatives'``).
    :param at: ISO 8601 timestamp of the step.
    :param actor: Who initiated the step. ``agent_url`` for an agent
        principal, ``principal`` for a service-account principal,
        possibly both.
    :param status: Step outcome. ``'submitted'`` for a kicked-off task,
        ``'completed'``/``'failed'`` for terminal states,
        ``'progress'`` for a mid-flight update.
    """

    id: str
    object_type: WorkflowObjectType
    object_id: str
    tool: str
    at: str
    actor: dict[str, str]
    status: Literal["submitted", "completed", "failed", "progress"]

A chronological event the framework recorded against an object.

Frozen because the framework writes the step record once at the transition; platform code reads but does not mutate. The shape mirrors the TS-side WorkflowStep interface so cross-language adopters get the same fields.

:param id: Stable step identifier (framework-allocated UUID). :param object_type: The object this step touched. :param object_id: Stable id of the touched object within :attr:object_type. :param tool: Wire verb that ran the step (e.g. 'create_media_buy', 'sync_creatives'). :param at: ISO 8601 timestamp of the step. :param actor: Who initiated the step. agent_url for an agent principal, principal for a service-account principal, possibly both. :param status: Step outcome. 'submitted' for a kicked-off task, 'completed'/'failed' for terminal states, 'progress' for a mid-flight update.

Instance variables

var actor : dict[str, str]
var at : str
var id : str
var object_id : str
var object_type : Literal['media_buy', 'creative', 'product', 'plan', 'audience', 'rights_grant', 'task']
var status : Literal['submitted', 'completed', 'failed', 'progress']
var tool : str