From 10e716718687f1d99d2414bd5aa33e38906bf10f Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 27 Oct 2026 03:21:50 +0000 Subject: [PATCH] fix(schema): accept and declare the adcp_version echo on every task response (#8518) versioning.mdx requires the adcp_version echo on every response, a response that validates against the served release, and an echo shape that is stable within the major. Closed responses rejected the echo, and open status payloads left it undeclared. - Closed responses declare adcp_version as a $ref to core/version-envelope.json#/properties/adcp_version on every closed arm: control_media_buy, decline_proposals, get_media_buy_issue, report_media_buy_issue, list_collections, list_proposals, and media-buy-commitment-response (buy_products, accept_proposal). - Open async status payloads compose core/version-envelope.json via root allOf: core/compact-task-working, core/compact-task-input-required and the non-compact *-async-response-{submitted,working,input-required} schemas. - get_reporting_status failure arms reference the envelope instead of local { "type": "string" } stubs (validation unchanged). - list_products' echo drops x-added-in 3.3.0, because the echo is backported to 3.2.x. - tests/response-version-echo.test.cjs enumerates every tool's response and async status schemas from discoverTools and asserts that each accepts, declares, and identically shapes the echo. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/response-adcp-version-echo.md | 5 + package.json | 2 +- .../core/compact-task-input-required.json | 3 + .../source/core/compact-task-working.json | 3 + ...ive-features-async-response-submitted.json | 3 + ...eatives-async-response-input-required.json | 3 + ...nc-creatives-async-response-submitted.json | 3 + ...sync-creatives-async-response-working.json | 3 + ...reative-async-response-input-required.json | 3 + ...ild-creative-async-response-submitted.json | 3 + ...build-creative-async-response-working.json | 3 + .../media-buy/control-media-buy-response.json | 3 + ...dia-buy-async-response-input-required.json | 3 + ...te-media-buy-async-response-submitted.json | 3 + ...eate-media-buy-async-response-working.json | 3 + .../media-buy/decline-proposals-response.json | 1 + .../get-media-buy-issue-response.json | 2 + ...roducts-async-response-input-required.json | 3 + ...get-products-async-response-submitted.json | 3 + .../get-products-async-response-working.json | 3 + .../get-reporting-status-response.json | 8 +- .../media-buy/list-collections-response.json | 1 + .../media-buy/list-products-response.json | 2 +- .../media-buy/list-proposals-response.json | 1 + .../media-buy-commitment-response.json | 3 + .../report-media-buy-issue-response.json | 2 + ...atalogs-async-response-input-required.json | 3 + ...ync-catalogs-async-response-submitted.json | 3 + .../sync-catalogs-async-response-working.json | 3 + ...dia-buy-async-response-input-required.json | 3 + ...te-media-buy-async-response-submitted.json | 3 + ...date-media-buy-async-response-working.json | 3 + .../get-signals-async-response-submitted.json | 3 + .../get-signals-async-response-working.json | 3 + tests/response-version-echo.test.cjs | 180 ++++++++++++++++++ tests/x-added-in-3-3-sweep.test.cjs | 3 +- 36 files changed, 275 insertions(+), 7 deletions(-) create mode 100644 .changeset/response-adcp-version-echo.md create mode 100644 tests/response-version-echo.test.cjs diff --git a/.changeset/response-adcp-version-echo.md b/.changeset/response-adcp-version-echo.md new file mode 100644 index 00000000000..33a114624a2 --- /dev/null +++ b/.changeset/response-adcp-version-echo.md @@ -0,0 +1,5 @@ +--- +"adcontextprotocol": patch +--- + +Every task response schema now accepts and declares the `adcp_version` echo that `versioning.mdx` requires on every response (#8518). Closed responses that rejected it now declare `adcp_version` as a `$ref` to `core/version-envelope.json#/properties/adcp_version`: `control_media_buy`, `decline_proposals`, `get_media_buy_issue`, `report_media_buy_issue`, `list_collections`, `list_proposals`, and `media-buy-commitment-response.json`, which covers `buy_products` and `accept_proposal`. Open status payloads that tolerated it without declaring it now compose `core/version-envelope.json` via root `allOf`: `core/compact-task-working.json`, `core/compact-task-input-required.json`, and the non-compact `*-async-response-{submitted,working,input-required}` payloads. The `list_products` echo drops its `x-added-in: 3.3.0` marker because the echo is backported to 3.2.x. A conforming seller already emits the echo, so no seller becomes non-conformant; a malformed echo (for example `"3.3.0"`) now fails validation where these schemas declare it. `tests/response-version-echo.test.cjs` enumerates every tool's response and async status schemas from the tool registry and fails when one rejects the echo, leaves it undeclared, or declares it with a different shape. diff --git a/package.json b/package.json index 31236fa4c72..dc369b0c24b 100644 --- a/package.json +++ b/package.json @@ -121,7 +121,7 @@ "test:storyboard-doc-parity": "node --test --test-force-exit --test-timeout=30000 tests/lint-universal-storyboard-doc-parity.test.cjs", "test:v4-compatibility-registry": "node --test --test-force-exit --test-timeout=30000 tests/lint-v4-compatibility-registry.test.cjs", "test:pagination-invariant": "node --test --test-force-exit --test-timeout=30000 tests/lint-pagination-invariant.test.cjs", - "test:version-envelope": "node --test --test-force-exit --test-timeout=30000 tests/lint-version-envelope.test.cjs tests/compact-version-envelope.test.cjs", + "test:version-envelope": "node --test --test-force-exit --test-timeout=30000 tests/lint-version-envelope.test.cjs tests/compact-version-envelope.test.cjs tests/response-version-echo.test.cjs", "test:test-dynamic-imports": "node --test --test-force-exit --test-timeout=30000 tests/lint-test-dynamic-imports.test.cjs", "test:sdk-shims": "node --test --test-force-exit --test-timeout=30000 tests/lint-sdk-shims.test.cjs tests/sdk-runtime-compat.test.cjs", "test:tenant-routing": "vitest run --config server/vitest.config.ts server/src/training-agent/tenants/tenant-smoke.test.ts", diff --git a/static/schemas/source/core/compact-task-input-required.json b/static/schemas/source/core/compact-task-input-required.json index 2ca6eaa94f4..9fc8d764f7e 100644 --- a/static/schemas/source/core/compact-task-input-required.json +++ b/static/schemas/source/core/compact-task-input-required.json @@ -3,6 +3,9 @@ "$id": "/schemas/core/compact-task-input-required.json", "title": "Compact Task Input Required", "description": "Bounded payload for a compact lifecycle task paused for buyer input or approval.", + "allOf": [ + { "$ref": "/schemas/core/version-envelope.json" } + ], "type": "object", "properties": { "reason": { "type": "string", "minLength": 1, "maxLength": 200 }, diff --git a/static/schemas/source/core/compact-task-working.json b/static/schemas/source/core/compact-task-working.json index 515b1c68d54..4bef108bad4 100644 --- a/static/schemas/source/core/compact-task-working.json +++ b/static/schemas/source/core/compact-task-working.json @@ -3,6 +3,9 @@ "$id": "/schemas/core/compact-task-working.json", "title": "Compact Task Working", "description": "Bounded progress payload shared by the compact lifecycle tools.", + "allOf": [ + { "$ref": "/schemas/core/version-envelope.json" } + ], "type": "object", "properties": { "percentage": { "type": "number", "minimum": 0, "maximum": 100 }, diff --git a/static/schemas/source/creative/get-creative-features-async-response-submitted.json b/static/schemas/source/creative/get-creative-features-async-response-submitted.json index 91f7094f510..6910a4447c4 100644 --- a/static/schemas/source/creative/get-creative-features-async-response-submitted.json +++ b/static/schemas/source/creative/get-creative-features-async-response-submitted.json @@ -3,6 +3,9 @@ "$id": "/schemas/creative/get-creative-features-async-response-submitted.json", "title": "Get Creative Features - Submitted", "description": "Async acknowledgement returned after get_creative_features accepts a provider evaluation but cannot complete it within the response window. Exact keyed request replays return this original acknowledgement with replayed: true and the same task_id. In AdCP 3.x providers SHOULD also return evaluation_id; it becomes required in AdCP 4.0. When present, exact replays and terminal results preserve it. The caller polls get_task_status using task_id.", + "allOf": [ + { "$ref": "/schemas/core/version-envelope.json" } + ], "type": "object", "properties": { "status": { diff --git a/static/schemas/source/creative/sync-creatives-async-response-input-required.json b/static/schemas/source/creative/sync-creatives-async-response-input-required.json index 6524e68e676..cca30b128a1 100644 --- a/static/schemas/source/creative/sync-creatives-async-response-input-required.json +++ b/static/schemas/source/creative/sync-creatives-async-response-input-required.json @@ -3,6 +3,9 @@ "$id": "/schemas/creative/sync-creatives-async-response-input-required.json", "title": "Sync Creatives - Input Required", "description": "Payload when sync_creatives task is paused waiting for buyer clarification or approval.", + "allOf": [ + { "$ref": "/schemas/core/version-envelope.json" } + ], "type": "object", "properties": { "reason": { diff --git a/static/schemas/source/creative/sync-creatives-async-response-submitted.json b/static/schemas/source/creative/sync-creatives-async-response-submitted.json index e82fafe8e06..6b08e5d60fe 100644 --- a/static/schemas/source/creative/sync-creatives-async-response-submitted.json +++ b/static/schemas/source/creative/sync-creatives-async-response-submitted.json @@ -3,6 +3,9 @@ "$id": "/schemas/creative/sync-creatives-async-response-submitted.json", "title": "Sync Creatives - Submitted", "description": "Async task envelope returned when the whole sync_creatives operation cannot be confirmed before the response is emitted — for example, when the seller batches ingestion or when async review must settle before per-item results can be issued. The buyer invokes get_task_status with task_id or receives a webhook when the task completes; the creatives array lands in the terminal result, not this envelope.", + "allOf": [ + { "$ref": "/schemas/core/version-envelope.json" } + ], "type": "object", "properties": { "status": { diff --git a/static/schemas/source/creative/sync-creatives-async-response-working.json b/static/schemas/source/creative/sync-creatives-async-response-working.json index e47b7815f2e..8d625dd2f47 100644 --- a/static/schemas/source/creative/sync-creatives-async-response-working.json +++ b/static/schemas/source/creative/sync-creatives-async-response-working.json @@ -3,6 +3,9 @@ "$id": "/schemas/creative/sync-creatives-async-response-working.json", "title": "Sync Creatives - Working", "description": "Progress payload for active sync_creatives task.", + "allOf": [ + { "$ref": "/schemas/core/version-envelope.json" } + ], "type": "object", "properties": { "percentage": { diff --git a/static/schemas/source/media-buy/build-creative-async-response-input-required.json b/static/schemas/source/media-buy/build-creative-async-response-input-required.json index fc0ec121b68..484fd6f83d5 100644 --- a/static/schemas/source/media-buy/build-creative-async-response-input-required.json +++ b/static/schemas/source/media-buy/build-creative-async-response-input-required.json @@ -3,6 +3,9 @@ "$id": "/schemas/media-buy/build-creative-async-response-input-required.json", "title": "Build Creative - Input Required", "description": "Payload when build_creative is paused waiting for user input. May occur during generation when the agent needs creative direction, or when brand guidelines require approval.", + "allOf": [ + { "$ref": "/schemas/core/version-envelope.json" } + ], "type": "object", "properties": { "reason": { diff --git a/static/schemas/source/media-buy/build-creative-async-response-submitted.json b/static/schemas/source/media-buy/build-creative-async-response-submitted.json index c445cb81cf2..6f81f5ee421 100644 --- a/static/schemas/source/media-buy/build-creative-async-response-submitted.json +++ b/static/schemas/source/media-buy/build-creative-async-response-submitted.json @@ -3,6 +3,9 @@ "$id": "/schemas/media-buy/build-creative-async-response-submitted.json", "title": "Build Creative - Submitted", "description": "Async task envelope returned when build_creative cannot be confirmed before the response — for example, when a slow generative pipeline or multi-minute LLM workflow is queued. The buyer invokes get_task_status with task_id or receives a webhook when the task completes; the creative_manifest(s) land in the terminal result, not this envelope.", + "allOf": [ + { "$ref": "/schemas/core/version-envelope.json" } + ], "type": "object", "properties": { "status": { diff --git a/static/schemas/source/media-buy/build-creative-async-response-working.json b/static/schemas/source/media-buy/build-creative-async-response-working.json index 216bb7b6163..f78f22563d0 100644 --- a/static/schemas/source/media-buy/build-creative-async-response-working.json +++ b/static/schemas/source/media-buy/build-creative-async-response-working.json @@ -3,6 +3,9 @@ "$id": "/schemas/media-buy/build-creative-async-response-working.json", "title": "Build Creative - Working", "description": "Progress payload for active build_creative task. Returned during AI generation, format transformation, or complex library retrieval with macro resolution.", + "allOf": [ + { "$ref": "/schemas/core/version-envelope.json" } + ], "type": "object", "properties": { "percentage": { diff --git a/static/schemas/source/media-buy/control-media-buy-response.json b/static/schemas/source/media-buy/control-media-buy-response.json index 7d2ead620e4..9f7a89b8263 100644 --- a/static/schemas/source/media-buy/control-media-buy-response.json +++ b/static/schemas/source/media-buy/control-media-buy-response.json @@ -12,6 +12,7 @@ "title": "Control Applied", "type": "object", "properties": { + "adcp_version": { "$ref": "/schemas/core/version-envelope.json#/properties/adcp_version" }, "status": { "type": "string", "const": "completed" }, "media_buy_id": { "type": "string", "minLength": 1, "x-entity": "media_buy" }, "revision": { "type": "integer", "minimum": 1 }, @@ -54,6 +55,7 @@ "title": "Control Error", "type": "object", "properties": { + "adcp_version": { "$ref": "/schemas/core/version-envelope.json#/properties/adcp_version" }, "status": { "type": "string", "const": "failed" }, "errors": { "type": "array", "items": { "$ref": "/schemas/core/error.json" }, "minItems": 1 }, "context": { "$ref": "/schemas/core/context.json" }, @@ -68,6 +70,7 @@ "title": "Control Submitted", "type": "object", "properties": { + "adcp_version": { "$ref": "/schemas/core/version-envelope.json#/properties/adcp_version" }, "status": { "type": "string", "const": "submitted" }, "task_id": { "type": "string", "minLength": 1, "x-entity": "task" }, "message": { "type": "string", "maxLength": 2000 }, diff --git a/static/schemas/source/media-buy/create-media-buy-async-response-input-required.json b/static/schemas/source/media-buy/create-media-buy-async-response-input-required.json index aabc0410333..91f742054ae 100644 --- a/static/schemas/source/media-buy/create-media-buy-async-response-input-required.json +++ b/static/schemas/source/media-buy/create-media-buy-async-response-input-required.json @@ -3,6 +3,9 @@ "$id": "/schemas/media-buy/create-media-buy-async-response-input-required.json", "title": "Create Media Buy - Input Required", "description": "Payload when task is paused waiting for user input or approval.", + "allOf": [ + { "$ref": "/schemas/core/version-envelope.json" } + ], "type": "object", "properties": { "reason": { diff --git a/static/schemas/source/media-buy/create-media-buy-async-response-submitted.json b/static/schemas/source/media-buy/create-media-buy-async-response-submitted.json index 3703223eb2e..3f88657249e 100644 --- a/static/schemas/source/media-buy/create-media-buy-async-response-submitted.json +++ b/static/schemas/source/media-buy/create-media-buy-async-response-submitted.json @@ -3,6 +3,9 @@ "$id": "/schemas/media-buy/create-media-buy-async-response-submitted.json", "title": "Create Media Buy - Submitted", "description": "Async task envelope returned when create_media_buy cannot be confirmed before the response is emitted. The buyer invokes get_task_status with task_id or receives a webhook when the task completes; the media_buy_id and packages land in the terminal result, not this envelope.", + "allOf": [ + { "$ref": "/schemas/core/version-envelope.json" } + ], "type": "object", "properties": { "status": { diff --git a/static/schemas/source/media-buy/create-media-buy-async-response-working.json b/static/schemas/source/media-buy/create-media-buy-async-response-working.json index e63ea67bff3..513ffabce82 100644 --- a/static/schemas/source/media-buy/create-media-buy-async-response-working.json +++ b/static/schemas/source/media-buy/create-media-buy-async-response-working.json @@ -3,6 +3,9 @@ "$id": "/schemas/media-buy/create-media-buy-async-response-working.json", "title": "Create Media Buy - Working", "description": "Progress payload for active create_media_buy task.", + "allOf": [ + { "$ref": "/schemas/core/version-envelope.json" } + ], "type": "object", "properties": { "percentage": { diff --git a/static/schemas/source/media-buy/decline-proposals-response.json b/static/schemas/source/media-buy/decline-proposals-response.json index 63c434cd1ae..74ed0944666 100644 --- a/static/schemas/source/media-buy/decline-proposals-response.json +++ b/static/schemas/source/media-buy/decline-proposals-response.json @@ -19,6 +19,7 @@ } ], "properties": { + "adcp_version": { "$ref": "/schemas/core/version-envelope.json#/properties/adcp_version" }, "results": { "type": "array", "minItems": 0, diff --git a/static/schemas/source/media-buy/get-media-buy-issue-response.json b/static/schemas/source/media-buy/get-media-buy-issue-response.json index a04bc230511..140e7f9a0c1 100644 --- a/static/schemas/source/media-buy/get-media-buy-issue-response.json +++ b/static/schemas/source/media-buy/get-media-buy-issue-response.json @@ -9,6 +9,7 @@ { "type": "object", "properties": { + "adcp_version": { "$ref": "/schemas/core/version-envelope.json#/properties/adcp_version" }, "outcome": { "type": "string", "const": "found" @@ -35,6 +36,7 @@ { "type": "object", "properties": { + "adcp_version": { "$ref": "/schemas/core/version-envelope.json#/properties/adcp_version" }, "outcome": { "type": "string", "const": "failed" diff --git a/static/schemas/source/media-buy/get-products-async-response-input-required.json b/static/schemas/source/media-buy/get-products-async-response-input-required.json index 856686f0bbd..e09938c3878 100644 --- a/static/schemas/source/media-buy/get-products-async-response-input-required.json +++ b/static/schemas/source/media-buy/get-products-async-response-input-required.json @@ -3,6 +3,9 @@ "$id": "/schemas/media-buy/get-products-async-response-input-required.json", "title": "Get Products - Input Required", "description": "Payload when search is paused waiting for user clarification.", + "allOf": [ + { "$ref": "/schemas/core/version-envelope.json" } + ], "type": "object", "properties": { "reason": { diff --git a/static/schemas/source/media-buy/get-products-async-response-submitted.json b/static/schemas/source/media-buy/get-products-async-response-submitted.json index 088542bb2c1..c0adac75a87 100644 --- a/static/schemas/source/media-buy/get-products-async-response-submitted.json +++ b/static/schemas/source/media-buy/get-products-async-response-submitted.json @@ -3,6 +3,9 @@ "$id": "/schemas/media-buy/get-products-async-response-submitted.json", "title": "Get Products - Submitted", "description": "Async task envelope returned when get_products cannot be confirmed before the response — for example, when custom or bespoke product curation is queued for processing. This envelope is valid only for `buying_mode: \"brief\"` and `buying_mode: \"refine\"`; `buying_mode: \"wholesale\"` is a synchronous wholesale product feed read and MUST NOT use the Submitted arm. The buyer can always poll `get_task_status` (legacy `tasks/get`) with `task_id`. If the originating request carried `push_notification_config`, the seller also delivers at least the terminal completion/failure notification to that webhook URL; intermediate progress notifications are MAY. The products array lands on the completion artifact, not this envelope.", + "allOf": [ + { "$ref": "/schemas/core/version-envelope.json" } + ], "type": "object", "properties": { "status": { diff --git a/static/schemas/source/media-buy/get-products-async-response-working.json b/static/schemas/source/media-buy/get-products-async-response-working.json index fe869ceeabf..95d5ef3776f 100644 --- a/static/schemas/source/media-buy/get-products-async-response-working.json +++ b/static/schemas/source/media-buy/get-products-async-response-working.json @@ -3,6 +3,9 @@ "$id": "/schemas/media-buy/get-products-async-response-working.json", "title": "Get Products - Working", "description": "Progress payload for active get_products task.", + "allOf": [ + { "$ref": "/schemas/core/version-envelope.json" } + ], "type": "object", "properties": { "percentage": { diff --git a/static/schemas/source/media-buy/get-reporting-status-response.json b/static/schemas/source/media-buy/get-reporting-status-response.json index 1de3313cf1f..823e4f2d508 100644 --- a/static/schemas/source/media-buy/get-reporting-status-response.json +++ b/static/schemas/source/media-buy/get-reporting-status-response.json @@ -153,8 +153,8 @@ "title": "Unavailable lookup", "type": "object", "properties": { - "adcp_version": { "type": "string" }, - "adcp_major_version": { "type": "integer" }, + "adcp_version": { "$ref": "/schemas/core/version-envelope.json#/properties/adcp_version" }, + "adcp_major_version": { "$ref": "/schemas/core/version-envelope.json#/properties/adcp_major_version" }, "status": { "type": "string", "const": "failed" }, "view": { "enum": ["summary", "periods", "revision"] }, "failure_kind": { "type": "string", "const": "lookup_unavailable" }, @@ -194,8 +194,8 @@ "title": "Operational failure", "type": "object", "properties": { - "adcp_version": { "type": "string" }, - "adcp_major_version": { "type": "integer" }, + "adcp_version": { "$ref": "/schemas/core/version-envelope.json#/properties/adcp_version" }, + "adcp_major_version": { "$ref": "/schemas/core/version-envelope.json#/properties/adcp_major_version" }, "status": { "type": "string", "const": "failed" }, "view": { "enum": ["summary", "periods", "revision"] }, "failure_kind": { "type": "string", "const": "operational" }, diff --git a/static/schemas/source/media-buy/list-collections-response.json b/static/schemas/source/media-buy/list-collections-response.json index 17c27fd4cf4..05e0437e66b 100644 --- a/static/schemas/source/media-buy/list-collections-response.json +++ b/static/schemas/source/media-buy/list-collections-response.json @@ -9,6 +9,7 @@ "propertyName": "outcome" }, "properties": { + "adcp_version": { "$ref": "/schemas/core/version-envelope.json#/properties/adcp_version" }, "outcome": { "type": "string", "enum": ["listed", "unchanged"], diff --git a/static/schemas/source/media-buy/list-products-response.json b/static/schemas/source/media-buy/list-products-response.json index 8890629c134..a194234874a 100644 --- a/static/schemas/source/media-buy/list-products-response.json +++ b/static/schemas/source/media-buy/list-products-response.json @@ -8,7 +8,7 @@ "propertyName": "outcome" }, "properties": { - "adcp_version": { "$ref": "/schemas/core/version-envelope.json#/properties/adcp_version", "x-added-in": "3.3.0" }, + "adcp_version": { "$ref": "/schemas/core/version-envelope.json#/properties/adcp_version" }, "outcome": { "type": "string", "enum": ["listed", "unchanged"], diff --git a/static/schemas/source/media-buy/list-proposals-response.json b/static/schemas/source/media-buy/list-proposals-response.json index 5dff66e03a0..d5e73172699 100644 --- a/static/schemas/source/media-buy/list-proposals-response.json +++ b/static/schemas/source/media-buy/list-proposals-response.json @@ -7,6 +7,7 @@ "description": "One page of proposals for the requested account with their seller-tracked dispositions, plus the current state of every opportunity those proposals belong to. Each snapshot is returned byte-for-byte as issued, so terms_digest still verifies; lifecycle state lives beside it, never inside it. Experimental (media_buy.open_opportunities).", "type": "object", "properties": { + "adcp_version": { "$ref": "/schemas/core/version-envelope.json#/properties/adcp_version" }, "outcome": { "type": "string", "const": "listed" }, "proposals": { "type": "array", diff --git a/static/schemas/source/media-buy/media-buy-commitment-response.json b/static/schemas/source/media-buy/media-buy-commitment-response.json index 1d3a829e4f8..ef09a2059ac 100644 --- a/static/schemas/source/media-buy/media-buy-commitment-response.json +++ b/static/schemas/source/media-buy/media-buy-commitment-response.json @@ -21,6 +21,7 @@ } }, "properties": { + "adcp_version": { "$ref": "/schemas/core/version-envelope.json#/properties/adcp_version" }, "status": { "type": "string", "const": "completed" }, "media_buy_id": { "type": "string", @@ -126,6 +127,7 @@ "title": "Commitment Error", "type": "object", "properties": { + "adcp_version": { "$ref": "/schemas/core/version-envelope.json#/properties/adcp_version" }, "status": { "type": "string", "const": "failed" }, "errors": { "type": "array", @@ -144,6 +146,7 @@ "title": "Commitment Submitted", "type": "object", "properties": { + "adcp_version": { "$ref": "/schemas/core/version-envelope.json#/properties/adcp_version" }, "status": { "type": "string", "const": "submitted" }, "task_id": { "type": "string", "minLength": 1, "x-entity": "task" }, "message": { "type": "string", "maxLength": 2000 }, diff --git a/static/schemas/source/media-buy/report-media-buy-issue-response.json b/static/schemas/source/media-buy/report-media-buy-issue-response.json index c8291755969..3469a7d3863 100644 --- a/static/schemas/source/media-buy/report-media-buy-issue-response.json +++ b/static/schemas/source/media-buy/report-media-buy-issue-response.json @@ -9,6 +9,7 @@ { "type": "object", "properties": { + "adcp_version": { "$ref": "/schemas/core/version-envelope.json#/properties/adcp_version" }, "outcome": { "type": "string", "const": "recorded" @@ -35,6 +36,7 @@ { "type": "object", "properties": { + "adcp_version": { "$ref": "/schemas/core/version-envelope.json#/properties/adcp_version" }, "outcome": { "type": "string", "const": "failed" diff --git a/static/schemas/source/media-buy/sync-catalogs-async-response-input-required.json b/static/schemas/source/media-buy/sync-catalogs-async-response-input-required.json index dd5dcd1f329..e89f857ca86 100644 --- a/static/schemas/source/media-buy/sync-catalogs-async-response-input-required.json +++ b/static/schemas/source/media-buy/sync-catalogs-async-response-input-required.json @@ -3,6 +3,9 @@ "$id": "/schemas/media-buy/sync-catalogs-async-response-input-required.json", "title": "Sync Catalogs - Input Required", "description": "Payload when sync_catalogs task is paused waiting for buyer clarification or approval.", + "allOf": [ + { "$ref": "/schemas/core/version-envelope.json" } + ], "type": "object", "properties": { "reason": { diff --git a/static/schemas/source/media-buy/sync-catalogs-async-response-submitted.json b/static/schemas/source/media-buy/sync-catalogs-async-response-submitted.json index 2aec666604b..26d1a9604ce 100644 --- a/static/schemas/source/media-buy/sync-catalogs-async-response-submitted.json +++ b/static/schemas/source/media-buy/sync-catalogs-async-response-submitted.json @@ -3,6 +3,9 @@ "$id": "/schemas/media-buy/sync-catalogs-async-response-submitted.json", "title": "Sync Catalogs - Submitted", "description": "Async task envelope returned when sync_catalogs cannot be confirmed before the response — for example, when catalog ingestion and deduplication are queued for batch processing. The buyer invokes get_task_status with task_id or receives a webhook when the task completes; the per-catalog results land in the terminal result, not this envelope.", + "allOf": [ + { "$ref": "/schemas/core/version-envelope.json" } + ], "type": "object", "properties": { "status": { diff --git a/static/schemas/source/media-buy/sync-catalogs-async-response-working.json b/static/schemas/source/media-buy/sync-catalogs-async-response-working.json index a0feb7bcf51..06b7f29d12b 100644 --- a/static/schemas/source/media-buy/sync-catalogs-async-response-working.json +++ b/static/schemas/source/media-buy/sync-catalogs-async-response-working.json @@ -3,6 +3,9 @@ "$id": "/schemas/media-buy/sync-catalogs-async-response-working.json", "title": "Sync Catalogs - Working", "description": "Progress payload for active sync_catalogs task.", + "allOf": [ + { "$ref": "/schemas/core/version-envelope.json" } + ], "type": "object", "properties": { "percentage": { diff --git a/static/schemas/source/media-buy/update-media-buy-async-response-input-required.json b/static/schemas/source/media-buy/update-media-buy-async-response-input-required.json index ebe0717fd65..756363bcc71 100644 --- a/static/schemas/source/media-buy/update-media-buy-async-response-input-required.json +++ b/static/schemas/source/media-buy/update-media-buy-async-response-input-required.json @@ -3,6 +3,9 @@ "$id": "/schemas/media-buy/update-media-buy-async-response-input-required.json", "title": "Update Media Buy - Input Required", "description": "Payload when update_media_buy task is paused waiting for user input or approval.", + "allOf": [ + { "$ref": "/schemas/core/version-envelope.json" } + ], "type": "object", "properties": { "reason": { diff --git a/static/schemas/source/media-buy/update-media-buy-async-response-submitted.json b/static/schemas/source/media-buy/update-media-buy-async-response-submitted.json index 64f8759b9ed..4c0a7fd10a5 100644 --- a/static/schemas/source/media-buy/update-media-buy-async-response-submitted.json +++ b/static/schemas/source/media-buy/update-media-buy-async-response-submitted.json @@ -3,6 +3,9 @@ "$id": "/schemas/media-buy/update-media-buy-async-response-submitted.json", "title": "Update Media Buy - Submitted", "description": "Async task envelope returned when update_media_buy cannot be confirmed before the response — for example, when operator re-approval is required for mid-flight changes. The buyer invokes get_task_status with task_id or receives a webhook when the task completes; the updated media buy state lands in the terminal result, not this envelope.", + "allOf": [ + { "$ref": "/schemas/core/version-envelope.json" } + ], "type": "object", "properties": { "status": { diff --git a/static/schemas/source/media-buy/update-media-buy-async-response-working.json b/static/schemas/source/media-buy/update-media-buy-async-response-working.json index 861fb7924a1..ff18f20472a 100644 --- a/static/schemas/source/media-buy/update-media-buy-async-response-working.json +++ b/static/schemas/source/media-buy/update-media-buy-async-response-working.json @@ -3,6 +3,9 @@ "$id": "/schemas/media-buy/update-media-buy-async-response-working.json", "title": "Update Media Buy - Working", "description": "Progress payload for active update_media_buy task.", + "allOf": [ + { "$ref": "/schemas/core/version-envelope.json" } + ], "type": "object", "properties": { "percentage": { diff --git a/static/schemas/source/signals/get-signals-async-response-submitted.json b/static/schemas/source/signals/get-signals-async-response-submitted.json index 98252f974cc..8cc21a2c4ba 100644 --- a/static/schemas/source/signals/get-signals-async-response-submitted.json +++ b/static/schemas/source/signals/get-signals-async-response-submitted.json @@ -3,6 +3,9 @@ "$id": "/schemas/signals/get-signals-async-response-submitted.json", "title": "Get Signals - Submitted", "description": "Async task envelope returned when get_signals cannot be confirmed before the response — for example, when semantic discovery depends on slow provider queries or review. This envelope is valid only for `discovery_mode: \"brief\"`; `discovery_mode: \"wholesale\"` is a synchronous wholesale signals feed read and MUST NOT use the Submitted arm. The caller can always poll `get_task_status` (legacy `tasks/get`) with `task_id`. If the originating request carried `push_notification_config`, the agent also delivers at least the terminal completion/failure notification to that webhook URL; intermediate progress notifications are MAY. The signals array lands on the completion artifact, not this envelope.", + "allOf": [ + { "$ref": "/schemas/core/version-envelope.json" } + ], "type": "object", "properties": { "status": { diff --git a/static/schemas/source/signals/get-signals-async-response-working.json b/static/schemas/source/signals/get-signals-async-response-working.json index 82a34165606..b31b9985f5b 100644 --- a/static/schemas/source/signals/get-signals-async-response-working.json +++ b/static/schemas/source/signals/get-signals-async-response-working.json @@ -3,6 +3,9 @@ "$id": "/schemas/signals/get-signals-async-response-working.json", "title": "Get Signals - Working", "description": "Progress payload for an active get_signals task after a submitted semantic discovery request has started processing. Valid only for `discovery_mode: \"brief\"`; wholesale signals feed reads report partial completion via `incomplete[]`, not async task progress.", + "allOf": [ + { "$ref": "/schemas/core/version-envelope.json" } + ], "type": "object", "properties": { "percentage": { diff --git a/tests/response-version-echo.test.cjs b/tests/response-version-echo.test.cjs new file mode 100644 index 00000000000..5a6ac14d0df --- /dev/null +++ b/tests/response-version-echo.test.cjs @@ -0,0 +1,180 @@ +'use strict'; + +/** + * Every task response accepts the `adcp_version` echo (#8518). + * + * versioning.mdx requires a seller to echo `adcp_version` on every response, + * requires the response to validate against the served release's schema, and + * keeps the echo's shape stable across the major. A response schema that does + * not declare the echo either rejects it (closed) or leaves it untyped (open), + * so a conforming seller cannot satisfy both rules at once. + * + * The task set comes from the tool registry (`discoverTools`, the same source + * as the published manifest): each tool's response schema plus its + * `*-async-response-*` status payloads. A new task is covered automatically. + * + * For each schema this asserts that: + * 1. a release-precision echo never trips `additionalProperties: false`; + * 2. the echo is declared, not merely tolerated: a full-semver value fails + * the envelope's release-precision pattern; + * 3. every declaration is exactly a `$ref` to + * core/version-envelope.json#/properties/adcp_version, or the envelope + * itself composed via `allOf`, so the shape is identical everywhere. + */ + +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const assert = require('node:assert/strict'); +const Ajv = require('ajv'); +const addFormats = require('ajv-formats'); + +const { discoverTools } = require('../scripts/build-schemas.cjs'); + +const SOURCE_DIR = path.resolve(__dirname, '..', 'static', 'schemas', 'source'); +const ENVELOPE_REF = '/schemas/core/version-envelope.json'; +const ECHO_REF = `${ENVELOPE_REF}#/properties/adcp_version`; + +function readSchema(uri) { + if (!uri.startsWith('/schemas/')) throw new Error(`Cannot load external schema: ${uri}`); + return JSON.parse(fs.readFileSync(path.join(SOURCE_DIR, uri.slice('/schemas/'.length)), 'utf8')); +} + +function taskResponseSchemas() { + const uris = new Set(); + for (const tool of discoverTools(SOURCE_DIR)) { + uris.add(`/schemas/${tool.response_schema}`); + for (const variant of tool.async_response_schemas) uris.add(`/schemas/${variant}`); + } + return [...uris].sort(); +} + +// Walk the root-level object composition of a response (root, allOf/anyOf/ +// oneOf/if-then-else arms, and $refs between them) without descending into +// property values, and collect every local `adcp_version` declaration. +function echoDeclarations(uri) { + const found = []; + const visited = new Set(); + const visit = (node, where) => { + if (!node || typeof node !== 'object') return; + if (typeof node.$ref === 'string') { + const [file, pointer] = node.$ref.split('#'); + if (file === ENVELOPE_REF && !pointer) { + found.push({ where, declaration: 'envelope' }); + return; + } + if (file && !visited.has(file)) { + visited.add(file); + visit(readSchema(file), file); + } + } + if (node.properties && Object.hasOwn(node.properties, 'adcp_version')) { + found.push({ where, declaration: node.properties.adcp_version }); + } + for (const keyword of ['allOf', 'anyOf', 'oneOf']) { + (node[keyword] || []).forEach((arm, index) => visit(arm, `${where}/${keyword}/${index}`)); + } + for (const keyword of ['then', 'else']) visit(node[keyword], `${where}/${keyword}`); + }; + visited.add(uri); + visit(readSchema(uri), uri); + return found; +} + +const SCHEMAS = taskResponseSchemas(); + +test('the tool registry yields the task responses this guard is meant to cover', () => { + assert.ok(SCHEMAS.length > 100, `expected the full task response set, saw ${SCHEMAS.length}`); + for (const uri of [ + '/schemas/media-buy/list-products-response.json', + '/schemas/media-buy/control-media-buy-response.json', + '/schemas/media-buy/decline-proposals-response.json', + '/schemas/media-buy/buy-products-response.json', + '/schemas/media-buy/accept-proposal-response.json', + '/schemas/media-buy/buy-products-async-response-submitted.json', + '/schemas/media-buy/create-media-buy-async-response-working.json', + ]) { + assert.ok(SCHEMAS.includes(uri), `${uri} missing from the registry-derived set`); + } +}); + +test('every task response accepts and declares the release-precision adcp_version echo', async () => { + // No discriminator option: every oneOf arm is evaluated, so a closed arm + // that rejects the echo is reported even when another arm would accept it. + const ajv = new Ajv({ allErrors: true, strict: false, loadSchema: async (uri) => readSchema(uri) }); + addFormats(ajv); + const rejecting = []; + const undeclared = []; + for (const uri of SCHEMAS) { + const validate = ajv.getSchema(uri) || await ajv.compileAsync(readSchema(uri)); + validate({ adcp_version: '3.3' }); + if ((validate.errors || []).some((error) => ( + error.keyword === 'additionalProperties' && error.params.additionalProperty === 'adcp_version' + ))) { + rejecting.push(uri); + } + validate({ adcp_version: '3.3.0' }); + if (!(validate.errors || []).some((error) => error.instancePath === '/adcp_version')) { + undeclared.push(uri); + } + } + assert.deepEqual(rejecting, [], 'closed response schemas reject the adcp_version echo'); + assert.deepEqual(undeclared, [], 'response schemas tolerate adcp_version without declaring its release-precision shape'); +}); + +test('every adcp_version echo declaration has the envelope shape', () => { + const drift = []; + for (const uri of SCHEMAS) { + const declarations = echoDeclarations(uri); + assert.ok(declarations.length > 0, `${uri} declares no adcp_version echo`); + for (const { where, declaration } of declarations) { + if (declaration === 'envelope') continue; + try { + assert.deepEqual(declaration, { $ref: ECHO_REF }); + } catch { + drift.push(`${where}: ${JSON.stringify(declaration)}`); + } + } + } + assert.deepEqual([...new Set(drift)], [], `adcp_version must be { "$ref": "${ECHO_REF}" }`); +}); + +test('compact purchase responses accept the echo on every arm (#8518 symptom)', async () => { + const ajv = new Ajv({ + allErrors: true, + strict: false, + discriminator: true, + loadSchema: async (uri) => readSchema(uri), + }); + addFormats(ajv); + const compile = async (tool) => ajv.compileAsync(readSchema(`/schemas/media-buy/${tool}-response.json`)); + const failed = { status: 'failed', errors: [{ code: 'INVALID_REQUEST', message: 'Rejected' }] }; + const submitted = { status: 'submitted', task_id: 'task-1' }; + const cases = { + 'list-products': [ + { outcome: 'listed', products: [], feed_version: 'feed-1', cache_scope: 'public' }, + { outcome: 'unchanged', feed_version: 'feed-1', cache_scope: 'public' }, + ], + 'control-media-buy': [ + { status: 'completed', media_buy_id: 'media-buy-1', revision: 2 }, + failed, + submitted, + ], + 'decline-proposals': [ + { results: [{ proposal_id: 'proposal-1', outcome: 'declined' }] }, + submitted, + ], + 'buy-products': [failed, submitted], + 'accept-proposal': [failed, submitted], + }; + for (const [tool, responses] of Object.entries(cases)) { + const validate = await compile(tool); + for (const response of responses) { + assert.equal(validate(response), true, `${tool} baseline: ${JSON.stringify(validate.errors)}`); + assert.equal(validate({ ...response, adcp_version: '3.3' }), true, + `${tool} rejected the echo: ${JSON.stringify(validate.errors)}`); + assert.equal(validate({ ...response, adcp_version: '3.3.0' }), false, + `${tool} accepted a patch-precision echo`); + } + } +}); diff --git a/tests/x-added-in-3-3-sweep.test.cjs b/tests/x-added-in-3-3-sweep.test.cjs index ee2417b47f2..7e0dac325b6 100644 --- a/tests/x-added-in-3-3-sweep.test.cjs +++ b/tests/x-added-in-3-3-sweep.test.cjs @@ -77,7 +77,6 @@ const PROPERTIES_3_3 = [ ['core/reporting-status-issue.json', ['properties', 'reporting_revision_id']], ['governance/check-governance-request.json', ['properties', 'budget_period_id']], ['governance/check-governance-response.json', ['properties', 'budget_period_id']], - ['media-buy/list-products-response.json', ['properties', 'adcp_version']], ['media-buy/refine-proposals-response.json', ['properties', 'results', 'items', 'properties', 'drifted_pins']], ['media-buy/sync-audiences-response.json', ['oneOf', 0, 'properties', 'audiences', 'items', 'properties', 'platform_id']], ['media-buy/sync-event-sources-response.json', ['oneOf', 0, 'properties', 'event_sources', 'items', 'properties', 'platform_id']], @@ -160,4 +159,6 @@ test('surfaces shipped in 3.2 are not marked 3.3', () => { // placement-evidence and ooh_metrics are in the 3.2 release artifacts. assert.equal(load('core/placement-evidence.json')['x-added-in'], '3.2.0'); assert.equal(load('core/delivery-metrics.json').properties.ooh_metrics['x-added-in'], undefined); + // The list_products adcp_version echo is backported to 3.2.x (#8518). + assert.equal(load('media-buy/list-products-response.json').properties.adcp_version['x-added-in'], undefined); });