id: webhook_emission
version: "1.4.0"
title: "Webhook emission — outbound webhook conformance (operation ID + signing + idempotency + sync silence)"
category: core
summary: "Any agent that emits webhooks MUST echo the caller-supplied operation_id in every payload, carry a stable idempotency_key across retries, avoid duplicate webhook side effects on request replay, and — when the buyer has not opted into the deprecated HMAC fallback — MUST sign deliveries under the RFC 9421 webhook profile. Sellers MUST NOT emit task webhooks for inline terminal responses; any future sync-completion notification mode would be explicit and capability-advertised. Graded by a runner hosting a webhook receiver during storyboard execution."
track: core
requires:
  - webhook_receiver

narrative: |
  Webhook emission is a cross-cutting capability, not a specialism. Sellers emit
  webhooks on `create_media_buy` status changes. Rights agents emit revocation
  notifications. Governance agents emit `collection_list_changed` and
  `property_list_changed`. Content-standards agents emit artifact webhooks.
  Every agent that accepts `push_notification_config` on any operation is in
  scope for this universal — no capability flag, no opt-in.

  Five load-bearing conformance requirements apply across this universal. The
  first four apply to emitted outbound webhooks; the fifth guards the absence
  of task webhooks on synchronous completions:

  1. **`operation_id` echoes the caller-supplied value from
     `push_notification_config.operation_id`.** The webhook URL is opaque to
     the seller. Sellers MUST NOT derive operation correlation by parsing the
     receiver URL path or query string.

  2. **`idempotency_key` is present on every payload and stable across retries.**
     Required by adcontextprotocol/adcp#2417. Receivers dedupe on this field;
     a sender that regenerates the key on retry breaks at-least-once delivery
     semantics and creates duplicate side effects downstream.

  3. **Idempotency replay does not duplicate outbound webhook side effects.**
     Replaying the same operation request with the same idempotency_key MUST
     return the cached response and MUST NOT emit a second logical webhook.

  4. **RFC 9421 webhook signing is baseline in 3.0** unless the buyer opts into
     the deprecated HMAC fallback via `push_notification_config.authentication`.
     Required by adcontextprotocol/adcp#2423. Signatures carry
     `tag="adcp/webhook-signing/v1"`, cover `content-digest`, and verify
     against the seller's brand.json-published JWKS.

  5. **Synchronous completions do not emit task webhooks.**
     `push_notification_config` registers a notification channel for operations
     whose initial response is non-terminal (`working` or `submitted`); it does
     not ask the seller to duplicate an inline terminal result onto the webhook
     channel. Buyer SDKs may normalize synchronous responses into local
     callbacks, promises, or handler invocations, but those local conveniences
     are not AdCP webhooks. In particular, advertised guaranteed-sync wholesale
     discovery requests either reject `push_notification_config` as a
     well-formed but unsupported runtime condition or stay synchronous without
     firing a completion webhook.

  Grading is observable-behavior only. The runner hosts a webhook receiver per
  the contract at `test-kits/webhook-receiver-runner.yaml`, drives storyboards
  that trigger webhooks from whatever operations the agent advertises, and
  asserts on what the agent sends back.

  This universal grades not_applicable when the runner does not host a webhook
  receiver (e.g., lint-only runs against an agent that has no webhook-emitting
  operations, or runs where the operator has not configured the receiver
  contract).

  The runner MUST resolve every `{{runner.webhook_url:<step_id>}}` placeholder
  before sending a request. If the receiver contract is not configured, the
  storyboard is not_applicable; the runner MUST NOT send a literal mustache
  token such as `{{runner.webhook_url:...}}` to the agent under test.

  **9421 is the on-ramp.** Any agent advertising webhook-emitting operations
  MUST publish a webhook-valid signing key at its `brand.json`
  `agents[].jwks_uri` and MUST emit valid 9421 signatures when triggered with
  no `authentication` block on `push_notification_config`. The canonical key
  purpose is `adcp_use: "request-signing"`; deprecated
  `adcp_use: "webhook-signing"` keys remain accepted on the webhook path for
  backward compatibility. Agents without published signing keys fail the
  `signing_keys_published` precheck before the signature phase runs; agents
  with keys but invalid signatures fail `signature_validity`. The
  deprecated HMAC fallback remains a buyer-side registration option in 3.x
  (`push_notification_config.authentication`), but it is not a path that
  exempts the agent from publishing 9421 keys — the runner registers as a
  9421-default buyer, and the agent is graded on what it does in that mode.
  This closes the on-ramp loophole that previously let agents self-declare
  themselves out of the signing phase via `webhook_auth_mode == 'hmac_legacy'`.

  **Clean seam**: the runner does NOT reimplement signature verification or
  idempotency dedup. It delegates dedup to `@adcp/client` primitives —
  `AsyncHandlerConfig.webhookDedup` and `WebhookMetadata.idempotency_key` —
  and signature verification to `@adcp/sdk/signing`'s published
  `verifyWebhookSignature`. The same code that production receivers rely on
  is what the conformance runner exercises; a single bug fix in the library
  covers both surfaces.

agent:
  interaction_model: webhook_emitter
  capabilities:
    - emits_webhooks
  examples:
    - "Any AdCP seller that accepts push_notification_config on create_media_buy, update_media_buy, or equivalent async operations"
    - "Rights agents that post revocation notifications"
    - "Governance agents that emit collection_list_changed or property_list_changed webhooks"
    - "Content-standards agents that emit artifact webhooks"

caller:
  role: compliance_runner
  example: "AdCP Verified runner with webhook receiver capability"

prerequisites:
  description: |
    The runner MUST be configured with a webhook receiver per the contract at
    `test-kits/webhook-receiver-runner.yaml`. The receiver operates in one of
    two modes: `loopback_mock` (intercepts at the `@adcp/client` AsyncHandler
    layer — zero network deps, used for lint runs) or `proxy_url` (operator-
    supplied HTTPS URL routing into the runner — used for AdCP Verified
    grading). Runners without a receiver grade this universal as
    not_applicable.

    The agent under test MUST advertise the operations it supports in
    `get_adcp_capabilities`. The runner drives whichever webhook-emitting
    operations are advertised; operations the agent does not support are
    skipped cleanly, not graded as failures.

    Agents advertising webhook-emitting operations MUST publish a JWKS at the
    `jwks_uri` on their brand.json `agents[]` entry containing a webhook-valid
    signing key. The canonical purpose is `adcp_use: "request-signing"`;
    deprecated `adcp_use: "webhook-signing"` keys remain accepted for backward
    compatibility. The `signing_keys_published` precheck asserts this
    directly; agents without published signing keys fail
    `webhook_signing_keys_unpublished` before the signature phase runs.
  test_kit: "test-kits/webhook-receiver-runner.yaml"

phases:
  - id: capability_discovery
    title: "Capability discovery"
    narrative: |
      Discover which async operations the agent supports so the runner knows
      which to drive for webhook emission.

    steps:
      - id: get_capabilities
        title: "Discover webhook-emitting operations"
        narrative: |
          The runner calls `get_adcp_capabilities` and inspects which async
          operations the agent supports. If the agent advertises none that
          accept `push_notification_config`, this universal does not apply
          and grading returns not_applicable (not a failure).
        task: get_adcp_capabilities
        schema_ref: "protocol/get-adcp-capabilities-request.json"
        response_schema_ref: "protocol/get-adcp-capabilities-response.json"
        doc_ref: "/protocol/get_adcp_capabilities"
        comply_scenario: capability_discovery
        stateful: false
        expected: |
          Return a capabilities block including supported_protocols. Webhook-
          emitting operations are discovered via the transport handshake
          (MCP tools/list, A2A skills), not the capabilities body; grading
          returns a stable not_applicable marker downstream when no webhook-
          emitting operation resolves through
          $test_kit.operations.primary_webhook_emitter.
        sample_request:
          context:
            correlation_id: "webhook_emission--get_capabilities"
        validations:
          - check: field_present
            path: "supported_protocols"
            description: "Agent declares its supported AdCP protocols"

  - id: idempotency_key_presence
    title: "Every webhook payload carries idempotency_key"
    narrative: |
      The runner triggers a webhook-emitting operation with a
      push_notification_config pointed at the runner's per-step receiver. The
      first inbound webhook MUST carry an `idempotency_key` matching the
      required pattern (`^[A-Za-z0-9_.:-]{16,255}$`). Absence of the field or
      a pattern mismatch fails the phase.

      This is the baseline conformance with adcontextprotocol/adcp#2417 —
      receivers cannot dedupe without it, so "I forgot to emit the field" is
      the single highest-impact publisher bug the universal catches.

    steps:
      - id: trigger_webhook_operation
        title: "Trigger an operation that emits a webhook"
        narrative: |
          Runner calls the first operation the capability block advertises as
          webhook-emitting (typically `create_media_buy` for sales agents,
          `acquire_rights` for rights agents, etc.), passing
          `push_notification_config.url = {{runner.webhook_url:trigger_webhook_operation}}`
          and an explicit `push_notification_config.operation_id` — no
          `authentication` block (9421 is the baseline for conformance).
        task: "$test_kit.operations.primary_webhook_emitter"
        task_default: create_media_buy
        schema_ref: "$test_kit.schemas.primary_request"
        response_schema_ref: "$test_kit.schemas.primary_response"
        doc_ref: "/building/implementation/webhooks"
        comply_scenario: webhook_trigger
        stateful: false
        sample_request:
          push_notification_config:
            url: "{{runner.webhook_url:trigger_webhook_operation}}"
            operation_id: "op_webhook_presence_explicit"
          idempotency_key: "$generate:uuid_v4#webhook_emission_trigger"
          context:
            correlation_id: "webhook_emission--trigger_webhook_operation"
        expected: |
          Agent accepts the request and begins async execution. Response either
          returns an immediate terminal status (no webhook needed) or an
          interim status indicating async processing (webhook will follow).

      - id: expect_webhook_presence
        title: "Assert inbound webhook carries a valid idempotency_key"
        narrative: |
          Wait for a webhook at the per-step URL. When it arrives, validate
          the payload against the webhook schema and assert the
          idempotency_key is present and matches the required pattern.
        task: expect_webhook
        triggered_by: trigger_webhook_operation
        filter:
          body:
            operation_id: "op_webhook_presence_explicit"
        timeout_seconds: 30
        expect_idempotency_key: true
        webhook_payload_schema_ref: "core/mcp-webhook-payload.json"
        stateful: true
        expected: |
          Webhook arrives within 30 seconds. Payload validates against
          mcp-webhook-payload.json. idempotency_key is present and matches
          the pattern `^[A-Za-z0-9_.:-]{16,255}$`. Any other outcome fails the
          phase with a specific error code: no_webhook_received,
          schema_violation, missing_idempotency_key, or
          invalid_idempotency_key_format.

  - id: operation_id_echo
    title: "Webhook payload echoes explicit operation_id"
    narrative: |
      The runner triggers a webhook-emitting operation with both a per-step
      receiver URL and an explicit `push_notification_config.operation_id`.
      The receiver URL's internal capture identifier is intentionally different
      from the explicit operation_id. The seller MUST treat the URL as opaque
      and echo the explicit field verbatim in the webhook payload.

      This catches the SDK/framework regression where the delivery layer had
      an operation id for routing but the JSON payload omitted it, and the
      equally dangerous variant where a seller parses a URL path segment
      instead of using the registered `operation_id` field.

    steps:
      - id: trigger_operation_id_echo
        title: "Trigger an operation with an explicit operation_id"
        narrative: |
          Runner calls a webhook-emitting operation with
          `push_notification_config.url = {{runner.webhook_url:trigger_operation_id_echo}}`
          and `push_notification_config.operation_id =
          op_storyboard_echo_123`. The URL is a receiver-routing detail; the
          payload echo must come from the explicit operation_id field.
        task: "$test_kit.operations.primary_webhook_emitter"
        task_default: create_media_buy
        schema_ref: "$test_kit.schemas.primary_request"
        response_schema_ref: "$test_kit.schemas.primary_response"
        doc_ref: "/building/implementation/webhooks"
        comply_scenario: webhook_operation_id_echo_trigger
        stateful: false
        sample_request:
          push_notification_config:
            url: "{{runner.webhook_url:trigger_operation_id_echo}}"
            operation_id: "op_storyboard_echo_123"
          idempotency_key: "$generate:uuid_v4#webhook_emission_operation_id_echo_trigger"
          context:
            correlation_id: "webhook_emission--trigger_operation_id_echo"
        expected: |
          Agent accepts the request and begins async execution. The webhook it
          emits uses the receiver URL only for delivery, and carries
          operation_id: op_storyboard_echo_123 in the JSON payload.

      - id: expect_operation_id_echo
        title: "Assert webhook payload operation_id came from push_notification_config"
        narrative: |
          Wait for a webhook whose payload operation_id equals the explicit
          registration value. The runner validates the full payload against
          mcp-webhook-payload.json, which requires operation_id, task_id,
          task_type, status, timestamp, and the task-specific result shape.
          A payload that omits operation_id, or echoes a value parsed from the
          URL instead of push_notification_config.operation_id, does not match
          this assertion and fails the phase.
        task: expect_webhook
        triggered_by: trigger_operation_id_echo
        filter:
          body:
            operation_id: "op_storyboard_echo_123"
        timeout_seconds: 30
        expect_idempotency_key: true
        webhook_payload_schema_ref: "core/mcp-webhook-payload.json"
        stateful: true
        expected: |
          Webhook arrives within 30 seconds. Payload validates against
          mcp-webhook-payload.json and carries top-level operation_id exactly
          equal to op_storyboard_echo_123. The payload carries the task_type,
          status, and result shape required by the schema; any task status is
          acceptable because every webhook for the operation must echo the
          registered operation_id.

  - id: idempotency_replay_side_effects
    title: "Idempotency replay does not duplicate webhook side effects"
    narrative: |
      The runner triggers a webhook-emitting operation, then replays the exact
      same request with the same idempotency_key and same webhook registration.
      The seller MUST return the cached task response and MUST NOT re-execute
      outbound webhook side effects on replay.

      This check lives in the webhook-emission universal rather than the core
      idempotency universal because it depends on runner-hosted webhook URL
      substitution. Core idempotency remains runnable by agents and runners
      that do not host a webhook receiver; webhook side-effect idempotency is
      graded here when the receiver contract is in scope.

    steps:
      - id: trigger_idempotent_webhook_initial
        title: "Trigger a webhook-emitting operation with a fresh idempotency key"
        narrative: |
          Runner calls a webhook-emitting operation with a fresh idempotency_key
          and a receiver URL. This first request establishes the cached response
          and the single logical webhook event that the replay below must not
          duplicate.
        task: "$test_kit.operations.primary_webhook_emitter"
        task_default: create_media_buy
        schema_ref: "$test_kit.schemas.primary_request"
        response_schema_ref: "$test_kit.schemas.primary_response"
        doc_ref: "/building/implementation/webhooks"
        comply_scenario: webhook_idempotency_replay_trigger
        stateful: true
        sample_request:
          push_notification_config:
            url: "{{runner.webhook_url:trigger_idempotent_webhook_initial}}"
            operation_id: "op_webhook_idempotency_replay"
          idempotency_key: "$generate:uuid_v4#webhook_emission_replay_key"
          context:
            correlation_id: "webhook_emission--idempotent_replay_initial"
        expected: |
          Agent accepts the request and emits at most one logical webhook event
          for this operation.

      - id: trigger_idempotent_webhook_replay
        title: "Replay the same request with the same idempotency key"
        narrative: |
          Runner re-sends the same logical request: same idempotency_key, same
          payload, same webhook receiver URL, and same explicit operation_id.
          The seller must return the cached response and must not emit another
          webhook for the replay.
        task: "$test_kit.operations.primary_webhook_emitter"
        task_default: create_media_buy
        schema_ref: "$test_kit.schemas.primary_request"
        response_schema_ref: "$test_kit.schemas.primary_response"
        doc_ref: "/building/implementation/webhooks"
        comply_scenario: webhook_idempotency_replay_trigger
        stateful: true
        sample_request:
          push_notification_config:
            url: "{{runner.webhook_url:trigger_idempotent_webhook_initial}}"
            operation_id: "op_webhook_idempotency_replay"
          idempotency_key: "$generate:uuid_v4#webhook_emission_replay_key"
          context:
            correlation_id: "webhook_emission--idempotent_replay_replay"
        validations:
          - check: field_value
            path: "replayed"
            value: true
            description: "Replay resolves from the idempotency cache"
        expected: |
          Agent returns the cached response for the original operation without
          re-executing outbound webhook side effects.

      - id: expect_no_duplicate_webhook_on_replay
        title: "Assert replay did not duplicate the logical webhook"
        narrative: |
          The runner counts webhooks arriving at the initial request's receiver
          URL across both the initial and replay windows. A conformant seller
          emits the logical webhook at most once; a seller that re-executes on
          replay emits a second event and fails this step.
        task: expect_webhook
        triggered_by: trigger_idempotent_webhook_initial
        filter:
          body:
            operation_id: "op_webhook_idempotency_replay"
        timeout_seconds: 30
        expect_max_deliveries_per_logical_event: 1
        webhook_payload_schema_ref: "core/mcp-webhook-payload.json"
        stateful: true
        expected: |
          At most one logical webhook event is delivered for the operation
          across the initial request and idempotency replay. Duplicate delivery
          with a distinct webhook idempotency_key but the same operation_id
          fails as duplicate_webhook_on_replay.

  - id: synchronous_completion_success_path
    title: "Synchronous-only reads stay terminal when accepted"
    narrative: |
      The runner calls a guaranteed-sync operation with
      `push_notification_config` present. Because wholesale `get_products` is
      a synchronous repair and reconciliation read, a conformant seller may
      accept the request only if it remains synchronous and does not treat the
      webhook registration as an opt-in sync-completion notification mode.

      This branch uses `get_products` with `buying_mode: "wholesale"` as the
      canonical conformance probe because wholesale product feed reads are
      explicitly synchronous repair and reconciliation reads. The webhook
      channel is meaningful only if curated `brief` or `refine` discovery
      enters the async lifecycle. A seller MUST NOT route advertised wholesale
      discovery through Submitted in response to an unsupported
      `push_notification_config`.

      Runners skip this phase unless the agent exposes `get_products` and
      advertises `media_buy.buying_modes` including `"wholesale"`.
    optional: true
    branch_set:
      id: synchronous_completion_handled
      semantics: any_of

    steps:
      - id: sync_get_products_with_webhook_config_success
        title: "Keep wholesale discovery synchronous when webhook config is present"
        narrative: |
          Runner sends a wholesale get_products request with an explicit
          operation_id and a receiver URL. The request is schema-valid; if the
          seller accepts it, the response must remain a terminal synchronous
          get_products response with no task_id. The load-bearing assertion is
          that the sync-only read did not become an async task.
        task: get_products
        requires_tool: get_products
        schema_ref: "media-buy/get-products-request.json"
        response_schema_ref: "media-buy/get-products-response.json"
        doc_ref: "/media-buy/task-reference/get_products"
        stateful: false
        sample_request:
          idempotency_key: "$generate:uuid_v4#webhook_emission_synchronous_completion_success_path_sync_get_products_with_webhook_config_success"
          buying_mode: "wholesale"
          account:
            brand:
              domain: "acmeoutdoor.example"
            operator: "pinnacle-agency.example"
          push_notification_config:
            url: "{{runner.webhook_url:sync_get_products_with_webhook_config_success}}"
            operation_id: "op_sync_get_products_no_webhook"
          context:
            correlation_id: "webhook_emission--sync_get_products_no_webhook"
        validations:
          - check: response_schema
            description: "Accepted wholesale get_products response matches get-products-response.json"
          - check: field_present
            path: "products"
            description: "Accepted wholesale get_products response returns the synchronous products collection"
          - check: field_absent
            path: "task_id"
            description: "Accepted wholesale get_products response does not mint an async task_id"
          - check: field_value
            path: "context.correlation_id"
            value: "webhook_emission--sync_get_products_no_webhook"
            description: "Context correlation_id returned unchanged"

      - id: expect_sync_get_products_silence
        title: "Assert synchronous completion emitted no task webhook"
        narrative: |
          Observe the receiver associated with the accepted synchronous
          get_products request for five seconds. The success branch contributes
          only after the full observation window completes without a matching
          task webhook; receiving one fails immediately.
        task: expect_no_webhook
        triggered_by: sync_get_products_with_webhook_config_success
        filter:
          body:
            operation_id: "op_sync_get_products_no_webhook"
        timeout_seconds: 5
        stateful: true
        contributes: true
        expected: |
          No webhook with operation_id op_sync_get_products_no_webhook arrives
          during the five-second observation window. A matching delivery fails
          with unexpected_webhook_received.

  - id: synchronous_completion_rejection_path
    title: "Synchronous-only reads may reject webhook registration"
    narrative: |
      Some sellers choose to reject `push_notification_config` on the wholesale
      `get_products` path rather than accepting it inertly. That is conformant
      only as a structured runtime rejection of a schema-valid request; the
      payload is not a malformed JSON Schema instance.
    optional: true
    branch_set:
      id: synchronous_completion_handled
      semantics: any_of

    steps:
      - id: sync_get_products_with_webhook_config_reject
        title: "Reject webhook registration as a well-formed runtime error"
        narrative: |
          Runner sends the same wholesale get_products request with an explicit
          operation_id and a receiver URL. A seller may reject the webhook
          registration, but because the request payload is schema-valid the
          rejection must be a structured INVALID_REQUEST-class runtime error,
          not a schema-invalid branch. This branch also fails if the seller
          returns a Submitted envelope instead of an error.
        task: get_products
        requires_tool: get_products
        schema_ref: "media-buy/get-products-request.json"
        response_schema_ref: "media-buy/get-products-response.json"
        doc_ref: "/media-buy/task-reference/get_products"
        expect_error: true
        negative_path: payload_well_formed
        stateful: false
        contributes: true
        sample_request:
          idempotency_key: "$generate:uuid_v4#webhook_emission_synchronous_completion_rejection_path_sync_get_products_with_webhook_config_reject"
          buying_mode: "wholesale"
          account:
            brand:
              domain: "acmeoutdoor.example"
            operator: "pinnacle-agency.example"
          push_notification_config:
            url: "{{runner.webhook_url:sync_get_products_with_webhook_config_reject}}"
            operation_id: "op_sync_get_products_no_webhook"
          context:
            correlation_id: "webhook_emission--sync_get_products_no_webhook"
        validations:
          - check: error_code
            allowed_values: ["INVALID_REQUEST", "VALIDATION_ERROR"]
            description: "Webhook registration on wholesale get_products may be rejected as a structured well-formed runtime error"

  - id: synchronous_completion_assertion
    title: "Synchronous-only reads do not enter async lifecycle"
    narrative: |
      Synthetic assertion over the two optional branches. A seller passes when
      it either returns a terminal synchronous get_products response without a
      task_id or rejects the schema-valid request with a structured runtime
      error. A Submitted envelope satisfies neither branch.

    steps:
      - id: assert_synchronous_completion_handled
        title: "Require synchronous handling or well-formed rejection"
        task: assert_contribution
        comply_scenario: webhook_emission
        stateful: false
        expected: |
          At least one of synchronous_completion_success_path or
          synchronous_completion_rejection_path contributed
          `synchronous_completion_handled`.
        validations:
          - check: any_of
            allowed_values: ["synchronous_completion_handled"]
            description: "Wholesale get_products with push_notification_config must either complete synchronously without task_id or reject as a well-formed runtime error; it must not enter Submitted async lifecycle."

  - id: idempotency_key_stability
    title: "idempotency_key is byte-identical across retries of the same event"
    narrative: |
      The runner's receiver deterministically returns HTTP 503 for the first
      `count` deliveries, then 200. The runner records the idempotency_key on
      every delivery and asserts all recorded keys are byte-identical. A
      sender that regenerates the key on retry breaks at-least-once semantics
      and fails the phase.

      This is the conformance test that cannot be replaced by schema
      validation — schema validation catches "no key at all" but not "wrong
      key on retry." Only a live observation catches publisher-side retry
      bugs.

    steps:
      - id: trigger_retry_scenario
        title: "Trigger a webhook that will be retried via 5xx response"
        narrative: |
          Runner calls a webhook-emitting operation with
          push_notification_config.url =
          {{runner.webhook_url:trigger_retry_scenario}} and an explicit
          push_notification_config.operation_id.
          The runner's receiver at that URL returns 503 for the first 3
          deliveries and 200 afterwards. Any conformant at-least-once sender
          will retry on each 5xx and eventually succeed.
        task: "$test_kit.operations.primary_webhook_emitter"
        task_default: create_media_buy
        schema_ref: "$test_kit.schemas.primary_request"
        response_schema_ref: "$test_kit.schemas.primary_response"
        doc_ref: "/building/implementation/webhooks"
        comply_scenario: webhook_retry_trigger
        stateful: false
        sample_request:
          push_notification_config:
            url: "{{runner.webhook_url:trigger_retry_scenario}}"
            operation_id: "op_webhook_retry_explicit"
          idempotency_key: "$generate:uuid_v4#webhook_emission_retry_trigger"
          context:
            correlation_id: "webhook_emission--trigger_retry_scenario"
        expected: |
          Agent accepts the request and begins async execution. The webhook
          it emits will be rejected with 503 by the runner and retried.

      - id: expect_key_stable_across_retries
        title: "Assert idempotency_key byte-identical across all deliveries"
        narrative: |
          Runner records every delivery and asserts all recorded keys are
          byte-identical. Requires at least 2 deliveries observed within the
          timeout window — a sender that doesn't retry at all cannot be
          graded on retry stability.
        task: expect_webhook_retry_keys_stable
        triggered_by: trigger_retry_scenario
        filter:
          body:
            operation_id: "op_webhook_retry_explicit"
        retry_trigger:
          count: 3
          http_status: 503
        timeout_seconds: 90
        expect_min_deliveries: 2
        stateful: true
        expected: |
          At least 2 deliveries of the same logical event observed within
          90 seconds; all deliveries carry byte-identical idempotency_key.
          Fails with insufficient_retries if fewer than 2 deliveries arrive,
          idempotency_key_rotated if the key changes across deliveries, or
          idempotency_key_format_changed if a later delivery carries a
          different-shaped value. If the payload omits or mismatches the
          registered operation_id, it will not match this payload assertion.

  - id: signing_keys_published
    title: "Agent publishes a webhook-valid signing JWKS at brand.json"
    narrative: |
      Precheck: any agent advertising webhook-emitting operations MUST publish
      a JWKS at the `jwks_uri` on its `brand.json` `agents[]` entry containing
      at least one webhook-valid signing key: canonical
      `adcp_use: "request-signing"`, or deprecated
      `adcp_use: "webhook-signing"` for backward compatibility. This is the
      on-ramp gate — an agent that has only ever signed HMAC and never
      published a 9421 signing key fails here, before the signature phase even
      runs.

      The runner resolves the agent's `brand.json` from its
      `get_adcp_capabilities` advertisement (or, for agents not yet member-
      registered, from operator configuration), fetches the JWKS at
      `agents[].jwks_uri`, and asserts at least one key has a webhook-valid
      `adcp_use` value and a non-revoked status. Absent or malformed JWKS
      produces `webhook_signing_keys_unpublished`; JWKS present but with no
      webhook-valid signing key produces
      `webhook_signing_keys_wrong_purpose`.

      This phase is observable-only and stateless — no traffic flows. It runs
      before the signature phase so an operator debugging "why did signing
      fail" gets a specific keys-vs-signing diagnostic instead of a generic
      `signature_key_unknown` deep in the verifier checklist.

    steps:
      - id: fetch_brand_json
        title: "Fetch brand.json and resolve jwks_uri"
        narrative: |
          Runner fetches the agent's `brand.json` (resolved from
          `get_adcp_capabilities` or operator configuration), iterates the
          `agents[]` array, and asserts at least one entry exposes a
          `jwks_uri` reachable at HTTP 200.
        task: fetch_brand_jwks
        stateful: false
        expected: |
          brand.json reachable and parseable; at least one `agents[]` entry
          has a `jwks_uri` returning a valid JWKS document. Fails with
          `brand_json_unreachable`, `brand_json_malformed`, or
          `agents_jwks_uri_missing`.

      - id: assert_webhook_signing_key_present
        title: "Assert JWKS contains a webhook-valid signing key"
        narrative: |
          Runner inspects the JWKS and asserts at least one key has
          `adcp_use: "request-signing"` or deprecated
          `adcp_use: "webhook-signing"`. Keys advertising other purposes
          (`response-signing`, `governance-signing`, `webhook-receipts`, etc.)
          do not satisfy this check.
        task: assert_jwks_purpose
        stateful: false
        expected: |
          At least one JWK in the agent's published JWKS carries
          `adcp_use: "request-signing"` or deprecated
          `adcp_use: "webhook-signing"` and is not marked revoked. Fails
          with `webhook_signing_keys_unpublished` (no JWKS or empty JWKS),
          `webhook_signing_keys_wrong_purpose` (JWKS present but no key
          with a webhook-valid signing purpose), or
          `webhook_signing_keys_all_revoked` (all webhook-valid signing keys
          revoked).

  - id: signature_validity
    title: "Webhook signatures validate under the 9421 profile"
    narrative: |
      Every outbound webhook MUST verify under the 14-step webhook verifier
      checklist per
      docs/building/by-layer/L1/security.mdx#verifier-checklist-for-webhooks.
      The runner registers the trigger as a 9421-default buyer (no
      `authentication` block on `push_notification_config`); the agent is
      graded on the signatures it emits in that mode.

      Verification is delegated to `@adcp/sdk/signing`'s published 9421
      webhook verifier (see the test-kit client_primitives section).

      Negative conformance — signatures that MUST be rejected with a specific
      webhook_signature_* error code — is tested via the published executable
      conformance vectors at
      `/compliance/{version}/test-vectors/webhook-signing/`, including stale
      signature and content-digest tamper cases. This phase grades positive-path
      agents emitting conformant signatures on live traffic.

    steps:
      - id: trigger_signed_webhook
        title: "Trigger a webhook the agent will sign"
        narrative: |
          Runner calls a webhook-emitting operation with no `authentication`
          block on push_notification_config and with an explicit operation_id,
          forcing 9421 signing per the baseline rule.
        task: "$test_kit.operations.primary_webhook_emitter"
        task_default: create_media_buy
        schema_ref: "$test_kit.schemas.primary_request"
        response_schema_ref: "$test_kit.schemas.primary_response"
        doc_ref: "/building/implementation/webhooks"
        comply_scenario: webhook_signed_trigger
        stateful: false
        sample_request:
          push_notification_config:
            url: "{{runner.webhook_url:trigger_signed_webhook}}"
            operation_id: "op_webhook_signed_explicit"
          idempotency_key: "$generate:uuid_v4#webhook_emission_signed_trigger"
          context:
            correlation_id: "webhook_emission--trigger_signed_webhook"
        expected: |
          Agent accepts the request; the webhook it emits will carry
          Signature-Input, Signature, and Content-Digest headers per the
          9421 webhook profile.

      - id: expect_signature_valid
        title: "Assert webhook signature verifies under the 9421 profile"
        narrative: |
          Runner delegates to the @adcp/sdk/signing 9421 webhook verifier (14-step
          checklist). A passing signature means: covered components include
          `@method`, `@target-uri`, `@authority`, `content-type`,
          `content-digest`; `tag` is `adcp/webhook-signing/v1`; `alg` is
          allowlisted; `keyid` resolves via the agent's brand.json
          `agents[]` jwks_uri; content-digest recomputes cleanly; nonce is
          not replayed.
        task: expect_webhook_signature_valid
        triggered_by: trigger_signed_webhook
        filter:
          body:
            operation_id: "op_webhook_signed_explicit"
        timeout_seconds: 30
        require_tag: "adcp/webhook-signing/v1"
        stateful: true
        expected: |
          Signature verifies under the 9421 webhook profile. Fails with the
          specific webhook_signature_* error code on any checklist failure
          (e.g., signature_invalid, signature_expired, signature_key_unknown,
          signature_digest_mismatch, signature_tag_invalid). If the payload
          omits or mismatches the registered operation_id, it will not match
          this payload assertion.

grading:
  pass_criteria: |
    Phases idempotency_key_presence, operation_id_echo,
    idempotency_key_stability, signing_keys_published, and
    signature_validity MUST pass when the agent advertises any
    webhook-emitting operation and the runner hosts a webhook receiver. The
    synchronous completion branch set MUST pass for agents that expose
    get_products and advertise `media_buy.buying_modes` including `"wholesale"`;
    otherwise the branch steps grade not_applicable.
    Agents without a published webhook-valid signing JWKS fail
    signing_keys_published — there is no exemption for "HMAC legacy mode."
    Agents that advertise no webhook-emitting operations grade the entire
    universal as not_applicable. Runners without a webhook receiver grade the
    entire universal as not_applicable.
  report_format: |
    Per-phase pass/fail/not_applicable, with per-step error codes on failure.
    Retry-stability failures include a diff of the observed keys across
    deliveries so operators can debug sender-side retry bugs without re-running
    the full suite. signing_keys_published failures cite the specific gap —
    `webhook_signing_keys_unpublished`, `webhook_signing_keys_wrong_purpose`,
    or `webhook_signing_keys_all_revoked` — so operators distinguish "never
    set up keys" from "set up keys with only non-webhook-valid purpose labels" without
    reading the JWKS by hand.
