diff --git a/.changeset/reopen-proposal-flow-schemas.md b/.changeset/reopen-proposal-flow-schemas.md new file mode 100644 index 00000000000..b075c7c506f --- /dev/null +++ b/.changeset/reopen-proposal-flow-schemas.md @@ -0,0 +1,12 @@ +--- +"adcontextprotocol": patch +--- + +Reopen the 3.2 proposal-flow schemas that were closed against DR-0019, as a correction recorded in DR-0036 (#8501). DR-0019 keeps request roots, response roots, and container objects open. These schemas now set `additionalProperties: true` at 24 locations: + +- the request roots of `list_products`, `request_proposals`, `refine_proposals`, `buy_products`, `accept_proposal`, `decline_proposals`, and `control_media_buy`; +- the response roots of `list_products`, `request_proposals`, `refine_proposals`, and `decline_proposals`, and the `refine_proposals` result row; +- the three branches of `media-buy-commitment-response.json` (`buy_products` and `accept_proposal`) and of `control-media-buy-response.json`; +- `core/canonical-proposal.json`, `core/canonical-product.json`, `media-buy/product-discovery-criteria.json`, `media-buy/proposal-refinement.json`, `media-buy/package-control.json`, and `core/opportunity-context.json`. + +Every payload that was valid stays valid. The rejections that closure provided are now stated by name: `core/canonical-product.json` forbids each field that only the legacy `core/product.json` defines, such as `format_ids`, `is_custom`, and `installments`; the `request_proposals` response forbids a root `feed_version` or `pricing_version`; and the error and submitted branches of `media-buy-commitment-response.json` and `control-media-buy-response.json` forbid `warnings`. This patch adds no fields. `commercial-terms.json` and the purchases inside it stay closed. diff --git a/docs/reference/migration/3-1-to-3-2.mdx b/docs/reference/migration/3-1-to-3-2.mdx index 2992ef97660..1c136918da1 100644 --- a/docs/reference/migration/3-1-to-3-2.mdx +++ b/docs/reference/migration/3-1-to-3-2.mdx @@ -324,6 +324,25 @@ Absence of the declaration means use the 3.x compatibility facades. Retry a stateful request with the same tool name, idempotency key, and payload; an idempotency key does not create replay identity across old and new task names. +**Correction: the proposal-flow schemas are open.** 3.2.7 and earlier published +these tasks' request and response schemas closed, with +`additionalProperties: false`. They also closed `canonical-proposal`, +`canonical-product`, `product-discovery-criteria`, `proposal-refinement`, +`package-control`, and `opportunity-context`. +[DR-0019](https://github.com/adcontextprotocol/adcp/blob/main/governance/decisions/DR-0019-closed-row-schemas-deliberate-class.md) +keeps roots and containers open, so later 3.2 patches reopen them. This is a +correction recorded in +[DR-0036](https://github.com/adcontextprotocol/adcp/blob/main/governance/decisions/DR-0036-closed-schemas-stay-closed-within-a-major.md) +([#8501](https://github.com/adcontextprotocol/adcp/issues/8501)). Every payload +that was valid stays valid. A field that only the legacy Product defines, +such as `format_ids`, `is_custom`, or `installments`, is still rejected on a +canonical product. A root `feed_version` or `pricing_version` on a +`request_proposals` response, and `warnings` on an error or submitted +`buy_products`, `accept_proposal`, or `control_media_buy` response, are also +still rejected. `commercial_terms` and the purchases inside it stay closed. A +validator on 3.2.7 or earlier still rejects unknown fields on these schemas, so +don't send fields the served release doesn't define. + ### Scope idempotency to mutations AdCP 3.2 requires durable replay for state-mutating requests, not guaranteed diff --git a/server/tests/unit/product-discovery-schema-parity.test.ts b/server/tests/unit/product-discovery-schema-parity.test.ts index 11d90e4ea98..55b91bf7baf 100644 --- a/server/tests/unit/product-discovery-schema-parity.test.ts +++ b/server/tests/unit/product-discovery-schema-parity.test.ts @@ -35,7 +35,7 @@ describe('product discovery MCP schema parity', () => { const source = sourceSchema(fileName); expect(runtime.required ?? []).toEqual(source.required ?? []); expect(runtime.dependencies ?? {}).toEqual(source.dependencies ?? {}); - expect(runtime.additionalProperties).toBe(false); + expect(runtime.additionalProperties).toBe(source.additionalProperties); expect(Object.keys(runtime.properties).sort()).toEqual(Object.keys(source.properties).sort()); if (source.properties.idempotency_key) { expect(runtime.properties.idempotency_key).toMatchObject({ @@ -64,7 +64,7 @@ describe('product discovery MCP schema parity', () => { expect(runtimeRefinement).toMatchObject({ type: sourceRefinement.type, required: sourceRefinement.required, - additionalProperties: false, + additionalProperties: sourceRefinement.additionalProperties, }); // Repository-local refs must be bundled because MCP consumers do not have @@ -78,11 +78,12 @@ describe('product discovery MCP schema parity', () => { uniqueItems: true, items: { enum: expect.arrayContaining(['product_id', 'format_options', 'pricing_options']) }, }); - expect(resolveLocalRef(list, list.properties.criteria)).toMatchObject({ additionalProperties: false }); + expect(resolveLocalRef(list, list.properties.criteria)) + .toMatchObject({ additionalProperties: sourceSchema('product-discovery-criteria').additionalProperties }); expect(resolveLocalRef(refineTool, refineTool.properties.refinements.items)) .toMatchObject({ required: ['proposal_id'], - additionalProperties: false, + additionalProperties: sourceRefinement.additionalProperties, // Definition annotations are intentionally stripped from tools/list; // dispatch still applies the source-schema default semantics. properties: { action: { enum: ['revise', 'finalize'] } }, diff --git a/static/schemas/source/core/canonical-product.json b/static/schemas/source/core/canonical-product.json index c74a7298fa4..8d10a452a3b 100644 --- a/static/schemas/source/core/canonical-product.json +++ b/static/schemas/source/core/canonical-product.json @@ -198,5 +198,30 @@ } } ], - "additionalProperties": false + "not": { + "anyOf": [ + { "required": ["audience_activation"] }, + { "required": ["cancellation_policy"] }, + { "required": ["conversion_tracking"] }, + { "required": ["creative_policy"] }, + { "required": ["data_provider_signals"] }, + { "required": ["delivery_measurement"] }, + { "required": ["enforced_policies"] }, + { "required": ["format_ids"] }, + { "required": ["included_signals"] }, + { "required": ["installments"] }, + { "required": ["is_custom"] }, + { "required": ["material_submission"] }, + { "required": ["measurement_readiness"] }, + { "required": ["metric_optimization"] }, + { "required": ["outcome_measurement"] }, + { "required": ["product_card"] }, + { "required": ["product_card_detailed"] }, + { "required": ["property_targeting_allowed"] }, + { "required": ["signal_targeting_options"] }, + { "required": ["trusted_match"] }, + { "required": ["vendor_metric_optimization"] } + ] + }, + "additionalProperties": true } diff --git a/static/schemas/source/core/canonical-proposal.json b/static/schemas/source/core/canonical-proposal.json index 33773a5a908..6bd0012bc68 100644 --- a/static/schemas/source/core/canonical-proposal.json +++ b/static/schemas/source/core/canonical-proposal.json @@ -81,5 +81,5 @@ "then": { "properties": { "commercial_terms": { "required": ["cancellation_terms"] } } } } ], - "additionalProperties": false + "additionalProperties": true } diff --git a/static/schemas/source/core/opportunity-context.json b/static/schemas/source/core/opportunity-context.json index 8dc3a3c9334..50d54afb3cc 100644 --- a/static/schemas/source/core/opportunity-context.json +++ b/static/schemas/source/core/opportunity-context.json @@ -82,5 +82,5 @@ "then": { "required": ["close_detail"] } } ], - "additionalProperties": false + "additionalProperties": true } diff --git a/static/schemas/source/media-buy/accept-proposal-request.json b/static/schemas/source/media-buy/accept-proposal-request.json index adcfec3ebb5..01657bd3a69 100644 --- a/static/schemas/source/media-buy/accept-proposal-request.json +++ b/static/schemas/source/media-buy/accept-proposal-request.json @@ -119,7 +119,7 @@ "ext": { "$ref": "/schemas/core/ext.json" } }, "required": ["idempotency_key", "account", "proposal_id", "proposal_terms_digest"], - "additionalProperties": false, + "additionalProperties": true, "not": { "anyOf": [ { "required": ["packages"] }, diff --git a/static/schemas/source/media-buy/buy-products-request.json b/static/schemas/source/media-buy/buy-products-request.json index 035df8566dc..b5e0dbcdac2 100644 --- a/static/schemas/source/media-buy/buy-products-request.json +++ b/static/schemas/source/media-buy/buy-products-request.json @@ -167,7 +167,7 @@ } } ], - "additionalProperties": false, + "additionalProperties": true, "not": { "anyOf": [ { "required": ["packages"] }, diff --git a/static/schemas/source/media-buy/control-media-buy-request.json b/static/schemas/source/media-buy/control-media-buy-request.json index f33815858da..777918e39da 100644 --- a/static/schemas/source/media-buy/control-media-buy-request.json +++ b/static/schemas/source/media-buy/control-media-buy-request.json @@ -172,7 +172,7 @@ } } ], - "additionalProperties": false, + "additionalProperties": true, "not": { "anyOf": [ { "required": ["creatives"] }, 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..cb8b0871479 100644 --- a/static/schemas/source/media-buy/control-media-buy-response.json +++ b/static/schemas/source/media-buy/control-media-buy-response.json @@ -47,7 +47,7 @@ "replayed": { "type": "boolean", "const": true } }, "required": ["status", "media_buy_id", "revision"], - "additionalProperties": false, + "additionalProperties": true, "not": { "required": ["errors"] } }, { @@ -61,8 +61,13 @@ "replayed": { "type": "boolean", "const": true } }, "required": ["status", "errors"], - "additionalProperties": false, - "not": { "required": ["media_buy_id"] } + "additionalProperties": true, + "not": { + "anyOf": [ + { "required": ["media_buy_id"] }, + { "required": ["warnings"] } + ] + } }, { "title": "Control Submitted", @@ -77,8 +82,13 @@ "replayed": { "type": "boolean", "const": true } }, "required": ["status", "task_id"], - "additionalProperties": false, - "not": { "required": ["media_buy_id"] } + "additionalProperties": true, + "not": { + "anyOf": [ + { "required": ["media_buy_id"] }, + { "required": ["warnings"] } + ] + } } ] } diff --git a/static/schemas/source/media-buy/decline-proposals-request.json b/static/schemas/source/media-buy/decline-proposals-request.json index 021f3597a31..97aba89f540 100644 --- a/static/schemas/source/media-buy/decline-proposals-request.json +++ b/static/schemas/source/media-buy/decline-proposals-request.json @@ -48,5 +48,5 @@ } }, "required": ["idempotency_key", "declines"], - "additionalProperties": false + "additionalProperties": true } diff --git a/static/schemas/source/media-buy/decline-proposals-response.json b/static/schemas/source/media-buy/decline-proposals-response.json index 2101b0de75f..f6d3fc792eb 100644 --- a/static/schemas/source/media-buy/decline-proposals-response.json +++ b/static/schemas/source/media-buy/decline-proposals-response.json @@ -64,5 +64,5 @@ "ext": { "$ref": "/schemas/core/ext.json" }, "replayed": { "type": "boolean", "const": true } }, - "additionalProperties": false + "additionalProperties": true } diff --git a/static/schemas/source/media-buy/list-products-request.json b/static/schemas/source/media-buy/list-products-request.json index 1285513c63d..7247bbd4192 100644 --- a/static/schemas/source/media-buy/list-products-request.json +++ b/static/schemas/source/media-buy/list-products-request.json @@ -82,5 +82,5 @@ } } ], - "additionalProperties": false + "additionalProperties": true } diff --git a/static/schemas/source/media-buy/list-products-response.json b/static/schemas/source/media-buy/list-products-response.json index 192f118c285..8ce02cb54e6 100644 --- a/static/schemas/source/media-buy/list-products-response.json +++ b/static/schemas/source/media-buy/list-products-response.json @@ -67,5 +67,5 @@ { "required": ["unchanged"] } ] }, - "additionalProperties": false + "additionalProperties": true } 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 163b4a8ea30..069406a7e86 100644 --- a/static/schemas/source/media-buy/media-buy-commitment-response.json +++ b/static/schemas/source/media-buy/media-buy-commitment-response.json @@ -119,7 +119,7 @@ } }, "required": ["status", "media_buy_id", "revision", "accepted_proposal", "purchase_bindings", "available_actions"], - "additionalProperties": false, + "additionalProperties": true, "not": { "required": ["errors"] } }, { @@ -137,8 +137,13 @@ "replayed": { "type": "boolean", "const": true } }, "required": ["status", "errors"], - "additionalProperties": false, - "not": { "required": ["media_buy_id"] } + "additionalProperties": true, + "not": { + "anyOf": [ + { "required": ["media_buy_id"] }, + { "required": ["warnings"] } + ] + } }, { "title": "Commitment Submitted", @@ -153,8 +158,13 @@ "replayed": { "type": "boolean", "const": true } }, "required": ["status", "task_id"], - "additionalProperties": false, - "not": { "required": ["media_buy_id"] } + "additionalProperties": true, + "not": { + "anyOf": [ + { "required": ["media_buy_id"] }, + { "required": ["warnings"] } + ] + } } ] } diff --git a/static/schemas/source/media-buy/package-control.json b/static/schemas/source/media-buy/package-control.json index b0d4f53eac3..714ec9dfc62 100644 --- a/static/schemas/source/media-buy/package-control.json +++ b/static/schemas/source/media-buy/package-control.json @@ -162,7 +162,7 @@ } } }, - "additionalProperties": false, + "additionalProperties": true, "not": { "anyOf": [ { "required": ["creatives"] }, diff --git a/static/schemas/source/media-buy/product-discovery-criteria.json b/static/schemas/source/media-buy/product-discovery-criteria.json index fa89879e41e..20a575ab63a 100644 --- a/static/schemas/source/media-buy/product-discovery-criteria.json +++ b/static/schemas/source/media-buy/product-discovery-criteria.json @@ -52,5 +52,5 @@ "ext": { "type": "object", "additionalProperties": true } }, "minProperties": 1, - "additionalProperties": false + "additionalProperties": true } diff --git a/static/schemas/source/media-buy/proposal-refinement.json b/static/schemas/source/media-buy/proposal-refinement.json index a45cbb82664..9f5bfaa30ea 100644 --- a/static/schemas/source/media-buy/proposal-refinement.json +++ b/static/schemas/source/media-buy/proposal-refinement.json @@ -142,5 +142,5 @@ } ] }, - "additionalProperties": false + "additionalProperties": true } diff --git a/static/schemas/source/media-buy/refine-proposals-request.json b/static/schemas/source/media-buy/refine-proposals-request.json index b30409de7e9..c7fc3252e51 100644 --- a/static/schemas/source/media-buy/refine-proposals-request.json +++ b/static/schemas/source/media-buy/refine-proposals-request.json @@ -60,5 +60,5 @@ } }, "required": ["idempotency_key", "refinements"], - "additionalProperties": false + "additionalProperties": true } diff --git a/static/schemas/source/media-buy/refine-proposals-response.json b/static/schemas/source/media-buy/refine-proposals-response.json index d43243141ec..7560fbb01a1 100644 --- a/static/schemas/source/media-buy/refine-proposals-response.json +++ b/static/schemas/source/media-buy/refine-proposals-response.json @@ -153,7 +153,7 @@ } } ], - "additionalProperties": false + "additionalProperties": true }, "allOf": [ { @@ -192,5 +192,5 @@ { "required": ["pagination"] } ] }, - "additionalProperties": false + "additionalProperties": true } diff --git a/static/schemas/source/media-buy/request-proposals-request.json b/static/schemas/source/media-buy/request-proposals-request.json index 37e54a3ccfc..a2e42cfc51d 100644 --- a/static/schemas/source/media-buy/request-proposals-request.json +++ b/static/schemas/source/media-buy/request-proposals-request.json @@ -80,5 +80,5 @@ } } ], - "additionalProperties": false + "additionalProperties": true } diff --git a/static/schemas/source/media-buy/request-proposals-response.json b/static/schemas/source/media-buy/request-proposals-response.json index 27d2d9414bc..708fa9dac88 100644 --- a/static/schemas/source/media-buy/request-proposals-response.json +++ b/static/schemas/source/media-buy/request-proposals-response.json @@ -244,8 +244,10 @@ { "required": ["refinement_applied"] }, { "required": ["pagination"] }, { "required": ["unchanged"] }, - { "required": ["wholesale_feed_version"] } + { "required": ["wholesale_feed_version"] }, + { "required": ["feed_version"] }, + { "required": ["pricing_version"] } ] }, - "additionalProperties": false + "additionalProperties": true } diff --git a/tests/buyer-fixture-products-schema.test.cjs b/tests/buyer-fixture-products-schema.test.cjs index 1a0ccfda28b..22497e095c7 100644 --- a/tests/buyer-fixture-products-schema.test.cjs +++ b/tests/buyer-fixture-products-schema.test.cjs @@ -111,8 +111,11 @@ test('a product valid under only one shape is rejected', () => { }, }; assert.deepEqual(productErrors(ajv, dualShape), []); + // installments exists only on the legacy Product, so the canonical Product + // rejects it by name. const errors = productErrors(ajv, { ...dualShape, installments: [] }); - assert.ok(errors.some((e) => e.startsWith('/schemas/core/canonical-product.json') && e.includes('installments')), errors.join('\n')); + assert.ok(errors.some((e) => e.startsWith('/schemas/core/canonical-product.json')), errors.join('\n')); + assert.ok(!errors.some((e) => e.startsWith('/schemas/core/product.json')), errors.join('\n')); const missing = productErrors(ajv, { product_id: 'p', name: 'P' }); assert.ok(missing.some((e) => e.startsWith('/schemas/core/product.json')), missing.join('\n')); }); diff --git a/tests/mcp-schema-projection.test.cjs b/tests/mcp-schema-projection.test.cjs index ea16c1301ea..0580a5e0728 100644 --- a/tests/mcp-schema-projection.test.cjs +++ b/tests/mcp-schema-projection.test.cjs @@ -49,6 +49,10 @@ const STORYBOARD_DIR = path.join(REPO_ROOT, 'static', 'compliance', 'source'); const LATEST_DIR = path.join(REPO_ROOT, 'dist', 'schemas', 'latest'); const PROJECTION_DIR = path.join(LATEST_DIR, 'mcp', MCP_PROTOCOL_VERSION); const PRODUCTION_PROFILE_DIR = path.join(PROJECTION_DIR, 'profiles', 'production'); +// The canonical Product's `not`, as the projection serializes it. +const CANONICAL_PRODUCT_PROHIBITION = JSON.stringify({ + not: JSON.parse(fs.readFileSync(path.join(SOURCE_DIR, 'core', 'canonical-product.json'), 'utf8')).not, +}).slice(1, -1); // The generated profiles describe the active surface of the package's release // line. Before the line's GA (x.y.0-rc.N) that is the x.y.0 surface the tool // manifest declares via added_in; once the line has a stable release it is the @@ -983,7 +987,10 @@ test('generated MCP projection covers every tool within AdCP safety bounds', () if (['list_products', 'request_proposals', 'refine_proposals'].includes(toolName)) { assert.match(output, /Canonical Product/, `${toolName} output must use the canonical-only Product view`); - assert.doesNotMatch(output, /format_ids|format-id\.json|v1_format_ref|update_packages|"update_media_buy"/, + // The canonical Product forbids the legacy Product's keys by name; + // forbidding them does not expose them. + const exposed = output.replaceAll(CANONICAL_PRODUCT_PROHIBITION, ''); + assert.doesNotMatch(exposed, /format_ids|format-id\.json|v1_format_ref|update_packages|"update_media_buy"/, `${toolName} output must not expose the legacy Product graph`); } } diff --git a/tests/products-only-brief-compatibility.test.cjs b/tests/products-only-brief-compatibility.test.cjs index ccf8d21b673..d0d57422c76 100644 --- a/tests/products-only-brief-compatibility.test.cjs +++ b/tests/products-only-brief-compatibility.test.cjs @@ -206,6 +206,10 @@ test('2.5, 3.0, and 3.1 preserve products-only brief discovery through an explic const fabricatedFence = structuredClone(vector.compact_projection); fabricatedFence.feed_version = `synthetic-${vector.source_version}`; assert.equal(validateCompact(fabricatedFence), false, `${vector.source_version} must not fabricate a feed fence`); + + const fabricatedPricingFence = structuredClone(vector.compact_projection); + fabricatedPricingFence.pricing_version = `synthetic-${vector.source_version}`; + assert.equal(validateCompact(fabricatedPricingFence), false, `${vector.source_version} must not fabricate a pricing fence`); } });