Module adcp.decisioning.webhook_emit
Task-webhook delivery support.
AdCP task webhooks describe status changes after the initial response.
When that response is already terminal, the buyer has the result inline
and no task webhook is emitted. The deprecated
auto_emit_completion_webhooks option is accepted for source
compatibility but ignored; enabling it emits a deprecation warning.
Async :class:TaskHandoff completion and failure webhooks require an atomic
terminal-state/outbox publisher. The SDK currently rejects framework-managed
push for that path; an external publisher may own it explicitly.
Global variables
var SPEC_WEBHOOK_TASK_TYPES : frozenset[str]-
Drift policy: bump this constant AND the JS
SPEC_WEBHOOK_TASK_TYPESin lockstep when the spec enum widens. A unit test pins this to the on-disk enum so out-of-band drift surfaces in CI.
Functions
async def emit_terminal_completion_webhook(*,
target: DeliveryTarget | None,
enabled: bool,
method_name: str,
params: Any,
status: str,
task_id: str,
result: Any = None) ‑> None-
Expand source code
async def emit_terminal_completion_webhook( *, target: DeliveryTarget | None, enabled: bool, method_name: str, params: Any, status: str, task_id: str, result: Any = None, ) -> None: """Deliver the terminal completion / failure webhook for an async task. Fired from the BACKGROUND completion path of :func:`adcp.decisioning.dispatch._project_handoff` — once, after the registry has recorded the terminal state. This is the async-path counterpart to :func:`maybe_emit_sync_completion`: when a seller returns a ``Submitted`` envelope (the request handed off to a task) AND the buyer supplied ``push_notification_config``, the spec (AdCP, adcp#5389) requires the seller to deliver at least the terminal completion / failure notification to that webhook. Buyers who registered a push config get notified without polling ``tasks/get``. Unlike the sync gate, this coroutine is already running inside the background task — there is no inline buyer response to protect, so the delivery is awaited directly rather than scheduled fire-and- forget. The whole body is wrapped in ``try/except Exception`` and logged-and-swallowed: a webhook delivery failure must never crash the background task or block the registry's terminal-state record (which the buyer can still read via ``tasks/get``). Skips silently when: * ``enabled`` is False (a low-level caller owns task delivery). * ``method_name`` isn't in :data:`SPEC_WEBHOOK_TASK_TYPES`. This gate runs FIRST, before any target check. SDK-internal, non-spec task types (e.g. ``finalize_proposal``, an interception of ``get_products`` in ``proposal_dispatch.py``) flow through ``_project_handoff`` like any async task but legitimately have no webhook target wired; per the spec-gate rule above :data:`SPEC_WEBHOOK_TASK_TYPES`, they skip delivery and rely on ``tasks/get`` polling / ``publishStatusChange``. Returning here before the ``target is None`` branch keeps a correctly-configured server from logging a spurious "silently dropped" WARNING on every async non-spec task. * The request didn't carry ``push_notification_config.url`` (polling-only via ``tasks/get`` — the spec permits this). Logs a WARNING when: * ``target`` is None but the buyer DID register a push config for a SPEC-eligible task type — their terminal notification is being silently dropped, the same misconfig the sync gate warns on. :param status: ``'completed'`` on success or ``'failed'`` on a terminal failure. The wire ``GeneratedTaskStatus`` enum. :param result: On success, the projected terminal artifact (the same shape persisted to the registry). On failure, the structured error wire dict (``error.to_wire()``) so the buyer sees the failure inline. ``operation_id`` is echoed verbatim from ``push_notification_config.operation_id`` and ``task_id`` is the registry-minted id. """ try: if not enabled: return # Spec gate FIRST — before any target / config inspection. Task # types outside the closed spec enum (SDK-internal interceptions # like ``finalize_proposal``) are not webhook-eligible; they skip # silently and rely on ``tasks/get`` / ``publishStatusChange``. # Running this ahead of the ``target is None`` branch is what # stops a correctly-configured server from emitting a spurious # "silently dropped" WARNING on every async non-spec task. The # sync emitter (:func:`maybe_emit_sync_completion`) gates the # same way. if method_name not in SPEC_WEBHOOK_TASK_TYPES: return config = getattr(params, "push_notification_config", None) if config is None and isinstance(params, dict): config = params.get("push_notification_config") if config is None: return # buyer didn't register — polling-only, nothing to do if target is None: # Buyer registered a push config but no sender / supervisor is # wired. Without this branch the terminal notification quietly # disappears — surfacing a warning gives the adopter a fast # path to the misconfig (mirrors the sync gate). try: url_for_log = getattr(config, "url", None) if url_for_log is None and isinstance(config, dict): url_for_log = config.get("url") except Exception: url_for_log = None logger.warning( "[adcp.decisioning] buyer registered push_notification_config " "(url=%s) for async %s (task_id=%s) but neither webhook_sender " "nor webhook_supervisor is wired — terminal %s webhook silently " "dropped. Pass one to " "adcp.decisioning.serve.create_adcp_server_from_platform.", url_for_log if url_for_log else "<unextractable>", method_name, task_id, status, ) return extracted = _extract_push_notification_url_and_token(params) if extracted is None: return url, token = extracted operation_id = _extract_push_operation_id(params) if operation_id is None: raise ValueError("push_notification_config.operation_id is required for task webhooks") # Defense-in-depth: strip credentials from the artifact BEFORE the # webhook target sees it. The dispatcher already strips before # persisting to the registry (:func:`_project_handoff`); this is a # second pass at the delivery boundary. Method-gated — non-account # tools short-circuit without walking the result. Failure payloads # (error wire dicts) never carry credentials but pass through the # same gate harmlessly. if result is not None: result = strip_credentials_from_wire_result(method_name, result) await target.send_mcp( url=url, task_id=task_id, status=status, task_type=method_name, result=result, operation_id=operation_id, token=token, ) except Exception: # Logged-and-swallowed: the background task's terminal state is # already recorded in the registry; the buyer can read it via # tasks/get regardless of webhook delivery outcome. logger.warning( "[adcp.decisioning] terminal %s webhook for async %s " "(task_id=%s) failed; registry terminal state already recorded", status, method_name, task_id, exc_info=True, )Deliver the terminal completion / failure webhook for an async task.
Fired from the BACKGROUND completion path of :func:
adcp.decisioning.dispatch._project_handoff— once, after the registry has recorded the terminal state. This is the async-path counterpart to :func:maybe_emit_sync_completion(): when a seller returns aSubmittedenvelope (the request handed off to a task) AND the buyer suppliedpush_notification_config, the spec (AdCP, adcp#5389) requires the seller to deliver at least the terminal completion / failure notification to that webhook. Buyers who registered a push config get notified without pollingtasks/get.Unlike the sync gate, this coroutine is already running inside the background task — there is no inline buyer response to protect, so the delivery is awaited directly rather than scheduled fire-and- forget. The whole body is wrapped in
try/except Exceptionand logged-and-swallowed: a webhook delivery failure must never crash the background task or block the registry's terminal-state record (which the buyer can still read viatasks/get).Skips silently when:
enabledis False (a low-level caller owns task delivery).method_nameisn't in :data:SPEC_WEBHOOK_TASK_TYPES. This gate runs FIRST, before any target check. SDK-internal, non-spec task types (e.g.finalize_proposal, an interception ofget_productsinproposal_dispatch.py) flow through_project_handofflike any async task but legitimately have no webhook target wired; per the spec-gate rule above :data:SPEC_WEBHOOK_TASK_TYPES, they skip delivery and rely ontasks/getpolling /publishStatusChange. Returning here before thetarget is Nonebranch keeps a correctly-configured server from logging a spurious "silently dropped" WARNING on every async non-spec task.- The request didn't carry
push_notification_config.url(polling-only viatasks/get— the spec permits this).
Logs a WARNING when:
targetis None but the buyer DID register a push config for a SPEC-eligible task type — their terminal notification is being silently dropped, the same misconfig the sync gate warns on.
:param status:
'completed'on success or'failed'on a terminal failure. The wireGeneratedTaskStatusenum. :param result: On success, the projected terminal artifact (the same shape persisted to the registry). On failure, the structured error wire dict (error.to_wire()) so the buyer sees the failure inline.operation_idis echoed verbatim frompush_notification_config.operation_idandtask_idis the registry-minted id. def external_task_webhook_owner_ready(*,
capabilities: DecisioningCapabilities,
sender: WebhookSender | None,
supervisor: WebhookDeliverySupervisor | None,
auto_emit_task_webhooks: bool) ‑> bool-
Expand source code
def external_task_webhook_owner_ready( *, capabilities: DecisioningCapabilities, sender: WebhookSender | None, supervisor: WebhookDeliverySupervisor | None, auto_emit_task_webhooks: bool, ) -> bool: """Return whether an external outbox may own TaskHandoff push delivery. This is deliberately stricter than checking ``auto_emit_task_webhooks``: disabling the SDK emitter alone does not prove that anybody will publish the promised terminal webhook. """ webhook_signing = getattr(capabilities, "webhook_signing", None) retry_horizon = getattr(webhook_signing, "delivery_retry_horizon_seconds", None) return ( auto_emit_task_webhooks is False and sender is None and supervisor is None and getattr(capabilities, "webhook_signing_managed_externally", False) is True and webhook_signing is not None and getattr(webhook_signing, "supported", False) is True and type(retry_horizon) is int and 86400 <= retry_horizon <= 604800 )Return whether an external outbox may own TaskHandoff push delivery.
This is deliberately stricter than checking
auto_emit_task_webhooks: disabling the SDK emitter alone does not prove that anybody will publish the promised terminal webhook. def maybe_emit_sync_completion(*,
sender: WebhookSender | None,
enabled: bool,
method_name: str,
params: Any,
result: Any,
supervisor: WebhookDeliverySupervisor | None = None) ‑> None-
Expand source code
def maybe_emit_sync_completion( *, sender: WebhookSender | None, enabled: bool, method_name: str, params: Any, result: Any, supervisor: WebhookDeliverySupervisor | None = None, ) -> None: """Preserve the retired option without emitting a task webhook. AdCP 3.2 requires an inline terminal response to remain silent on the task-webhook channel. Callers that still pass ``enabled=True`` receive a deprecation warning and must return a real :class:`TaskHandoff` when callback delivery is required. """ del sender, method_name, params, result, supervisor if enabled: warnings.warn( "auto_emit_completion_webhooks is ignored under AdCP 3.2: an " "inline terminal response MUST remain silent on the task-webhook " "channel. Return a real async TaskHandoff when callback delivery " "is required.", DeprecationWarning, stacklevel=2, ) returnPreserve the retired option without emitting a task webhook.
AdCP 3.2 requires an inline terminal response to remain silent on the task-webhook channel. Callers that still pass
enabled=Truereceive a deprecation warning and must return a real :class:TaskHandoffwhen callback delivery is required. def task_webhook_owner_ready(*,
capabilities: DecisioningCapabilities,
sender: WebhookSender | None,
supervisor: WebhookDeliverySupervisor | None,
auto_emit_task_webhooks: bool,
registry: Any = None) ‑> bool-
Expand source code
def task_webhook_owner_ready( *, capabilities: DecisioningCapabilities, sender: WebhookSender | None, supervisor: WebhookDeliverySupervisor | None, auto_emit_task_webhooks: bool, registry: Any = None, ) -> bool: """Return whether either the SDK atomic outbox or an external owner is ready.""" if external_task_webhook_owner_ready( capabilities=capabilities, sender=sender, supervisor=supervisor, auto_emit_task_webhooks=auto_emit_task_webhooks, ): return True if getattr(capabilities, "webhook_signing_managed_externally", False) is not False: return False webhook_signing = getattr(capabilities, "webhook_signing", None) advertised_horizon = getattr(webhook_signing, "delivery_retry_horizon_seconds", None) outbox = getattr(registry, "task_webhook_outbox", None) outbox_horizon = getattr(outbox, "delivery_retry_horizon_seconds", None) outbox_sender = getattr(outbox, "_sender", None) outbox_sender_resolver = getattr(outbox, "_sender_resolver", None) return ( auto_emit_task_webhooks is True and sender is None and supervisor is None and _sdk_task_outbox_pair_ready(registry, outbox) and webhook_signing is not None and getattr(webhook_signing, "supported", False) is True and type(advertised_horizon) is int and getattr(outbox, "supports_atomic_task_outbox", False) is True and getattr(outbox, "delivery_state_is_durable", False) is True and type(outbox_horizon) is int and outbox_horizon == advertised_horizon and ( getattr(outbox_sender, "signs_with_rfc9421", False) is True or outbox_sender_resolver is not None ) )Return whether either the SDK atomic outbox or an external owner is ready.
def validate_webhook_sender_for_platform(*,
advertised_tools: frozenset[str] | set[str],
sender: Any,
auto_emit: bool,
supervisor: Any = None) ‑> None-
Expand source code
def validate_webhook_sender_for_platform( *, advertised_tools: frozenset[str] | set[str], sender: Any, auto_emit: bool, supervisor: Any = None, ) -> None: """Accept the retired option without imposing sync-webhook wiring.""" del advertised_tools, sender, auto_emit, supervisorAccept the retired option without imposing sync-webhook wiring.
def validate_webhook_signing_for_capabilities(*,
capabilities: DecisioningCapabilities,
sender: WebhookSender | None,
supervisor: WebhookDeliverySupervisor | None = None,
auto_emit_task_webhooks: bool = True,
registry: Any = None) ‑> None-
Expand source code
def validate_webhook_signing_for_capabilities( *, capabilities: DecisioningCapabilities, sender: WebhookSender | None, supervisor: WebhookDeliverySupervisor | None = None, auto_emit_task_webhooks: bool = True, registry: Any = None, ) -> None: """Server-boot fail-fast for the #384 capabilities-vs-wiring invariant. When the platform's :class:`DecisioningCapabilities` declares ``webhook_signing.supported=True``, the AdCP capabilities schema binds the seller to producing RFC 9421 ``Signature`` headers on EVERY outbound webhook — the schema description on the ``supported`` field reads "When false or absent, ... receivers MUST NOT expect a Signature header," so by contrapositive when ``true`` they MUST. There is no per-delivery opt-out in AdCP 3.x; ``legacy_hmac_fallback`` is a downgrade switch for receivers that have NOT adopted RFC 9421, not a substitute for the seller's RFC 9421 capability. The wired :class:`~adcp.webhook_sender.WebhookSender` MUST therefore be configured with a JWK signing key whose ``alg`` is also present in the advertised ``algorithms`` list. A bearer-only or HMAC sender, or a JWK sender whose alg is not advertised, would emit deliveries that conformant verifiers reject — silent blackout for any buyer enforcing RFC 9421. The check keys on the capability advertisement, not on ``reporting_delivery_methods=["webhook"]``: 3.x explicitly permits HMAC/Bearer-only delivery via ``legacy_hmac_fallback``, so the delivery-method axis is a poor gate. ``webhook_signing.supported`` is the self-consistency contract the spec supports directly. SDK senders and supervisors are inspected to provide precise diagnostics. Framework-managed publication additionally requires a ``PgTaskWebhookOutbox`` attached to the task registry; external publishers declare external ownership and disable SDK automatic emission. :raises AdcpError: ``code='INVALID_REQUEST'`` when capabilities declare RFC 9421 signing support but no sender (or a non-JWK sender, or a JWK sender whose alg doesn't match the advertised algorithms) is wired. Matches the recovery posture of sibling boot-time validators (terminal). """ adopter_managed = getattr(capabilities, "webhook_signing_managed_externally", False) task_outbox = getattr(registry, "task_webhook_outbox", None) from adcp.decisioning.types import AdcpError if not isinstance(adopter_managed, bool): raise AdcpError( "INVALID_REQUEST", message=( "DecisioningCapabilities.webhook_signing_managed_externally " "must be a bool. Non-bool values are rejected so a mistyped " "configuration cannot bypass SDK webhook-signing validation." ), recovery="terminal", details={ "field": "webhook_signing_managed_externally", "value_type": type(adopter_managed).__name__, }, ) webhook_signing = getattr(capabilities, "webhook_signing", None) advertised_legacy_hmac = bool( webhook_signing is not None and webhook_signing.model_dump(mode="python").get("legacy_hmac_fallback") is True ) configured_legacy_hmac = getattr(task_outbox, "legacy_hmac_fallback", None) if configured_legacy_hmac is not None and configured_legacy_hmac is not advertised_legacy_hmac: raise AdcpError( "INVALID_REQUEST", message=( "PgTaskWebhookOutbox legacy_hmac_fallback must exactly match " "capabilities.webhook_signing.legacy_hmac_fallback so buyers " "cannot select an unadvertised mode or rely on a disabled one" ), recovery="terminal", details={ "missing": "webhook_signing_legacy_hmac_alignment", "advertised_legacy_hmac_fallback": advertised_legacy_hmac, "outbox_legacy_hmac_fallback": configured_legacy_hmac, }, ) if webhook_signing is None or not getattr(webhook_signing, "supported", False): if adopter_managed is True: raise AdcpError( "INVALID_REQUEST", message=( "webhook_signing_managed_externally=True requires " "capabilities.webhook_signing.supported=True and an " "advertised delivery_retry_horizon_seconds" ), recovery="terminal", details={"missing": "webhook_signing.supported"}, ) return retry_horizon = getattr(webhook_signing, "delivery_retry_horizon_seconds", None) if type(retry_horizon) is not int or not 86400 <= retry_horizon <= 604800: raise AdcpError( "INVALID_REQUEST", message=( "capabilities.webhook_signing.supported=True on an AdCP 3.2 " "publisher requires delivery_retry_horizon_seconds. Receivers " "use this advertised window to retain immutable delivery-key " "bindings and publication proof. Declare a value from 86400 " "through 604800 seconds that your delivery system can honor." ), recovery="terminal", details={ "missing": "webhook_signing.delivery_retry_horizon_seconds", "capabilities_webhook_signing_supported": True, }, ) if adopter_managed is True: if task_outbox is not None: raise AdcpError( "INVALID_REQUEST", message=( "webhook_signing_managed_externally=True conflicts with the " "PgTaskRegistry task_webhook_outbox; choose exactly one owner" ), recovery="terminal", details={"missing": "single_task_webhook_owner"}, ) if auto_emit_task_webhooks: raise AdcpError( "INVALID_REQUEST", message=( "webhook_signing_managed_externally=True requires " "auto_emit_task_webhooks=False so the SDK cannot race the " "adopter's durable outbox" ), recovery="terminal", details={"missing": "external_task_webhook_ownership"}, ) if sender is not None or supervisor is not None: raise AdcpError( "INVALID_REQUEST", message=( "Externally managed webhook publication must not wire an " "SDK webhook_sender or webhook_supervisor into the automatic " "TaskHandoff path" ), recovery="terminal", details={"missing": "unwired_sdk_webhook_target"}, ) logger.info( "[adcp.decisioning] capabilities.webhook_signing.supported=True " "and DecisioningCapabilities.webhook_signing_managed_externally=True; " "skipping SDK WebhookSender validation. Operator owns the RFC 9421 " "delivery contract for outbound webhooks." ) return internal_outbox_ready = _sdk_task_outbox_pair_ready(registry, task_outbox) if task_outbox is not None and not internal_outbox_ready: raise AdcpError( "INVALID_REQUEST", message=( "SDK-managed task webhook publication requires the concrete " "PgTaskRegistry/PgTaskWebhookOutbox pair using the same pool; " "custom publishers must use webhook_signing_managed_externally=True" ), recovery="terminal", details={"missing": "verified_sdk_task_webhook_outbox_pair"}, ) if internal_outbox_ready: if sender is not None or supervisor is not None: raise AdcpError( "INVALID_REQUEST", message=( "A registry-backed task_webhook_outbox is the sole SDK " "delivery owner; do not also wire webhook_sender or " "webhook_supervisor into the handler" ), recovery="terminal", details={"missing": "single_sdk_task_webhook_owner"}, ) if not auto_emit_task_webhooks: raise AdcpError( "INVALID_REQUEST", message=( "A registry-backed task_webhook_outbox requires " "auto_emit_task_webhooks=True; False declares external ownership" ), recovery="terminal", details={"missing": "sdk_task_webhook_ownership"}, ) outbox_horizon = getattr(task_outbox, "delivery_retry_horizon_seconds", None) if ( getattr(task_outbox, "supports_atomic_task_outbox", False) is not True or getattr(task_outbox, "delivery_state_is_durable", False) is not True or type(outbox_horizon) is not int or outbox_horizon != retry_horizon ): raise AdcpError( "INVALID_REQUEST", message=( "The registry task_webhook_outbox does not prove atomic durable " "publication for the advertised retry horizon" ), recovery="terminal", details={ "missing": "atomic_durable_webhook_outbox", "advertised_horizon_seconds": retry_horizon, "outbox_horizon_seconds": outbox_horizon, }, ) outbox_sender_resolver = ( getattr(task_outbox, "_sender_resolver", None) if internal_outbox_ready else None ) resolved_sender: Any = ( getattr(task_outbox, "_sender", None) if internal_outbox_ready else sender ) # Tenant-aware outboxes resolve and validate the active RFC 9421 sender # on every attempt. There is intentionally no single boot-time key or # algorithm to introspect because rotation is part of the contract. sender_introspectable = outbox_sender_resolver is None if resolved_sender is None and supervisor is not None and not internal_outbox_ready: # Both reference supervisors store the underlying WebhookSender # on ``_sender``. Custom Protocol-only impls (Celery/Kafka # queue-only adopters) may not. Their supervisor must still expose the # durable-delivery contract checked below; log that signature bytes # cannot be inspected, but do not bypass the retention check. resolved_sender = getattr(supervisor, "_sender", None) if resolved_sender is None: sender_introspectable = False logger.warning( "[adcp.decisioning] capabilities.webhook_signing.supported=True " "but supervisor %s has no introspectable _sender attribute; " "boot validator cannot verify the wired sender produces RFC 9421 " "headers. Operator owns the contract — confirm out-of-band that " "outbound deliveries from this supervisor carry Signature / " "Signature-Input.", type(supervisor).__name__, ) if resolved_sender is None and sender_introspectable: raise AdcpError( "INVALID_REQUEST", message=( "capabilities.webhook_signing.supported=True declares this " "platform signs outbound webhooks per RFC 9421, but neither " "webhook_sender nor webhook_supervisor was wired. Buyers " "enforcing RFC 9421 verification on inbound webhooks would " "see every delivery from this seller fail signature check. " "Either wire a WebhookSender via WebhookSender.from_jwk(...) " "or WebhookSender.from_pem(...), or remove " "webhook_signing.supported from the capabilities declaration." ), recovery="terminal", details={ "missing": "webhook_sender_with_rfc9421_key", "capabilities_webhook_signing_supported": True, }, ) if sender_introspectable and not getattr(resolved_sender, "signs_with_rfc9421", False): raise AdcpError( "INVALID_REQUEST", message=( "capabilities.webhook_signing.supported=True declares this " "platform signs outbound webhooks per RFC 9421, but the " "wired WebhookSender is not configured for JWK signing " "(bearer-token, AdCP-legacy HMAC, and Standard-Webhooks " "HMAC senders do not produce RFC 9421 Signature / " "Signature-Input headers). Reconstruct the sender via " "WebhookSender.from_jwk(...) or WebhookSender.from_pem(...), " "or remove webhook_signing.supported from the capabilities " "declaration if this seller does not in fact sign per " "RFC 9421." ), recovery="terminal", details={ "missing": "webhook_sender_with_rfc9421_key", "capabilities_webhook_signing_supported": True, "sender_auth_mode": type(getattr(resolved_sender, "_auth", None)).__name__, }, ) # Cross-check the wired sender's signature algorithm against the # advertised set. A seller declaring ``algorithms=["ed25519"]`` and # wiring an ES256 sender would emit deliveries pinned verifiers # reject — same silent-blackout failure mode the supported-check # closes, one axis deeper. ``algorithms`` is optional on the wire; # skip the cross-check when omitted (no advertisement to violate). advertised_algorithms = getattr(webhook_signing, "algorithms", None) if advertised_algorithms and sender_introspectable: sender_alg = getattr(getattr(resolved_sender, "_auth", None), "alg", None) advertised_alg_values = [getattr(a, "value", a) for a in advertised_algorithms] if sender_alg not in advertised_alg_values: raise AdcpError( "INVALID_REQUEST", message=( "capabilities.webhook_signing.algorithms advertises " f"{advertised_alg_values!r} but the wired WebhookSender " f"signs with {sender_alg!r}. Buyers pinning their RFC 9421 " "verifier to the advertised algorithms reject every " "delivery whose Signature-Input ``alg=`` is outside the " "set. Align the sender's alg with the capability " "declaration, or widen ``algorithms`` to include the " "sender's value." ), recovery="terminal", details={ "missing": "webhook_signing_algorithm_alignment", "advertised_algorithms": advertised_alg_values, "sender_alg": sender_alg, }, ) if internal_outbox_ready: return raise AdcpError( "INVALID_REQUEST", message=( "No atomic terminal-state/outbox publisher is configured for the " "AdCP 3.2 retry-horizon contract. Attach PgTaskWebhookOutbox to " "PgTaskRegistry, or set webhook_signing_managed_externally=True " "with auto_emit_task_webhooks=False when adopter infrastructure " "owns atomic publication and reconciliation." ), recovery="terminal", details={"missing": "external_durable_webhook_outbox"}, )Server-boot fail-fast for the #384 capabilities-vs-wiring invariant.
When the platform's :class:
DecisioningCapabilitiesdeclareswebhook_signing.supported=True, the AdCP capabilities schema binds the seller to producing RFC 9421Signatureheaders on EVERY outbound webhook — the schema description on thesupportedfield reads "When false or absent, … receivers MUST NOT expect a Signature header," so by contrapositive whentruethey MUST. There is no per-delivery opt-out in AdCP 3.x;legacy_hmac_fallbackis a downgrade switch for receivers that have NOT adopted RFC 9421, not a substitute for the seller's RFC 9421 capability.The wired :class:
~adcp.webhook_sender.WebhookSenderMUST therefore be configured with a JWK signing key whosealgis also present in the advertisedalgorithmslist. A bearer-only or HMAC sender, or a JWK sender whose alg is not advertised, would emit deliveries that conformant verifiers reject — silent blackout for any buyer enforcing RFC 9421.The check keys on the capability advertisement, not on
reporting_delivery_methods=["webhook"]: 3.x explicitly permits HMAC/Bearer-only delivery vialegacy_hmac_fallback, so the delivery-method axis is a poor gate.webhook_signing.supportedis the self-consistency contract the spec supports directly.SDK senders and supervisors are inspected to provide precise diagnostics. Framework-managed publication additionally requires a
PgTaskWebhookOutboxattached to the task registry; external publishers declare external ownership and disable SDK automatic emission.:raises AdcpError:
code='INVALID_REQUEST'when capabilities declare RFC 9421 signing support but no sender (or a non-JWK sender, or a JWK sender whose alg doesn't match the advertised algorithms) is wired. Matches the recovery posture of sibling boot-time validators (terminal).