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_controllerand other test-only surfaces … adcp.decisioning.account_projection-
Wire-emit projections for AdCP v3 :class:
Accountpayloads … adcp.decisioning.accounts-
Account resolution:
AccountStoreProtocol + 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:
DecisioningPlatformadopters … 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 gatestargeting_overlayecho 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
AccountStorefactory ("Shape B") … adcp.decisioning.observed_modes-
Process-scoped tracker of explicit
Account.modevalues 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'onget_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:
AccountStorefactory forresolution='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:AccountStorebuilder 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_buypatch requests. adcp.decisioning.upstream-
HTTP client primitives for AdCP translator adapters …
adcp.decisioning.validate_capabilities-
Boot-time validation of the projected
get_adcp_capabilitiesresponse … 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_modeagainst the wire spec's mutual-exclusion rules.Per the wire description on
GetProductsRequest:buying_mode='wholesale'—briefMUST NOT be provided.buying_mode='refine'—briefMUST NOT be provided;adcp.decisioning.refine[]drives iteration.buying_mode='brief'—briefis required (handled by Pydantic validation upstream).
Raises :class:
AdcpError(INVALID_REQUEST)with the offending field on violation. Called at the top of theget_productsshim 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_STATEon 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.detailsfor buyer-side debugging. :raises AdcpError: withcode='INVALID_STATE'andrecovery='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_STATEon 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.,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.detailsfor buyer-side debugging. :raises AdcpError: withcode='INVALID_STATE'andrecovery='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 + nosandbox is True: raises.mode in {'sandbox', 'mock'}(or legacysandbox is True): no-op, dispatch proceeds.
The
detailspayload 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 tomode='sandbox'and unlock test-only surfaces on a live principal. Sourcemode(andsandbox) 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
toolif 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:
ApiKeyCredentialor :class:OAuthCredentialto a :class:BuyerAgent(orNoneto reject). Signed traffic getsPERMISSION_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 outPosition-match the request's
adcp.decisioning.refine[]with the adopter's outcomes.Each
refines[i]entry has a discriminatedscope('request','product', or'proposal'). This function emits a parallelrefinement_applied[i]carrying the same scope, the matching ID field (product_id/proposal_id), and the adopter'sstatus+notes.:param refines:
request.refine(length N). :param outcomes: Adopter's per-entry outcomes (must also be length N). :returns: Wire-shapeRefinementApplied(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 wrapperWrap a platform method with typed
before/afterhooks.Type-preserving: the returned callable has the same
async (params, ctx) -> Ressignature asinnerso it slots into a typed :class:DecisioningPlatformshape without casts.Validates
inneris callable eagerly at wrap time so adopters who reference an optional method that wasn't implemented on the underlying platform get a clear :class:TypeErrorat 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:
TypeErrorimmediately. :param before: Optional pre-call hook. Returning :class:ShortCircuitskips the inner method and feeds the wrapped value throughafter. ReturningNonefalls through. Returning a bare non-:class:ShortCircuitnon-Nonevalue raises :class:TypeError. :param after: Optional post-call hook. Runs whether the result came frominneror from abeforeshort-circuit, and BEFORE response-schema validation. Decorations must satisfy the wire schema; vendor-specific data goes underext. :returns: A wrapper with the same signature asinner. :raises TypeError: wheninneris 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, registryBuild 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:TaskHandofflifecycle.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, missingadcp.decisioning.accounts, governance opt-in violations (D15 round-4), and unknown specialisms (UserWarning per round-3 D14).:param platform: The adopter's :class:
DecisioningPlatformsubclass instance. :param executor: Bring-your-own :class:ThreadPoolExecutor— for operators with audit-instrumented thread pools or wrappers around stdlib's executor. Mutually exclusive withthread_pool_size. Operator owns lifecycle (caller'sshutdown(wait=True)responsibility). :param thread_pool_size: Size the default framework-allocated executor. Mutually exclusive withexecutor. 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 unlessADCP_DECISIONING_ALLOW_INMEMORY_TASKS=1is set. :param state_reader: Custom :class:StateReaderimpl (D15 — workflow-state reads). Default is the v6.0 stub (empty returns + one-time UserWarning per method). :param resource_resolver: Custom :class:ResourceResolverimpl (D15 — async framework-mediated fetches). Default is the v6.0 stub (raisesNotImplementedErrorwith a pointer to v6.1). :param webhook_sender: Bring-your-own :class:WebhookSenderfor 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 registeredpush_notification_config. DefaultNone. 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.WebhookDeliverySupervisorand pass that viawebhook_supervisor=instead. :param webhook_supervisor: Bring-your-own :class:~adcp.webhook_supervisor.WebhookDeliverySupervisorfor reliable delivery (retry, circuit breaker, attempt audit). When passed, the F12 auto-emit path routes through it instead ofwebhook_sender. The reference :class:~adcp.webhook_supervisor.InMemoryWebhookDeliverySupervisorwraps a sender; adopters with infra-side retry (Celery, Kafka, durable outbox) implement the Protocol against their queue. Mutually optional withwebhook_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(bothrecovery="terminal", nodetailspayload — the code itself is the discriminator per AdCP 3.1); unrecognized →PERMISSION_DENIEDwith nodetails.scopeso the wire shape does not enumerate whichagent_urls are onboarded with this seller. The resolved :class:BuyerAgentis threaded onto :attr:RequestContext.buyer_agentso platform methods can read commercial context (billing capabilities, default terms, adopter ext) without a second registry call. DefaultNone— pre-trust beta adopters running existing key-based auth without commercial gating omit this and the dispatch path falls through toAccountStore.resolve()unchanged. :param auto_emit_completion_webhooks: F12 feature gate. WhenTrue(default), the framework auto-fires a completion webhook on the sync-success arm of mutating tools whenever the request suppliedpush_notification_config.urlAND the tool is in :data:SPEC_WEBHOOK_TASK_TYPES. Buyers passing the URL expect notification regardless of whether the seller routed sync vs HITL. SetFalsefor 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:MediaBuyStorewrapper that gatestargeting_overlayecho on the seller's declared specialisms. Typically built via :func:create_media_buy_store()with the seller'sadcp.decisioning.capabilitiesso the persistence layer only fires for sellers claimingproperty-listsorcollection-lists. When wired, the framework callspersist_from_createon successfulcreate_media_buy(via the same on-complete hook the proposal flow uses, so HITL completions also persist), callsmerge_from_updateon successfuladcp.decisioning.update_media_buy, and callsbackfillbefore returning fromget_media_buys. DefaultNone— sellers who don't claim the relevant specialisms or who echotargeting_overlaythemselves omit this and pay no overhead. :param advertise_all: Mirror of the same flag on :func:serve()— controls how :meth:PlatformHandler.get_advertised_toolsand the eventualtools/listresponse filter the handler's tool universe.False(default, spec-aligned) drops tools whose method is still the SDK'snot_supportedshim;Trueadvertises every tool the platform's claimed specialisms cover regardless of override status. Stored on the returned handler so adopters can callhandler.get_advertised_tools()to inspect the effective set without standing up a server. :param validate_at_init: WhenTrue(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 withRuntimeError: asyncio.run() cannot be called from a running event loopwhen the constructor is invoked from inside an async context (test fixtures, Starlettelifespan, in-process A2A test clients). PassFalsein 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:PlatformRouterviaproposal_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": ...}). Seedocs/proposals/product-architecture.md§ "Tenant binding model".:raises ValueError: when
executorandthread_pool_sizeare 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:
InMemoryProposalStorewith 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:
MediaBuyStorewith specialism-awaretargeting_overlayecho gating.:param adopter_store: The persistence + echo implementation. Must satisfy the :class:
MediaBuyStoreProtocol — 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 mutatecapabilities.specialismsafter 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:
MediaBuyStorewrapper. Whencapabilities.specialismsintersects{property-lists, collection-lists}, every method delegates toadopter_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:
AccountStorebacked by an upstream OAuth-protected listing endpoint.The returned object satisfies the :class:
AccountStoreProtocol (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 delegatesadcp.decisioning.resolveand 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:DynamicBearerso 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'sauth_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 returnsNone— 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, elsebody["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_tokenresolver. The return value flows through as the per-callauth_contexton :meth:UpstreamHttpClient.get(). Defaults to forwardingctx.auth_infoverbatim — works when the http client's token resolver reads from :class:AuthInfodirectly.Behavior:
- The returned store only handles the
{account_id}discriminated-union arm of :class:AccountReference. Other arms ({brand, operator}) andNoneref returnNonewithout calling upstream. Adopters needing natural-key fallback compose their own resolver around this one. - Upstream errors propagate verbatim —
http_clientalready projects non-2xx to spec-conformant :class:AdcpErrorcodes (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'streat_404_as_nonedefault), 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"]}, ), ) - The returned store only handles the
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:
AccountStorebacked by a fixed publisher- curated roster.The returned object conforms to the :class:
AccountStoreProtocol plus the optional :class:AccountStoreList, :class:AccountStoreUpsert, and :class:AccountStoreSyncGovernanceProtocols.upsertandsync_governancefail closed withPERMISSION_DENIEDper entry — adopters who need to support write paths use a custom :class:AccountStoreimplementation instead.:param roster: Mapping from
account_id→ :class:Account. Each value'sidMUST match its key; mismatch raises :class:ValueErrorat 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:
AccountStorewhose:* :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
iddoes 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:
AccountStorewhoseadcp.decisioning.resolve/upsert/list/sync_governancemethods enforce tenant isolation.Canonical helper for the multi-tenant pattern described in :class:
~adcp.decisioning.AccountStore— composes tenant scope into the returnedAccount.idso 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:AccountStoreshould reach for this factory first.:param resolve_by_ref:
(ref, ctx) -> Account | None. Resolves a wire :class:AccountReferenceto the framework Account it points at — independent of who the caller is. ReturnNoneif the ref is unknown (helper emitsACCOUNT_NOT_FOUNDfor that row). May be sync or async. :param resolve_from_auth:(ctx) -> tenant_id | None. Derives the tenant from the auth principal. ReturnNoneif no principal is resolvable (no auth, principal not registered) — every entry on per-entry tools then failsPERMISSION_DENIED(fail-closed). :param tenant_id:(account) -> str. Stable identity for tenant- equality checks. The helper comparestenant_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-2adcp.decisioning.resolve(no-ref tools) and bylist. :param upsert_row: Optional(ref, ctx) -> SyncAccountsResultRowper-entry storage callback. Cross-tenant entries and unknown-ref entries NEVER reach this callback — the helper buildsPERMISSION_DENIED/ACCOUNT_NOT_FOUNDrows for those before invoking adopter code. Omit for adopters whose platform doesn't claimsync_accounts; the helper returnsaction='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) raisesKeyError. :param default_upstream: Returned by :meth:TranslationMap.to_upstream()when the AdCP key isn't in the map.None(default) raisesKeyError.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:
AdcpErrorcodes 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:
NoAuthfor 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: WhenTrue(default), GET/DELETE 404 responses returnNonerather than raising. SetFalseto surface 404 asAdcpError(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 mutationsSplit an
adcp.decisioning.update_media_buypatch into ordered logical mutations.patchmay be a generated PydanticUpdateMediaBuyRequestor a plain mapping.current_media_buyis optional; when supplied, the helper can promote coarse mutations into specific actions such asincrease_budget,decrease_budget,extend_flight,shorten_flight, andreallocate_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 packagesDerive a
list[PackageRequest]from a committed proposal's allocations.Default even-percentage distribution per
ProductAllocation:budget = total_budget.amount * allocation.allocation_percentage / 100product_id,pricing_option_idcopied from the allocationstart_time/end_timecopied when present (per-flight scheduling per spec)pacing,targeting_overlay,bid_priceetc. are NOT derived — adopters whose proposals carry these per allocation should override :meth:ProposalManager.derive_packagesand emit them explicitly.
Currency. Per spec,
PackageRequest.budgetis intotal_budget.currency(the media-buy-level unit). This helper treatsallocation_percentageas 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_packagesand apply their own FX conversion before emitting packages.:param proposal_payload: The committed proposal's wire payload (typically
ProposalRecord.proposal_payload). Must contain anallocations[]array; absent / empty allocations are treated as a buyer error and surfaced asINVALID_REQUEST. :param total_budget: TheTotalBudget(or dict-shaped equivalent withamount/currencykeys) from the buyer'sCreateMediaBuyRequest. Must be non-None — the buyer must supplytotal_budgetwhenever they omitpackages. :param recipes: Unused by the built-in derivation. Threaded through so the signature matches :meth:ProposalManager.derive_packagesfor adopters who delegate to this helper from inside their override.:raises AdcpError:
INVALID_REQUESTwhen the proposal payload lacksallocations[], when an allocation is missing required fields (product_id,pricing_option_id,allocation_percentage), or whentotal_budgetis 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_ACTIONmutations 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'sproperty_idswithallowed_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 ofby_idproperty IDs must be a subset ofallowed_property_ids.True(permissive) — any non-empty intersection is sufficient.
A product is included if ANY of its
publisher_propertiesentries 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_productsresponse. :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
modeoff any account-shaped value, with back-compat for the legacysandbox: boolfield.Returns the explicit mode if present; otherwise infers
'sandbox'fromsandbox is True; otherwise'live'.Adopters that have not yet migrated to the
modefield continue to work —account.sandbox is Truereads as sandbox mode through this helper. New code should prefermodedirectly.Unknown / unrecognized
modevalues 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 urlRead
account.metadata['mock_upstream_url']safely.Adopters populate this in :meth:
AccountStore.resolve()formode='mock'accounts so the framework's :meth:DecisioningPlatform.upstream_for()knows which mock-server fixture URL to point the adapter's :class:UpstreamHttpClientat. 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
Nonewhen:account.metadatais not a :class:Mapping.mock_upstream_urlis absent.mock_upstream_urlis 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 FalseReturn True when refine is reachable through this platform.
A platform supports refine if it implements
refine_get_productsdirectly OR — in the case of aPlatformRouter— if any of its wired ProposalManagers declares the refine capability. The router itself doesn't exposerefine_get_products; refine routes through the ProposalManager'srefine_productsmethod 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
Trueformode in {'sandbox', 'mock'}(or legacysandbox is True);Falseformode == '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_actionscontains a capability coveringmutation. 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 generatedMediaBuyAvailableActionmodels. Whenallowed_modesis provided, wire entries with a non-matchingmodeare 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
accountsafe 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_entityisNoneorbilling_entity.bankis already absent — defensive copy viamodel_copy()so callers can mutate the returned object freely without touching the caller's input.The original
accountobject 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
entitywithbankcleared.Same posture as :func:
project_account_for_response()but operating on a :class:BusinessEntitydirectly — useful for adopters serializing standalone billing-entity payloads (admin APIs, brand-rights flows) that don't go through the :class:Accountenvelope.The original
entityis 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}]}withincompletecontaining at least one entry (min_length=1on the wire schema). Uses a raw dict to stay above the import-layering boundary (only the whitelist intests/test_import_layering.pymay import fromadcp.types._generated).scopeis"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_methodreturns, 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_waitis 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:
RefineResultinto a wire :class:GetProductsResponse.Builds
refinement_applied[]from the request'sadcp.decisioning.refine[]and the adopter'sper_refine_outcome, then attachesproductsandproposals.: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_filteringis set.Centralises the three-level
getattrchain used in bothhandler.pyandserve.pyso 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 NoneExtract
account_idfrom an :class:AccountReference.:class:
AccountReferenceis 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. ReturnsNoneifrefisNoneor 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, orNone. :returns: Theaccount_idstring when present;Noneotherwise. - :class:
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 hookBuild a :data:
BeforeHookthat requires the request's account field equalctx.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:BeforeHookthat raises :class:AdcpErrorwithPERMISSION_DENIEDon mismatch (or missing field) and falls through (returnsNone) 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 hookBuild a :data:
BeforeHookthat requires the request's advertiser field equalctx.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:BeforeHookthat raises :class:AdcpErrorwithPERMISSION_DENIEDon mismatch (or missingadvertiser_idin 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 hookBuild a :data:
BeforeHookthat requires the request's organization field equalctx.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:BeforeHookthat raises :class:AdcpErrorwithPERMISSION_DENIEDon mismatch (or missingorganization_idin 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 excFetch the buyer's authorized property IDs from the agent at
ref.agent_url.:param ref:
PropertyListReferencewire object (hasagent_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_tokenis 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) * factorConvert
GetProductsRequest.time_budgetto a seconds deadline.Returns
Nonewhen: -adcp.decisioning.time_budgetisNone(field absent) — no deadline. -unit == 'campaign'— seller decides timing; SDK does not install a deadline. The rawadcp.decisioning.time_budgetvalue still reaches the adopter viaparams.For all other units (seconds / minutes / hours / days), returns
interval * unit_secondsas a positivefloat. 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:
DecisioningPlatformsubclass instance. :param name: Server name advertised on AdCP capabilities. Defaults to the platform class's__name__. :param executor: BYO :class:ThreadPoolExecutorper :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:StateReaderimpl (D15). :param resource_resolver: Custom :class:ResourceResolverimpl (D15). :param webhook_sender: BYO :class:WebhookSenderfor 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 registeredpush_notification_config. Transport only — one attempt, no retry.Nonedisables emission silently. :param webhook_supervisor: BYO :class:~adcp.webhook_supervisor.WebhookDeliverySupervisorfor reliable delivery (retry, circuit breaker, attempt audit). Takes precedence overwebhook_senderfor F12 auto-emit when both are passed. Production sellers typically pass an :class:~adcp.webhook_supervisor.InMemoryWebhookDeliverySupervisorwrapping their sender. :param auto_emit_completion_webhooks: F12 — auto-fire a completion webhook on the sync-success arm of mutating tools when the request suppliedpush_notification_config.url. DefaultTrue. SetFalsefor adopters who emit webhooks manually inside their handlers. :param mock_ad_server: Optional :class:MockAdServerwhoseget_traffic()is wired intoGET /_debug/trafficwhenenable_debug_endpoints=True. DefaultNone— adopters with no anti-façade recorder leave this off. :param enable_debug_endpoints: WhenTrue, mountGET /_debug/trafficexposing the JSON dict returned bymock_ad_server.get_traffic(). Defaults toFalse; 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(). WhenTrue,tools/listadvertises every method on the handler regardless of override status. DefaultFalse— the override-detection filter trims unimplemented platform methods. Adopters with explicit-not-supported intent (e.g., spec-compliance storyboards) passTrue. :param serve_kwargs: Forwarded to :func:serve(). Use forhost,port,transport,test_controller,context_factory,middleware,validation,response_enhancer(a server-wide :data:~adcp.server.ResponseEnhancerapplied to every response on both transports),config(:class:ServeConfigbundle), etc. Passconfig=ServeConfig(transport="a2a", ...)to supply all server options as a single typed object rather than individual kwargs. Passvalidation=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_argscallable. 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(). DefaultTrueruns the capabilities-shape boot validator in sync; passFalseand run :func:validate_capabilities_response_shape_async()yourself when invokingserve()from inside a running event loop (e.g.asyncio.run(your_main())that callsserve()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_urlto a :class:BuyerAgent(orNoneto reject). Bearer traffic getsPERMISSION_DENIED(withdetailsomitted — 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 wireProject a framework :class:
Account[TMeta]to the wireAccountshape.Strips
metadataandauth_info(framework-internal — never on the wire); renamesid→account_id; passes through wire-shaped optional fields. Stripsbilling_entity.bankper the schema's write-only constraint.For
governance_agents, stripsauthenticationfrom 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.listand 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 wireProject a :class:
SyncAccountsResultRowto the wire shape returned bysync_accounts.Applies the same
billing_entity.bankstrip as :func:to_wire_account()— the wire schema marks bank coordinates write-only on EVERY response, not justlist_accounts. Adopters returning a row that spreads a DB record carryingbank(e.g.,{**db.findByBrand(r.brand), 'action': 'updated'}) have it stripped before emit.Used by the framework when emitting
sync_accountsresponses. Adopters never call this directly — they returnlist[SyncAccountsResultRow]from :meth:AccountStore.upsertand 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 wireProject a :class:
SyncGovernanceResultRowto the wire shape returned bysync_governance.Critically: each
governance_agents[i]is reduced to{url, categories?}only — the spec marksauthentication.credentialswrite-only (the buyer sends the bearer; the seller persists it for outboundcheck_governancecalls 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:
AdcpErrorBILLING_NOT_PERMITTED_FOR_AGENTwhenrequested_billingis not inagent.billing_capabilities.Called by the framework's
sync_accountsshim 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
detailspayload deliberately carries onlyrejected_billing(and an optionalsuggested_billing) — it MUST NOT carry the agent's fullpermitted_billingsubset. 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:- The response validates against the bundled
protocol/get-adcp-capabilities-response.jsonschema (via :func:validate_response()). supported_protocolsis present and non-empty (specminItems: 1; doubled-up here so the diagnostic names the invariant directly).- When the seller claims
media_buy,account.supported_billingis present and non-empty (the invariant the v3 ref seller violated pre-#402; specprotocol/get-adcp-capabilities-response.jsonrequiresaccount.required: ["supported_billing"]withminItems: 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, Starlettelifespanhandlers, anything insideasyncio.run) should use :func:validate_capabilities_response_shape_async()instead and pair it withcreate_adcp_server_from_platform(..., validate_at_init=False).:raises AdcpError:
INVALID_REQUESTwithrecovery="terminal"on any violation;detailscarry 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 (theasyncio.runmachinery raises this directly). - The response validates against the bundled
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, Starlettelifespan, in-process A2A test clients) so the SDK doesn't try to spin up a second event loop and crash withRuntimeError: 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):
platform.capabilitiesis a populated :class:DecisioningCapabilities(not the base default).platform.accountsis a real :class:AccountStore(anything truthy with aadcp.decisioning.resolvemethod) — None catches subclasses that forgot to attach a store.- 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. - 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 oneUserWarningper method (deduped across overlapping specialisms). SettingADCP_DECISIONING_STRICT_VALIDATE_PLATFORM=1flips the soft warning into a hard INVALID_REQUEST error. - Governance opt-in fail-fast (D15 round-4): if any claimed
specialism is in :data:
GOVERNANCE_SPECIALISMSANDcapabilities.governance_awareis 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
detailscarry 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 sendsadcp.decisioning.property_listwould receive unfiltered products withproperty_list_appliedabsent or False, mismatching what the seller'sget_adcp_capabilitiesadvertised.: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:
AccountStoreand threaded through every dispatch via :class:RequestContext.metadatais the typed extension point — adopters define a TypedDict (or dataclass) carrying their per-account data (adapterinstance, OAuth credentials, network IDs, sandbox flags, etc.) and parameterizeAccount[TenantMeta]soctx.account.metadata.adaptertypechecks 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_identitythe 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 theaccount-status.jsonenum 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 whenmetadatais 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
idbecause 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 wireAccountshape 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 throughcast/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()stripsbankon 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 inpending_approval. Carriesurl/message/expires_atdriving thepending_approval → activelifecycle. :param governance_agents: Governance agent endpoints registered on this account. The wire schema marksauthentication.credentialswrite-only — the framework stripsauthenticationon 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 onlist_accountsresponses. 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 oncomply_test_controllerand other test-only surfaces. Seedocs/proposals/lifecycle-state-and-sandbox-authority.md.Ancestors
- typing.Generic
Instance variables
var account_scope : AccountScope | Nonevar auth_info : dict[str, Any] | Nonevar billing_entity : BusinessEntity | Nonevar credit_limit : CreditLimit | Nonevar governance_agents : list[GovernanceAgent] | Nonevar id : strvar metadata : TMetavar mode : Literal['live', 'sandbox', 'mock']var name : strvar payment_terms : PaymentTerms | Nonevar rate_card : str | Nonevar reporting_bucket : ReportingBucket | Noneprop 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.Truewhen :attr:modeis'sandbox'or'mock';Falsefor'live'. Adopters reading the legacyaccount.sandboxboolean keep working — the property derives from :attr:mode. New code should read :attr:modedirectly to distinguish'sandbox'from'mock'. var setup : AccountSetup | Nonevar 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_accountsor 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.resolvefor every tool dispatch (before the handler method runs). Adopters in'explicit'mode useref.account_idfrom the wire;'implicit'mode readsctx.auth_infoto look up the principal-bound account;'derived'mode synthesizes a per-principal account from the one platform.The :attr:
resolutionliteral 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-tenantTenantRegistry). 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.AccountReferencedirectly for outbound calls and don't go through this seam.Buyers send a per-tenant
account_refon the wire; sellers don't control what string a buyer picks, and the sameaccount_ref("acme", "default", sequential ids) may arrive from buyers calling different seller tenants. The transport setstenant_idfrom the Host header (via :class:~adcp.server.SubdomainTenantMiddleware), andadcp.decisioning.resolveis the single layer that mintsAccount.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.idhere, 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_idback out insideProposalStore.put_draft(), or threading a separatetenant_idargument through every Protocol method) is the wrong layer: it forces every store to re-derive whatadcp.decisioning.resolvealready knows, and adopter encoding-convention changes silently break every downstream Protocol call site.Pick a stable tenant identifier. The tenant value baked into
Account.idlives 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_storeships 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:
Accountor raise on miss.:param ref: The wire reference object (typically
request.accountcarryingaccount_id/account_ref).Nonefor tools that don't carry an explicit account ref — adopters in'derived'/'implicit'modes ignore it. :param auth_info: Verified principal info.Nonefor 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.iscoroutineat 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_accountsAPI 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 forbilling_entity.bankandgovernance_agents[].authentication).Security migration note. Pre-this-release, adopters had no way to scope
list_accountsper-principal — impls either returned all accounts (over-disclosure) or rejected the operation. Post-this-release, scoping becomes possible viactx.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_infoandctx.agentcarry 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_governanceAPI 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 returnsUNSUPPORTED_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.
entriesis the wire request'sadcp.decisioning.accounts[]— each entry pairs an :class:AccountReferencewith itsgovernance_agents[]. The framework has already deduped onidempotency_keyand 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_agentsis empty clears the binding for that account.Write-only credentials. Each
governance_agents[i].authentication.credentialsis the bearer the seller presents to that governance agent on outboundcheck_governancecalls. Persist them — silently dropping ships unauthenticated requests once cross-agent calls are wired. The framework stripsauthenticationfrom eachgovernance_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_infoandctx.agentcarry the caller's principal. Adopters MUST gate per-entry persistence by the caller's tenant: each entry'saccount.operator(oraccount_id) must map to the same tenant the auth principal authorizes; otherwise return a row withstatus='failed'carryingerrors=[{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_accountsAPI surface. Optional adopter-side feature that complements :class:AccountStore.resolve().Not parameterized over
TMeta— :meth:upsertreturns :class:SyncAccountsResultRow(a wire-shaped row, no per-platform metadata) rather thanAccount[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.
ctxis optional on the platform side, so adopter impls written before ctx threading landed (noctxparameter) keep working — the framework's :func:_call_with_optional_ctxshim probes via :func:inspect.signatureand dropsctxfor 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_accountsAPI surface. Framework normalizes the wire request; platform upserts and returns per-account result rows. Raise :class:AdcpErrorfor buyer-facing rejection.ctx.auth_infocarries the caller's authenticated principal;ctx.agentcarries the resolved :class:BuyerAgentrecord (when a registry is configured). Adopters implementing principal-keyed gates (e.g., per-buyer-agentBILLING_NOT_PERMITTED_FOR_AGENTon the spec's billing surfaces) read the principal here — same threading as :meth:AccountStore.resolve().Prefer
ctx.agentoverctx.auth_infofor commercial-relationship decisions.ctx.agentis the registry-resolved durable identity (status, billing capabilities, default account terms);ctx.auth_infocarries 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 NotImplementedErrorFull-request
sync_accountsAPI surface.Prefer this over :class:
AccountStoreUpsertwhen a store needs request-level fields such aspush_notification_config,delete_missing, ordry_run. The framework calls this hook with the parsed :class:SyncAccountsRequestand then projects the return value through the samesync_accountsresponse path as the legacyupserthook.Backwards compatibility: stores that only implement :meth:
AccountStoreUpsert.upsert()continue to receiveparams.accountsexactly 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 NotImplementedErrorPersist a full
sync_accountsrequest.Use this hook when request-envelope data must be stored or acted on.
ctxcarries the same principal and buyer-agent context as the legacyupsertpath.
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 outWire-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 wireadcp_errorenvelope:.. 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 atschemas/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 withrecovery='transient'. :param details: Free-form extras for codes that need them (e.g.{'errors': [...]}for multi-error preflight).Ancestors
- builtins.Exception
- builtins.BaseException
Subclasses
- AccountNotFoundError
- AuthRequiredError
- BillingNotPermittedForAgentError
- MediaBuyNotFoundError
- PermissionDeniedError
- RateLimitedError
- ServiceUnavailableError
- UnsupportedFeatureError
- ValidationError
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 outProject to the AdCP wire
adcp_errorenvelope.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 : strvar key : strvar 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: strBearer / API-key credential. The framework's authentication layer extracts the
key_idfrom 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 : strvar 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
Tdirectly) or async (returnAwaitable[T]); the dispatch adapter detects via :func:asyncio.iscoroutinefunctionand runs sync methods on a thread pool.Throw :class:
AdcpErrorfor 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); raiseAdcpError(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 — callctx.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 resultTerminal wrapper that emits one :class:
AuditEventper 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 inmissevents with credential-stuffing activity.The event
operationis 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:AuditSinkto write events to. IfNone, outcomes log atDEBUGinstead. :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_urlThe verified principal authenticated for a request.
Populated by the framework's signed-request verifier (:func:
adcp.signing.signed_request_verifier) or a customauthenticate=callable wired via :func:serve(). Threaded onto :attr:RequestContext.auth_infoso 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:BuyerAgentRegistryconsumes. When an adopter constructsAuthInfowith only the flat fields,__post_init__synthesizes a typed bearer :class:adcp.decisioning.Credentialfrom them and emits a :class:DeprecationWarningpointing 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
AuthInfowithout an explicitcredential=. - 4.5.0 — synthesis is removed; flat-field-only construction
stops auto-populating
credential, and the registry dispatch will reject the request withPERMISSION_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 →credentialsynthesis. :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 verifiedagent_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 fromcredential.agent_urlwhencredentialis an :class:HttpSigCredential.Nonefor bearer / OAuth / unauthenticated traffic and forkind="signed_request"constructions that don't pass a typed credential (the SDK deliberately refuses to deriveagent_urlfrom the unverifiedprincipalstring — see__post_init__for the rationale). :param operator: Operator / transport-tenant label — the AdCP v3 operator binding (separate from the buyer agent). Distinct fromToolContext.tenant_idonly for adopters running the AAO community proxy in front of a multi-operator deployment; most adopters leave thisNone. :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:
AuthInfofrom a :class:VerifiedSigner.The supported migration target for the AuthInfo flat-field deprecation. Verifier middleware that runs RFC 9421 verification produces a
VerifiedSignercarrying the cryptographic claims (key_id,verified_at, optionalagent_url); this helper projects that into a typed :class:HttpSigCredential+ :class:AuthInfoready 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_urlfor the commercial-identity registry to dispatch — without it, the framework has no key to look up. The verifier is configured with theagent_urlclaim shape per the AdCP v3 profile; if it'sNonehere the verifier wasn't told to extract it and this helper raises :class:ValueErrorwith a pointer to the misconfiguration. Buyers don't see this — server boot time error.:param signer: The :class:
VerifiedSignerreturned 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 leaveNone. :param extra: Adopter passthrough. :param max_verified_age_s: Maximum age (seconds) ofsigner.verified_atpermitted at construction time. When set andnow() - signer.verified_at > max_verified_age_s, raises :class:ValueError— adopter caching a staleVerifiedSignerand replaying it later fails fast rather than passing a stale-but- cryptographically-valid credential into the registry dispatch. DefaultNonekeeps the v6.0-alpha behavior; production sellers SHOULD set a short window (e.g. 300s) matching the RFC 9421 nonce TTL. :param now: Overridetime.time()formax_verified_age_scomparisons — only useful in tests. DefaultNoneuses wall-clock. :raises ValueError: whensigner.agent_urlisNone, or whenmax_verified_age_sis set andsigner.verified_atis older than the window.
Instance variables
var agent_url : str | Nonevar credential : Credential | Nonevar extra : Mapping[str, Any]var key_id : str | Nonevar kind : strvar operator : str | Nonevar principal : str | Nonevar scopes : list[str]
- 4.4.0 (this release) — flat-field synthesis still works but
warns. Adopter code stays runnable; the warning points at every
callsite that constructs
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_billingcapability 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.detailsMUST conform toerror-details/billing-not-permitted-for-agent.json(rejected_billingplus an optional singlesuggested_billingretry 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 inerror.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
Tdirectly) or async (returnAwaitable[T]); the dispatch adapter detects via :func:asyncio.iscoroutinefunctionand runs sync methods on a thread pool.Throw :class:
AdcpErrorfor 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:AcquireRightsRejectedResponsearm so the buyer sees the structured wire response withreason+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. Carriesrights_id,status: 'acquired',brand_id,terms,generation_credentials(scoped per-LLM-provider keys), andrights_constraintso the buyer can plumb the grant directly into creative generation. - :class:
AcquireRightsPendingResponse— clearance pending counter-signature, legal review, or rights-holder approval. Carriesrights_id,status: 'pending_approval',brand_id, plus optionaldetailandestimated_response_time. Async delivery is webhook-only — the buyer'spush_notification_config.urlreceives the eventualAcquiredorRejectedoutcome. The spec does NOT define a polling tool foracquire_rights; do not reach fortasks_gethere. - :class:
AcquireRightsRejectedResponse— terminal rejection. Carriesrights_id,status: 'rejected',brand_id,reason, and optionalsuggestions[]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:AcquireRightsRejectedResponsearm — that's the structured business outcome path. - :class:
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, localizednames, 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
rightsarray is valid (= "no rights available for the requested terms"); throwAdcpErroronly for buyer-fixable rejection (e.g., unsupported jurisdiction).Note: the wire field is
rights, NOTofferings. Adopters who named their internal modelofferingstranslate 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_urlis 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 : strvar allowed_brands : frozenset[str] | None-
Pre-RFC allowlist of brand domains this agent can transact for. Once :class:
BrandAuthorizationResolverlands (Tier 3, gated on ADCP #3690), this becomes a static fallback layered on top of per-request authz againstbrand.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_terms : BuyerAgentDefaultTerms | Nonevar display_name : strvar 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 = NoneCommercial 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;
Nonefields fall through to the agent's default.Mirrors the spec
Accountshape onschemas/cache/core/account.json(3.0-compliant, 3.1-ready —billing_entityis the 3.1 field that 3.0-only adopters can leaveNone).Instance variables
var billing_entity : collections.abc.Mapping[str, typing.Any] | Nonevar credit_limit : collections.abc.Mapping[str, typing.Any] | Nonevar payment_terms : str | Nonevar 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_urlfor cryptographically-verified signed traffic — the framework already validated the RFC 9421 signature and extractedagent_urlfrom the JWK claim. - :meth:
resolve_by_credentialfor bearer / API-key / OAuth — the adopter looks up against their existing key table.
Returning
Nonerejects the request withPERMISSION_DENIED(detailsomitted — 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.
- :meth:
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_urlstrings 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:RateLimitedBuyerAgentRegistryor 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 — emitscached_hit/cached_missevents 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.Lockto serialize entry insertion and LRU promotion. The lock is held only across the dict update — the innerresolve_*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/_storemay 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:
invalidatefor 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
invalidateso 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
OrderedDictwhile_storeis reordering or evicting can corrupt the LRU order or raiseRuntimeError: 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 resultResolve via cache, falling through to
inneron 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 resultResolve via cache, falling through to
inneron 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, orconditions(approved-if). Status changes (plan moving frompending_approval→active→closed) flow viactx.publish_status_change(resource_type='plan', ...).Methods may be sync (return
Tdirectly) or async (returnAwaitable[T]); the dispatch adapter detects via :func:asyncio.iscoroutinefunctionand runs sync methods on a thread pool.Throw :class:
AdcpErrorfor buyer-fixable rejection (PLAN_NOT_FOUND,INVALID_REQUEST, etc.). Use the :meth:check_governanceresponsestatus: '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
phasefield 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 = NonePer-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:CapabilityOverlapand add typed fields:.. code-block:: python
@dataclass(frozen=True) class GAMCapabilityOverlap(CapabilityOverlap): line_item_priorities: frozenset[int] | None = None forecast_modes: frozenset[str] | None = NoneThe 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:
CapabilityOverlapupstream rather than as an undocumented key in a shared dict.:param pricing_models: Subset of wire
pricing_modelsthe buyer can choose. Validated against the matching :attr:PricingOption.pricing_modelon 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'stargeting_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;Nonemeans no framework gate.Instance variables
var delivery_types : frozenset[str] | Nonevar pricing_models : frozenset[str] | Nonevar signal_types : frozenset[str] | Nonevar 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)' ), ] = NoneBase 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 setadditionalProperties: trueoverride this withextra='allow'in their ownmodel_config.Set
ADCP_STRICT_VALIDATION=1in the environment ("1","true","yes","on"are accepted) to flip the default toextra='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 adcpruns — mutatingos.environ["ADCP_STRICT_VALIDATION"]after the firstadcpimport 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_configon 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.selfis explicitly positional-only to allowselfas a field name.Ancestors
- AdCPBaseModel
- pydantic.main.BaseModel
Class variables
var account : adcp.types.generated_poc.core.account_ref.AccountReference | Nonevar base_collections : list[adcp.types.generated_poc.collection.base_collection_source.BaseCollectionSource] | Nonevar brand : adcp.types.generated_poc.core.brand_ref.BrandReference | Nonevar cache_duration_hours : int | Nonevar collection_count : int | Nonevar created_at : pydantic.types.AwareDatetime | Nonevar description : str | Nonevar filters : adcp.types.generated_poc.collection.collection_list_filters.CollectionListFilters | Nonevar list_id : strvar model_configvar name : strvar updated_at : pydantic.types.AwareDatetime | Nonevar 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-sellerfetch_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_tokenand 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
Tdirectly) or async (returnAwaitable[T]); the dispatch adapter detects via :func:asyncio.iscoroutinefunctionand runs sync methods on a thread pool.Throw :class:
AdcpErrorfor buyer-fixable rejection (REFERENCE_NOT_FOUND,INVALID_REQUEST,POLICY_VIOLATIONfor 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_artifactto 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
Tdirectly) or async (returnAwaitable[T]); the dispatch adapter detects via :func:asyncio.iscoroutinefunctionand runs sync methods on a thread pool.Throw :class:
AdcpErrorfor 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_idreferences an existing creative; return the manifest with tag fields populated (vast_tag, click trackers, etc.). Whenreq.media_buy_id+req.package_idare also set, generate placement-specific tags with macro substitution baked in. - Inline build:
req.creative_manifestis 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.jsononeOfdoesn't include aSubmittedarm (spec inconsistency tracked asadcontextprotocol/adcp#3392). Until the spec rolls Submitted into theoneOf, slow tag-generation pipelines await in-request; status changes flow viactx.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:BuildCreativeSuccessResponseenvelope when you needsandbox/expires_at/preview. - Library lookup:
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_reportstatus changes viactx.publish_status_changewhen 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. Whenreq.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:
SyncCreativesSuccessResponsefor the sync fast path ORctx.handoff_to_task(fn)for HITL — brand-suitability, S&P review.action: 'created'for new entries,'updated'for replacements,'unchanged'when matching. Optionalstatus: '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
Tdirectly) or async (returnAwaitable[T]); the dispatch adapter detects via :func:asyncio.iscoroutinefunctionand runs sync methods on a thread pool.Throw :class:
AdcpErrorfor buyer-fixable rejection (UNSUPPORTED_FEATUREfor missing optionals,POLICY_VIOLATIONfor 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 onreqshape.Return shape is discriminated by the wire spec's Single vs Multi response arms:
- Single manifest, no metadata: return a :class:
CreativeManifestdirectly. Framework wraps as{creative_manifest: <manifest>}. Use this for single-format requests (target_format_id) when you don't need to setsandbox/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:
BuildCreativeSuccessResponsewithsandbox/expires_at/previewpopulated. Framework passes through unchanged.
Adopters route on
req.target_format_ids(multi) vsreq.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). - Single manifest, no metadata: return a :class:
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_FEATUREto buyers callingpreview_creativeagainst 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:SyncCreativesSuccessResponsefor the sync fast path ORctx.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_FEATUREwhen 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-generatedget_adcp_capabilitiesresponse 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_versionsandidempotency. Required on the wire; defaults toNonemeans 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 whenmedia_buyis insupported_protocols.execution.targeting.geo_postal_areasmay 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 whensignalsis insupported_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 viacomply_test_controller. Omit entirely if unsupported. :param experimental_features: Experimental surfaces implemented by the platform. Required for experimental capability blocks such asmeasurement. :param supported_protocols: Override for thesupported_protocolswire field. DefaultNone= derive from :attr:adcp.decisioning.specialismsviaSPECIALISM_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 aProtocolclass under :mod:adcp.decisioning.specialisms. Drives method-conformance validation at boot AND projects to the wireadcp.decisioning.specialismsfield. :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: SetTrueONLY when the platform implementsgovernance-*specialisms AND has wired a custom :class:StateReaderthat returns real :data:GovernanceContextJWSvalues. DefaultsFalse— 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
Trueonly when the platform advertiseswebhook_signing.supported=Truebut 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'sportfolio.primary_channelsrequiresportfolio.publisher_domainsalongside, which the flatchannelsfield cannot supply). Usemedia_buy=MediaBuy(portfolio=Portfolio(...))instead. Deprecated; emitsDeprecationWarningat projection. :param pricing_models: Pricing models —'cpm','cpc', etc. Superseded bymedia_buy.supported_pricing_models. The projection prefers the structured field when both are set; emitsDeprecationWarningwhenpricing_modelsis set. :param supported_billing: Billing parties this seller invoices — any subset of{"operator", "agent", "advertiser"}. Superseded byaccount.supported_billing. The projection prefers the structured field when both are set; emitsDeprecationWarningwhensupported_billingis set (alone or alongsideaccount).Instance variables
var account : adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response.Account | Nonevar adcp : adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response.Adcp | Nonevar auto_paginate : boolvar brand : adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response.Brand | Nonevar channels : list[str]var compliance_testing : adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response.ComplianceTesting | Nonevar config : dict[str, typing.Any]var creative : adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response.Creative | Nonevar creative_agents : list[typing.Any]var experimental_features : list[adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response.ExperimentalFeature | str] | Nonevar governance : adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response.Governance | Nonevar governance_aware : boolvar identity : adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response.Identity | Nonevar measurement : adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response.Measurement | Nonevar media_buy : adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response.MediaBuy | Nonevar pricing_models : list[str]var request_signing : adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response.RequestSigning | Nonevar signals : adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response.Signals | Nonevar 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 | Nonevar supported_billing : list[str]var supported_protocols : list[adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response.SupportedProtocol] | Nonevar webhook_signing : adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response.WebhookSigning | Nonevar 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 clientAdopter-facing base class for the v6.0 framework.
Subclasses set:
- :attr:
adcp.decisioning.capabilities— what the platform claims to support - :attr:
adcp.decisioning.accounts— an :class:AccountStoreinstance 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:
create_adcp_server_from_platform()) discovers methods viahasattr, validates againstcapabilities.specialisms, and routes requests through the framework's existingserve()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
- LazyPlatformRouter
- PlatformRouter
- adcp.decisioning.platform_router._RegistryPlatformAdapter
Class variables
var accounts : AccountStore[Any]-
Required: the platform's account-resolution strategy. Subclasses set to a :class:
SingletonAccounts, :class:ExplicitAccounts, :class:FromAuthAccounts, or custom :class:AccountStoreinstance. Type erased toAnyat the base because the typed shape is platform-specific (differentTMetaper adopter);validate_platform()confirms an :class:AccountStoreinstance is set. var capabilities : DecisioningCapabilities-
Required: the platform's capability declaration. Subclasses override.
var upstream_url : str | None-
When :attr:
upstream_urlisNone, :meth:upstream_forrefuses to construct a client formode='live'/mode='sandbox'accounts (raisingCONFIGURATION_ERROR).mode='mock'accounts always read fromaccount.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 NoneOptionally override capabilities for the current discovery request.
The class-level :attr:
adcp.decisioning.capabilitiesdeclaration 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
Noneto use :attr:adcp.decisioning.capabilitiesunchanged, or return a complete :class:DecisioningCapabilitiesinstance for this request. The framework still performs the canonicalget_adcp_capabilitiesresponse projection; this hook is not a raw wire-response override. Manual postal compatibility projection is not needed; request-scoped overrides run before the framework projectsgeo_postal_areasfor 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:
UpstreamHttpClientpointed 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 throughauth/ctx.auth_info).mode='mock': client atctx.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 onehttpx.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.modeandctx.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: WhenTrue(default), GET/DELETE 404s returnNonerather than raising.:raises AdcpError:
CONFIGURATION_ERRORwhen:- 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.
- :attr:
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_tokenreceives the optionalauth_contextpassed 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 viaaccount.account_idin the body.The framework passes
reffrom the parsed request body (typicallyrequest.account);adcp.decisioning.resolvereadsref["account_id"]and routes through the adopter-suppliedloader. 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 takesaccount_id. Adopters needing principal-vs-account scope enforcement implement the :class:AccountStoreProtocol directly with a custom resolve that readsauth_info, OR add a request middleware that runs before the handler. The framework does NOT silently bindauth_infoto 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: strand returning an :class:Accountinstance. Sync or async. RaisesAdcpError(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: GetProductsRequestFramework-internal request shape passed to :meth:
ProposalManager.finalize_proposal.Constructed by the dispatcher when a buyer's
get_productsrequest withbuying_mode='refine'carries arefine[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_idfield. :param recipes:product_id -> Recipemapping pulled from the :class:ProposalStoredraft. The adopter's finalize logic typically lock-prices these and emits the committed proposal. :param proposal_payload: The draft's wireProposalshape (the same payload the adopter returned on the priorget_products/refine_productscall). Adopter typically modifies this with locked pricing and returns it on :class:FinalizeProposalSuccess. :param ask: The buyer's per-entry refineasktext — what they want finalized. Free-form; adopter consumes. :param parent_request: The parent :class:GetProductsRequestso the adopter sees the full envelope (account, etc.) without the framework projecting fields one-by-one.Instance variables
var ask : str | Nonevar parent_request : GetProductsRequestvar proposal_id : strvar 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 = NoneAdopter-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.Proposalwithproposal_status='committed'+expires_atpopulated on the nextget_productsresponse payload.:param proposal: The wire
Proposalshape with locked pricing andproposal_status='committed'. Adopter typically derives this from :attr:FinalizeProposalRequest.proposal_payloadwith 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:ProposalStorebacking. 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_secondswindow), the framework rejectscreate_media_buycalls referencing the proposal withPROPOSAL_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 : datetimevar 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.", ), ] = NoneBase 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 setadditionalProperties: trueoverride this withextra='allow'in their ownmodel_config.Set
ADCP_STRICT_VALIDATION=1in the environment ("1","true","yes","on"are accepted) to flip the default toextra='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 adcpruns — mutatingos.environ["ADCP_STRICT_VALIDATION"]after the firstadcpimport 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_configon 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.selfis explicitly positional-only to allowselfas a field name.Ancestors
- AdCPBaseModel
- pydantic.main.BaseModel
Class variables
var accepts_parameters : list[adcp.types.generated_poc.enums.format_id_parameter.FormatIdParameter] | Nonevar accessibility : adcp.types.generated_poc.core.format.Accessibility | Nonevar 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]] | Nonevar canonical : adcp.types.generated_poc.core.canonical_projection_ref.CanonicalProjectionReference | Nonevar delivery : dict[str, typing.Any] | Nonevar description : str | Nonevar disclosure_capabilities : list[adcp.types.generated_poc.core.format.DisclosureCapability] | Nonevar example_url : pydantic.networks.AnyUrl | Nonevar format_card : adcp.types.generated_poc.core.format.FormatCard | Nonevar format_card_detailed : adcp.types.generated_poc.core.format.FormatCardDetailed | Nonevar format_id : adcp.types.generated_poc.core.format_id.FormatReferenceStructuredObjectvar model_configvar name : strvar renders : list[adcp.types.generated_poc.core.format.Renders | adcp.types.generated_poc.core.format.Renders1] | Nonevar reported_metrics : list[adcp.types.generated_poc.enums.available_metric.AvailableMetric] | Nonevar supported_disclosure_positions : list[adcp.types.generated_poc.enums.disclosure_position.DisclosurePosition] | Nonevar 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, ), ] = NoneBase 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 setadditionalProperties: trueoverride this withextra='allow'in their ownmodel_config.Set
ADCP_STRICT_VALIDATION=1in the environment ("1","true","yes","on"are accepted) to flip the default toextra='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 adcpruns — mutatingos.environ["ADCP_STRICT_VALIDATION"]after the firstadcpimport 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_configon 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.selfis explicitly positional-only to allowselfas a field name.Ancestors
- AdCPBaseModel
- pydantic.main.BaseModel
Class variables
var agent_url : pydantic.networks.AnyUrlvar duration_ms : float | Nonevar height : int | Nonevar id : strvar model_configvar 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.principaland routes through the adopter- suppliedloader. The wirerefis 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: strand returning an :class:Accountinstance. 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: floatRFC 9421 HTTP-Signatures credential.
agent_urlis 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 : strvar keyid : strvar 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:
MockAdServerimplementation.Uses a
threading.Lockrather than anasyncio.Lockbecause the recorder is called from both async platform methods AND from sync platform methods (which the framework dispatches on aThreadPoolExecutor). 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 recordProcess-local :class:
ProposalStorereference 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_gracepastexpires_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:
getand :meth:get_by_media_buy_idhonorexpected_account_id— cross-tenant probes returnNone, 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_atbefore eviction. Default 7 days. :param clock: Test injectable; defaults tolambda: 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
- Drafts older than
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:TaskRegistryProtocol.Production-mode gate: :func:
adcp.decisioning.serve.serverefuses to wire this whenADCP_ENVindicates production unlessADCP_DECISIONING_ALLOW_INMEMORY_TASKS=1is set. The gate readsregistry.is_durable; subclassing this class for instrumentation does NOT bypass the gate (theFalseis inherited). Custom durable impls setis_durable = Trueexplicitly. Production sellers runningsales-broadcast-tvor 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_productsmethod. 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_productscalls through this streaming path instead of the plain method. Batches are collected until theadcp.decisioning.time_budgetdeadline; remaining scopes are projected toincomplete[].Until then, a
adcp.decisioning.time_budgettimeout returnsproducts: []+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 batchNote:
get_products_incrementalMUST be anasync defthat yields — i.e., an async generator function. The framework detects it viaasyncio.isasyncgenfunction, notasyncio.iscoroutinefunction. If your data source is synchronous, wrap the yield in anasync defbody rather than returning a sync generator.campaignunit: ifreq.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
DecisioningPlatformconstructed on first request.A :class:
PlatformRoutervariant 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 samectx.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:
invalidatefor 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_threadinside 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:
invalidatefrom tenant rotation / deactivation paths::router.invalidate("tenant-a") # specific tenant router.invalidate() # all platforms — ops "drop everything"Behavior on
invalidateof 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:Accountwhosemetadata['tenant_id']keys the factory. :param factory: Callable taking atenant_idstring and returning a :class:DecisioningPlatform(sync or async). Must not returnNone— return a typed :class:AdcpErrorraise 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-tenantget_productsroutes to the manager when wired; otherwise falls through to the lazily-resolved platform'sget_products. :param proposal_stores: Optional{tenant_id: ProposalStore}— eager per-tenant proposal store dict. Mirrors the eager :class:PlatformRouter'sproposal_stores=shape for adopters with a small known tenant set. Mutually exclusive withproposal_store_factory. Stores are dict-cheap to hold; the lazy machinery applies only toplatforms(which wrap upstream connections / credentials and warrant the LRU eviction). See #722. :param proposal_store_factory: OptionalCallable[[str], ProposalStore | None]— lazy-build flavor. Called on every :meth:proposal_store_for_tenantinvocation (no internal caching), and the framework'sadcp.decisioning.proposal_dispatchcalls 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_cacheor your own memoization — mostProposalStoreimplementations hold long-lived DB pool references and adopters typically return the same instance per tenant. ReturnNonefor tenants that don't need a store (pure-catalog mode without finalize). Mutually exclusive withproposal_stores. See #722. :param cache_size: Maximum number of cached :class:DecisioningPlatforminstances. Bounded LRU eviction past this size. Default 256. Adopters with more concurrent active tenants override. :param cache_ttl_seconds: Per-entry TTL in seconds.0means size-only eviction (no time-based expiry). Default 3600.0 (one hour). Distinct from :class:CallableSubdomainTenantRouterwhich rejects0— 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:invalidatefrom your rotation path.:raises ValueError: when
cache_size <= 0orcache_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_productsdispatch.Resolves the tenant, then routes to the wired :class:
ProposalManagerwhen one exists for the tenant (refine vs. plain selection mirrors PlatformRouter); otherwise falls through to the lazily-resolved platform'sget_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) + 1Drop 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.
Noneclears 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_FOUNDwhen the factory rejects the tenant.INTERNAL_ERRORwhen the factory returns a non-:class:DecisioningPlatformvalue. 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:
ProposalManagerfortenant_id, orNone. 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 NoneReturn the :class:
ProposalStorefortenant_id, orNone.Resolution order:
- Eager
proposal_storesdict, when wired. proposal_store_factory(tenant_id), when wired. The factory is invoked on every call — adopters who need caching wrap the factory themselves (mostProposalStoreimplementations hold long-lived pool references and are cheap to return on repeat calls).None— the tenant has no store wired and the framework'sadcp.decisioning.proposal_dispatchfalls through to the v1 (no-proposal) path.
Sibling-API parity with :meth:
PlatformRouter.proposal_store_for_tenant()for adopters wiring a :class:ProposalStoreper #722. The framework'sadcp.decisioning.proposal_dispatchduck-types this method viahasattr(platform, "proposal_store_for_tenant"). - Eager
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_idnot 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_createrecords overlays from a successfulcreate_media_buy, joining the request's per-package overlay with the response's seller-assignedpackage_id(orbuyer_refwhen present). - :meth:
merge_from_updateappliesadcp.decisioning.update_media_buypatches with deep-merge semantics: keys absent from the patch keep prior values, keys present with non-null values replace, keys present andNoneclear. - :meth:
backfillfills in missingpackages[].targeting_overlayon aget_media_buysresponse 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: ...
- :meth:
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_productsto a running mock-server.Symmetric with :meth:
DecisioningPlatform.upstream_for()'s mock-mode dispatch (see Phase 2 —mock_upstream_urlonAccount.metadata). Adopter declares amock_upstream_urlpointing atbin/adcp.js mock-server <specialism>; the framework forwardsget_productsrequests 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:
ProposalManagersubclass 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
GetProductsRequestpayloads 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:ApiKeyhere. :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 client : UpstreamHttpClient-
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._clientThe 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_urlThe 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 resultForward 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.GetProductsResponsepayloads, so the dict round-trips cleanly through the existingGetProductsResponsevalidation. 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_productsendpoint withbuying_mode='refine'. The forwarder POSTs to the same URL; the mock-server distinguishes by readingbuying_modeon 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 = NoneOAuth client-credentials grant. Includes the verified scopes.
Instance variables
var client_id : strvar expires_at : float | Nonevar 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
Tdirectly) or async (returnAwaitable[T]); the dispatch adapter detects via :func:asyncio.iscoroutinefunctionand runs sync methods on a thread pool so a blocking sync handler doesn't serialize the event loop.Throw :class:
AdcpErrorfor 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:
GetSignalsResponsedirectly for the sync fast path. Brief-driven discovery MAY hand off viactx.handoff_to_task(fn)when provider discovery needs background work (cross-provider fan-out, identity-graph lookups); the framework projects the handoff to the wiresubmittedenvelope. The buyer reaches the terminal :class:GetSignalsResponseviatasks/getpolling, and — when the request carriedpush_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 withAdcpError(INVALID_REQUEST, field='discovery_mode').Note
get_signalsships ONLY thesubmittedandworkingasync arms — it has noinput_requiredarm (unlikeget_productsandcreate_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:
AdcpErrorwith code"AGENT_SUSPENDED"/"AGENT_BLOCKED"directly — those are dedicated AdCP 3.1 codes with their ownrecovery="terminal"semantics, not flavors ofPERMISSION_DENIED.:param scope: When the gate is a per-agent non-status provisioning constraint, set to
'agent'(withreason); when billing-relationship, set to'billing'. Sellers MUST emitscope='agent'only when buyer-agent identity has been established (signed-request derivation or credential-to-agent mapping); otherwise omit. :param reason: Whenscope='agent', the registered provisioning gate that fired ('sandbox_only', etc.) — seeerror-details/agent-permission-denied.json. :param message: Optional human-readable override of the default. :param details: Additional fields merged intoerror.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. Setis_durable = Trueso production-mode gates accept it without requiring the dev-mode bypass.:param pool:
psycopg_pool.AsyncConnectionPoolowned by the caller. Each operation acquires a short-lived connection; :meth:try_reserve_consumptionholds 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 whoseproposal_draftstable is already taken. Defaults toadcp_proposal_drafts. :param recipe_decoder: Callable(payload: dict) -> Recipeused to rehydrate stored recipe payloads back to typed :class:Recipeinstances. Adopters with subclasses (GAMRecipe,KevelRecipe, etc.) MUST supply a decoder that branches onrecipe_kind. Defaults to :meth:Recipe.model_validatewhich only works for the baseRecipeshape.:raises ImportError: when psycopg/psycopg-pool are not installed. :raises ValueError: when
table_nameis 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_namekwarg the store was constructed with. Idempotent viaCREATE TABLE IF NOT EXISTS— safe to call on every application boot. The equivalent raw DDL ships at :file:adcp/decisioning/pg/proposal_store.sqlin 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. Setis_durable = Trueso the production-mode gate in :func:adcp.decisioning.serve.create_adcp_server_from_platformaccepts it without requiringADCP_DECISIONING_ALLOW_INMEMORY_TASKS=1.Parameters
pool: An :class:
psycopg_pool.AsyncConnectionPoolowned 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 fixeddecisioning_taskstable 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
completedwithresultas the terminal artifact.Idempotent on repeated calls with an equal
result; raises :class:ValueErroron conflicting re-completion.Uses an atomic
UPDATE … RETURNINGso 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
_tablekwarg the store was constructed with. Idempotent viaCREATE TABLE IF NOT EXISTS— safe to call on every application boot. The equivalent raw DDL ships atadcp/decisioning/pg/decisioning_tasks.sqlin 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.InMemoryTaskRegistrycontract. 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
failedwitherroras the terminal payload.Idempotent on repeated calls with an equal
error; raises :class:ValueErroron 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_idpredicate 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_idAllocate a task_id, persist a
submittedrow, return the id.Mirrors :meth:
~adcp.decisioning.InMemoryTaskRegistry.issueincluding the account_id validation guard — empty or sentinel account_ids would allow cross-tenant task-id probing via theWHERE account_id = %spredicate 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
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.TaskRegistrydocstring).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. Setis_durable = Trueso the production-mode gate in :func:adcp.decisioning.serve.create_adcp_server_from_platformaccepts it without requiringADCP_DECISIONING_ALLOW_INMEMORY_TASKS=1.Parameters
pool: An :class:
psycopg_pool.AsyncConnectionPoolowned 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 fixeddecisioning_taskstable 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
completedwithresultas the terminal artifact.Idempotent on repeated calls with an equal
result; raises :class:ValueErroron conflicting re-completion.Uses an atomic
UPDATE … RETURNINGso 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
_tablekwarg the store was constructed with. Idempotent viaCREATE TABLE IF NOT EXISTS— safe to call on every application boot. The equivalent raw DDL ships atadcp/decisioning/pg/decisioning_tasks.sqlin 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.InMemoryTaskRegistrycontract. 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
failedwitherroras the terminal payload.Idempotent on repeated calls with an equal
error; raises :class:ValueErroron 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_idpredicate 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_idAllocate a task_id, persist a
submittedrow, return the id.Mirrors :meth:
~adcp.decisioning.InMemoryTaskRegistry.issueincluding the account_id validation guard — empty or sentinel account_ids would allow cross-tenant task-id probing via theWHERE account_id = %spredicate 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
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.TaskRegistrydocstring).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:
DecisioningPlatformthat 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- leveladcp.decisioning.capabilitiesis the union of every child's specialisms; individual calls that the resolved tenant's platform doesn't implement raiseUNSUPPORTED_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:Accountwhosemetadata['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 perserve()call. :param proposal_managers: Optional{tenant_id: ProposalManager}mapping for the two-platform composition (seedocs/proposals/product-architecture.md). When a tenant has a wired :class:ProposalManager, the router routesget_products(and refine-modeget_productswhen the manager declares :attr:ProposalCapabilities.refineand implementsrefine_products) to that manager instead of the tenant's :class:DecisioningPlatform. Tenants without an entry fall through toplatform.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'stools/listfilter reads this to advertise the right tools, andvalidate_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:
platformsis empty (a router with no children is misconfiguration, not a valid empty state), or when :attr:proposal_managerscontains 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_productsdispatch.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 toproposal_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_delegatewould 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_forinstead, which projects toACCOUNT_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:
ProposalManagerfortenant_id, orNonewhen the tenant falls through to its platform's ownget_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:
ProposalStorefortenant_id, orNonewhen 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=Truewithout 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_configsonce percreate_media_buyrequest, before invoking the platform method. Adopters return a dict keyed byproduct_id; the framework injects it asconfigs=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_UNAVAILABLEwithrecovery='transient'to the buyer.The
ctxparameter carries the resolvedRequestContext, includingctx.accountfor 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 usesproposal_idwithout an explicitpackagesarray. :param ctx: Resolved request context — usectx.accountfor 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
GetProductsResponsebatches during streaming.Passed to
IncrementalGetProducts.get_products_incremental()so the framework can collect whatever batches the adopter yields before the deadline. The framework readscheckpoint.batchesafter timeout to projectproductsandincomplete[].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:
fetchwhenproperty_list_filteringis declared in capabilities and the request carries aPropertyListReferencereference. 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
PropertyListReferencereference. :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.AdcpErrorwithrecovery='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.' ), ] = NoneBase 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 setadditionalProperties: trueoverride this withextra='allow'in their ownmodel_config.Set
ADCP_STRICT_VALIDATION=1in the environment ("1","true","yes","on"are accepted) to flip the default toextra='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 adcpruns — mutatingos.environ["ADCP_STRICT_VALIDATION"]after the firstadcpimport 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_configon 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.selfis explicitly positional-only to allowselfas a field name.Ancestors
- AdCPBaseModel
- pydantic.main.BaseModel
Class variables
var agent_url : pydantic.networks.AnyUrlvar auth_token : str | Nonevar list_id : strvar 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.' ), ] = NoneBase 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 setadditionalProperties: trueoverride this withextra='allow'in their ownmodel_config.Set
ADCP_STRICT_VALIDATION=1in the environment ("1","true","yes","on"are accepted) to flip the default toextra='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 adcpruns — mutatingos.environ["ADCP_STRICT_VALIDATION"]after the firstadcpimport 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_configon 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.selfis explicitly positional-only to allowselfas a field name.Ancestors
- AdCPBaseModel
- pydantic.main.BaseModel
Class variables
var agent_url : pydantic.networks.AnyUrlvar auth_token : str | Nonevar list_id : strvar 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
Tdirectly) or async (returnAwaitable[T]); the dispatch adapter detects via :func:asyncio.iscoroutinefunctionand runs sync methods on a thread pool.Throw :class:
AdcpErrorfor 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_tokenthe buyer stores in their secret manager. Token is scoped to thislist_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_tokenimmediately and signal cache invalidation to sellers (reducedcache_valid_untilor 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_tokenfrom :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 = NoneBase 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 setadditionalProperties: trueoverride this withextra='allow'in their ownmodel_config.Set
ADCP_STRICT_VALIDATION=1in the environment ("1","true","yes","on"are accepted) to flip the default toextra='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 adcpruns — mutatingos.environ["ADCP_STRICT_VALIDATION"]after the firstadcpimport 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_configon 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.selfis explicitly positional-only to allowselfas a field name.Ancestors
- AdCPBaseModel
- pydantic.main.BaseModel
Class variables
var allocations : list[adcp.types.generated_poc.core.product_allocation.ProductAllocation]var brief_alignment : str | Nonevar description : str | Nonevar expires_at : pydantic.types.AwareDatetime | Nonevar ext : adcp.types.generated_poc.core.ext.ExtensionObject | Nonevar forecast : adcp.types.generated_poc.core.delivery_forecast.DeliveryForecast | Nonevar insertion_order : adcp.types.generated_poc.core.insertion_order.InsertionOrder | Nonevar model_configvar name : strvar proposal_id : strvar proposal_status : adcp.types.generated_poc.enums.proposal_status.ProposalStatus | Nonevar 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_specialismfield 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_productsis only invoked when :attr:adcp.decisioning.refineis 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 whereget_productsreturns a static catalog and buyers reference products directly atcreate_media_buy. :param refine: When True, the manager implements :meth:ProposalManager.refine_products()and the framework routesget_productsrequests withbuying_mode='refine'to that method. When False, refine requests fall through toget_products(or surfaceUNSUPPORTED_FEATUREif 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 thatInventoryStore/SignalStoreprimitives 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 aRateCardStoreprimitive is wired. :param availability_reservations: The manager reserves capacity at proposal time (typical for guaranteed). Informational in v1; thefinalizetransition that drives the actual hold lands in a subsequent PR. :param multi_decisioning: The manager emits products whose recipes route to >1 :class:DecisioningPlatformper 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 fromget_productswithout a separatefinalize_proposalstep. WhenTrue, the framework calls :meth:ProposalStore.commit()immediately after :meth:ProposalStore.put_draft()on every proposal returned, promotingDRAFT → COMMITTEDso thatcreate_media_buy(proposal_id=X)can calltry_reserve_consumptionwithout a separate buyer round-trip. Mutually exclusive withfinalize=True(finalize is the explicit lifecycle; auto-commit is the bypass). Adopters wiring their own commit lifecycle (e.g. webhook-driven approval) leave thisFalse. See #723. :param auto_commit_ttl_seconds: TTL applied to the auto-committed proposal'sexpires_at. Used only when :attr:auto_commit_on_put_draftisTrue. Defaults to604800(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 getPROPOSAL_EXPIREDand re-request the brief.Instance variables
var auto_commit_on_put_draft : boolvar auto_commit_ttl_seconds : intvar availability_reservations : boolvar derive_packages_from_allocations : bool-
Opt-in: when
True, the framework auto-derivesreq.packagesfrom the proposal'sallocations[]array oncreate_media_buy(proposal_id=..., total_budget=...)calls with no explicitpackages[]. DefaultFalsepreserves the pre-#727 semantics (framework leavesreq.packagesempty; seller adapter handles it). Adopters whosecreate_media_buyadapter currently readsctx.recipesdirectly 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_packagesfor custom math.Either flipping this to
TrueOR implementingadcp.decisioning.derive_packagesactivates the framework's auto-injection. See :func:derive_packages_from_proposal()for the built-in even-percentage helper. var dynamic_products : boolvar expires_at_grace_seconds : intvar finalize : boolvar multi_decisioning : boolvar rate_card_pricing : boolvar refine : boolvar 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.Productcarries a typedadcp.decisioning.implementation_config(a recipe; see :class:Recipe) the bound :class:DecisioningPlatformconsumes atcreate_media_buy.Methods may be sync or async; the dispatch adapter detects via :func:
asyncio.iscoroutinefunctionand runs sync methods on a thread pool. Same convention as the existing :class:SalesPlatformProtocol 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 viaget_productsand the framework surfacesUNSUPPORTED_FEATUREon refine requests when this method is absent.
Future-state surfaces (deferred to subsequent PRs):
finalizetransition handling (buying_mode='refine'+action='finalize'→ committed proposal with locked pricingexpires_at)- Capability-overlap declaration on :class:
Recipe+ framework validation - Recipe lifecycle (session cache → persisted store → hydration
at
create_media_buy)
Throw :class:
AdcpErrorfor 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.ProductSHOULD carry anadcp.decisioning.implementation_configmatching the bound :class:DecisioningPlatform's recipe schema (see :class:Recipe). v1 treatsadcp.decisioning.implementation_configas opaquedict[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
finalizetransition; adopters return draft proposals and rely on the buyer driving lifecycle via subsequentcreate_media_buycalls. 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_productsresponse.Per the spec, refine is a
buying_modevalue onget_products— the wire envelope is the same. The framework routes refine requests to this method when:- The wired ProposalManager declares
:attr:
ProposalCapabilities.refine= True, AND - The request has
buying_mode == 'refine', AND - The manager subclass implements this method.
Otherwise, refine requests fall through to :meth:
get_products(the manager handles refinement itself) or surfaceUNSUPPORTED_FEATUREif neither path is wired.v1 does NOT handle the
finalizeaction — that's a subsequent PR. Adopters implementing this method today should treataction='finalize'entries asUNSUPPORTED_FEATUREand return a structured error. - The wired ProposalManager declares
:attr:
- :meth:
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 = 1The 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 -> Recipemapping. The :class:ProposalManagerreturned these alongside products on get_products / refine_products; the framework persists them so :meth:DecisioningPlatform.create_media_buycan hydratectx.recipesfrom this same record. :param proposal_payload: The wireProposalshape. 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 rejectscreate_media_buycalls 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_iduse this. :param recipe_schema_version: Captured at :meth:put_drafttime. 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 : strvar expires_at : datetime | Nonevar media_buy_id : str | Nonevar proposal_id : strvar proposal_payload : Mapping[str, Any]var recipe_schema_version : intvar recipes : Mapping[str, Recipe]var state : ProposalState
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 terminalLifecycle states for a stored proposal.
No
EXPIREDmember: 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 COMMITTEDvar CONSUMEDvar CONSUMINGvar 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 → CONSUMEDtwo-phase transition prevents the inventory double-spend race that a check-then-act sequence onCOMMITTEDwould expose. Two parallelcreate_media_buy(proposal_id=X)calls cannot both reserve the proposal — the second :meth:try_reserve_consumptionraisesPROPOSAL_NOT_COMMITTEDonce the first transitioned the record. Adapter dispatch runs against the reservation; on success the framework calls :meth:finalize_consumption(records themedia_buy_id); on failure the framework calls :meth:release_consumption(rolls back toCOMMITTEDso the buyer can retry).Transitions outside this graph (commit-from-COMMITTED, finalize_consumption-from-DRAFT, etc.) raise :class:
AdcpErrorwithcode='INTERNAL_ERROR'— those are framework / adopter bugs, not buyer-facing rejections.account_idis opaque. The framework threadsctx.account.id(whatever the adopter's :class:~adcp.decisioning.AccountStore.resolvereturned) 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 intoAccount.idat the :class:~adcp.decisioning.AccountStorelayer once, and the composite(account_id, proposal_id)then carries unique identity across the entire deployment without needing a separatetenant_idparameter on this Protocol. See the AccountStore docstring's "Multi-tenant deployments" section for the canonical encoding pattern, and :func:~adcp.decisioning.create_tenant_storefor the typed helper.Ancestors
- typing.Protocol
- typing.Generic
Class variables
var is_durable : ClassVar[bool]-
Drives the production-mode gate.
Falsefor :class:InMemoryProposalStore;Truefor 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
DRAFT→COMMITTED.Idempotent on re-call with equal
expires_at+proposal_payload. A second commit with different values raisesINTERNAL_ERROR— adopter bug.expected_account_idscopes 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 onproposal_ideither 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_idscopes 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
CONSUMING→CONSUMEDand record themedia_buy_idback-reference for :meth:get_by_media_buy_idreverse-index lookups.:raises AdcpError:
INTERNAL_ERRORif the record is not inCONSUMING(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: whenexpected_account_idis supplied, a mismatch returnsNonerather 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_idfor the given tenant.expected_account_idis required (no default) becausemedia_buy_idis 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)wheremedia_buy_id IS NOT NULL.Returns
Nonefor 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
COMMITTED→CONSUMEDtransition.New code uses :meth:
try_reserve_consumption+ :meth:finalize_consumptionfor 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_idscopes 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_idto overwrite. Calling :meth:put_drafton a record currently in :attr:ProposalState.COMMITTEDor :attr:ProposalState.CONSUMEDis 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:
CONSUMING→COMMITTED.Called by the framework when the adapter's :meth:
create_media_buyraises (transient upstream error, validation, etc.) so the buyer can retry withoutPROPOSAL_NOT_COMMITTED. Idempotent on a record already inCOMMITTED(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:
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 raisesPROPOSAL_NOT_COMMITTED.:raises AdcpError:
PROPOSAL_NOT_FOUNDwhen no record exists,PROPOSAL_NOT_COMMITTEDwhen state is notCOMMITTED(already CONSUMING / CONSUMED / DRAFT). Adopters backed by SQL implement this withSELECT … FOR UPDATEor 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_credentialis queryable with arbitrarykey_idstrings; 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). Defaultrps_per_tenantso a steady state can sustainrps_per_tenantcalls/sec but bursts are capped at the same number. Adopters with bursty real traffic raise this. :param audit_sink: Optional audit sink — emitsrate_limitedevents 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:
AdcpErrorPERMISSION_DENIEDwith NOdetailsand a generic message matching the registry-miss path. This is deliberate — a distinctRATE_LIMITEDcode or any populateddetailsfield would itself be an enumeration oracle (the attacker learns "thisagent_urlis 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_afterinterval.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 = NoneBase 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_overlapfor framework-gated buyer-request validation (per v1.5 § D4).Adopter subclasses are pure Pydantic — round-trip via
model_dump()to land inProduct.implementation_configon the wire response, andmodel_validate(d)to rehydrate when an adopter receives the dict back atcreate_media_buytime.The base intentionally does NOT declare
recipe_kinditself; each subclass MUST declare it as aLiteral["..."]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:CapabilityOverlapactivates 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.selfis explicitly positional-only to allowselfas a field name.Ancestors
- pydantic.main.BaseModel
Class variables
var capability_overlap : CapabilityOverlap | Nonevar 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, constructingrefinement_applied[]from :attr:per_refine_outcome+ the request'sadcp.decisioning.refine[]array.:param products: Updated product list (per spec, refine returns a revised set — drop omitted, add
more_like_thisdiscoveries, update pricing on remaining). :param proposals: Updated proposal list.Nonewhen the seller does not produce proposals (sales-non-guaranteed without proposal mode). Empty list when proposals were all omitted. :param per_refine_outcome: Exactlylen(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 = NonePer-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 wirerefinement_applied[]by zipping outcomes with the request's refine entries — adopter does NOT echoscope,product_id, orproposal_idmanually.:param status:
'applied','partial', or'unable'per the wire enum (see :class:adcp.types.generated_poc.bundled.media_buy'sRefinementApplied.status). :param notes: Adopter's explanation; recommended when status is'partial'or'unable'.Instance variables
var notes : str | Nonevar 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:
ToolContextso the existing framework primitives (idempotency middleware, observability, A2A executor) consume it as aToolContextwhile adopter Protocol methods read the typed :attr:accountdirectly.Framework-only construction. Adopter code receives a
RequestContextfrom the framework on every dispatch via the hydration helper inadcp.decisioning.dispatch. Direct construction is supported for tests only — production code that builds aRequestContextfrom outside the dispatch seam is a bug. Adopters who need to modify the context (custom middleware, test doubles foradcp.decisioning.state/adcp.decisioning.resolve) should use :func:dataclasses.replace, not raw construction. Mirrors the TS-sideto-context.ts:buildRequestContextcontract.:param account: The resolved account, with typed
metadata: TMeta. The framework's idempotency middleware readsctx.caller_identityfor cache scoping; the dispatch adapter setscaller_identity = account.idso caching scopes per resolved account, not per raw auth principal. :param auth_info: Optional verified principal info. On bearer-token flows the dispatch helper synthesizesAuthInfo(kind="bearer", principal=...)from the :data:adcp.server.auth.current_principalContextVar so adopters can branch onctx.auth_info.kind == "bearer"(the typed flow discriminator) without reaching into framework-private state.Nonewhen the request is unauthenticated (dev /'derived'fixtures). :param now: Monotonic timestamp for the request — adopters use this rather thandatetime.now()directly so tests can inject deterministic clocks.Adopters call :meth:
handoff_to_taskto promote a method to the HITL background-task path. The framework dispatcher detects the returned :class:TaskHandoffvia type-identity and projects it to the wireSubmittedenvelope.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 equalsaccount.idfor'explicit'-resolution adopters; can diverge for'derived'/'implicit'modes.Common patterns:
- Routing to the right adapter? →
ctx.account.metadata.adapter(typed via theTMetageneric). - Authorization check? →
ctx.auth_principal(who's calling) againstctx.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".NonewhenRequestContextis constructed in tests without a transport-awareToolContext, or when a customcontext_factoryomitsmetadata["transport"]. Production dispatch always populates this field. Note: even when the server is started withtransport="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_transportinstead. :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— raisesNotImplementedErroron every call. v6.1 wires the backing fetchers. :param auth_principal: Typed convenience field carrying the verified principal label. Sourced from :class:AuthInfo.principalon signed-request flows and from the :data:adcp.server.auth.current_principalContextVar on bearer-token flows (the framework's :class:BearerTokenAuthMiddlewarepopulates the ContextVar; the dispatch helper reads it whenauth_infois absent). The right read for "who's calling?" — distinct fromcaller_identity, which the framework mutates into a composite cache scope key for idempotency.Ancestors
- ToolContext
- typing.Generic
Instance variables
var account : Account[TMeta]var auth_info : AuthInfo | Nonevar auth_principal : str | Nonevar buyer_agent : BuyerAgent | Nonevar now : datetimevar recipes : Mapping[str, Recipe]var resolve : ResourceResolvervar state : StateReadervar 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 runsfnafter returning, persistsfn's terminal artifact to the task registry, and emits a push-notification webhook on terminal state.fnreceives aTaskHandoffContext(defined in :mod:adcp.decisioning.dispatch) carrying:id— framework-issued task UUIDupdate(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_workflowinstead. 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, callsfnONCE synchronously (or awaits it if a coroutine) to register the work into the adopter's external system, persistssubmittedstate, and returns the wire envelope. NO background coroutine runs in the framework.fnreceives a :class:TaskHandoffContextcarryingid(framework-allocated task_id) and_registry(adopter can stash a reference for later completion). The adopter's external workflow later callsregistry.complete(task_id, result)orregistry.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, sametasks/getpolling, same push-notification webhook on terminal state.Rollback. If
fnraises during enqueue, the framework discards the just-allocated task_id from the registry and propagates the exception (wrapped toAdcpErrorper 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:
WorkflowHandofffor the full semantics.
- Routing to the right adapter? →
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:
AccountStoremethods 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
ResolveContextshape:auth_info,tool_name,agent. Adopters read these to implement principal-keyed gates onsync_accounts/list_accounts/sync_governance(e.g., the spec'sBILLING_NOT_PERMITTED_FOR_AGENTper-buyer-agent gate from adcontextprotocol/adcp#3851) without re-deriving identity from the request.Prefer
agentoverauth_infofor commercial-relationship decisions.agentis the registry-resolved durable identity (status, billing capabilities, default account terms);auth_infois the raw transport-level credential. For billing gates the registry-resolved identity is canonical.:param auth_info: Verified principal info.
Nonefor 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:BuyerAgentwhen a :class:BuyerAgentRegistryis wired.Noneotherwise.Instance variables
var agent : BuyerAgent | Nonevar auth_info : AuthInfo | Nonevar 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 throughcapabilities.creative_agentsfor creative-format reads, hits the framework's localCreativePlatform.list_formatsfor 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.resolvefield is populated by the dispatch hydration helper. Adopters substituting test doubles use :func:dataclasses.replaceon the context, not direct construction.Mirrors the TS-side
ResourceResolverinterface insrc/lib/server/decisioning/context.ts. v6.0 ships the contract + the no-op stub (raisesNotImplementedErroron every call); v6.1 lands the backing fetchers.Note
:class:
runtime_checkableProtocols only check attribute presence. Whether a method isasync defis irrelevant to the runtimeisinstancecheck — a sync method namedadcp.decisioning.property_listwould pass the structural check but fail atawaittime. Use mypy to enforceasync defsignatures 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_agentsdeclaration with a framework-managed cache; self-hosted formats hit the localCreativePlatform.list_formats. Returns the resolved :class:Formatwith 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) passrevalidate=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=Truerather 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 NotImplementedErrorUnified hybrid interface for every
sales-*specialism.Methods may be sync (return
Tdirectly) or async (returnAwaitable[T]); the dispatch adapter detects via :func:inspect.iscoroutinefunctionand runs sync methods on a thread pool via :func:asyncio.to_threadso 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:TaskHandoffvia type-identity and projects to the wireSubmittedenvelope.Throw :class:
AdcpErrorfor 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:
CreateMediaBuySuccessResponsedirectly 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_idfield present → sync successtask_id+status='submitted'→ polltasks_getor receive webhook
Framework injection: when a :class:
~adcp.decisioning.ProductConfigStoreis wired viaconfig_store=in :func:adcp.decisioning.serve.create_adcp_server_from_platform, the framework calls the store before invoking this method and injects the result asconfigs: dict[str, dict[str, Any]]. Declareconfigsin 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.packagesisNone(proposal-id flow),configsarrives 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:
GetProductsResponsedirectly for the sync fast path. Brief / refine discovery MAY hand off viactx.handoff_to_task(fn)when composing the catalog needs background work (custom curation queue, slow proposal generation); the framework projects the handoff to the wiresubmittedenvelope. The buyer reaches the terminal :class:GetProductsResponseviatasks/getpolling, and — when the request carriedpush_notification_config— the framework also delivers the terminal completion / failure webhook to the buyer's URL from the background completion path (exactly once).get_productsexposes thesubmitted/working/input_requiredasync 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'sadcp.decisioning.time_budgetdeclares the gap viaincomplete[]on a sync response rather than handing off; returning a handoff from a wholesale call is rejected at the framework layer withAdcpError(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 viactx.publish_status_change(resource_type='proposal', ...)rather than blockingget_productswaiting 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 implementrefine_get_productsrejectbuying_mode='refine'withAdcpError(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 NotImplementedErrorSync product catalogs with the platform.
Required when claiming
sales-catalog-driven.validate_platform()hard-fails at server boot when this method is absent on asales-catalog-drivenplatform.Discovery mode: when
req.catalogs is None, return the account's existing synced catalogs without modification (read-only path per AdCP spec). Checkreq.catalogsbefore 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=Truewithreq.catalogs=Noneis spec-undefined — reject it withAdcpError("INVALID_REQUEST", field="catalogs")rather than silently deleting buyer-managed catalogs.Return a list of :class:
~adcp.types.SyncCatalogResultrows (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
Submittedarm yet (adcp#3392). Re-approval flows return the success with thestatusfield omitted (in-spec per the schema description) and drive lifecycle viactx.publish_status_change. v6.1 + adcp#3392 expand this signature to :data:SalesResultso re-approval flows can hand off cleanly.
-
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_afterMAY 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: TWrapper a
beforehook returns to skip the inner method.Returning
ShortCircuit(value=...)from a :data:BeforeHookskips the wrapped inner method and feeds the wrapped value throughafter(if any) back to the caller. ReturningNonefalls 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:TypeErrorat runtime rather than silent short-circuit-with-Nonebehavior — 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
afterruns 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:OwnedSignalsPlatformforsignal-ownedplatforms where returned signals are already usable in later media-buy targeting and there is no buyer-triggered destination provisioning step.Ancestors
- OwnedSignalsPlatform
- typing.Protocol
- typing.Generic
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
deploymentsrows in their current state —'pending'is a valid sync return for slow activation pipelines.Subsequent state changes (per-deployment
activating/deployed/failed) flow viactx.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'(unknownsignal_agent_segment_id),code='POLICY_VIOLATION'(buyer lacks rights to activate this data), orcode='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.idfrom 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'sresponse_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 isf"{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 forAccount.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:Accountcarriesmodeand is stamped explicit so the framework's observed-modes tracker counts the resolution. DefaultNone— leaves accounts at the implicit-default'live'(pre-mode behavior). Pass'sandbox'for a single-platform conformance / dev deployment that should admitcomply_test_controller; pass'live'to deliberately mark the singleton as production (the env-fallback guard then trips loudly ifADCP_SANDBOX=1is 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.statefield is populated by the dispatch hydration helper. Adopters substituting test doubles use :func:dataclasses.replaceon the context, not direct construction.Mirrors the TS-side
WorkflowStateReaderinterface insrc/lib/server/decisioning/context.ts. v6.0 ships the contract + the no-op stub; v6.1 lands the backing store.Note
:class:
runtime_checkableProtocols match by attribute name only — return types (including :data:GovernanceContextJWS, which is a :func:typing.NewTypeinvisible at runtime) and method signatures are NOT enforced byisinstance. A custom impl that returnsintfromgovernance_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_idthreaded acrossget_products → refine → create_media_buywithout platform code. ReturnsNoneif 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).
Nonefor non-governance flows. Framework verifies before exposure; platform code can trust the value.Adopters claiming
governance-*specialisms incapabilities.specialismsMUST setcapabilities.governance_aware=Trueand wire a realStateReaderthat returns real JWS tokens. The default stub returnsNone, which would silently skip the gate — server boot fails fast if a governance specialism is claimed without the opt-in. Seedocs/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 = NonePer-account result row returned by an adopter's
accounts.upsertimplementation. Maps to one element of the wiresync_accountsresponse'sadcp.decisioning.accounts[]array.Carries the same optional commercial / lifecycle fields as the wire shape so adopters can echo
setup(forpending_approvalaccounts),billing_entity,payment_terms, etc. on creation. The framework projects through :func:to_wire_sync_accounts_row()before emit, applying the samebilling_entity.bankstrip as :func:to_wire_account()(write-only contract).MUST NOT carry auth-derived fields. This shape is emitted on the
sync_accountsresponse wire. Adopters MUST NOT add anauth_infokey 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'saccount.operator. :param action: Required.created/updated/unchanged/failed. :param status: Required. AdCP account-status enum value. :param account_id: Seller-assigned account identifier (whenactioniscreated). :param name: Human-readable account name assigned by the seller. :param billing: Invoiced-to party (operator/agent/advertiser). :param billing_entity: Business entity invoiced.bankis stripped on emit (write-only). :param setup: Setup payload forpending_approvalaccounts. :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 isfailed). :param warnings: Non-fatal warnings about this account. :param sandbox: Sandbox-account marker, echoed from the request.Instance variables
var account_id : str | Nonevar account_scope : AccountScope | Nonevar action : Literal['created', 'updated', 'unchanged', 'failed'] | strvar billing : Literal['operator', 'agent', 'advertiser'] | Nonevar billing_entity : BusinessEntity | Nonevar brand : dict[str, Any]var credit_limit : CreditLimit | Nonevar errors : list[dict[str, Any]] | Nonevar name : str | Nonevar operator : strvar payment_terms : PaymentTerms | Nonevar rate_card : str | Nonevar sandbox : bool | Nonevar setup : AccountSetup | Nonevar status : strvar 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_governancerequest'sadcp.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:AccountReferencewith itsgovernance_agents[].The
governance_agentslist carriesauthentication.credentialson the input — adopters persist these for outboundcheck_governancecalls. The framework stripsauthenticationon 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[], includingauthentication(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 : AccountReferencevar 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 = NonePer-entry result row returned by
AccountStore.sync_governance.Maps to one element of the wire
sync_governanceresponse'sadcp.decisioning.accounts[]array. The framework projects through :func:to_wire_sync_governance_row()before emit, strippingauthentication(write-only) from every governance agent.Replace semantics, per spec. Each
sync_governancecall REPLACES the previously synced governance agents for the referenced account. An entry whosegovernance_agentsis 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'anderrors=[{code: 'PERMISSION_DENIED', ...}]for the rejected entry.:param account: AccountReference, echoed from the request. :param status:
synced(governance agents persisted) orfailed(could not complete; seeadcp.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 isfailed).Instance variables
var account : AccountReferencevar errors : list[dict[str, Any]] | Nonevar governance_agents : list[dict[str, Any]] | Nonevar 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 righttype), 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:TaskHandoffis 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:
WorkflowHandoffinstead — obtained via :meth:RequestContext.handoff_to_workflow(). The framework allocates atask_id, persistssubmittedstate, and returns the wire envelope; the adopter's external system later callsregistry.complete(task_id, result)orregistry.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.idBEFORE 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, ) returnWrite a progress payload. Transitions
submitted→workingon 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-tvor any HITL flow refuse to start with a non-durable registry unless the operator explicitly opts in viaADCP_DECISIONING_ALLOW_INMEMORY_TASKS=1. The framework readsregistry.is_durableto make this decision; subclassing :class:InMemoryTaskRegistryfor instrumentation does NOT bypass the gate (the subclass inheritsis_durable = False). Custom durable impls MUST setis_durable = Trueexplicitly. The Protocol declares this as a class-levelbool.Lifecycle (framework-driven; adopters call only :meth:
TaskHandoffContextmethods, not these directly):- Dispatch detects
ctx.handoff_to_task(fn)returned from a platform method. Allocates a task_id and calls :meth:issueto persist thesubmittedrow. - Dispatch projects the wire
Submittedenvelope to the buyer. - Dispatch runs
fn(task_handoff_ctx)in the background. The adopter callstask_handoff_ctx.update(progress)zero or more times; the framework routes each to :meth:update_progress(also transitionssubmitted→workingon first update). - When
fnreturns, dispatch calls :meth:completewith the terminal artifact (a JSON-serialized spec response). - When
fnraises :class:AdcpError(or any exception, wrapped toINTERNAL_ERROR), dispatch calls :meth:failwith 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 (onefnper handoff, no concurrentupdate_progress/completeagainst the same task_id).Cross-tenant safety: every read MUST be account-scoped. The :meth:
getmethod takes an optionalexpected_account_id— when supplied (the wiretasks/getpath always supplies it), a mismatch returnsNone, 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. Seetests/test_decisioning_task_registry_cross_tenant.pyfor the regression suite.account_idis opaque. The framework threadsctx.account.id(whatever the adopter's :class:~adcp.decisioning.AccountStore.resolvereturned) into every method. The registry MUST NOT parse it or re-derive tenant scope from it. Multi-tenant adopters encode their tenant scope intoAccount.idonce at the :class:~adcp.decisioning.AccountStorelayer; 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.
Falsefor in-memory / lossy impls;Truefor durable backings (PostgreSQL, Redis, etc.). The framework's production-mode gate refuses non-durable registries unless the operator explicitly opts in viaADCP_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
completedwithresultas the terminal artifact.resultMUST be the JSON-serialized spec response shape (e.g.CreateMediaBuySuccessResponseviamodel_dump()). Idempotent on repeated calls with equalresult; non-idempotent re-completion with different result raisesValueError. 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
discardon a task that has progressed pastsubmitted— that's the wrong recovery path; usefail()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
failedwitherroras the terminal wire-shaped error payload.errorMUST be the :meth:AdcpError.to_wire()shape sotasks/getround-trips the specadcp_errorenvelope verbatim. Idempotent on repeated calls with equalerror. 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 returnNoneif the stored record'saccount_iddoes not match. The wiretasks/getpath always supplies the authenticated principal's account_id so adopters can't probe across tenants. :returns: The record dict (per :meth:TaskRecord.to_dict) orNoneif 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
submittedrow, 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 ontasks/getreads; NOT included in the synchronous Submitted envelope (perschemas/cache/core/protocol-envelope.json). :param request_context: Buyer-suppliedadcp.decisioning.contextextension from the request that issued this task. Persisted on the row and surfaced at the top level oftasks/getresponses (sibling ofresult/error) so buyers can correlate polled task state with the kick-off request.Nonewhen 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 ontasks/getreads, identical to pre-#563 behavior). :param _extra: Forward-compat slot for kwargs added by future framework versions. Custom registry impls MUST include**_extra: Anyon theirissue()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
submitted→workingon first call. No-op transition on subsequent calls (already inworking).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.
- Dispatch detects
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._reverseReversible mapping between AdCP wire values and upstream platform values.
Construct via :func:
create_translation_map(). Both lookup methods raise :class:KeyErrorfor unknown keys unless a default is configured. Use thehas_*predicates to guard a lookup, or passdefault_adcp/default_upstreamfor 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") # TrueAncestors
- 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._forwardTrue when
valueis 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._reverseTrue when
valueis 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_keyisn't in the map and nodefault_adcpwas 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_keyisn't in the map and nodefault_upstreamwas 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_capabilitiesand 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_buypatch.actionis 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 marksresolutionas"coarse".allowed_action_candidatesare ordered from specific to broad. A caller can use them to match a mutation against either fine-grained capabilities such asincrease_budgetor coarse capabilities such asupdate_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_actionscovers this mutation.allowed_actionsmay be a simple string list or the wireavailable_actions[]shape returned byget_media_buys. By default, wire entries must be immediately executable (self_serveorconditional_self_serve); passallowed_modes=Noneto 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 singlehttpx.AsyncClientacross calls. Call :meth:acloseon shutdown to release the pool, or useasync withif your wiring supports it.Method signatures:
- :meth:
get— returns parsed JSONdict, orNoneon 404 whentreat_404_as_none=True. - :meth:
post/ :meth:put— returns parsed JSONdict. - :meth:
delete— returns parsed JSONdict, orNoneon 404 whentreat_404_as_none=True.
All non-2xx responses raise :class:
AdcpErrorwith a spec-conformant code. The 404 code is configurable per call vianot_found_codefor 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 = NoneRelease the underlying
httpx.AsyncClientconnection 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, orNoneon 404 whentreat_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, orNoneon 404 whentreat_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 resultPOST pathwith 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 resultPUT pathwith JSON body. Returns parsed JSON.
- :meth:
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.
fieldSHOULD 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 atask_id, calls the adopter's enqueue fn ONCE synchronously to register the work into the adopter's external system, persistssubmittedstate, and returns the wire envelope. NO background coroutine runs.The adopter's external workflow later calls
registry.complete(task_id, result)orregistry.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 viatasks/getor 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
AdcpErrorper 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
WorkflowStepinterface 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_urlfor an agent principal,principalfor 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 : strvar id : strvar object_id : strvar object_type : Literal['media_buy', 'creative', 'product', 'plan', 'audience', 'rights_grant', 'task']var status : Literal['submitted', 'completed', 'failed', 'progress']var tool : str