diff --git a/MIGRATION_v7_to_v8.md b/MIGRATION_v7_to_v8.md index 0c4309317..f0f20d9de 100644 --- a/MIGRATION_v7_to_v8.md +++ b/MIGRATION_v7_to_v8.md @@ -64,10 +64,16 @@ that task does not share the server lifecycle. The registry commits terminal state and the immutable prepared webhook in one explicit transaction, including when the supplied pool uses autocommit. The -body and callback token are AES-256-GCM encrypted at rest and bound to the task, -account, URL, operation, status, and idempotency key. The retry horizon starts -at the first delivery attempt; the worker replays the same body/key and retains -proof until that exact advertised horizon ends. +body, callback token, and any explicit legacy authentication selector and +credentials are AES-256-GCM encrypted at rest and bound to the task, account, +URL, operation, status, and idempotency key. When +`push_notification_config.authentication` is present, the outbox emits the +selected Bearer or HMAC-SHA256 mode; when absent, it emits RFC 9421. The retry +horizon starts at the first delivery attempt; the worker replays the same +body/key and retains proof until that exact advertised horizon ends. Enable +HMAC only by setting `legacy_hmac_fallback=True` on the outbox and advertising +the matching `webhook_signing.legacy_hmac_fallback=true` capability; boot +validation rejects either side being enabled alone. SDK-managed publication requires the exact `PgTaskRegistry` and `PgTaskWebhookOutbox` types. Subclasses are rejected because overriding task @@ -160,6 +166,15 @@ networks may temporarily retain unsigned legacy callbacks with `allow_unauthenticated_webhooks=True`; multi-agent clients must scope this escape by agent ID. +## Scheduled reporting webhook correlation + +`ReportingWebhook.operation_id` is now required. Buyers must assign it when +creating or updating a reporting registration, and sellers must echo it +verbatim in every scheduled `McpWebhookPayload`. Existing persisted reporting +registrations have no conforming value to migrate automatically: flag or skip +those rows until the buyer supplies an operation ID. Do not derive one from a +media buy ID or callback URL. + ## Webhook activity metadata `ActivityType.WEBHOOK_RECEIVED` no longer copies the complete callback into diff --git a/SCHEMA_DELTAS.md b/SCHEMA_DELTAS.md index 0f0db1c28..c7f74de04 100644 --- a/SCHEMA_DELTAS.md +++ b/SCHEMA_DELTAS.md @@ -1,3 +1,6 @@ # Generated-types delta -_No field-shape changes detected._ +## Field changes + +- `core/reporting_webhook.py` + - `ReportingWebhook`: `+operation_id` diff --git a/docs/handler-authoring.md b/docs/handler-authoring.md index 7f3088dce..f236624b0 100644 --- a/docs/handler-authoring.md +++ b/docs/handler-authoring.md @@ -1438,13 +1438,20 @@ and encryption key; multiple replicas are safe. Avoid an unretained `create_task()` beside synchronous `serve()`, because it is not tied to the server's startup/shutdown lifecycle. -The task registry captures URL, buyer-supplied `operation_id`, and token as an -encrypted, authenticated registration when the task is issued. Its +The task registry captures URL, buyer-supplied `operation_id`, token, and any +explicit legacy authentication selector and credentials as an encrypted, +authenticated registration when the task is issued. Presence of +`push_notification_config.authentication` selects Bearer or HMAC-SHA256; +absence selects the configured RFC 9421 sender. HMAC-SHA256 is admitted only +when both `PgTaskWebhookOutbox(legacy_hmac_fallback=True)` and the advertised +`webhook_signing.legacy_hmac_fallback` capability are true. Resolver-based +deployments can preserve hardened legacy egress policy with +`legacy_allowed_destination_ports=` and `legacy_transport_hooks=`. Its `complete()` / `fail()` transaction writes terminal state and the encrypted, authenticated webhook envelope together. The task-row -copy of the callback token is cleared in that transaction. Workers use expiring -leases and exact retries; the 1–7 day horizon begins on the first attempt and -must exactly match the advertised value. +copy of the callback registration is cleared in that transaction. Workers use +expiring leases and exact retries; the 1–7 day horizon begins on the first +attempt and must exactly match the advertised value. ### Multi-tenant webhook signing diff --git a/schemas/cache/3.2.0-beta.6/bundled/media-buy/accept-proposal-request.json b/schemas/cache/3.2.0-beta.6/bundled/media-buy/accept-proposal-request.json index 920e77ae1..9a1b0bcae 100644 --- a/schemas/cache/3.2.0-beta.6/bundled/media-buy/accept-proposal-request.json +++ b/schemas/cache/3.2.0-beta.6/bundled/media-buy/accept-proposal-request.json @@ -335,6 +335,13 @@ "format": "uri", "description": "Webhook endpoint URL for reporting notifications" }, + "operation_id": { + "type": "string", + "description": "Buyer-supplied correlation identifier for scheduled reporting webhooks. The seller MUST echo this value verbatim into every McpWebhookPayload operation_id field for this registration and MUST NOT derive or fabricate it from media_buy_id or the callback URL. Buyers SHOULD generate a stable unique value for each reporting registration.", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_.:-]{1,255}$" + }, "token": { "type": "string", "description": "Optional client-provided token for webhook validation. Echoed back in webhook payload to validate request authenticity.", @@ -436,6 +443,7 @@ }, "required": [ "url", + "operation_id", "authentication", "reporting_frequency" ], @@ -674,4 +682,4 @@ "generatedAt": "2026-08-23T20:02:49.185Z", "note": "This is a bundled schema with all $ref resolved inline. For the modular version with references, use the parent directory." } -} \ No newline at end of file +} diff --git a/schemas/cache/3.2.0-beta.6/bundled/media-buy/buy-products-request.json b/schemas/cache/3.2.0-beta.6/bundled/media-buy/buy-products-request.json index 16e596938..7793c83bf 100644 --- a/schemas/cache/3.2.0-beta.6/bundled/media-buy/buy-products-request.json +++ b/schemas/cache/3.2.0-beta.6/bundled/media-buy/buy-products-request.json @@ -6185,6 +6185,13 @@ "format": "uri", "description": "Webhook endpoint URL for reporting notifications" }, + "operation_id": { + "type": "string", + "description": "Buyer-supplied correlation identifier for scheduled reporting webhooks. The seller MUST echo this value verbatim into every McpWebhookPayload operation_id field for this registration and MUST NOT derive or fabricate it from media_buy_id or the callback URL. Buyers SHOULD generate a stable unique value for each reporting registration.", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_.:-]{1,255}$" + }, "token": { "type": "string", "description": "Optional client-provided token for webhook validation. Echoed back in webhook payload to validate request authenticity.", @@ -6286,6 +6293,7 @@ }, "required": [ "url", + "operation_id", "authentication", "reporting_frequency" ], @@ -6851,4 +6859,4 @@ "generatedAt": "2026-08-23T20:02:49.545Z", "note": "This is a bundled schema with all $ref resolved inline. For the modular version with references, use the parent directory." } -} \ No newline at end of file +} diff --git a/schemas/cache/3.2.0-beta.6/bundled/media-buy/control-media-buy-request.json b/schemas/cache/3.2.0-beta.6/bundled/media-buy/control-media-buy-request.json index d2381eb73..f38143cdf 100644 --- a/schemas/cache/3.2.0-beta.6/bundled/media-buy/control-media-buy-request.json +++ b/schemas/cache/3.2.0-beta.6/bundled/media-buy/control-media-buy-request.json @@ -5070,6 +5070,13 @@ "format": "uri", "description": "Webhook endpoint URL for reporting notifications" }, + "operation_id": { + "type": "string", + "description": "Buyer-supplied correlation identifier for scheduled reporting webhooks. The seller MUST echo this value verbatim into every McpWebhookPayload operation_id field for this registration and MUST NOT derive or fabricate it from media_buy_id or the callback URL. Buyers SHOULD generate a stable unique value for each reporting registration.", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_.:-]{1,255}$" + }, "token": { "type": "string", "description": "Optional client-provided token for webhook validation. Echoed back in webhook payload to validate request authenticity.", @@ -5171,6 +5178,7 @@ }, "required": [ "url", + "operation_id", "authentication", "reporting_frequency" ], @@ -5694,4 +5702,4 @@ "generatedAt": "2026-08-23T20:02:49.565Z", "note": "This is a bundled schema with all $ref resolved inline. For the modular version with references, use the parent directory." } -} \ No newline at end of file +} diff --git a/schemas/cache/3.2.0-beta.6/bundled/media-buy/create-media-buy-request.json b/schemas/cache/3.2.0-beta.6/bundled/media-buy/create-media-buy-request.json index 8523675fa..836e232b8 100644 --- a/schemas/cache/3.2.0-beta.6/bundled/media-buy/create-media-buy-request.json +++ b/schemas/cache/3.2.0-beta.6/bundled/media-buy/create-media-buy-request.json @@ -49983,6 +49983,13 @@ "format": "uri", "description": "Webhook endpoint URL for reporting notifications" }, + "operation_id": { + "type": "string", + "description": "Buyer-supplied correlation identifier for scheduled reporting webhooks. The seller MUST echo this value verbatim into every McpWebhookPayload operation_id field for this registration and MUST NOT derive or fabricate it from media_buy_id or the callback URL. Buyers SHOULD generate a stable unique value for each reporting registration.", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_.:-]{1,255}$" + }, "token": { "type": "string", "description": "Optional client-provided token for webhook validation. Echoed back in webhook payload to validate request authenticity.", @@ -50034,6 +50041,7 @@ }, "required": [ "url", + "operation_id", "authentication", "reporting_frequency" ], @@ -100280,4 +100288,4 @@ "generatedAt": "2026-08-23T20:02:49.684Z", "note": "This is a bundled schema with all $ref resolved inline. For the modular version with references, use the parent directory." } -} \ No newline at end of file +} diff --git a/schemas/cache/3.2.0-beta.6/bundled/media-buy/update-media-buy-request.json b/schemas/cache/3.2.0-beta.6/bundled/media-buy/update-media-buy-request.json index 0ff6da23b..a5cae34b2 100644 --- a/schemas/cache/3.2.0-beta.6/bundled/media-buy/update-media-buy-request.json +++ b/schemas/cache/3.2.0-beta.6/bundled/media-buy/update-media-buy-request.json @@ -93089,6 +93089,13 @@ "format": "uri", "description": "Webhook endpoint URL for reporting notifications" }, + "operation_id": { + "type": "string", + "description": "Buyer-supplied correlation identifier for scheduled reporting webhooks. The seller MUST echo this value verbatim into every McpWebhookPayload operation_id field for this registration and MUST NOT derive or fabricate it from media_buy_id or the callback URL. Buyers SHOULD generate a stable unique value for each reporting registration.", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_.:-]{1,255}$" + }, "token": { "type": "string", "description": "Optional client-provided token for webhook validation. Echoed back in webhook payload to validate request authenticity.", @@ -93140,6 +93147,7 @@ }, "required": [ "url", + "operation_id", "authentication", "reporting_frequency" ], @@ -95439,4 +95447,4 @@ "generatedAt": "2026-08-23T20:02:50.403Z", "note": "This is a bundled schema with all $ref resolved inline. For the modular version with references, use the parent directory." } -} \ No newline at end of file +} diff --git a/schemas/cache/3.2.0-beta.6/core/mcp-webhook-payload.json b/schemas/cache/3.2.0-beta.6/core/mcp-webhook-payload.json index e44fb5cea..2b706a319 100644 --- a/schemas/cache/3.2.0-beta.6/core/mcp-webhook-payload.json +++ b/schemas/cache/3.2.0-beta.6/core/mcp-webhook-payload.json @@ -52,7 +52,7 @@ }, "operation_id": { "type": "string", - "description": "Client-generated correlation identifier for the operation that produced this webhook. Buyers supply this value at webhook registration time via `push_notification_config.operation_id`; sellers MUST echo it verbatim in every webhook payload. Sellers MUST NOT derive `operation_id` by parsing `push_notification_config.url` \u2014 the URL is opaque to the seller. Receivers MAY dispatch endpoints by URL path or query string, but MUST correlate the operation using this payload field, not URL-derived values. See [Webhooks \u2014 Operation IDs and URL templates](/docs/building/by-layer/L3/webhooks#operation-ids-and-url-templates) for the full normative wire contract." + "description": "Client-generated correlation identifier for the operation that produced this webhook. Buyers supply this value at webhook registration time via `push_notification_config.operation_id` or, for scheduled delivery reports, `reporting_webhook.operation_id`; sellers MUST echo it verbatim in every webhook payload. Sellers MUST NOT derive `operation_id` by parsing either registration URL \u2014 URLs are opaque to the seller. Receivers MAY dispatch endpoints by URL path or query string, but MUST correlate the operation using this payload field, not URL-derived values. See [Webhooks \u2014 Operation IDs and URL templates](/docs/building/by-layer/L3/webhooks#operation-ids-and-url-templates) for the full normative wire contract." }, "task_id": { "type": "string", @@ -216,4 +216,4 @@ } } ] -} \ No newline at end of file +} diff --git a/schemas/cache/3.2.0-beta.6/core/reporting-webhook.json b/schemas/cache/3.2.0-beta.6/core/reporting-webhook.json index 3472a1c33..b9381384a 100644 --- a/schemas/cache/3.2.0-beta.6/core/reporting-webhook.json +++ b/schemas/cache/3.2.0-beta.6/core/reporting-webhook.json @@ -9,6 +9,13 @@ "format": "uri", "description": "Webhook endpoint URL for reporting notifications" }, + "operation_id": { + "type": "string", + "description": "Buyer-supplied correlation identifier for scheduled reporting webhooks. The seller MUST echo this value verbatim into every McpWebhookPayload operation_id field for this registration and MUST NOT derive or fabricate it from media_buy_id or the callback URL. Buyers SHOULD generate a stable unique value for each reporting registration.", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_.:-]{1,255}$" + }, "token": { "type": "string", "description": "Optional client-provided token for webhook validation. Echoed back in webhook payload to validate request authenticity.", @@ -60,8 +67,9 @@ }, "required": [ "url", + "operation_id", "authentication", "reporting_frequency" ], "additionalProperties": true -} \ No newline at end of file +} diff --git a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/media-buy/accept-proposal-request.json b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/media-buy/accept-proposal-request.json index 134b51759..4d7259330 100644 --- a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/media-buy/accept-proposal-request.json +++ b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/media-buy/accept-proposal-request.json @@ -426,6 +426,13 @@ "format": "uri", "description": "Webhook endpoint URL for reporting notifications" }, + "operation_id": { + "type": "string", + "description": "Buyer-supplied correlation identifier for scheduled reporting webhooks. The seller MUST echo this value verbatim into every McpWebhookPayload operation_id field for this registration and MUST NOT derive or fabricate it from media_buy_id or the callback URL. Buyers SHOULD generate a stable unique value for each reporting registration.", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_.:-]{1,255}$" + }, "token": { "type": "string", "description": "Optional client-provided token for webhook validation. Echoed back in webhook payload to validate request authenticity.", @@ -477,6 +484,7 @@ }, "required": [ "url", + "operation_id", "authentication", "reporting_frequency" ], @@ -696,4 +704,4 @@ "additionalProperties": true } } -} \ No newline at end of file +} diff --git a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/media-buy/buy-products-request.json b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/media-buy/buy-products-request.json index a4a56217e..5e9e20879 100644 --- a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/media-buy/buy-products-request.json +++ b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/media-buy/buy-products-request.json @@ -4544,6 +4544,13 @@ "format": "uri", "description": "Webhook endpoint URL for reporting notifications" }, + "operation_id": { + "type": "string", + "description": "Buyer-supplied correlation identifier for scheduled reporting webhooks. The seller MUST echo this value verbatim into every McpWebhookPayload operation_id field for this registration and MUST NOT derive or fabricate it from media_buy_id or the callback URL. Buyers SHOULD generate a stable unique value for each reporting registration.", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_.:-]{1,255}$" + }, "token": { "type": "string", "description": "Optional client-provided token for webhook validation. Echoed back in webhook payload to validate request authenticity.", @@ -4595,6 +4602,7 @@ }, "required": [ "url", + "operation_id", "authentication", "reporting_frequency" ], @@ -4802,4 +4810,4 @@ "additionalProperties": true } } -} \ No newline at end of file +} diff --git a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/media-buy/control-media-buy-request.json b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/media-buy/control-media-buy-request.json index 16ee02718..63b792f7d 100644 --- a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/media-buy/control-media-buy-request.json +++ b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/media-buy/control-media-buy-request.json @@ -3512,6 +3512,13 @@ "format": "uri", "description": "Webhook endpoint URL for reporting notifications" }, + "operation_id": { + "type": "string", + "description": "Buyer-supplied correlation identifier for scheduled reporting webhooks. The seller MUST echo this value verbatim into every McpWebhookPayload operation_id field for this registration and MUST NOT derive or fabricate it from media_buy_id or the callback URL. Buyers SHOULD generate a stable unique value for each reporting registration.", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_.:-]{1,255}$" + }, "token": { "type": "string", "description": "Optional client-provided token for webhook validation. Echoed back in webhook payload to validate request authenticity.", @@ -3563,6 +3570,7 @@ }, "required": [ "url", + "operation_id", "authentication", "reporting_frequency" ], @@ -3692,4 +3700,4 @@ "additionalProperties": true } } -} \ No newline at end of file +} diff --git a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/media-buy/create-media-buy-request.json b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/media-buy/create-media-buy-request.json index f62058a0b..ff9dce514 100644 --- a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/media-buy/create-media-buy-request.json +++ b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/media-buy/create-media-buy-request.json @@ -9299,6 +9299,13 @@ "format": "uri", "description": "Webhook endpoint URL for reporting notifications" }, + "operation_id": { + "type": "string", + "description": "Buyer-supplied correlation identifier for scheduled reporting webhooks. The seller MUST echo this value verbatim into every McpWebhookPayload operation_id field for this registration and MUST NOT derive or fabricate it from media_buy_id or the callback URL. Buyers SHOULD generate a stable unique value for each reporting registration.", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_.:-]{1,255}$" + }, "token": { "type": "string", "description": "Optional client-provided token for webhook validation. Echoed back in webhook payload to validate request authenticity.", @@ -9350,6 +9357,7 @@ }, "required": [ "url", + "operation_id", "authentication", "reporting_frequency" ], @@ -9361,4 +9369,4 @@ "total_budget" ] } -} \ No newline at end of file +} diff --git a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/media-buy/update-media-buy-request.json b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/media-buy/update-media-buy-request.json index 1a6988e4b..939436c7d 100644 --- a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/media-buy/update-media-buy-request.json +++ b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/media-buy/update-media-buy-request.json @@ -9149,6 +9149,13 @@ "format": "uri", "description": "Webhook endpoint URL for reporting notifications" }, + "operation_id": { + "type": "string", + "description": "Buyer-supplied correlation identifier for scheduled reporting webhooks. The seller MUST echo this value verbatim into every McpWebhookPayload operation_id field for this registration and MUST NOT derive or fabricate it from media_buy_id or the callback URL. Buyers SHOULD generate a stable unique value for each reporting registration.", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_.:-]{1,255}$" + }, "token": { "type": "string", "description": "Optional client-provided token for webhook validation. Echoed back in webhook payload to validate request authenticity.", @@ -9200,6 +9207,7 @@ }, "required": [ "url", + "operation_id", "authentication", "reporting_frequency" ], @@ -9270,4 +9278,4 @@ ] } } -} \ No newline at end of file +} diff --git a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/media-buy/accept-proposal-request.json b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/media-buy/accept-proposal-request.json index 38331a32d..e6e14a17d 100644 --- a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/media-buy/accept-proposal-request.json +++ b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/media-buy/accept-proposal-request.json @@ -341,6 +341,12 @@ "type": "string", "format": "uri" }, + "operation_id": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_.:-]{1,255}$" + }, "token": { "type": "string", "minLength": 16 @@ -386,6 +392,7 @@ }, "required": [ "url", + "operation_id", "authentication", "reporting_frequency" ], @@ -586,4 +593,4 @@ } }, "description": "Accept one committed immutable proposal. Depending on proposal_kind, acceptance creates a MediaBuy, applies an amendment, or applies a negotiated cancellation. The proposal already contains the commercial terms, so callers do not repeat packages, dates, targeting, or creatives." -} \ No newline at end of file +} diff --git a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/media-buy/buy-products-request.json b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/media-buy/buy-products-request.json index 91020a734..bac17bf4c 100644 --- a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/media-buy/buy-products-request.json +++ b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/media-buy/buy-products-request.json @@ -3817,6 +3817,12 @@ "type": "string", "format": "uri" }, + "operation_id": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_.:-]{1,255}$" + }, "token": { "type": "string", "minLength": 16 @@ -3862,6 +3868,7 @@ }, "required": [ "url", + "operation_id", "authentication", "reporting_frequency" ], @@ -4054,4 +4061,4 @@ } }, "description": "Create a MediaBuy directly from published product offers. Provide exactly one brand source: top-level brand when account is ID-only, or brand and operator inside a natural-key account. This buyer-composed path accepts published commercial terms, supports targeting and delivery controls, and never accepts inline creatives or creative assignments. The seller records an immutable accepted proposal snapshot so later commercial amendments can use refine_proposals." -} \ No newline at end of file +} diff --git a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/media-buy/control-media-buy-request.json b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/media-buy/control-media-buy-request.json index cc4536aec..8a5ba7876 100644 --- a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/media-buy/control-media-buy-request.json +++ b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/media-buy/control-media-buy-request.json @@ -3066,6 +3066,12 @@ "type": "string", "format": "uri" }, + "operation_id": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_.:-]{1,255}$" + }, "token": { "type": "string", "minLength": 16 @@ -3111,6 +3117,7 @@ }, "required": [ "url", + "operation_id", "authentication", "reporting_frequency" ], @@ -3227,4 +3234,4 @@ } }, "description": "Apply operational delivery controls or non-commercial metadata changes inside the MediaBuy's accepted proposal envelope. Provide at least one control field. cancellation_reason requires canceled: true; cancellation is mutually exclusive with every other control. Sellers return REQUOTE_REQUIRED when budget, targeting, or another control would change the commercial envelope; the buyer then forks the accepted proposal through refine_proposals. Creative mutation, new products/packages, flight changes, pricing changes, and billing-term changes are not accepted here." -} \ No newline at end of file +} diff --git a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/model-context/media-buy/accept-proposal-request.json b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/model-context/media-buy/accept-proposal-request.json index 073768da5..dddbe08b3 100644 --- a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/model-context/media-buy/accept-proposal-request.json +++ b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/model-context/media-buy/accept-proposal-request.json @@ -256,6 +256,9 @@ "url": { "type": "string" }, + "operation_id": { + "type": "string" + }, "token": { "type": "string" }, @@ -294,6 +297,7 @@ }, "required": [ "url", + "operation_id", "authentication", "reporting_frequency" ], @@ -483,4 +487,4 @@ "additionalProperties": true } } -} \ No newline at end of file +} diff --git a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/model-context/media-buy/buy-products-request.json b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/model-context/media-buy/buy-products-request.json index 22be8ad5e..05856f0c8 100644 --- a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/model-context/media-buy/buy-products-request.json +++ b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/model-context/media-buy/buy-products-request.json @@ -3401,6 +3401,9 @@ "url": { "type": "string" }, + "operation_id": { + "type": "string" + }, "token": { "type": "string" }, @@ -3439,6 +3442,7 @@ }, "required": [ "url", + "operation_id", "authentication", "reporting_frequency" ], @@ -3620,4 +3624,4 @@ "additionalProperties": true } } -} \ No newline at end of file +} diff --git a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/model-context/media-buy/control-media-buy-request.json b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/model-context/media-buy/control-media-buy-request.json index 4de486481..2b6a5edd4 100644 --- a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/model-context/media-buy/control-media-buy-request.json +++ b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/model-context/media-buy/control-media-buy-request.json @@ -2743,6 +2743,9 @@ "url": { "type": "string" }, + "operation_id": { + "type": "string" + }, "token": { "type": "string" }, @@ -2781,6 +2784,7 @@ }, "required": [ "url", + "operation_id", "authentication", "reporting_frequency" ], @@ -2884,4 +2888,4 @@ "additionalProperties": true } } -} \ No newline at end of file +} diff --git a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/production/media-buy/accept-proposal-request.json b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/production/media-buy/accept-proposal-request.json index 38331a32d..e6e14a17d 100644 --- a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/production/media-buy/accept-proposal-request.json +++ b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/production/media-buy/accept-proposal-request.json @@ -341,6 +341,12 @@ "type": "string", "format": "uri" }, + "operation_id": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_.:-]{1,255}$" + }, "token": { "type": "string", "minLength": 16 @@ -386,6 +392,7 @@ }, "required": [ "url", + "operation_id", "authentication", "reporting_frequency" ], @@ -586,4 +593,4 @@ } }, "description": "Accept one committed immutable proposal. Depending on proposal_kind, acceptance creates a MediaBuy, applies an amendment, or applies a negotiated cancellation. The proposal already contains the commercial terms, so callers do not repeat packages, dates, targeting, or creatives." -} \ No newline at end of file +} diff --git a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/production/media-buy/buy-products-request.json b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/production/media-buy/buy-products-request.json index 91020a734..bac17bf4c 100644 --- a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/production/media-buy/buy-products-request.json +++ b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/production/media-buy/buy-products-request.json @@ -3817,6 +3817,12 @@ "type": "string", "format": "uri" }, + "operation_id": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_.:-]{1,255}$" + }, "token": { "type": "string", "minLength": 16 @@ -3862,6 +3868,7 @@ }, "required": [ "url", + "operation_id", "authentication", "reporting_frequency" ], @@ -4054,4 +4061,4 @@ } }, "description": "Create a MediaBuy directly from published product offers. Provide exactly one brand source: top-level brand when account is ID-only, or brand and operator inside a natural-key account. This buyer-composed path accepts published commercial terms, supports targeting and delivery controls, and never accepts inline creatives or creative assignments. The seller records an immutable accepted proposal snapshot so later commercial amendments can use refine_proposals." -} \ No newline at end of file +} diff --git a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/production/media-buy/control-media-buy-request.json b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/production/media-buy/control-media-buy-request.json index cc4536aec..8a5ba7876 100644 --- a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/production/media-buy/control-media-buy-request.json +++ b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/production/media-buy/control-media-buy-request.json @@ -3066,6 +3066,12 @@ "type": "string", "format": "uri" }, + "operation_id": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_.:-]{1,255}$" + }, "token": { "type": "string", "minLength": 16 @@ -3111,6 +3117,7 @@ }, "required": [ "url", + "operation_id", "authentication", "reporting_frequency" ], @@ -3227,4 +3234,4 @@ } }, "description": "Apply operational delivery controls or non-commercial metadata changes inside the MediaBuy's accepted proposal envelope. Provide at least one control field. cancellation_reason requires canceled: true; cancellation is mutually exclusive with every other control. Sellers return REQUOTE_REQUIRED when budget, targeting, or another control would change the commercial envelope; the buyer then forks the accepted proposal through refine_proposals. Creative mutation, new products/packages, flight changes, pricing changes, and billing-term changes are not accepted here." -} \ No newline at end of file +} diff --git a/schemas/patches/01-reporting-webhook-operation-id.patch b/schemas/patches/01-reporting-webhook-operation-id.patch new file mode 100644 index 000000000..89d4218a6 --- /dev/null +++ b/schemas/patches/01-reporting-webhook-operation-id.patch @@ -0,0 +1,646 @@ +# Patch: buyer-supplied operation_id on reporting-webhook +# Reason: scheduled media-buy delivery webhooks use McpWebhookPayload, whose +# operation_id is required and must be copied from buyer registration. +# Filed: SDK issue #1092 +# Upstream status: filed; not present in AdCP 3.2.0-beta.6. +# Drop when: the pinned AdCP bundle includes the same required correlation field. + +diff --git a/schemas/cache/3.2.0-beta.6/bundled/media-buy/accept-proposal-request.json b/schemas/cache/3.2.0-beta.6/bundled/media-buy/accept-proposal-request.json +index 920e77ae1..9a1b0bcae 100644 +--- a/schemas/cache/3.2.0-beta.6/bundled/media-buy/accept-proposal-request.json ++++ b/schemas/cache/3.2.0-beta.6/bundled/media-buy/accept-proposal-request.json +@@ -335,6 +335,13 @@ + "format": "uri", + "description": "Webhook endpoint URL for reporting notifications" + }, ++ "operation_id": { ++ "type": "string", ++ "description": "Buyer-supplied correlation identifier for scheduled reporting webhooks. The seller MUST echo this value verbatim into every McpWebhookPayload operation_id field for this registration and MUST NOT derive or fabricate it from media_buy_id or the callback URL. Buyers SHOULD generate a stable unique value for each reporting registration.", ++ "minLength": 1, ++ "maxLength": 255, ++ "pattern": "^[A-Za-z0-9_.:-]{1,255}$" ++ }, + "token": { + "type": "string", + "description": "Optional client-provided token for webhook validation. Echoed back in webhook payload to validate request authenticity.", +@@ -436,6 +443,7 @@ + }, + "required": [ + "url", ++ "operation_id", + "authentication", + "reporting_frequency" + ], +@@ -674,4 +682,4 @@ + "generatedAt": "2026-08-23T20:02:49.185Z", + "note": "This is a bundled schema with all $ref resolved inline. For the modular version with references, use the parent directory." + } +-} +\ No newline at end of file ++} +diff --git a/schemas/cache/3.2.0-beta.6/bundled/media-buy/buy-products-request.json b/schemas/cache/3.2.0-beta.6/bundled/media-buy/buy-products-request.json +index 16e596938..7793c83bf 100644 +--- a/schemas/cache/3.2.0-beta.6/bundled/media-buy/buy-products-request.json ++++ b/schemas/cache/3.2.0-beta.6/bundled/media-buy/buy-products-request.json +@@ -6185,6 +6185,13 @@ + "format": "uri", + "description": "Webhook endpoint URL for reporting notifications" + }, ++ "operation_id": { ++ "type": "string", ++ "description": "Buyer-supplied correlation identifier for scheduled reporting webhooks. The seller MUST echo this value verbatim into every McpWebhookPayload operation_id field for this registration and MUST NOT derive or fabricate it from media_buy_id or the callback URL. Buyers SHOULD generate a stable unique value for each reporting registration.", ++ "minLength": 1, ++ "maxLength": 255, ++ "pattern": "^[A-Za-z0-9_.:-]{1,255}$" ++ }, + "token": { + "type": "string", + "description": "Optional client-provided token for webhook validation. Echoed back in webhook payload to validate request authenticity.", +@@ -6286,6 +6293,7 @@ + }, + "required": [ + "url", ++ "operation_id", + "authentication", + "reporting_frequency" + ], +@@ -6851,4 +6859,4 @@ + "generatedAt": "2026-08-23T20:02:49.545Z", + "note": "This is a bundled schema with all $ref resolved inline. For the modular version with references, use the parent directory." + } +-} +\ No newline at end of file ++} +diff --git a/schemas/cache/3.2.0-beta.6/bundled/media-buy/control-media-buy-request.json b/schemas/cache/3.2.0-beta.6/bundled/media-buy/control-media-buy-request.json +index d2381eb73..f38143cdf 100644 +--- a/schemas/cache/3.2.0-beta.6/bundled/media-buy/control-media-buy-request.json ++++ b/schemas/cache/3.2.0-beta.6/bundled/media-buy/control-media-buy-request.json +@@ -5070,6 +5070,13 @@ + "format": "uri", + "description": "Webhook endpoint URL for reporting notifications" + }, ++ "operation_id": { ++ "type": "string", ++ "description": "Buyer-supplied correlation identifier for scheduled reporting webhooks. The seller MUST echo this value verbatim into every McpWebhookPayload operation_id field for this registration and MUST NOT derive or fabricate it from media_buy_id or the callback URL. Buyers SHOULD generate a stable unique value for each reporting registration.", ++ "minLength": 1, ++ "maxLength": 255, ++ "pattern": "^[A-Za-z0-9_.:-]{1,255}$" ++ }, + "token": { + "type": "string", + "description": "Optional client-provided token for webhook validation. Echoed back in webhook payload to validate request authenticity.", +@@ -5171,6 +5178,7 @@ + }, + "required": [ + "url", ++ "operation_id", + "authentication", + "reporting_frequency" + ], +@@ -5694,4 +5702,4 @@ + "generatedAt": "2026-08-23T20:02:49.565Z", + "note": "This is a bundled schema with all $ref resolved inline. For the modular version with references, use the parent directory." + } +-} +\ No newline at end of file ++} +diff --git a/schemas/cache/3.2.0-beta.6/bundled/media-buy/create-media-buy-request.json b/schemas/cache/3.2.0-beta.6/bundled/media-buy/create-media-buy-request.json +index 8523675fa..836e232b8 100644 +--- a/schemas/cache/3.2.0-beta.6/bundled/media-buy/create-media-buy-request.json ++++ b/schemas/cache/3.2.0-beta.6/bundled/media-buy/create-media-buy-request.json +@@ -49983,6 +49983,13 @@ + "format": "uri", + "description": "Webhook endpoint URL for reporting notifications" + }, ++ "operation_id": { ++ "type": "string", ++ "description": "Buyer-supplied correlation identifier for scheduled reporting webhooks. The seller MUST echo this value verbatim into every McpWebhookPayload operation_id field for this registration and MUST NOT derive or fabricate it from media_buy_id or the callback URL. Buyers SHOULD generate a stable unique value for each reporting registration.", ++ "minLength": 1, ++ "maxLength": 255, ++ "pattern": "^[A-Za-z0-9_.:-]{1,255}$" ++ }, + "token": { + "type": "string", + "description": "Optional client-provided token for webhook validation. Echoed back in webhook payload to validate request authenticity.", +@@ -50034,6 +50041,7 @@ + }, + "required": [ + "url", ++ "operation_id", + "authentication", + "reporting_frequency" + ], +@@ -100280,4 +100288,4 @@ + "generatedAt": "2026-08-23T20:02:49.684Z", + "note": "This is a bundled schema with all $ref resolved inline. For the modular version with references, use the parent directory." + } +-} +\ No newline at end of file ++} +diff --git a/schemas/cache/3.2.0-beta.6/bundled/media-buy/update-media-buy-request.json b/schemas/cache/3.2.0-beta.6/bundled/media-buy/update-media-buy-request.json +index 0ff6da23b..a5cae34b2 100644 +--- a/schemas/cache/3.2.0-beta.6/bundled/media-buy/update-media-buy-request.json ++++ b/schemas/cache/3.2.0-beta.6/bundled/media-buy/update-media-buy-request.json +@@ -93089,6 +93089,13 @@ + "format": "uri", + "description": "Webhook endpoint URL for reporting notifications" + }, ++ "operation_id": { ++ "type": "string", ++ "description": "Buyer-supplied correlation identifier for scheduled reporting webhooks. The seller MUST echo this value verbatim into every McpWebhookPayload operation_id field for this registration and MUST NOT derive or fabricate it from media_buy_id or the callback URL. Buyers SHOULD generate a stable unique value for each reporting registration.", ++ "minLength": 1, ++ "maxLength": 255, ++ "pattern": "^[A-Za-z0-9_.:-]{1,255}$" ++ }, + "token": { + "type": "string", + "description": "Optional client-provided token for webhook validation. Echoed back in webhook payload to validate request authenticity.", +@@ -93140,6 +93147,7 @@ + }, + "required": [ + "url", ++ "operation_id", + "authentication", + "reporting_frequency" + ], +@@ -95439,4 +95447,4 @@ + "generatedAt": "2026-08-23T20:02:50.403Z", + "note": "This is a bundled schema with all $ref resolved inline. For the modular version with references, use the parent directory." + } +-} +\ No newline at end of file ++} +diff --git a/schemas/cache/3.2.0-beta.6/core/reporting-webhook.json b/schemas/cache/3.2.0-beta.6/core/reporting-webhook.json +index 3472a1c33..b9381384a 100644 +--- a/schemas/cache/3.2.0-beta.6/core/reporting-webhook.json ++++ b/schemas/cache/3.2.0-beta.6/core/reporting-webhook.json +@@ -9,6 +9,13 @@ + "format": "uri", + "description": "Webhook endpoint URL for reporting notifications" + }, ++ "operation_id": { ++ "type": "string", ++ "description": "Buyer-supplied correlation identifier for scheduled reporting webhooks. The seller MUST echo this value verbatim into every McpWebhookPayload operation_id field for this registration and MUST NOT derive or fabricate it from media_buy_id or the callback URL. Buyers SHOULD generate a stable unique value for each reporting registration.", ++ "minLength": 1, ++ "maxLength": 255, ++ "pattern": "^[A-Za-z0-9_.:-]{1,255}$" ++ }, + "token": { + "type": "string", + "description": "Optional client-provided token for webhook validation. Echoed back in webhook payload to validate request authenticity.", +@@ -60,8 +67,9 @@ + }, + "required": [ + "url", ++ "operation_id", + "authentication", + "reporting_frequency" + ], + "additionalProperties": true +-} +\ No newline at end of file ++} +diff --git a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/media-buy/accept-proposal-request.json b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/media-buy/accept-proposal-request.json +index 134b51759..4d7259330 100644 +--- a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/media-buy/accept-proposal-request.json ++++ b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/media-buy/accept-proposal-request.json +@@ -426,6 +426,13 @@ + "format": "uri", + "description": "Webhook endpoint URL for reporting notifications" + }, ++ "operation_id": { ++ "type": "string", ++ "description": "Buyer-supplied correlation identifier for scheduled reporting webhooks. The seller MUST echo this value verbatim into every McpWebhookPayload operation_id field for this registration and MUST NOT derive or fabricate it from media_buy_id or the callback URL. Buyers SHOULD generate a stable unique value for each reporting registration.", ++ "minLength": 1, ++ "maxLength": 255, ++ "pattern": "^[A-Za-z0-9_.:-]{1,255}$" ++ }, + "token": { + "type": "string", + "description": "Optional client-provided token for webhook validation. Echoed back in webhook payload to validate request authenticity.", +@@ -477,6 +484,7 @@ + }, + "required": [ + "url", ++ "operation_id", + "authentication", + "reporting_frequency" + ], +@@ -696,4 +704,4 @@ + "additionalProperties": true + } + } +-} +\ No newline at end of file ++} +diff --git a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/media-buy/buy-products-request.json b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/media-buy/buy-products-request.json +index a4a56217e..5e9e20879 100644 +--- a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/media-buy/buy-products-request.json ++++ b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/media-buy/buy-products-request.json +@@ -4544,6 +4544,13 @@ + "format": "uri", + "description": "Webhook endpoint URL for reporting notifications" + }, ++ "operation_id": { ++ "type": "string", ++ "description": "Buyer-supplied correlation identifier for scheduled reporting webhooks. The seller MUST echo this value verbatim into every McpWebhookPayload operation_id field for this registration and MUST NOT derive or fabricate it from media_buy_id or the callback URL. Buyers SHOULD generate a stable unique value for each reporting registration.", ++ "minLength": 1, ++ "maxLength": 255, ++ "pattern": "^[A-Za-z0-9_.:-]{1,255}$" ++ }, + "token": { + "type": "string", + "description": "Optional client-provided token for webhook validation. Echoed back in webhook payload to validate request authenticity.", +@@ -4595,6 +4602,7 @@ + }, + "required": [ + "url", ++ "operation_id", + "authentication", + "reporting_frequency" + ], +@@ -4802,4 +4810,4 @@ + "additionalProperties": true + } + } +-} +\ No newline at end of file ++} +diff --git a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/media-buy/control-media-buy-request.json b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/media-buy/control-media-buy-request.json +index 16ee02718..63b792f7d 100644 +--- a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/media-buy/control-media-buy-request.json ++++ b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/media-buy/control-media-buy-request.json +@@ -3512,6 +3512,13 @@ + "format": "uri", + "description": "Webhook endpoint URL for reporting notifications" + }, ++ "operation_id": { ++ "type": "string", ++ "description": "Buyer-supplied correlation identifier for scheduled reporting webhooks. The seller MUST echo this value verbatim into every McpWebhookPayload operation_id field for this registration and MUST NOT derive or fabricate it from media_buy_id or the callback URL. Buyers SHOULD generate a stable unique value for each reporting registration.", ++ "minLength": 1, ++ "maxLength": 255, ++ "pattern": "^[A-Za-z0-9_.:-]{1,255}$" ++ }, + "token": { + "type": "string", + "description": "Optional client-provided token for webhook validation. Echoed back in webhook payload to validate request authenticity.", +@@ -3563,6 +3570,7 @@ + }, + "required": [ + "url", ++ "operation_id", + "authentication", + "reporting_frequency" + ], +@@ -3692,4 +3700,4 @@ + "additionalProperties": true + } + } +-} +\ No newline at end of file ++} +diff --git a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/media-buy/create-media-buy-request.json b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/media-buy/create-media-buy-request.json +index f62058a0b..ff9dce514 100644 +--- a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/media-buy/create-media-buy-request.json ++++ b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/media-buy/create-media-buy-request.json +@@ -9299,6 +9299,13 @@ + "format": "uri", + "description": "Webhook endpoint URL for reporting notifications" + }, ++ "operation_id": { ++ "type": "string", ++ "description": "Buyer-supplied correlation identifier for scheduled reporting webhooks. The seller MUST echo this value verbatim into every McpWebhookPayload operation_id field for this registration and MUST NOT derive or fabricate it from media_buy_id or the callback URL. Buyers SHOULD generate a stable unique value for each reporting registration.", ++ "minLength": 1, ++ "maxLength": 255, ++ "pattern": "^[A-Za-z0-9_.:-]{1,255}$" ++ }, + "token": { + "type": "string", + "description": "Optional client-provided token for webhook validation. Echoed back in webhook payload to validate request authenticity.", +@@ -9350,6 +9357,7 @@ + }, + "required": [ + "url", ++ "operation_id", + "authentication", + "reporting_frequency" + ], +@@ -9361,4 +9369,4 @@ + "total_budget" + ] + } +-} +\ No newline at end of file ++} +diff --git a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/media-buy/update-media-buy-request.json b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/media-buy/update-media-buy-request.json +index 1a6988e4b..939436c7d 100644 +--- a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/media-buy/update-media-buy-request.json ++++ b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/media-buy/update-media-buy-request.json +@@ -9149,6 +9149,13 @@ + "format": "uri", + "description": "Webhook endpoint URL for reporting notifications" + }, ++ "operation_id": { ++ "type": "string", ++ "description": "Buyer-supplied correlation identifier for scheduled reporting webhooks. The seller MUST echo this value verbatim into every McpWebhookPayload operation_id field for this registration and MUST NOT derive or fabricate it from media_buy_id or the callback URL. Buyers SHOULD generate a stable unique value for each reporting registration.", ++ "minLength": 1, ++ "maxLength": 255, ++ "pattern": "^[A-Za-z0-9_.:-]{1,255}$" ++ }, + "token": { + "type": "string", + "description": "Optional client-provided token for webhook validation. Echoed back in webhook payload to validate request authenticity.", +@@ -9200,6 +9207,7 @@ + }, + "required": [ + "url", ++ "operation_id", + "authentication", + "reporting_frequency" + ], +@@ -9270,4 +9278,4 @@ + ] + } + } +-} +\ No newline at end of file ++} +diff --git a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/media-buy/accept-proposal-request.json b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/media-buy/accept-proposal-request.json +index 38331a32d..e6e14a17d 100644 +--- a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/media-buy/accept-proposal-request.json ++++ b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/media-buy/accept-proposal-request.json +@@ -341,6 +341,12 @@ + "type": "string", + "format": "uri" + }, ++ "operation_id": { ++ "type": "string", ++ "minLength": 1, ++ "maxLength": 255, ++ "pattern": "^[A-Za-z0-9_.:-]{1,255}$" ++ }, + "token": { + "type": "string", + "minLength": 16 +@@ -386,6 +392,7 @@ + }, + "required": [ + "url", ++ "operation_id", + "authentication", + "reporting_frequency" + ], +@@ -586,4 +593,4 @@ + } + }, + "description": "Accept one committed immutable proposal. Depending on proposal_kind, acceptance creates a MediaBuy, applies an amendment, or applies a negotiated cancellation. The proposal already contains the commercial terms, so callers do not repeat packages, dates, targeting, or creatives." +-} +\ No newline at end of file ++} +diff --git a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/media-buy/buy-products-request.json b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/media-buy/buy-products-request.json +index 91020a734..bac17bf4c 100644 +--- a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/media-buy/buy-products-request.json ++++ b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/media-buy/buy-products-request.json +@@ -3817,6 +3817,12 @@ + "type": "string", + "format": "uri" + }, ++ "operation_id": { ++ "type": "string", ++ "minLength": 1, ++ "maxLength": 255, ++ "pattern": "^[A-Za-z0-9_.:-]{1,255}$" ++ }, + "token": { + "type": "string", + "minLength": 16 +@@ -3862,6 +3868,7 @@ + }, + "required": [ + "url", ++ "operation_id", + "authentication", + "reporting_frequency" + ], +@@ -4054,4 +4061,4 @@ + } + }, + "description": "Create a MediaBuy directly from published product offers. Provide exactly one brand source: top-level brand when account is ID-only, or brand and operator inside a natural-key account. This buyer-composed path accepts published commercial terms, supports targeting and delivery controls, and never accepts inline creatives or creative assignments. The seller records an immutable accepted proposal snapshot so later commercial amendments can use refine_proposals." +-} +\ No newline at end of file ++} +diff --git a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/media-buy/control-media-buy-request.json b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/media-buy/control-media-buy-request.json +index cc4536aec..8a5ba7876 100644 +--- a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/media-buy/control-media-buy-request.json ++++ b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/media-buy/control-media-buy-request.json +@@ -3066,6 +3066,12 @@ + "type": "string", + "format": "uri" + }, ++ "operation_id": { ++ "type": "string", ++ "minLength": 1, ++ "maxLength": 255, ++ "pattern": "^[A-Za-z0-9_.:-]{1,255}$" ++ }, + "token": { + "type": "string", + "minLength": 16 +@@ -3111,6 +3117,7 @@ + }, + "required": [ + "url", ++ "operation_id", + "authentication", + "reporting_frequency" + ], +@@ -3227,4 +3234,4 @@ + } + }, + "description": "Apply operational delivery controls or non-commercial metadata changes inside the MediaBuy's accepted proposal envelope. Provide at least one control field. cancellation_reason requires canceled: true; cancellation is mutually exclusive with every other control. Sellers return REQUOTE_REQUIRED when budget, targeting, or another control would change the commercial envelope; the buyer then forks the accepted proposal through refine_proposals. Creative mutation, new products/packages, flight changes, pricing changes, and billing-term changes are not accepted here." +-} +\ No newline at end of file ++} +diff --git a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/model-context/media-buy/accept-proposal-request.json b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/model-context/media-buy/accept-proposal-request.json +index 073768da5..dddbe08b3 100644 +--- a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/model-context/media-buy/accept-proposal-request.json ++++ b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/model-context/media-buy/accept-proposal-request.json +@@ -256,6 +256,9 @@ + "url": { + "type": "string" + }, ++ "operation_id": { ++ "type": "string" ++ }, + "token": { + "type": "string" + }, +@@ -294,6 +297,7 @@ + }, + "required": [ + "url", ++ "operation_id", + "authentication", + "reporting_frequency" + ], +@@ -483,4 +487,4 @@ + "additionalProperties": true + } + } +-} +\ No newline at end of file ++} +diff --git a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/model-context/media-buy/buy-products-request.json b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/model-context/media-buy/buy-products-request.json +index 22be8ad5e..05856f0c8 100644 +--- a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/model-context/media-buy/buy-products-request.json ++++ b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/model-context/media-buy/buy-products-request.json +@@ -3401,6 +3401,9 @@ + "url": { + "type": "string" + }, ++ "operation_id": { ++ "type": "string" ++ }, + "token": { + "type": "string" + }, +@@ -3439,6 +3442,7 @@ + }, + "required": [ + "url", ++ "operation_id", + "authentication", + "reporting_frequency" + ], +@@ -3620,4 +3624,4 @@ + "additionalProperties": true + } + } +-} +\ No newline at end of file ++} +diff --git a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/model-context/media-buy/control-media-buy-request.json b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/model-context/media-buy/control-media-buy-request.json +index 4de486481..2b6a5edd4 100644 +--- a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/model-context/media-buy/control-media-buy-request.json ++++ b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/media-buy/model-context/media-buy/control-media-buy-request.json +@@ -2743,6 +2743,9 @@ + "url": { + "type": "string" + }, ++ "operation_id": { ++ "type": "string" ++ }, + "token": { + "type": "string" + }, +@@ -2781,6 +2784,7 @@ + }, + "required": [ + "url", ++ "operation_id", + "authentication", + "reporting_frequency" + ], +@@ -2884,4 +2888,4 @@ + "additionalProperties": true + } + } +-} +\ No newline at end of file ++} +diff --git a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/production/media-buy/accept-proposal-request.json b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/production/media-buy/accept-proposal-request.json +index 38331a32d..e6e14a17d 100644 +--- a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/production/media-buy/accept-proposal-request.json ++++ b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/production/media-buy/accept-proposal-request.json +@@ -341,6 +341,12 @@ + "type": "string", + "format": "uri" + }, ++ "operation_id": { ++ "type": "string", ++ "minLength": 1, ++ "maxLength": 255, ++ "pattern": "^[A-Za-z0-9_.:-]{1,255}$" ++ }, + "token": { + "type": "string", + "minLength": 16 +@@ -386,6 +392,7 @@ + }, + "required": [ + "url", ++ "operation_id", + "authentication", + "reporting_frequency" + ], +@@ -586,4 +593,4 @@ + } + }, + "description": "Accept one committed immutable proposal. Depending on proposal_kind, acceptance creates a MediaBuy, applies an amendment, or applies a negotiated cancellation. The proposal already contains the commercial terms, so callers do not repeat packages, dates, targeting, or creatives." +-} +\ No newline at end of file ++} +diff --git a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/production/media-buy/buy-products-request.json b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/production/media-buy/buy-products-request.json +index 91020a734..bac17bf4c 100644 +--- a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/production/media-buy/buy-products-request.json ++++ b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/production/media-buy/buy-products-request.json +@@ -3817,6 +3817,12 @@ + "type": "string", + "format": "uri" + }, ++ "operation_id": { ++ "type": "string", ++ "minLength": 1, ++ "maxLength": 255, ++ "pattern": "^[A-Za-z0-9_.:-]{1,255}$" ++ }, + "token": { + "type": "string", + "minLength": 16 +@@ -3862,6 +3868,7 @@ + }, + "required": [ + "url", ++ "operation_id", + "authentication", + "reporting_frequency" + ], +@@ -4054,4 +4061,4 @@ + } + }, + "description": "Create a MediaBuy directly from published product offers. Provide exactly one brand source: top-level brand when account is ID-only, or brand and operator inside a natural-key account. This buyer-composed path accepts published commercial terms, supports targeting and delivery controls, and never accepts inline creatives or creative assignments. The seller records an immutable accepted proposal snapshot so later commercial amendments can use refine_proposals." +-} +\ No newline at end of file ++} +diff --git a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/production/media-buy/control-media-buy-request.json b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/production/media-buy/control-media-buy-request.json +index cc4536aec..8a5ba7876 100644 +--- a/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/production/media-buy/control-media-buy-request.json ++++ b/schemas/cache/3.2.0-beta.6/mcp/2026-07-28/profiles/production/media-buy/control-media-buy-request.json +@@ -3066,6 +3066,12 @@ + "type": "string", + "format": "uri" + }, ++ "operation_id": { ++ "type": "string", ++ "minLength": 1, ++ "maxLength": 255, ++ "pattern": "^[A-Za-z0-9_.:-]{1,255}$" ++ }, + "token": { + "type": "string", + "minLength": 16 +@@ -3111,6 +3117,7 @@ + }, + "required": [ + "url", ++ "operation_id", + "authentication", + "reporting_frequency" + ], +@@ -3227,4 +3234,4 @@ + } + }, + "description": "Apply operational delivery controls or non-commercial metadata changes inside the MediaBuy's accepted proposal envelope. Provide at least one control field. cancellation_reason requires canceled: true; cancellation is mutually exclusive with every other control. Sellers return REQUOTE_REQUIRED when budget, targeting, or another control would change the commercial envelope; the buyer then forks the accepted proposal through refine_proposals. Creative mutation, new products/packages, flight changes, pricing changes, and billing-term changes are not accepted here." +-} +\ No newline at end of file ++} diff --git a/schemas/patches/02-mcp-webhook-operation-id-source.patch b/schemas/patches/02-mcp-webhook-operation-id-source.patch new file mode 100644 index 000000000..1a06432f8 --- /dev/null +++ b/schemas/patches/02-mcp-webhook-operation-id-source.patch @@ -0,0 +1,27 @@ +# Patch: reporting registration source for webhook operation_id +# Reason: scheduled delivery webhooks copy operation_id from ReportingWebhook, +# not PushNotificationConfig. +# Filed: SDK issue #1092 +# Upstream status: filed; not present in AdCP 3.2.0-beta.6. +# Drop when: the pinned AdCP bundle documents both registration sources. + +diff --git a/schemas/cache/3.2.0-beta.6/core/mcp-webhook-payload.json b/schemas/cache/3.2.0-beta.6/core/mcp-webhook-payload.json +index e44fb5cea..2b706a319 100644 +--- a/schemas/cache/3.2.0-beta.6/core/mcp-webhook-payload.json ++++ b/schemas/cache/3.2.0-beta.6/core/mcp-webhook-payload.json +@@ -52,7 +52,7 @@ + }, + "operation_id": { + "type": "string", +- "description": "Client-generated correlation identifier for the operation that produced this webhook. Buyers supply this value at webhook registration time via `push_notification_config.operation_id`; sellers MUST echo it verbatim in every webhook payload. Sellers MUST NOT derive `operation_id` by parsing `push_notification_config.url` \u2014 the URL is opaque to the seller. Receivers MAY dispatch endpoints by URL path or query string, but MUST correlate the operation using this payload field, not URL-derived values. See [Webhooks \u2014 Operation IDs and URL templates](/docs/building/by-layer/L3/webhooks#operation-ids-and-url-templates) for the full normative wire contract." ++ "description": "Client-generated correlation identifier for the operation that produced this webhook. Buyers supply this value at webhook registration time via `push_notification_config.operation_id` or, for scheduled delivery reports, `reporting_webhook.operation_id`; sellers MUST echo it verbatim in every webhook payload. Sellers MUST NOT derive `operation_id` by parsing either registration URL \u2014 URLs are opaque to the seller. Receivers MAY dispatch endpoints by URL path or query string, but MUST correlate the operation using this payload field, not URL-derived values. See [Webhooks \u2014 Operation IDs and URL templates](/docs/building/by-layer/L3/webhooks#operation-ids-and-url-templates) for the full normative wire contract." + }, + "task_id": { + "type": "string", +@@ -216,4 +216,4 @@ + } + } + ] +-} +\ No newline at end of file ++} diff --git a/skills/build-seller-agent/SKILL.md b/skills/build-seller-agent/SKILL.md index 252b273d4..6d989ea3d 100644 --- a/skills/build-seller-agent/SKILL.md +++ b/skills/build-seller-agent/SKILL.md @@ -572,6 +572,7 @@ async with sender: result = await sender.send_mcp( url=str(config.url), task_id=task_id, + operation_id=config.operation_id, task_type="create_media_buy", status="completed", result=response_dict, @@ -587,14 +588,18 @@ from adcp.webhooks import deliver, create_mcp_webhook_payload response = await deliver( config, # PushNotificationConfig or ReportingWebhook from the request create_mcp_webhook_payload( - task_id=task_id, task_type="create_media_buy", - status="completed", result=response_dict, + task_id=task_id, + operation_id=config.operation_id, + task_type="create_media_buy", + status="completed", + result=response_dict, ), ) response.raise_for_status() ``` Notes: +- Copy `config.operation_id` verbatim into every MCP webhook payload. Do not derive it from the task, media buy, or callback URL. Legacy persisted reporting registrations without one must be flagged or skipped until the buyer supplies it. - `deliver` hashes/signs the exact bytes it POSTs for HMAC-SHA256; for Bearer it attaches the credential as `Authorization`. Either way, the signer and the wire cannot disagree. - `deliver` emits a `DeprecationWarning` on first use; migrate to `WebhookSender` for 4.0. - If your buyer relies on `config.token` echo, pass `token_field="push_token"` (pick a name you and the receiver agree on — there is no spec-defined field name). diff --git a/src/adcp/decisioning/__init__.py b/src/adcp/decisioning/__init__.py index 1361d0926..26c323d86 100644 --- a/src/adcp/decisioning/__init__.py +++ b/src/adcp/decisioning/__init__.py @@ -224,6 +224,7 @@ def create_media_buy( TaskHandoffContext, TaskRegistry, TaskState, + TaskWebhookAuthentication, ) from adcp.decisioning.tenant_store import create_tenant_store from adcp.decisioning.time_budget import ( @@ -469,6 +470,7 @@ def __init__(self, *args: object, **kwargs: object) -> None: "TaskHandoffContext", "TaskRegistry", "TaskState", + "TaskWebhookAuthentication", "WebhookSenderResolver", "WebhookSenderResolution", "WebhookSigningScopeResolver", diff --git a/src/adcp/decisioning/dispatch.py b/src/adcp/decisioning/dispatch.py index 075bf9fe9..6865f407d 100644 --- a/src/adcp/decisioning/dispatch.py +++ b/src/adcp/decisioning/dispatch.py @@ -74,6 +74,7 @@ ) from adcp.decisioning.webhook_emit import ( SPEC_WEBHOOK_TASK_TYPES, + _extract_push_authentication, _extract_push_notification_url_and_token, _extract_push_operation_id, emit_terminal_completion_webhook, @@ -2092,14 +2093,16 @@ async def _project_handoff( and getattr(registry, "task_webhook_outbox", None) is not None ): push_url, push_token = push_target + push_authentication = _extract_push_authentication(request_params) signing_scope_id: str | None = None signing_scope_resolver = getattr(registry, "resolve_webhook_signing_scope", None) - if signing_scope_resolver is not None: + if push_authentication is None and signing_scope_resolver is not None: signing_scope_id = await signing_scope_resolver(ctx) issue_kwargs.update( webhook_url=push_url, webhook_operation_id=_extract_push_operation_id(request_params), webhook_token=push_token, + webhook_authentication=push_authentication, webhook_signing_scope_id=signing_scope_id, ) task_id = await registry.issue(**issue_kwargs) @@ -2369,14 +2372,16 @@ async def _project_workflow_handoff( and getattr(registry, "task_webhook_outbox", None) is not None ): push_url, push_token = push_target + push_authentication = _extract_push_authentication(request_params) signing_scope_id: str | None = None signing_scope_resolver = getattr(registry, "resolve_webhook_signing_scope", None) - if signing_scope_resolver is not None: + if push_authentication is None and signing_scope_resolver is not None: signing_scope_id = await signing_scope_resolver(ctx) issue_kwargs.update( webhook_url=push_url, webhook_operation_id=_extract_push_operation_id(request_params), webhook_token=push_token, + webhook_authentication=push_authentication, webhook_signing_scope_id=signing_scope_id, ) task_id = await registry.issue(**issue_kwargs) diff --git a/src/adcp/decisioning/pg/task_registry.py b/src/adcp/decisioning/pg/task_registry.py index 68c200e3f..0e1129cdd 100644 --- a/src/adcp/decisioning/pg/task_registry.py +++ b/src/adcp/decisioning/pg/task_registry.py @@ -71,6 +71,7 @@ async def main(): from typing import TYPE_CHECKING, Any, ClassVar, TypeAlias from adcp.decisioning.account_projection import strip_credentials_from_wire_result +from adcp.decisioning.task_registry import TaskWebhookAuthentication if TYPE_CHECKING: from psycopg_pool import AsyncConnectionPool @@ -262,6 +263,7 @@ async def issue( webhook_url: str | None = None, webhook_operation_id: str | None = None, webhook_token: str | None = None, + webhook_authentication: TaskWebhookAuthentication | None = None, webhook_signing_scope_id: str | None = None, **_extra: Any, ) -> str: @@ -287,11 +289,17 @@ async def issue( raise ValueError("webhook_operation_id must be non-empty when supplied") if webhook_url is None and webhook_signing_scope_id is not None: raise ValueError("webhook_signing_scope_id requires webhook_url") + if webhook_url is None and webhook_authentication is not None: + raise ValueError("webhook_authentication requires webhook_url") + if webhook_authentication is not None and webhook_signing_scope_id is not None: + raise ValueError( + "legacy webhook_authentication must not carry an RFC 9421 signing scope" + ) outbox = self.task_webhook_outbox if webhook_url is not None: if outbox is None: raise ValueError("webhook registration requires the registry's task_webhook_outbox") - outbox.validate_registration(webhook_url) + outbox.validate_registration(webhook_url, webhook_authentication) task_id = f"task_{uuid.uuid4().hex[:16]}" encrypted_registration: bytes | None = None registration_nonce: bytes | None = None @@ -305,6 +313,7 @@ async def issue( url=webhook_url, operation_id=webhook_operation_id, token=webhook_token, + authentication=webhook_authentication, signing_scope_id=webhook_signing_scope_id, ) now = time.time() @@ -534,7 +543,7 @@ async def _enqueue_terminal_if_registered( ) if registration_nonce is None: raise RuntimeError(f"Task {task_id!r} has incomplete webhook registration") - url, operation_id, token, signing_scope_id = ( + url, operation_id, token, authentication, signing_scope_id = ( self.task_webhook_outbox._open_registration_with_scope( account_id=account_id, task_id=task_id, @@ -553,6 +562,7 @@ async def _enqueue_terminal_if_registered( url=url, operation_id=operation_id, token=token, + authentication=authentication, signing_scope_id=signing_scope_id, ) # The encrypted outbox envelope now owns the callback registration. diff --git a/src/adcp/decisioning/pg/task_webhook_outbox.py b/src/adcp/decisioning/pg/task_webhook_outbox.py index f7f8be51f..bd5f4c74d 100644 --- a/src/adcp/decisioning/pg/task_webhook_outbox.py +++ b/src/adcp/decisioning/pg/task_webhook_outbox.py @@ -11,6 +11,7 @@ from __future__ import annotations import asyncio +import base64 import inspect import json import logging @@ -25,11 +26,13 @@ from cryptography.exceptions import InvalidTag from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from adcp.decisioning.task_registry import TaskWebhookAuthentication from adcp.signing.jwks import SSRFValidationError from adcp.webhook_sender import ( PreparedWebhook, ScopePermanentlyUnknown, ScopeTransientlyUnavailable, + TransportHook, WebhookDeliveryResult, WebhookSender, WebhookSenderResolution, @@ -58,6 +61,8 @@ MIN_RETRY_HORIZON_SECONDS = 86_400 MAX_RETRY_HORIZON_SECONDS = 604_800 MAX_SIGNING_SCOPE_ID_BYTES = 255 +_LEGACY_AUTH_SCHEMES = frozenset({"Bearer", "HMAC-SHA256"}) +_ENCRYPTED_DELIVERY_VERSION = 1 class PgTaskWebhookOutbox: @@ -82,6 +87,9 @@ def __init__( delivery_retry_horizon_seconds: int, retry: RetryPolicy | None = None, lease_seconds: int = 60, + legacy_hmac_fallback: bool = False, + legacy_allowed_destination_ports: frozenset[int] | None = None, + legacy_transport_hooks: tuple[TransportHook, ...] | None = None, table: str = DEFAULT_TABLE, ) -> None: if not PG_AVAILABLE: @@ -112,6 +120,8 @@ def __init__( ) if type(lease_seconds) is not int or lease_seconds <= 1: raise ValueError("lease_seconds must be an integer greater than 1") + if type(legacy_hmac_fallback) is not bool: + raise ValueError("legacy_hmac_fallback must be a bool") sender_timeout = float(getattr(sender, "_timeout", 0.0)) if sender is not None else 0.0 if sender is not None and lease_seconds < sender_timeout + 5: raise ValueError( @@ -139,6 +149,17 @@ def __init__( self.delivery_retry_horizon_seconds = delivery_retry_horizon_seconds self._retry = resolved_retry self._lease_seconds = lease_seconds + self.legacy_hmac_fallback = legacy_hmac_fallback + self._legacy_allowed_destination_ports = ( + legacy_allowed_destination_ports + if legacy_allowed_destination_ports is not None + else getattr(sender, "_allowed_destination_ports", None) + ) + self._legacy_transport_hooks = ( + legacy_transport_hooks + if legacy_transport_hooks is not None + else tuple(getattr(sender, "_transport_hooks", ())) + ) self._table = table self._worker_started = False @@ -267,12 +288,18 @@ async def enqueue_terminal( url: str, operation_id: str, token: str | None, + authentication: TaskWebhookAuthentication | None = None, signing_scope_id: str | None = None, ) -> int: """Insert a terminal webhook using the caller's open transaction.""" if status not in {"completed", "failed"}: raise ValueError(f"terminal webhook status must be completed or failed, got {status!r}") - self._validate_scope_for_mode(signing_scope_id, require_resolver_scope=False) + self._validate_authentication(authentication) + self._validate_scope_for_mode( + signing_scope_id, + authentication=authentication, + require_resolver_scope=False, + ) preparer = self._sender or WebhookSender prepared = preparer.prepare_mcp( url=url, @@ -295,7 +322,8 @@ async def enqueue_terminal( idempotency_key=prepared.idempotency_key, signing_scope_id=signing_scope_id, ) - encrypted_body = self._cipher.encrypt(nonce, prepared.body, aad) + protected_body = self._protect_delivery_body(prepared.body, authentication) + encrypted_body = self._cipher.encrypt(nonce, protected_body, aad) cursor = await conn.execute( self._sql_insert, ( @@ -317,9 +345,14 @@ async def enqueue_terminal( raise RuntimeError("task webhook outbox insert returned no id") return int(row[0]) - def validate_registration(self, url: str) -> None: + def validate_registration( + self, + url: str, + authentication: TaskWebhookAuthentication | None = None, + ) -> None: """Validate callback syntax before a task is accepted as Submitted.""" self._validate_callback_url(url) + self._validate_authentication(authentication) def protect_registration( self, @@ -330,17 +363,31 @@ def protect_registration( url: str, operation_id: str, token: str | None, + authentication: TaskWebhookAuthentication | None = None, signing_scope_id: str | None = None, ) -> tuple[bytes, bytes]: """Encrypt and authenticate callback registration at task issue time.""" self._validate_callback_url(url) - self._validate_scope_for_mode(signing_scope_id, require_resolver_scope=True) + self._validate_authentication(authentication) + self._validate_scope_for_mode( + signing_scope_id, + authentication=authentication, + require_resolver_scope=True, + ) nonce = os.urandom(12) plaintext = json.dumps( { "url": url, "operation_id": operation_id, "token": token, + "authentication": ( + { + "scheme": authentication.scheme, + "credentials": authentication.credentials, + } + if authentication is not None + else None + ), "signing_scope_id": signing_scope_id, }, ensure_ascii=False, @@ -373,12 +420,14 @@ def open_registration( The three-item return shape is retained for compatibility. Durable registry dispatch uses the private scope-aware decoder below. """ - url, operation_id, token, _signing_scope_id = self._open_registration_with_scope( - account_id=account_id, - task_id=task_id, - task_type=task_type, - encrypted_registration=encrypted_registration, - nonce=nonce, + url, operation_id, token, _authentication, _signing_scope_id = ( + self._open_registration_with_scope( + account_id=account_id, + task_id=task_id, + task_type=task_type, + encrypted_registration=encrypted_registration, + nonce=nonce, + ) ) return url, operation_id, token @@ -390,7 +439,13 @@ def _open_registration_with_scope( task_type: str, encrypted_registration: bytes, nonce: bytes, - ) -> tuple[str, str, str | None, str | None]: + ) -> tuple[ + str, + str, + str | None, + TaskWebhookAuthentication | None, + str | None, + ]: """Verify and decrypt callback registration with its trusted scope.""" try: plaintext = self._cipher.decrypt( @@ -410,6 +465,7 @@ def _open_registration_with_scope( url = value.get("url") operation_id = value.get("operation_id") token = value.get("token") + authentication_value = value.get("authentication") signing_scope_id = value.get("signing_scope_id") if not isinstance(url, str) or not isinstance(operation_id, str): raise ValueError("task webhook registration has invalid URL or operation_id") @@ -417,9 +473,29 @@ def _open_registration_with_scope( raise ValueError("task webhook registration token must be a string or null") if signing_scope_id is not None and not isinstance(signing_scope_id, str): raise ValueError("task webhook registration signing scope must be a string or null") + authentication: TaskWebhookAuthentication | None = None + if authentication_value is not None: + if not isinstance(authentication_value, dict): + raise ValueError("task webhook registration authentication must be an object") + scheme = authentication_value.get("scheme") + credentials = authentication_value.get("credentials") + if not isinstance(scheme, str) or not isinstance(credentials, str): + raise ValueError("task webhook registration authentication is invalid") + try: + authentication = TaskWebhookAuthentication( + scheme=scheme, + credentials=credentials, + ) + except (TypeError, ValueError) as exc: + raise ValueError("task webhook registration authentication is invalid") from exc self._validate_callback_url(url) - self._validate_scope_for_mode(signing_scope_id, require_resolver_scope=False) - return url, operation_id, token, signing_scope_id + self._validate_authentication(authentication) + self._validate_scope_for_mode( + signing_scope_id, + authentication=authentication, + require_resolver_scope=False, + ) + return url, operation_id, token, authentication, signing_scope_id async def run_worker( self, @@ -491,7 +567,8 @@ async def process_one(self) -> bool: signing_scope_id=(str(signing_scope_id) if signing_scope_id is not None else None), ) try: - body_bytes = self._cipher.decrypt(bytes(nonce), bytes(encrypted_body), aad) + protected_body = self._cipher.decrypt(bytes(nonce), bytes(encrypted_body), aad) + body_bytes, authentication = self._open_delivery_body(protected_body) self._validate_stored_body( body_bytes, task_id=str(task_id), @@ -527,6 +604,7 @@ async def process_one(self) -> bool: self._deliver_prepared( prepared, str(signing_scope_id) if signing_scope_id is not None else None, + authentication, ), timeout=self._lease_seconds - 1, ) @@ -571,9 +649,9 @@ async def process_one(self) -> bool: return True if delivery is not None and not self._is_retryable_http_status(delivery.status_code): - error_message = ( - f"permanent HTTP {delivery.status_code}: {delivery.response_body[:200]!r}" - ) + # Receiver-controlled bodies can echo Authorization/signature + # material. Never persist them in the plaintext last_error column. + error_message = f"permanent HTTP {delivery.status_code}" async with self._pool.connection() as conn: await conn.execute( self._sql_quarantine, @@ -588,9 +666,9 @@ async def process_one(self) -> bool: delay = self._retry_delay(int(attempt_count)) http_status = delivery.status_code if delivery is not None else None if delivery is not None: - error_message = f"HTTP {delivery.status_code}: {delivery.response_body[:200]!r}" + error_message = f"HTTP {delivery.status_code}" elif error is not None: - error_message = f"{type(error).__name__}: {error}" + error_message = f"{type(error).__name__}: delivery failed" else: error_message = "delivery failed without a result" async with self._pool.connection() as conn: @@ -613,7 +691,10 @@ async def _quarantine_permanent_delivery_error( task_id: str, error: BaseException, ) -> None: - error_message = f"permanent delivery validation failure: {type(error).__name__}: {error}" + # Validation errors can originate in adopter-provided transport hooks, + # whose messages may contain request credentials. Persist only the + # local exception discriminator in the plaintext last_error column. + error_message = f"permanent delivery validation failure: {type(error).__name__}" async with self._pool.connection() as conn: await conn.execute( self._sql_quarantine, @@ -675,9 +756,16 @@ def _validate_scope_for_mode( self, signing_scope_id: str | None, *, + authentication: TaskWebhookAuthentication | None, require_resolver_scope: bool, ) -> None: self._validate_signing_scope_id(signing_scope_id) + if authentication is not None: + if signing_scope_id is not None: + raise ValueError( + "legacy webhook authentication must not carry an RFC 9421 signing scope" + ) + return if self._sender is not None and signing_scope_id is not None: raise ValueError("fixed-sender outboxes must not carry a signing_scope_id") if ( @@ -727,10 +815,117 @@ async def _deliver_prepared( self, prepared: PreparedWebhook, signing_scope_id: str | None, + authentication: TaskWebhookAuthentication | None = None, ) -> WebhookDeliveryResult: - """Resolve, validate, and send within the caller's single lease budget.""" - sender = await self._resolve_delivery_sender(signing_scope_id) - return await sender.send_prepared(prepared) + """Select the registered mode and send within one lease budget.""" + if authentication is None: + sender = await self._resolve_delivery_sender(signing_scope_id) + return await sender.send_prepared(prepared) + if signing_scope_id is not None: + raise ValueError("legacy webhook authentication cannot use an RFC 9421 scope") + sender = self._legacy_sender(authentication) + try: + return await sender.send_prepared(prepared) + finally: + await sender.aclose() + + def _legacy_sender(self, authentication: TaskWebhookAuthentication) -> WebhookSender: + """Build an SDK-owned, IP-pinned sender for encrypted legacy credentials.""" + self._validate_authentication(authentication) + # Keep the HTTP attempt inside the outbox lease even for very short + # adopter-configured leases. Private destinations remain disabled. + timeout_seconds = max(0.1, min(10.0, self._lease_seconds - 5.0)) + common: dict[str, Any] = { + "timeout_seconds": timeout_seconds, + "allow_private_destinations": False, + "allowed_destination_ports": self._legacy_allowed_destination_ports, + "transport_hooks": self._legacy_transport_hooks, + } + if authentication.scheme == "Bearer": + return WebhookSender.from_bearer_token(authentication.credentials, **common) + return WebhookSender.from_adcp_legacy_hmac( + authentication.credentials.encode("utf-8"), + key_id="adcp-task-registration", + **common, + ) + + def _validate_authentication( + self, + authentication: TaskWebhookAuthentication | None, + ) -> None: + if authentication is None: + return + if not isinstance(authentication, TaskWebhookAuthentication): + raise ValueError("webhook authentication must be TaskWebhookAuthentication or None") + if authentication.scheme not in _LEGACY_AUTH_SCHEMES: + raise ValueError( + f"unsupported task webhook authentication scheme {authentication.scheme!r}; " + "supported legacy schemes are 'Bearer' and 'HMAC-SHA256'" + ) + if authentication.scheme == "HMAC-SHA256" and not self.legacy_hmac_fallback: + raise ValueError( + "task webhook HMAC-SHA256 authentication requires " + "legacy_hmac_fallback=True and a matching capability advertisement" + ) + if any(char in authentication.credentials for char in ("\r", "\n", "\x00")): + raise ValueError("webhook authentication credentials contain a control character") + + @staticmethod + def _protect_delivery_body( + body: bytes, + authentication: TaskWebhookAuthentication | None, + ) -> bytes: + """Keep old RFC rows byte-compatible; wrap encrypted legacy secrets.""" + if authentication is None: + return body + return json.dumps( + { + "task_webhook_delivery_version": _ENCRYPTED_DELIVERY_VERSION, + "body": base64.b64encode(body).decode("ascii"), + "authentication": { + "scheme": authentication.scheme, + "credentials": authentication.credentials, + }, + }, + ensure_ascii=False, + separators=(",", ":"), + ).encode("utf-8") + + def _open_delivery_body( + self, + protected_body: bytes, + ) -> tuple[bytes, TaskWebhookAuthentication | None]: + """Decode a legacy-auth envelope or accept a pre-feature RFC body.""" + try: + value = json.loads(protected_body) + except (json.JSONDecodeError, UnicodeDecodeError): + return protected_body, None + if not isinstance(value, dict) or "task_webhook_delivery_version" not in value: + return protected_body, None + if value.get("task_webhook_delivery_version") != _ENCRYPTED_DELIVERY_VERSION: + raise ValueError("unsupported encrypted task webhook delivery version") + authentication_value = value.get("authentication") + if not isinstance(authentication_value, dict): + raise ValueError("encrypted task webhook authentication is missing") + scheme = authentication_value.get("scheme") + credentials = authentication_value.get("credentials") + encoded_body = value.get("body") + if ( + not isinstance(scheme, str) + or not isinstance(credentials, str) + or not isinstance(encoded_body, str) + ): + raise ValueError("encrypted task webhook delivery envelope is invalid") + try: + authentication = TaskWebhookAuthentication( + scheme=scheme, + credentials=credentials, + ) + body = base64.b64decode(encoded_body, validate=True) + except (TypeError, ValueError) as exc: + raise ValueError("encrypted task webhook delivery envelope is invalid") from exc + self._validate_authentication(authentication) + return body, authentication @staticmethod def _envelope_aad( diff --git a/src/adcp/decisioning/task_registry.py b/src/adcp/decisioning/task_registry.py index 6ff677ffc..c4479b259 100644 --- a/src/adcp/decisioning/task_registry.py +++ b/src/adcp/decisioning/task_registry.py @@ -51,6 +51,27 @@ TaskState = Literal["submitted", "working", "completed", "failed"] +@dataclass(frozen=True) +class TaskWebhookAuthentication: + """Buyer-selected authentication for a durable task webhook. + + ``scheme`` deliberately remains a string instead of a closed ``Literal``: + custom registries can persist future protocol schemes without waiting for + a new SDK type. SDK-owned publishers validate the schemes they support + before issuing a task. Credentials are excluded from ``repr`` so routine + diagnostics cannot disclose the buyer's bearer token or HMAC secret. + """ + + scheme: str + credentials: str = field(repr=False) + + def __post_init__(self) -> None: + if not isinstance(self.scheme, str) or not self.scheme: + raise ValueError("webhook authentication scheme must be a non-empty string") + if not isinstance(self.credentials, str) or not self.credentials: + raise ValueError("webhook authentication credentials must be a non-empty string") + + @dataclass class TaskRecord: """The framework's per-task storage row. @@ -219,6 +240,7 @@ async def issue( webhook_url: str | None = None, webhook_operation_id: str | None = None, webhook_token: str | None = None, + webhook_authentication: TaskWebhookAuthentication | None = None, webhook_signing_scope_id: str | None = None, **_extra: Any, ) -> str: @@ -249,6 +271,10 @@ async def issue( echo verbatim in every task webhook. Required with ``webhook_url``. :param webhook_token: Optional buyer validation token to echo in the webhook payload. Treat as sensitive callback registration data. + :param webhook_authentication: Optional buyer-selected legacy + authentication mode and credentials. Presence selects that mode; + absence selects the RFC 9421 signing profile. Durable registries + must treat the credentials as secret registration data. :param webhook_signing_scope_id: Framework-derived opaque scope used by an SDK-managed tenant-aware webhook outbox. This value must originate from trusted server-side ``RequestContext`` metadata, @@ -621,4 +647,5 @@ async def _noop_heartbeat() -> None: "TaskRecord", "TaskRegistry", "TaskState", + "TaskWebhookAuthentication", ] diff --git a/src/adcp/decisioning/webhook_emit.py b/src/adcp/decisioning/webhook_emit.py index db18a8a0f..04b5c33bc 100644 --- a/src/adcp/decisioning/webhook_emit.py +++ b/src/adcp/decisioning/webhook_emit.py @@ -21,6 +21,7 @@ from adcp.decisioning.account_projection import ( strip_credentials_from_wire_result, ) +from adcp.decisioning.task_registry import TaskWebhookAuthentication if TYPE_CHECKING: from adcp.decisioning.platform import DecisioningCapabilities @@ -158,6 +159,42 @@ def _extract_push_operation_id(params: Any) -> str | None: return operation_id +def _extract_push_authentication(params: Any) -> TaskWebhookAuthentication | None: + """Extract the explicit legacy authentication selector from task push config. + + Presence is significant: it selects legacy Bearer or HMAC-SHA256 instead + of the default RFC 9421 profile. Both generated Pydantic requests and plain + dictionaries are accepted at this framework seam. + """ + 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 None + authentication = getattr(config, "authentication", None) + if authentication is None and isinstance(config, dict): + authentication = config.get("authentication") + if authentication is None: + return None + if not isinstance(authentication, dict) and not hasattr(authentication, "schemes"): + raise ValueError("push_notification_config.authentication must be an object") + schemes = getattr(authentication, "schemes", None) + credentials = getattr(authentication, "credentials", None) + if isinstance(authentication, dict): + schemes = authentication.get("schemes") + credentials = authentication.get("credentials") + if not isinstance(schemes, (list, tuple)) or len(schemes) != 1: + raise ValueError( + "push_notification_config.authentication.schemes must contain exactly one scheme" + ) + raw_scheme = getattr(schemes[0], "value", schemes[0]) + if not isinstance(raw_scheme, str): + raise ValueError("push_notification_config.authentication scheme must be a string") + if not isinstance(credentials, str): + raise ValueError("push_notification_config.authentication.credentials must be a string") + return TaskWebhookAuthentication(scheme=raw_scheme, credentials=credentials) + + def maybe_emit_sync_completion( *, sender: WebhookSender | None, @@ -413,6 +450,26 @@ def validate_webhook_signing_for_capabilities( ) 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( diff --git a/src/adcp/types/generated_poc/core/mcp_webhook_payload.py b/src/adcp/types/generated_poc/core/mcp_webhook_payload.py index 2c6ff0613..b57fdf0eb 100644 --- a/src/adcp/types/generated_poc/core/mcp_webhook_payload.py +++ b/src/adcp/types/generated_poc/core/mcp_webhook_payload.py @@ -39,7 +39,7 @@ class McpWebhookPayload(AdCPBaseModel): operation_id: Annotated[ str, Field( - description='Client-generated correlation identifier for the operation that produced this webhook. Buyers supply this value at webhook registration time via `push_notification_config.operation_id`; sellers MUST echo it verbatim in every webhook payload. Sellers MUST NOT derive `operation_id` by parsing `push_notification_config.url` — the URL is opaque to the seller. Receivers MAY dispatch endpoints by URL path or query string, but MUST correlate the operation using this payload field, not URL-derived values. See [Webhooks — Operation IDs and URL templates](/docs/building/by-layer/L3/webhooks#operation-ids-and-url-templates) for the full normative wire contract.' + description='Client-generated correlation identifier for the operation that produced this webhook. Buyers supply this value at webhook registration time via `push_notification_config.operation_id` or, for scheduled delivery reports, `reporting_webhook.operation_id`; sellers MUST echo it verbatim in every webhook payload. Sellers MUST NOT derive `operation_id` by parsing either registration URL — URLs are opaque to the seller. Receivers MAY dispatch endpoints by URL path or query string, but MUST correlate the operation using this payload field, not URL-derived values. See [Webhooks — Operation IDs and URL templates](/docs/building/by-layer/L3/webhooks#operation-ids-and-url-templates) for the full normative wire contract.' ), ] task_id: Annotated[ diff --git a/src/adcp/types/generated_poc/core/reporting_webhook.py b/src/adcp/types/generated_poc/core/reporting_webhook.py index 15dddfd04..4f48f15b0 100644 --- a/src/adcp/types/generated_poc/core/reporting_webhook.py +++ b/src/adcp/types/generated_poc/core/reporting_webhook.py @@ -45,6 +45,15 @@ class ReportingWebhook(AdCPBaseModel): extra='allow', ) url: Annotated[AnyUrl, Field(description='Webhook endpoint URL for reporting notifications')] + operation_id: Annotated[ + str, + Field( + description='Buyer-supplied correlation identifier for scheduled reporting webhooks. The seller MUST echo this value verbatim into every McpWebhookPayload operation_id field for this registration and MUST NOT derive or fabricate it from media_buy_id or the callback URL. Buyers SHOULD generate a stable unique value for each reporting registration.', + max_length=255, + min_length=1, + pattern='^[A-Za-z0-9_.:-]{1,255}$', + ), + ] token: Annotated[ str | None, Field( diff --git a/src/adcp/types/v32.pyi b/src/adcp/types/v32.pyi index 965310465..ccd36456f 100644 --- a/src/adcp/types/v32.pyi +++ b/src/adcp/types/v32.pyi @@ -41,6 +41,7 @@ class _ExternalCorePushNotificationConfig(TypedDict, total=False): class _ExternalCoreReportingWebhook(TypedDict, total=False): url: Required[builtins.str] + operation_id: Required[builtins.str] token: NotRequired[builtins.str] authentication: Required[_ExternalCoreReportingWebhookAuthentication] reporting_frequency: Required[Literal['hourly', 'daily', 'monthly']] diff --git a/src/adcp/webhooks.py b/src/adcp/webhooks.py index bc932d881..ce7fa2a45 100644 --- a/src/adcp/webhooks.py +++ b/src/adcp/webhooks.py @@ -152,10 +152,11 @@ def create_mcp_webhook_payload( Plain dicts are validated against :class:`AdcpAsyncResponseData`'s discriminated union. timestamp: When the webhook was generated. Defaults to current UTC. - operation_id: Client-generated identifier supplied through - ``push_notification_config.operation_id``. Required for every - task webhook; publishers echo it verbatim and MUST NOT derive it - from the receiver URL. + operation_id: Client-generated identifier supplied through the + registration's ``push_notification_config.operation_id`` or + ``reporting_webhook.operation_id``. Required for every task + webhook; publishers echo it verbatim and MUST NOT derive it from + the receiver URL. notification_id: Stable identity for one logical notification. Terminal task webhooks default to ``"{task_id}.terminal"`` so re-emissions under different delivery keys still converge. @@ -218,8 +219,8 @@ def create_mcp_webhook_payload( idempotency_key = generate_webhook_idempotency_key() if not operation_id: raise ValueError( - "operation_id is required for AdCP task webhooks; copy " - "push_notification_config.operation_id verbatim" + "operation_id is required for AdCP task webhooks; copy the buyer-supplied " + "push_notification_config.operation_id or reporting_webhook.operation_id verbatim" ) status_value = status.value if hasattr(status, "value") else str(status) diff --git a/tests/test_decisioning_dispatch.py b/tests/test_decisioning_dispatch.py index d184569f7..1ab128725 100644 --- a/tests/test_decisioning_dispatch.py +++ b/tests/test_decisioning_dispatch.py @@ -1155,6 +1155,62 @@ async def issue(self, **kwargs): assert registry.issue_kwargs["webhook_signing_scope_id"] != request.context["signing_scope_id"] +@pytest.mark.asyncio +async def test_handoff_threads_legacy_authentication_without_resolving_rfc_scope( + executor: ThreadPoolExecutor, +) -> None: + class _PushConfig(BaseModel): + url: str + operation_id: str + authentication: dict[str, Any] + + class _PushRequest(BaseModel): + push_notification_config: _PushConfig + + class _Registry(InMemoryTaskRegistry): + task_webhook_outbox = object() + + def __init__(self) -> None: + super().__init__() + self.issue_kwargs: dict[str, Any] = {} + + async def resolve_webhook_signing_scope(self, _context): + raise AssertionError("legacy authentication must not resolve an RFC 9421 scope") + + async def issue(self, **kwargs): + self.issue_kwargs = dict(kwargs) + return await super().issue(**kwargs) + + registry = _Registry() + ctx = _build_request_context(ToolContext(), Account(id="buyer-account"), None) + request = _PushRequest( + push_notification_config=_PushConfig( + url="https://buyer.example/webhook", + operation_id="op-legacy", + authentication={ + "schemes": ["Bearer"], + "credentials": "bearer-secret-" * 3, + }, + ) + ) + + await _project_handoff( + TaskHandoff(lambda _task_ctx: {"ok": True}), + ctx, + method_name="create_media_buy", + registry=registry, + executor=executor, + request_params=request, + webhook_auto_emit=False, + webhook_external_owner_ready=True, + ) + + authentication = registry.issue_kwargs["webhook_authentication"] + assert authentication.scheme == "Bearer" + assert authentication.credentials == "bearer-secret-" * 3 + assert registry.issue_kwargs["webhook_signing_scope_id"] is None + + @pytest.mark.asyncio async def test_handoff_async_fn_completes_via_registry( executor: ThreadPoolExecutor, diff --git a/tests/test_reporting_webhook_operation_id.py b/tests/test_reporting_webhook_operation_id.py new file mode 100644 index 000000000..bb3b0c8ab --- /dev/null +++ b/tests/test_reporting_webhook_operation_id.py @@ -0,0 +1,68 @@ +"""Reporting webhooks carry buyer-supplied MCP envelope correlation.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from adcp.types import McpWebhookPayload, ReportingWebhook +from adcp.types.v32 import AcceptProposalRequest + +_AUTHENTICATION = { + "schemes": ["Bearer"], + "credentials": "buyer-reporting-token-1234567890", +} + +_REPOSITORY_ROOT = Path(__file__).resolve().parents[1] + + +def test_reporting_webhook_requires_operation_id() -> None: + with pytest.raises(ValidationError, match="operation_id"): + ReportingWebhook( + url="https://buyer.example/reporting", + authentication=_AUTHENTICATION, + reporting_frequency="daily", + ) + + +def test_reporting_webhook_accepts_buyer_supplied_operation_id() -> None: + webhook = ReportingWebhook( + url="https://buyer.example/reporting", + operation_id="reporting.mb_123.v1", + authentication=_AUTHENTICATION, + reporting_frequency="daily", + ) + + assert webhook.operation_id == "reporting.mb_123.v1" + + +def test_versioned_request_schema_requires_reporting_operation_id() -> None: + with pytest.raises(ValidationError, match="operation_id.*required property"): + AcceptProposalRequest( + adcp_version="3.2-beta.6", + idempotency_key="accept-request-1234", + account={ + "brand": {"domain": "example.com"}, + "operator": "agency.example", + }, + proposal_id="proposal-1", + proposal_terms_digest="sha256:" + "x" * 43, + reporting_webhook={ + "url": "https://buyer.example/reporting", + "authentication": _AUTHENTICATION, + "reporting_frequency": "daily", + }, + ) + + +def test_webhook_payload_schema_documents_both_operation_id_sources() -> None: + schema_path = _REPOSITORY_ROOT / "schemas/cache/3.2.0-beta.6/core/mcp-webhook-payload.json" + schema = json.loads(schema_path.read_text()) + description = schema["properties"]["operation_id"]["description"] + + assert "push_notification_config.operation_id" in description + assert "reporting_webhook.operation_id" in description + assert McpWebhookPayload.model_fields["operation_id"].description == description diff --git a/tests/test_task_webhook_outbox_pg.py b/tests/test_task_webhook_outbox_pg.py index 2503eca95..2600f7a03 100644 --- a/tests/test_task_webhook_outbox_pg.py +++ b/tests/test_task_webhook_outbox_pg.py @@ -10,11 +10,13 @@ import pytest +from adcp.decisioning.task_registry import TaskWebhookAuthentication from adcp.webhook_sender import ( PreparedWebhook, ScopePermanentlyUnknown, ScopeTransientlyUnavailable, WebhookDeliveryResult, + WebhookSender, WebhookSenderResolution, ) @@ -52,6 +54,8 @@ def _sender() -> MagicMock: sender.signs_with_rfc9421 = True sender._owns_client = True sender._allow_private_destinations = False + sender._allowed_destination_ports = None + sender._transport_hooks = () sender._timeout = 10.0 sender._auth.alg = "ed25519" body = json.dumps( @@ -273,7 +277,7 @@ def test_scoped_registration_is_encrypted_and_mode_bound() -> None: task_type="create_media_buy", encrypted_registration=encrypted, nonce=nonce, - ) == ("https://buyer.example/webhook", "op_1", None, "tenant-key-scope-a") + ) == ("https://buyer.example/webhook", "op_1", None, None, "tenant-key-scope-a") assert outbox.open_registration( account_id="acct_1", task_id="task_1", @@ -352,6 +356,130 @@ def test_registration_is_encrypted_and_bound_at_issue_time() -> None: ) +@pytest.mark.parametrize( + "authentication", + [ + None, + TaskWebhookAuthentication("Bearer", "bearer-secret-" * 3), + TaskWebhookAuthentication("HMAC-SHA256", "hmac-secret-" * 3), + ], +) +def test_registration_round_trips_every_authentication_mode(authentication) -> None: + outbox = _outbox( + MagicMock(), + _sender(), + legacy_hmac_fallback=( + authentication is not None and authentication.scheme == "HMAC-SHA256" + ), + ) + encrypted, nonce = outbox.protect_registration( + account_id="acct_1", + task_id="task_1", + task_type="create_media_buy", + url="https://buyer.example/webhook", + operation_id="op_1", + token="buyer-token", + authentication=authentication, + ) + assert authentication is None or authentication.credentials.encode() not in encrypted + assert outbox._open_registration_with_scope( + account_id="acct_1", + task_id="task_1", + task_type="create_media_buy", + encrypted_registration=encrypted, + nonce=nonce, + ) == ( + "https://buyer.example/webhook", + "op_1", + "buyer-token", + authentication, + None, + ) + + +def test_task_webhook_authentication_repr_redacts_credentials() -> None: + authentication = TaskWebhookAuthentication("Bearer", "secret-never-log") + + assert "secret-never-log" not in repr(authentication) + assert "Bearer" in repr(authentication) + + +def test_pre_authentication_registration_remains_readable() -> None: + outbox = _outbox(MagicMock(), _sender()) + nonce = b"o" * 12 + plaintext = json.dumps( + { + "url": "https://buyer.example/webhook", + "operation_id": "op_legacy", + "token": "buyer-token", + }, + separators=(",", ":"), + ).encode() + encrypted = outbox._cipher.encrypt( + nonce, + plaintext, + outbox._registration_aad( + account_id="acct_1", + task_id="task_legacy", + task_type="create_media_buy", + ), + ) + + assert outbox._open_registration_with_scope( + account_id="acct_1", + task_id="task_legacy", + task_type="create_media_buy", + encrypted_registration=encrypted, + nonce=nonce, + ) == ( + "https://buyer.example/webhook", + "op_legacy", + "buyer-token", + None, + None, + ) + + +@pytest.mark.asyncio +async def test_registration_rejects_unknown_authentication_before_task_issue() -> None: + from adcp.decisioning.pg.task_registry import PgTaskRegistry + + pool = MagicMock() + outbox = _outbox(pool, _sender()) + with patch("adcp.decisioning.pg.task_registry.PG_AVAILABLE", True): + registry = PgTaskRegistry(pool=pool, task_webhook_outbox=outbox) + + with pytest.raises(ValueError, match="unsupported task webhook authentication"): + await registry.issue( + account_id="acct_1", + task_type="create_media_buy", + webhook_url="https://buyer.example/webhook", + webhook_operation_id="op_1", + webhook_authentication=TaskWebhookAuthentication("Digest", "secret"), + ) + pool.connection.assert_not_called() + + +@pytest.mark.asyncio +async def test_registration_rejects_unadvertised_hmac_before_task_issue() -> None: + from adcp.decisioning.pg.task_registry import PgTaskRegistry + + pool = MagicMock() + outbox = _outbox(pool, _sender()) + with patch("adcp.decisioning.pg.task_registry.PG_AVAILABLE", True): + registry = PgTaskRegistry(pool=pool, task_webhook_outbox=outbox) + + with pytest.raises(ValueError, match="legacy_hmac_fallback=True"): + await registry.issue( + account_id="acct_1", + task_type="create_media_buy", + webhook_url="https://buyer.example/webhook", + webhook_operation_id="op_1", + webhook_authentication=TaskWebhookAuthentication("HMAC-SHA256", "hmac-secret-" * 3), + ) + pool.connection.assert_not_called() + + @pytest.mark.asyncio async def test_enqueue_persists_prepared_bytes_and_horizon_on_callers_connection() -> None: conn = _connection((41,)) @@ -390,6 +518,125 @@ async def test_enqueue_persists_prepared_bytes_and_horizon_on_callers_connection assert params[-1] == 86_400 +@pytest.mark.asyncio +@pytest.mark.parametrize("status", ["completed", "failed"]) +@pytest.mark.parametrize( + "authentication", + [ + TaskWebhookAuthentication("Bearer", "bearer-secret-" * 3), + TaskWebhookAuthentication("HMAC-SHA256", "hmac-secret-" * 3), + ], +) +async def test_enqueue_encrypts_legacy_credentials_for_success_and_failure( + status: str, + authentication: TaskWebhookAuthentication, +) -> None: + conn = _connection((41,)) + outbox = _outbox( + MagicMock(), + _sender(), + legacy_hmac_fallback=authentication.scheme == "HMAC-SHA256", + ) + + await outbox.enqueue_terminal( + conn, + task_id="task_1", + account_id="acct_1", + task_type="create_media_buy", + status=status, + result={"errors": []} if status == "failed" else {"media_buy_id": "mb_1"}, + url="https://buyer.example/webhook", + operation_id="op_1", + token=None, + authentication=authentication, + ) + + params = conn.execute.await_args.args[1] + encrypted_body, nonce = params[8], params[9] + assert authentication.credentials.encode() not in encrypted_body + protected = outbox._cipher.decrypt( + nonce, + encrypted_body, + outbox._envelope_aad( + account_id="acct_1", + task_id="task_1", + task_type="create_media_buy", + status=status, + url="https://buyer.example/webhook", + operation_id="op_1", + idempotency_key="whk_1234567890123456", + ), + ) + body, opened_authentication = outbox._open_delivery_body(protected) + assert opened_authentication == authentication + assert body == outbox._sender.prepare_mcp.return_value.body + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("authentication", "factory_name"), + [ + (TaskWebhookAuthentication("Bearer", "bearer-secret-" * 3), "from_bearer_token"), + ( + TaskWebhookAuthentication("HMAC-SHA256", "hmac-secret-" * 3), + "from_adcp_legacy_hmac", + ), + ], +) +async def test_delivery_selects_explicit_legacy_mode(authentication, factory_name) -> None: + rfc_sender = _sender() + legacy_sender = _sender() + legacy_sender.aclose = AsyncMock() + outbox = _outbox( + MagicMock(), + rfc_sender, + legacy_hmac_fallback=authentication.scheme == "HMAC-SHA256", + ) + prepared = rfc_sender.prepare_mcp.return_value + + with patch.object( + WebhookSender, + factory_name, + return_value=legacy_sender, + ) as factory: + await outbox._deliver_prepared(prepared, None, authentication) + + factory.assert_called_once() + legacy_sender.send_prepared.assert_awaited_once_with(prepared) + legacy_sender.aclose.assert_awaited_once() + rfc_sender.send_prepared.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_resolver_legacy_delivery_preserves_outbox_transport_policy() -> None: + legacy_sender = _sender() + legacy_sender.aclose = AsyncMock() + hook = MagicMock() + outbox = _resolver_outbox( + MagicMock(), + MagicMock(resolve=AsyncMock()), + legacy_allowed_destination_ports=frozenset({443, 9443}), + legacy_transport_hooks=(hook,), + ) + authentication = TaskWebhookAuthentication("Bearer", "bearer-secret-" * 3) + prepared = legacy_sender.prepare_mcp.return_value + + with patch.object( + WebhookSender, + "from_bearer_token", + return_value=legacy_sender, + ) as factory: + await outbox._deliver_prepared(prepared, None, authentication) + + factory.assert_called_once_with( + authentication.credentials, + timeout_seconds=10.0, + allow_private_destinations=False, + allowed_destination_ports=frozenset({443, 9443}), + transport_hooks=(hook,), + ) + + @pytest.mark.asyncio async def test_worker_recovers_after_transient_iteration_failure() -> None: outbox = _outbox(MagicMock(), _sender()) @@ -620,6 +867,24 @@ async def test_scope_resolution_errors_retry_or_quarantine( assert expected_sql in settle_conn.execute.await_args.args[0] +@pytest.mark.asyncio +async def test_permanent_delivery_error_does_not_persist_exception_message() -> None: + quarantine_conn = _connection(None) + outbox = _outbox(_pool(quarantine_conn), _sender()) + + await outbox._quarantine_permanent_delivery_error( + row_id=7, + lease_token="lease-token", + task_id="task_1", + error=ValueError("bearer-secret-must-not-be-persisted"), + ) + + sql, params = quarantine_conn.execute.await_args.args + assert "state = 'invalid'" in sql + assert params[0] == "permanent delivery validation failure: ValueError" + assert "bearer-secret" not in params[0] + + @pytest.mark.asyncio async def test_sender_resolution_is_bounded_by_the_delivery_lease() -> None: async def never_resolves(_scope: str) -> Any: @@ -724,7 +989,7 @@ async def test_worker_releases_failed_delivery_for_horizon_retry() -> None: idempotency_key="whk_1234567890123456", url="https://buyer.example/webhook", response_headers={}, - response_body=b"try later", + response_body=b"bearer-secret-must-not-be-persisted", sent_body=sender.prepare_mcp.return_value.body, ) outbox = _outbox(MagicMock(), sender) @@ -767,6 +1032,8 @@ async def test_worker_releases_failed_delivery_for_horizon_retry() -> None: sql, params = release_conn.execute.await_args.args assert "state = CASE" in sql assert params[1] == 503 + assert params[2] == "HTTP 503" + assert "bearer-secret" not in params[2] assert params[3] == 7 @@ -825,6 +1092,7 @@ async def test_registry_completion_enqueues_on_same_transaction_connection() -> "op_1", "buyer-token", None, + None, ) ) pool = _pool(conn) @@ -847,6 +1115,7 @@ async def test_registry_completion_enqueues_on_same_transaction_connection() -> url="https://buyer.example/webhook", operation_id="op_1", token="buyer-token", + authentication=None, signing_scope_id=None, ) outbox._open_registration_with_scope.assert_called_once_with( @@ -882,6 +1151,7 @@ async def test_registry_failure_enqueues_on_same_explicit_transaction() -> None: "op_1", None, None, + None, ) ) pool = _pool(conn) @@ -902,6 +1172,7 @@ async def test_registry_failure_enqueues_on_same_explicit_transaction() -> None: url="https://buyer.example/webhook", operation_id="op_1", token=None, + authentication=None, signing_scope_id=None, ) conn.transaction.assert_called_once_with() diff --git a/tests/test_webhook_signing_capabilities.py b/tests/test_webhook_signing_capabilities.py index 75ed68f59..102f387da 100644 --- a/tests/test_webhook_signing_capabilities.py +++ b/tests/test_webhook_signing_capabilities.py @@ -205,7 +205,12 @@ def __init__(self, outbox: _AtomicOutbox) -> None: self.task_webhook_outbox = outbox -def _sdk_registry_with_outbox(sender: WebhookSender, horizon: int = 86400): +def _sdk_registry_with_outbox( + sender: WebhookSender, + horizon: int = 86400, + *, + legacy_hmac_fallback: bool = False, +): from adcp.decisioning.pg.task_registry import PgTaskRegistry from adcp.decisioning.pg.task_webhook_outbox import PgTaskWebhookOutbox @@ -219,6 +224,7 @@ def _sdk_registry_with_outbox(sender: WebhookSender, horizon: int = 86400): sender=sender, encryption_key=b"e" * 32, delivery_retry_horizon_seconds=horizon, + legacy_hmac_fallback=legacy_hmac_fallback, ) return PgTaskRegistry(pool=pool, task_webhook_outbox=outbox) @@ -434,6 +440,53 @@ def test_boot_accepts_registry_backed_atomic_outbox() -> None: ) +def test_boot_accepts_advertised_and_enabled_legacy_hmac_fallback() -> None: + sender = WebhookSender.from_jwk(_jwk_with_private()) + validate_webhook_signing_for_capabilities( + capabilities=_Caps( + webhook_signing=WebhookSigning( + supported=True, + delivery_retry_horizon_seconds=86400, + algorithms=["ed25519"], + legacy_hmac_fallback=True, + ) + ), + sender=None, + supervisor=None, + registry=_sdk_registry_with_outbox(sender, legacy_hmac_fallback=True), + ) + + +@pytest.mark.parametrize( + ("advertised", "configured"), + [(False, True), (True, False)], +) +def test_boot_rejects_legacy_hmac_capability_mismatch( + advertised: bool, + configured: bool, +) -> None: + sender = WebhookSender.from_jwk(_jwk_with_private()) + with pytest.raises(AdcpError) as exc_info: + validate_webhook_signing_for_capabilities( + capabilities=_Caps( + webhook_signing=WebhookSigning( + supported=True, + delivery_retry_horizon_seconds=86400, + algorithms=["ed25519"], + legacy_hmac_fallback=advertised, + ) + ), + sender=None, + supervisor=None, + registry=_sdk_registry_with_outbox( + sender, + legacy_hmac_fallback=configured, + ), + ) + + assert exc_info.value.details["missing"] == "webhook_signing_legacy_hmac_alignment" + + def test_boot_accepts_registry_backed_tenant_sender_resolver() -> None: validate_webhook_signing_for_capabilities( capabilities=_Caps( diff --git a/tests/test_webhooks_deliver.py b/tests/test_webhooks_deliver.py index 8c211a88a..685f68bf1 100644 --- a/tests/test_webhooks_deliver.py +++ b/tests/test_webhooks_deliver.py @@ -88,6 +88,7 @@ async def test_bearer_auth_adds_authorization_header() -> None: client, captured = await _capture_client() config = ReportingWebhook( url="https://buyer.example/webhooks/report", + operation_id="reporting-op-1", authentication=RWAuth(schemes=["Bearer"], credentials=_BEARER_TOKEN), reporting_frequency=ReportingFrequency.daily, ) @@ -208,6 +209,7 @@ async def test_retry_produces_byte_identical_body() -> None: client, captured = await _capture_client() config = ReportingWebhook( url="https://buyer.example/webhooks/report", + operation_id="reporting-op-1", authentication=RWAuth(schemes=["Bearer"], credentials=_BEARER_TOKEN), reporting_frequency=ReportingFrequency.daily, ) @@ -314,6 +316,7 @@ async def test_extra_headers_merge_but_reserved_are_rejected() -> None: client, captured = await _capture_client() config = ReportingWebhook( url="https://buyer.example/webhooks/report", + operation_id="reporting-op-1", authentication=RWAuth(schemes=["Bearer"], credentials=_BEARER_TOKEN), reporting_frequency=ReportingFrequency.daily, ) @@ -360,6 +363,7 @@ async def test_timeout_seconds_with_client_raises() -> None: client, _ = await _capture_client() config = ReportingWebhook( url="https://buyer.example/webhooks/report", + operation_id="reporting-op-1", authentication=RWAuth(schemes=["Bearer"], credentials=_BEARER_TOKEN), reporting_frequency=ReportingFrequency.daily, ) @@ -481,6 +485,7 @@ async def test_deprecation_warning_fires_for_legacy_auth() -> None: client, _ = await _capture_client() config = ReportingWebhook( url="https://buyer.example/webhooks/report", + operation_id="reporting-op-1", authentication=RWAuth(schemes=["Bearer"], credentials=_BEARER_TOKEN), reporting_frequency=ReportingFrequency.daily, ) diff --git a/tests/type_checks/versioned_types.py b/tests/type_checks/versioned_types.py index 1cad9ffe3..6eb29adc1 100644 --- a/tests/type_checks/versioned_types.py +++ b/tests/type_checks/versioned_types.py @@ -113,6 +113,15 @@ account={}, proposal_id="proposal-1", proposal_terms_digest="sha256:terms", + reporting_webhook={ + "url": "https://buyer.example/reporting", + "operation_id": "reporting.accept-1", + "authentication": { + "schemes": ["Bearer"], + "credentials": "buyer-reporting-token-1234567890", + }, + "reporting_frequency": "daily", + }, ) assert accept_request32.adcp_version is not None assert_type(accept_request32.adcp_version, str)