From 6657af7001cc209533de7e5912ff650d9f789f30 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 13 Nov 2026 14:27:28 +0000 Subject: [PATCH] feat(media-buy): choose installments through refine_proposals without opening commercial_terms (#8967) A buyer can ask for specific installments of a product during negotiation (experimental, media_buy.installment_refinement). installment_changes returns on proposal-refinement.json with the #8246 shape: a list of {product_id, installment_ref, action}, unique by (product_id, installment_ref), with installment_ref the closed core/canonical-installment-ref.json. The seller answers with a draft that buys, for each named product, one product whose installments are exactly the selected set (the product's installments with omits removed and includes added), often one it derives for the selection. Buying that product buys its complete installment set, so terms_digest binds the choice through the product_id and nothing in commercial_terms changes. unsatisfied_installment_changes on the refine_proposals result row repeats each unmet entry with a reason, on a partial or unable result with constraint_unsatisfiable. installment_changes joins proposal_refinement.supported_dimensions, and listing it requires media_buy.installment_refinement in experimental_features. The rules are normative in the schemas, refine_proposals.mdx and collections-and-installments.mdx: an offered product's installments never change, the buyer rejects a mismatched answer, and the seller checks product membership before it resolves any reference. New core/installment-change.json and core/installment-change-set.json, every new field experimental and added in 3.3.0. The MCP analysis pins (714 instances, 195 unique) and the capabilities worst case (99,101,140 bytes, on top of #8458's 99,101,090) are re-measured. The 4.0 page, registry entry, known limitations and experimental status say what 3.3 offers before 4.0 binds the slots in the signed terms. Training-agent support is out of scope. Refs #8967, #8501 Co-Authored-By: Claude Opus 5.5 --- .changeset/installment-refinement.md | 16 ++ docs/building/by-layer/L1/security.mdx | 2 +- .../collections-and-installments.mdx | 13 +- .../task-reference/refine_proposals.mdx | 78 +++++++- docs/protocol/get_adcp_capabilities.mdx | 1 + docs/reference/experimental-status.mdx | 1 + docs/reference/known-limitations.mdx | 2 +- docs/reference/migration/v4-compatibility.mdx | 2 +- package.json | 2 +- .../source/universal/v4-compatibility.yaml | 19 +- .../source/core/installment-change-set.json | 20 ++ .../source/core/installment-change.json | 28 +++ .../enums/proposal-refinement-reason.json | 2 +- static/schemas/source/index.json | 8 + .../source/media-buy/proposal-refinement.json | 11 +- .../media-buy/refine-proposals-response.json | 24 ++- .../get-adcp-capabilities-response.json | 30 ++- tests/canonical-installments.test.cjs | 3 +- tests/capabilities-structural-bounds.test.cjs | 2 +- tests/installment-refinement.test.cjs | 179 ++++++++++++++++++ tests/mcp-schema-analysis.test.cjs | 9 +- tests/withdrawn-to-4-0.test.cjs | 10 +- 22 files changed, 428 insertions(+), 34 deletions(-) create mode 100644 .changeset/installment-refinement.md create mode 100644 static/schemas/source/core/installment-change-set.json create mode 100644 static/schemas/source/core/installment-change.json create mode 100644 tests/installment-refinement.test.cjs diff --git a/.changeset/installment-refinement.md b/.changeset/installment-refinement.md new file mode 100644 index 00000000000..bd18108475d --- /dev/null +++ b/.changeset/installment-refinement.md @@ -0,0 +1,16 @@ +--- +"adcontextprotocol": minor +--- + +Choose specific installments through `refine_proposals` (experimental, `media_buy.installment_refinement`, adcp#8967). + +A buyer sends `installment_changes`, a list of `{ product_id, installment_ref, action }` entries unique by `(product_id, installment_ref)`. `installment_ref` is the closed `core/canonical-installment-ref.json`, and `action` is `include` or `omit`. The seller answers with a draft that buys, for each named product, one product whose `installments[]` is exactly the selected set. That's the named product's installments with every `omit` removed and every `include` added, often in a product the seller derives for the selection. Entries a seller can't satisfy come back in `unsatisfied_installment_changes` on the result row, each with a `reason`, on a `partial` or `unable` result with `reason_code: "constraint_unsatisfiable"`. + +Nothing in `commercial_terms` changes. Buying a product still buys its complete installment set, so `terms_digest` binds the choice through the `product_id`. Three rules make that hold: +- A seller MUST NOT change the installments of a product it has offered; a different set is a different `product_id`. +- The buyer compares each selected set with the returned product's `installments[]` and rejects a mismatch. +- The seller checks product membership before it resolves any installment reference. + +A seller that supports it lists `media_buy.installment_refinement` in `experimental_features` and `installment_changes` in `media_buy.proposal_refinement.supported_dimensions`, and the capabilities schema requires the first whenever the second is present. Buyers send `installment_changes` only to such a seller. This is a new feature id. `media_buy.installment_selection`, which named the signed `purchases[].installments[]` withdrawn after beta.1, stays withdrawn, and 4.0 keeps that design (`installment_selection_required_in_4_0`). + +New schemas: `core/installment-change.json` and `core/installment-change-set.json`. Every new field is `x-status: experimental` and `x-added-in: 3.3.0`. The training agent doesn't implement it yet. diff --git a/docs/building/by-layer/L1/security.mdx b/docs/building/by-layer/L1/security.mdx index 7f57b75f600..9e4428cd214 100644 --- a/docs/building/by-layer/L1/security.mdx +++ b/docs/building/by-layer/L1/security.mdx @@ -2557,7 +2557,7 @@ schema-valid. The annotations are registered as a 4.0 tightening in the [4.0 compatibility checks](/docs/reference/migration/v4-compatibility#capabilities-stay-within-their-structural-bounds). **Worst case.** With the 4.0 bounds applied, the largest schema-valid -capabilities document is 99,101,090 bytes (about 95 MiB), counting only the +capabilities document is 99,101,140 bytes (about 95 MiB), counting only the members the schema declares. `scripts/capabilities-structural-bounds.cjs` computes it and `tests/capabilities-structural-bounds.test.cjs` pins it, so the figure moves only when a declared member or limit changes. The calculation assumes every declared member is diff --git a/docs/media-buy/product-discovery/collections-and-installments.mdx b/docs/media-buy/product-discovery/collections-and-installments.mdx index 80bfe104813..c5902ab02fe 100644 --- a/docs/media-buy/product-discovery/collections-and-installments.mdx +++ b/docs/media-buy/product-discovery/collections-and-installments.mdx @@ -789,7 +789,18 @@ Two qualifications matter in practice. ### Buying a product that names installments -A purchase buys the product as offered: run-of-collection when the product names no installments, and the product's complete installment set when it does. In 3.3 a purchase cannot name the exact slots it buys, and `refine_proposals` cannot drop or swap one. `commercial_terms` is held closed until 4.0, so a slot selection waits for 4.0's declared extension point. Installment selection is planned for 4.0, where `commercial_terms` gets a declared extension point with must-understand; see [Planned for 4.0](/docs/reference/migration/v4-compatibility#installment_selection_required_in_4_0). +A purchase buys the product as offered: run-of-collection when the product names no installments, and the product's complete installment set when it does. A purchase can't name a subset of a product's installments, because `commercial_terms` is held closed until 4.0. In 3.3 a buyer instead asks for specific installments during negotiation, and the seller answers with a product whose installments are exactly the chosen set ([below](#choosing-installments-experimental)). 4.0 puts the chosen installments in the signed terms themselves; see [Planned for 4.0](/docs/reference/migration/v4-compatibility#installment_selection_required_in_4_0). + +#### Choosing installments (experimental) + +A seller that lists `media_buy.installment_refinement` in `experimental_features`, and `installment_changes` in `media_buy.proposal_refinement.supported_dimensions`, lets a buyer choose installments with [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals#choose-installments-experimental). Buyers MUST NOT send `installment_changes` to any other seller. Each entry names a product the proposal buys, an installment by its `(collection_ref, installment_id)` reference, and `include` or `omit`. + +For each product named, the **selected set** is that product's installments with every `omit` removed and every `include` added. The seller satisfies the request by returning a draft that buys one product whose `installments[]` is exactly the selected set. That can be the product itself, when its set is already the selection, or a product the seller offers for that selection in its place, such as `creator_juniper_vale__2026-12-03`. Buying that product buys its complete installment set, which is the selection, so `terms_digest` binds the choice through the `product_id` as it always has and every 3.x buyer reads the purchase the way it already does. + +- **A product's installments never change once it is offered.** A different set is a different `product_id`. The digest binds the `product_id`, not the slots, so this rule is what keeps an accepted selection fixed. +- **The buyer checks the answer.** Before finalizing, the buyer compares each selected set with the `installments[]` of the product the returned draft buys, and rejects a mismatch. After finalize, `holds[]` reads back which installments the hold covers ([Installment holds](/docs/media-buy/task-reference/refine_proposals#installment-holds-experimental)). +- **Membership comes before resolution.** The seller checks that each entry's `product_id` is one the source proposal buys, or one `product_changes` adds in the same request, before it resolves any installment reference, so an unauthorized caller never makes it resolve one. +- **What the seller can't satisfy, it reports.** Each unmet entry comes back in `unsatisfied_installment_changes` with a `reason`, on a `partial` or `unable` result whose `reason_code` is `constraint_unsatisfiable`. A committed proposal can read back which installments its hold covers, in `holds[]` (experimental, `media_buy.installment_holds`); see [Installment holds](/docs/media-buy/task-reference/refine_proposals#installment-holds-experimental). `holds[]` is readback about the hold finalize placed, not contract, and is not covered by `terms_digest`. diff --git a/docs/media-buy/task-reference/refine_proposals.mdx b/docs/media-buy/task-reference/refine_proposals.mdx index b144900da02..0f22efa72ea 100644 --- a/docs/media-buy/task-reference/refine_proposals.mdx +++ b/docs/media-buy/task-reference/refine_proposals.mdx @@ -60,6 +60,7 @@ Typed fields are deliberately small and mechanically verifiable: | `constraints.impressions` | Hard volume floor. Satisfied only when every purchase carries `impressions` and their sum is at least `min`. | | `constraints.flight` | Flight-window bounds. `start_no_later_than` requires a concrete `commercial_terms.start_time` at or before the bound (`asap` is unverifiable and unsatisfied); `end_no_earlier_than` requires `end_time` at or after it. | | `product_changes` | Map keyed by product ID. `include` requires a product in `commercial_terms.purchases`; `omit` requires it to be absent. Keying makes contradictory actions unrepresentable. | +| `installment_changes` | Experimental (`media_buy.installment_refinement`). A list of `{ product_id, installment_ref, action }` entries, unique by `(product_id, installment_ref)`, asking for specific installments of a product. See [Choose installments](#choose-installments-experimental). | | `alternatives.count` | Number of drafts with distinct commercial terms requested, from 2 through the protocol maximum of 10 and no higher than a seller's advertised `max_alternatives`. Returned alternatives have distinct `commercial_terms` and therefore unique `terms_digest` values; diversity strategy remains free text. | | `criteria` | Structured discovery criteria to replace on the source proposal. Omitted criteria fields remain inherited; concrete targeting and offer filters belong here. | | `remove_media_buy_frequency_cap` | Set to `true` to produce a revised draft without the inherited root frequency cap. Mutually exclusive with `criteria.media_buy_frequency_cap`. | @@ -89,7 +90,7 @@ The response contains one ordered `results[]` entry per requested source proposa | Code | Meaning | |---|---| | `commercially_declined` | The seller understood terms requested through the free-text `ask` but declined to offer them. | -| `constraint_unsatisfiable` | A typed constraint or product change was not satisfied — whether the seller cannot or will not meet it. | +| `constraint_unsatisfiable` | A typed constraint, product change, or installment change was not satisfied — whether the seller cannot or will not meet it. | | `unsupported_dimension` | The seller cannot parse or validate a requested typed dimension. | | `uninterpreted` | Some or all of the free-text `ask` could not be interpreted. | | `alternatives_unavailable` | Fewer distinct drafts were produced than requested. | @@ -101,7 +102,7 @@ Typed failures always use `constraint_unsatisfiable`, regardless of whether the A draft that violates a hard constraint MUST NOT be returned as `revised`. For `constraints.total_budget`, satisfaction requires a present `commercial_terms.total_budget`, the requested currency, and an amount inside every supplied bound. An absent total, currency mismatch, or out-of-range amount is unsatisfied: the seller returns `partial`, or `unable` when it cannot produce an honest draft, with `reason_code: "constraint_unsatisfiable"` and `unsatisfied_constraints: ["total_budget"]`. The other `constraints` keys behave identically under their satisfaction rules in the request schema. -Every draft on a `partial` result MUST satisfy every typed constraint whose key is absent from `unsatisfied_constraints`, and every product action absent from `unsatisfied_product_changes`, whatever the `reason_code`. This is the invariant that lets a buyer trust typed compliance without parsing prose. Constraint failures are identified through `unsatisfied_constraints` (echoing request `constraints` keys), `unsatisfied_product_changes` (a subset of the requested product-action map), or any combination of them. +Every draft on a `partial` result MUST satisfy every typed constraint whose key is absent from `unsatisfied_constraints`, every product action absent from `unsatisfied_product_changes`, and every installment entry absent from `unsatisfied_installment_changes`, whatever the `reason_code`. This is the invariant that lets a buyer trust typed compliance without parsing prose. Constraint failures are identified through `unsatisfied_constraints` (echoing request `constraints` keys), `unsatisfied_product_changes` (a subset of the requested product-action map), or any combination of them. A `partial` response to the request above looks like: @@ -158,6 +159,79 @@ Initial proposal discovery is different: `request_proposals.criteria.offer_filte The surfaces otherwise share `ask` and product `include`/`omit` verbs, but not response envelopes. Adapters must not invent support for per-proposal constraints or alternatives that the legacy envelope cannot represent faithfully. The legacy `more_like_this` verb has no typed compact equivalent and maps only lossily into `ask`. +## Choose installments (experimental) + +Experimental (`media_buy.installment_refinement`, [#8967](https://github.com/adcontextprotocol/adcp/issues/8967)). A buyer can ask for specific installments of a product that declares them: these two creator posts, not that one. Send `installment_changes` only to a seller that lists `media_buy.installment_refinement` in `experimental_features` and `installment_changes` in `media_buy.proposal_refinement.supported_dimensions`. A seller that doesn't support the dimension rejects it at task level with [`UNSUPPORTED_FEATURE`](/docs/building/verification/compliance-catalog#error-code-unsupported-feature), like any omitted dimension. + +Nothing new goes into `commercial_terms`. The seller answers with a draft that buys a product whose `installments[]` is exactly the chosen set, often one it derives for the selection. Buying that product buys its complete installment set, so `terms_digest` binds the choice through the `product_id`. + +```json +{ + "idempotency_key": "550e8400-e29b-41d4-a716-446655441007", + "refinements": [ + { + "proposal_id": "proposal_creators_q4", + "action": "revise", + "installment_changes": [ + { + "product_id": "creator_juniper_vale", + "installment_ref": { + "collection_ref": { "publisher_domain": "availnet.example", "collection_id": "juniper_vale" }, + "installment_id": "2026-12-10" + }, + "action": "omit" + } + ] + } + ] +} +``` + +If `creator_juniper_vale` declared the December 3 and December 10 posts, the selected set is the December 3 post alone. The returned draft buys a product that declares only that post, for example `creator_juniper_vale__2026-12-03`, and the response's `products` carries it. + +- **Selected set.** For each product named, the selected set is that product's `installments[]` with every `omit` removed and every `include` added. An `omit` names an installment the product declares. An `include` names an installment of a collection the product's `collections` name. +- **Satisfying draft.** Every returned draft buys exactly one product whose `installments[]` equals the selected set, in place of the named product unless they are the same. A `product_changes` `include` of a named product is satisfied by that purchase. An entry that names a product `product_changes` omits is contradictory. +- **Fixed sets.** A seller MUST NOT change the installments of a product it has offered. A different set is a different `product_id`. +- **Buyer check.** Before finalizing, the buyer compares each selected set with the returned product's `installments[]` and rejects a mismatch. +- **Shortfalls.** An entry the seller can't satisfy in every draft comes back in `unsatisfied_installment_changes`, repeating the request entry and adding a `reason`. The result is `partial` or `unable` with `reason_code: "constraint_unsatisfiable"`. Every draft on a `partial` result satisfies every entry the list leaves out. + +```json +{ + "source_proposal_id": "proposal_creators_q4", + "outcome": "partial", + "reason_code": "constraint_unsatisfiable", + "reason": "The December 17 post is already sold.", + "unsatisfied_installment_changes": [ + { + "product_id": "creator_juniper_vale", + "installment_ref": { + "collection_ref": { "publisher_domain": "availnet.example", "collection_id": "juniper_vale" }, + "installment_id": "2026-12-17" + }, + "action": "include", + "reason": "Sold to another buyer." + } + ], + "proposals": [ + { "proposal_id": "proposal_creators_q4_r2", "parent_proposal_id": "proposal_creators_q4", "proposal_status": "draft", "...": "..." } + ] +} +``` + +Checks run in this order, so an unauthorized caller never causes reference resolution: + +| # | Condition | Result | +|---|---|---| +| 1 | The seller declares `supported_dimensions` without `installment_changes` | Task-level [`UNSUPPORTED_FEATURE`](/docs/building/verification/compliance-catalog#error-code-unsupported-feature) before mutation | +| 2 | Two entries repeat the same `(product_id, installment_ref)`, whether or not their actions agree | Task-level [`INVALID_REQUEST`](/docs/building/verification/compliance-catalog#error-code-invalid-request); the seller never picks a winner by position | +| 3 | `product_id` is neither bought by the source proposal nor included by `product_changes` in the same request, or `product_changes` omits it | Task-level `INVALID_REQUEST`; no draft is created | +| 4 | `installment_ref` doesn't resolve for the named product | Task-level [`REFERENCE_NOT_FOUND`](/docs/building/verification/compliance-catalog#error-code-reference-not-found); no draft is created | +| 5 | The change resolves, but the seller will not or cannot make it | `partial` or `unable` with `reason_code: "constraint_unsatisfiable"` and the entry in `unsatisfied_installment_changes` with its `reason` | + +A swap is not atomic. The seller may satisfy the `omit`, fail the `include`, and return `partial` with a plan worse than the source, so say so in `ask` when you need all or nothing. Adding a creator and choosing its slots is one round trip, because an entry may name a product that `product_changes` includes in the same request. + +See [Choosing installments](/docs/media-buy/product-discovery/collections-and-installments#choosing-installments-experimental) for how a selection relates to collections and to [installment holds](#installment-holds-experimental). + ## Finalize and hold inventory Finalization is seller commitment, not buyer acceptance. It locks the already-negotiated terms and reserves inventory until the returned `expires_at`: diff --git a/docs/protocol/get_adcp_capabilities.mdx b/docs/protocol/get_adcp_capabilities.mdx index 28e60c80d02..acea09edb42 100644 --- a/docs/protocol/get_adcp_capabilities.mdx +++ b/docs/protocol/get_adcp_capabilities.mdx @@ -745,6 +745,7 @@ When `lifecycle_tools` includes `refine_proposals`, `proposal_refinement` option | `impressions` | `constraints.impressions` | Sum `commercial_terms.purchases[].impressions` and compare with the minimum. | | `flight` | `constraints.flight` | Compare `commercial_terms.start_time` and `end_time` with the window bounds. | | `product_changes` | `product_changes` | Check each keyed `include`/`omit` action against `commercial_terms.purchases[].product_id`. | +| `installment_changes` | `installment_changes` | Experimental (`media_buy.installment_refinement`, which the seller MUST also list in `experimental_features`). For each named product, check that the draft buys one product whose `installments[]` equals the selected set. See [Choose installments](/docs/media-buy/task-reference/refine_proposals#choose-installments-experimental). | | `alternatives` | `alternatives.count` | Count drafts and verify that their `commercial_terms` — and therefore `terms_digest` values — are distinct. | | `criteria` | `criteria` | Apply structured discovery changes and preserve omitted criteria from the source proposal. | diff --git a/docs/reference/experimental-status.mdx b/docs/reference/experimental-status.mdx index 5494106ea17..dd99c5e044a 100644 --- a/docs/reference/experimental-status.mdx +++ b/docs/reference/experimental-status.mdx @@ -145,6 +145,7 @@ Schemas marked with `x-status: experimental` are the authoritative source. The c | `media_buy.hold_ranking` | `hold_policy.rankable` (`core/hold-policy.json`) and the committed proposal's `hold_rank` (`core/canonical-proposal.json`): advisory readback of the buyer's own position in a seller's queue of holds, described in [Hold ranking](/docs/media-buy/task-reference/refine_proposals#hold-ranking-experimental) ([#8086](https://github.com/adcontextprotocol/adcp/issues/8086)) | One named adopter so far: Availnet, a creator marketplace. Ranking is advisory only: no right-of-first-refusal deadline, no rank-change webhook, and no read of a committed proposal, so `hold_rank` is a snapshot that a buyer refreshes through an `extend_hold` successor. Whether buyers need a fresher readback, and whether sellers will report exact queue depth, needs field experience. `available: true` without `rankable: true` keeps its exclusive, first-position meaning. Requires `media_buy.hold_policy`; omitted for callers below 3.3, for whom the seller treats `rankable` as `false`. | | `media_buy.collection_catalog` | [`list_collections`](/docs/media-buy/task-reference/list_collections) (schemas: `media-buy/list-collections-request.json`, `media-buy/list-collections-response.json`), its `collections_version` / `if_collections_version` tokens, and the `collection_card.updated`, `collection_card.removed` and `collection_catalog.bulk_change` webhook event types (branches of `core/wholesale-feed-event.json`, delivered in `core/wholesale-feed-webhook.json` and listed in `wholesale_feed_webhooks.event_types`) ([#8183](https://github.com/adcontextprotocol/adcp/issues/8183)) | Standalone catalog read and push channel for collection cards, with one adopter behind the card surface so far (Availnet, a creator marketplace) and no emitting agent yet. Requires `media_buy.collection_cards`, because the response carries card fields; a seller can serve cards in-band without it. Webhooks are optional within the feature and declared per type. The filter set, the reuse of the wholesale feed envelope with `collections_version` in `wholesale_feed_version`, and the bulk threshold for follower sweeps may change. Not served to callers below 3.3. Storyboards and a `seed_collection` controller scenario wait for training-agent support ([#8181](https://github.com/adcontextprotocol/adcp/issues/8181)). | | `media_buy.installment_holds` | The committed proposal's `holds` array (`core/canonical-proposal.json`): `product_id`, `installment_ref`, and, under a rankable policy, `hold_rank` for each installment the hold covers, described in [Installment holds](/docs/media-buy/task-reference/refine_proposals#installment-holds-experimental) ([#8086](https://github.com/adcontextprotocol/adcp/issues/8086)) | One named adopter so far: Availnet, a creator marketplace, where the held unit is a creator's specific upcoming video. Readback only: entries describe one all-or-none hold and share the proposal's `expires_at`, so there are no partial holds. Whether entries need their own `expires_at`, which only makes sense if partial holds are ever allowed, needs field experience. Requires `media_buy.hold_policy`; omitted for callers below 3.3. | +| `media_buy.installment_refinement` | `installment_changes` on [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals#choose-installments-experimental) (`core/installment-change-set.json`, `core/installment-change.json`), `unsatisfied_installment_changes` on its results, and the `installment_changes` value of `media_buy.proposal_refinement.supported_dimensions` ([#8967](https://github.com/adcontextprotocol/adcp/issues/8967)) | One adopter so far: Availnet, a creator marketplace, through Apostra. The buyer chooses installments during negotiation, and the seller answers with a product whose `installments[]` is the chosen set, often one it derives for the selection; nothing new enters `commercial_terms`. 4.0 binds the chosen installments in the signed terms instead ([Installment selection](/docs/reference/migration/v4-compatibility#installment_selection_required_in_4_0)), so this surface may be replaced then. Not the withdrawn `media_buy.installment_selection`, which named the signed field. | | `identity.trust_json` | The `trust/v1/trust.json` and `trust/v1/trust-acknowledgements.json` schemas, described in [trust.json (experimental)](/docs/reference/trust-json) ([RFC #7809](https://github.com/adcontextprotocol/adcp/issues/7809), [#7819](https://github.com/adcontextprotocol/adcp/pull/7819)). In 3.3 its reader is the experimental Web Bot Auth signing profile, which derives a trust record from an agent's origin ([Web Bot Auth identities](/docs/building/by-layer/L1/security#web-bot-auth-identities)); grants are read at `sync_accounts` and on the freshness schedule ([Grants in trust records](/docs/building/by-layer/L2/accounts-and-agents#grants-in-trust-records-experimental), [DR-0025](https://github.com/adcontextprotocol/adcp/blob/main/governance/decisions/DR-0025-demand-side-grants-in-trust-json.md)). No one is required to publish `/.well-known/trust.json`, and publishing one is not implementing a task, so publishers declare nothing in `experimental_features`. | The document shape for agents, grants, and acknowledgements is published ahead of the behavior that would use it, so implementers can review it. Agents are identified by origin, one per origin, and the record carries no keys ([`specs/agent-identity-3-3.md`](https://github.com/adcontextprotocol/adcp/blob/main/specs/agent-identity-3-3.md)). The document name and its `/.well-known` path may change before graduation: the JournalList `trust.txt` naming outreach is still open. The record's location is derived from the agent's origin, so no capability pointer is planned. Resolution and no-downgrade rules are in the Web Bot Auth identities section of the security page. | | `media_buy.collection_cards` | The `images`, `sample_content` and `url` fields on `core/collection.json`, the `brand_ref` field on `core/talent.json`, the `sample_content` role on `core/product-card-reference-asset.json` (#8092), and the `followers` field on `core/collection-distribution.json` entries, a `core/audience-size.json` count (#8098); the top-level `collections` array (`core/response-collection.json`) on the `list_products`, `request_proposals` and `refine_proposals` responses, and the `include_collections` request flag that opts into it (#8137). The 3.3 betas also carried both on the deprecated `get_products`; they are withdrawn from it (#8501). See [collection cards](/docs/media-buy/product-discovery/collections-and-installments#collection-cards-experimental). | Display and identity slice of the creator-card RFC, with a single adopter so far (Availnet, a creator marketplace) and no field-testing across parties yet. The array bounds, the use of `published-post` references for samples, the `brand_ref`-over-`brand_url` precedence, the opt-in flag, and the closed list of fields (`images`, `sample_content`, `url`, `description`, `season`, `cadence`, `distribution[].followers`) on which a response copy wins over `adagents.json` may change. `adagents.json` publishers cannot declare `experimental_features`, so consumers treat the collection fields as experimental wherever they appear. Sellers that emit the `sample_content` role or response `collections`, or accept `include_collections`, declare the id. Whether a follower count belongs on the distribution entry, and how sellers report platform-rounded counts, may change. Audience composition (`audience_evidence`) and average views (forecast `views`) reuse existing surfaces and are not part of this surface. | | `brand.collection_links` | The `collections` field on the [`get_brand_identity`](/docs/brand-protocol/tasks/get_brand_identity) response and its `collections` value in the request `fields` and response `available_fields` lists, plus `publisher_domain` on `brand.json` brand-level collection links (#8137) | Brand-side half of the creator-card work: lets a buyer go from a talent's `brand_ref` to the person's channels and confirm the link from both sides. One adopter so far (Availnet, a creator marketplace). The link shape, the public visibility level, and whether `seller_agent_url` stays on the link may change. `brand.json` publishers cannot declare `experimental_features`, so consumers treat `publisher_domain` there as experimental. Brand agents that return `collections` declare the id. | diff --git a/docs/reference/known-limitations.mdx b/docs/reference/known-limitations.mdx index e866ac40855..197775a2e5f 100644 --- a/docs/reference/known-limitations.mdx +++ b/docs/reference/known-limitations.mdx @@ -54,7 +54,7 @@ Request-specific configured offers are not a gap: canonical products carry no `i `installments` was the other absent member and shipped in 3.3 ([#8230](https://github.com/adcontextprotocol/adcp/issues/8230)). These bounds on it remain: -- **A purchase buys the product as offered; it cannot name slots.** `commercial_terms` is held closed until 4.0, so a purchase cannot carry the exact installments it buys and `refine_proposals` cannot drop or swap one. A buyer sees which slots a product includes and can accept or decline the product as a fixed set. Installment selection inside `purchases[]` is planned for 4.0, where `commercial_terms` gets a declared extension point with must-understand ([adcp#8501](https://github.com/adcontextprotocol/adcp/issues/8501), [Planned for 4.0](/docs/reference/migration/v4-compatibility#installment_selection_required_in_4_0)). Installment references are seller-owned data and are not definition-pinned; a buyer detects a rescheduled slot by re-reading the product, bounded by each installment's `valid_until`. Seller-initiated reschedule offers are [#8102](https://github.com/adcontextprotocol/adcp/issues/8102). +- **A purchase buys the product as offered; it cannot name slots.** `commercial_terms` is held closed until 4.0, so a purchase cannot carry the exact installments it buys. A buyer chooses slots during negotiation instead: with experimental `installment_changes` on `refine_proposals` (`media_buy.installment_refinement`), the seller answers with a product whose installments are the chosen set ([Choose installments](/docs/media-buy/task-reference/refine_proposals#choose-installments-experimental)). The digest binds that product's `product_id`, not the slots, so the selection holds because a seller MUST NOT change an offered product's installments. Installment selection inside `purchases[]` is planned for 4.0, where `commercial_terms` gets a declared extension point with must-understand ([adcp#8501](https://github.com/adcontextprotocol/adcp/issues/8501), [Planned for 4.0](/docs/reference/migration/v4-compatibility#installment_selection_required_in_4_0)). Installment references are seller-owned data and are not definition-pinned; a buyer detects a rescheduled slot by re-reading the product, bounded by each installment's `valid_until`. Seller-initiated reschedule offers are [#8102](https://github.com/adcontextprotocol/adcp/issues/8102). - **Absence is run-of-collection only inside a projection that asked for the field,** and only in one direction. A product that declares `collections` and omits `installments` in a projection that requested them is run-of-collection by definition. A product that declares neither says nothing about scheduled inventory; the compact lifecycle has no way to assert "this collection has no bookable installments in the flight", and buyers that need that answer re-query closer to the flight using the installments' `valid_until`. - **No per-installment pricing or forecast.** `pricing_options` and `forecast` stay product-scoped, so a product whose slots carry different rates is modelled as several products. - **Holds read back the slate, not a bought subset.** Per-installment hold readback shipped as the committed proposal's `holds[]` (experimental, `media_buy.installment_holds`, [#8086](https://github.com/adcontextprotocol/adcp/issues/8086), [Installment holds](/docs/media-buy/task-reference/refine_proposals#installment-holds-experimental)). It sits outside `commercial_terms`, so it is readback rather than contract and is not covered by `terms_digest`. Every entry's `product_id` is a product in `commercial_terms.purchases`, every installment is one that product includes within that purchase's flight, and a seller declaring the feature MUST emit `holds` on any committed proposal allocating a product that declares installments. Because a 3.3 purchase buys the product as offered, the product's installments bound the entries. No rule says `holds[]` MUST enumerate exactly the set the seller reserved, so a conformant seller can read back a hold narrower than the one it took; that wording is owned by [#8086](https://github.com/adcontextprotocol/adcp/issues/8086). Whether revising a proposal releases a hold remains seller policy. diff --git a/docs/reference/migration/v4-compatibility.mdx b/docs/reference/migration/v4-compatibility.mdx index 9d0f49f12f9..8c7862804b1 100644 --- a/docs/reference/migration/v4-compatibility.mdx +++ b/docs/reference/migration/v4-compatibility.mdx @@ -177,7 +177,7 @@ Nothing here is graded. 3.3 has no wire form for any of it after beta.1, so a ru **What it was:** experimental `media_buy.installment_selection`: `purchases[].installments[]` named the exact slots a purchase buys, and `refine_proposals` gained `installment_changes` with `unsatisfied_installment_changes` to drop or swap one ([#8246](https://github.com/adcontextprotocol/adcp/pull/8246), merged 2026-10-24 and published in `3.3.0-beta.1`). It is removed after beta.1. -**What stays in 3.3:** `installments[]` on the canonical product (`core/canonical-installment.json`), the `installments` entry in `list_products` `fields`, and installment hold readback (`holds[]`). A purchase buys the product as offered, which is the product's complete installment set when it declares one. +**What stays in 3.3:** `installments[]` on the canonical product (`core/canonical-installment.json`), the `installments` entry in `list_products` `fields`, and installment hold readback (`holds[]`). A purchase buys the product as offered, which is the product's complete installment set when it declares one. A buyer chooses installments during negotiation with `installment_changes` on `refine_proposals`, under the separate experimental feature id `media_buy.installment_refinement` ([#8967](https://github.com/adcontextprotocol/adcp/issues/8967)): the seller answers with a product whose `installments[]` is the chosen set, and the digest binds that product's `product_id`. 4.0 binds the slots themselves, and products derived for one selection can retire then. **Design to keep:** diff --git a/package.json b/package.json index 341915eeb11..4e11f7aa6ba 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "verify:schema-discovery": "node scripts/verify-schema-discovery.mjs", "typecheck": "tsc --project server/tsconfig.json --noEmit && npm run typecheck:fixed-trace-rollout-tests", "typecheck:fixed-trace-rollout-tests": "node scripts/typecheck-fixed-trace-rollout-tests.mjs", - "test:schemas": "node tests/schema-validation.test.cjs && node --test tests/account-ref.test.cjs tests/canonical-account-ref.test.cjs tests/image-bleed.test.cjs tests/sync-plans-reserved-names.test.cjs tests/seller-offers-contract.test.cjs tests/service-commitments.test.cjs tests/sync-platform-id.test.cjs tests/assignment-landing-page.test.cjs tests/assignment-trackers.test.cjs tests/assignment-replace.test.cjs tests/outcome-target.test.cjs tests/trusted-match-offer-creative-data.test.cjs tests/accessibility-violation-details.test.cjs tests/portfolio-routing-scope.test.cjs tests/catalog-item-availability-updates.test.cjs tests/catalog-ingestion-capabilities.test.cjs tests/sdk-implementation-metadata.test.cjs tests/seller-optimized-package-controls.test.cjs tests/compact-product-lifecycle-storyboards.test.cjs tests/timezone-resolution-storyboards.test.cjs tests/terminal-package-retention-storyboard.test.cjs tests/total-budget-redistribution-currency.test.cjs tests/dooh-allocation.test.cjs tests/x-added-in-3-3-sweep.test.cjs tests/url-pattern-loosening.test.cjs tests/performance-standard-metric-id.test.cjs tests/identity-absence-coherence.test.cjs tests/match-keys.test.cjs tests/execution-requirements.test.cjs tests/execution-readiness.test.cjs tests/schema-deprecation-metadata.test.cjs tests/products-only-brief-compatibility.test.cjs tests/async-identity-convergence.test.cjs tests/creative-rotation.test.cjs tests/creative-start-policy.test.cjs tests/canonical-forecast-point-parity.test.cjs tests/creative-revisions.test.cjs tests/creative-live-updates.test.cjs tests/creative-delivery-contracts.test.cjs tests/tracker-execution-contracts.test.cjs tests/tracker-execution-package-integration.test.cjs tests/metric-identity-coherence.test.cjs tests/print-metrics.test.cjs tests/installment-lifecycle-reporting.test.cjs tests/rights-channel-restrictions.test.cjs tests/daypart-time-of-day.test.cjs tests/audience-size-range.test.cjs tests/media-buy-lineage.test.cjs tests/delivery-projection.test.cjs tests/viewable-rate-optimization-goal.test.cjs tests/sort-contract-delivery-reporting.test.cjs tests/time-based-views-contract.test.cjs tests/metric-qualifier-parity.test.cjs tests/collection-mechanism.test.cjs tests/requested-metrics-contract.test.cjs tests/auto-breakdown-negotiation-contract.test.cjs tests/format-delivery-reporting-contract.test.cjs tests/inventory-delivery-reporting-contract.test.cjs tests/property-delivery-conformance.test.cjs tests/lint-schema-enum-drift.test.cjs tests/lint-schema-required-declared.test.cjs tests/format-asset-type-parity.test.cjs tests/canonical-manifest-constructibility.test.cjs tests/synthetic-depiction.test.cjs tests/creative-rendering-authority.test.cjs tests/buyer-reason.test.cjs tests/mutation-outcome.test.cjs tests/error-recovery-vectors.test.cjs tests/reporting-status-contract.test.cjs tests/reporting-reconciliation-fixture.test.cjs tests/reporting-core-fixture.test.cjs tests/reporting-native-version-ref-bounds.test.cjs tests/trust-json-v1.test.cjs tests/refine-proposals-expected-confirmation-at.test.cjs tests/hold-policy.test.cjs tests/hold-policy-vectors.test.cjs tests/canonical-installments.test.cjs tests/withdrawn-to-4-0.test.cjs tests/get-products-deprecated-withdrawals.test.cjs tests/deprecated-task-withdrawals.test.cjs tests/collection-catalog.test.cjs tests/collection-copies-vectors.test.cjs tests/account-change-record.test.cjs tests/notification-config-payload-view.test.cjs tests/account-requests.test.cjs tests/reporting-subscriptions.test.cjs tests/reporting-subscription-probe.test.cjs tests/reporting-subscription-coverage-vectors.test.cjs tests/reporting-subscription-coverage-properties.test.cjs tests/product-roster-coverage-vectors.test.cjs && npm run test:premium-display-formats && npm run test:geo-region-targeting", + "test:schemas": "node tests/schema-validation.test.cjs && node --test tests/account-ref.test.cjs tests/canonical-account-ref.test.cjs tests/image-bleed.test.cjs tests/sync-plans-reserved-names.test.cjs tests/seller-offers-contract.test.cjs tests/service-commitments.test.cjs tests/sync-platform-id.test.cjs tests/assignment-landing-page.test.cjs tests/assignment-trackers.test.cjs tests/assignment-replace.test.cjs tests/outcome-target.test.cjs tests/trusted-match-offer-creative-data.test.cjs tests/accessibility-violation-details.test.cjs tests/portfolio-routing-scope.test.cjs tests/catalog-item-availability-updates.test.cjs tests/catalog-ingestion-capabilities.test.cjs tests/sdk-implementation-metadata.test.cjs tests/seller-optimized-package-controls.test.cjs tests/compact-product-lifecycle-storyboards.test.cjs tests/timezone-resolution-storyboards.test.cjs tests/terminal-package-retention-storyboard.test.cjs tests/total-budget-redistribution-currency.test.cjs tests/dooh-allocation.test.cjs tests/x-added-in-3-3-sweep.test.cjs tests/url-pattern-loosening.test.cjs tests/performance-standard-metric-id.test.cjs tests/identity-absence-coherence.test.cjs tests/match-keys.test.cjs tests/execution-requirements.test.cjs tests/execution-readiness.test.cjs tests/schema-deprecation-metadata.test.cjs tests/products-only-brief-compatibility.test.cjs tests/async-identity-convergence.test.cjs tests/creative-rotation.test.cjs tests/creative-start-policy.test.cjs tests/canonical-forecast-point-parity.test.cjs tests/creative-revisions.test.cjs tests/creative-live-updates.test.cjs tests/creative-delivery-contracts.test.cjs tests/tracker-execution-contracts.test.cjs tests/tracker-execution-package-integration.test.cjs tests/metric-identity-coherence.test.cjs tests/print-metrics.test.cjs tests/installment-lifecycle-reporting.test.cjs tests/rights-channel-restrictions.test.cjs tests/daypart-time-of-day.test.cjs tests/audience-size-range.test.cjs tests/media-buy-lineage.test.cjs tests/delivery-projection.test.cjs tests/viewable-rate-optimization-goal.test.cjs tests/sort-contract-delivery-reporting.test.cjs tests/time-based-views-contract.test.cjs tests/metric-qualifier-parity.test.cjs tests/collection-mechanism.test.cjs tests/requested-metrics-contract.test.cjs tests/auto-breakdown-negotiation-contract.test.cjs tests/format-delivery-reporting-contract.test.cjs tests/inventory-delivery-reporting-contract.test.cjs tests/property-delivery-conformance.test.cjs tests/lint-schema-enum-drift.test.cjs tests/lint-schema-required-declared.test.cjs tests/format-asset-type-parity.test.cjs tests/canonical-manifest-constructibility.test.cjs tests/synthetic-depiction.test.cjs tests/creative-rendering-authority.test.cjs tests/buyer-reason.test.cjs tests/mutation-outcome.test.cjs tests/error-recovery-vectors.test.cjs tests/reporting-status-contract.test.cjs tests/reporting-reconciliation-fixture.test.cjs tests/reporting-core-fixture.test.cjs tests/reporting-native-version-ref-bounds.test.cjs tests/trust-json-v1.test.cjs tests/refine-proposals-expected-confirmation-at.test.cjs tests/hold-policy.test.cjs tests/hold-policy-vectors.test.cjs tests/canonical-installments.test.cjs tests/withdrawn-to-4-0.test.cjs tests/installment-refinement.test.cjs tests/get-products-deprecated-withdrawals.test.cjs tests/deprecated-task-withdrawals.test.cjs tests/collection-catalog.test.cjs tests/collection-copies-vectors.test.cjs tests/account-change-record.test.cjs tests/notification-config-payload-view.test.cjs tests/account-requests.test.cjs tests/reporting-subscriptions.test.cjs tests/reporting-subscription-probe.test.cjs tests/reporting-subscription-coverage-vectors.test.cjs tests/reporting-subscription-coverage-properties.test.cjs tests/product-roster-coverage-vectors.test.cjs && npm run test:premium-display-formats && npm run test:geo-region-targeting", "test:performance-feedback": "node --test --test-force-exit --test-timeout=30000 tests/performance-feedback-contract.test.cjs", "test:dist-schema-version-ids": "node --test --test-force-exit --test-timeout=30000 tests/dist-schema-version-ids.test.cjs", "test:examples": "node tests/example-validation-simple.test.cjs && npm run test:tmp-context-merge", diff --git a/static/compliance/source/universal/v4-compatibility.yaml b/static/compliance/source/universal/v4-compatibility.yaml index fe0a4a7a4d5..bb533e82ef6 100644 --- a/static/compliance/source/universal/v4-compatibility.yaml +++ b/static/compliance/source/universal/v4-compatibility.yaml @@ -300,17 +300,20 @@ checks: applies_to: [sellers] detection: "A proposal for a product that declares installments cannot name the exact installments a purchase buys, or accept a refine_proposals installment_changes list." fix: >- - Nothing in 3.3. A purchase buys the product as offered, and the - installments a product includes are readable on the product. Selecting - installments inside purchases[] and refining them with - installment_changes wait for 4.0, where commercial_terms gets a declared - extension point that the buyer must understand (adcp#8501). + A purchase buys the product as offered, and the installments a product + includes are readable on the product. In 3.3 a buyer chooses installments + with installment_changes on refine_proposals + (media_buy.installment_refinement, adcp#8967), and the seller answers + with a product whose installments are the chosen set. Naming the bought + installments inside purchases[] waits for 4.0, where commercial_terms + gets a declared extension point that the buyer must understand + (adcp#8501). observed_by: none: reason: >- - Selection is a buyer-sent and seller-authored term on - commercial_terms, which is closed in 3.x. 3.3 has no wire form for - it, so a 3.x run cannot observe it. + The signed selection is a buyer-sent and seller-authored term on + commercial_terms, which is held closed until 4.0. 3.3 has no wire + form for it, so a 3.x run cannot observe it. - id: canonical_counting_mechanisms_required_in_4_0 issue: 6878 change: new_requirement diff --git a/static/schemas/source/core/installment-change-set.json b/static/schemas/source/core/installment-change-set.json new file mode 100644 index 00000000000..24d8459b68a --- /dev/null +++ b/static/schemas/source/core/installment-change-set.json @@ -0,0 +1,20 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/installment-change-set.json", + "title": "Installment Change Set", + "x-status": "experimental", + "x-added-in": "3.3.0", + "description": "Installment-level actions for one refine_proposals request (media_buy.installment_refinement). A tuple cannot be an object key, so this is a list rather than a map like core/product-change-map.json. Entries MUST be unique by (product_id, installment_ref); a seller that receives a repeated or contradictory pair rejects the request at task level with INVALID_REQUEST. Buyers SHOULD order entries by the JCS bytes of (product_id, installment_ref) so a rebuilt retry hashes to the same idempotency payload, and sellers MUST NOT make the result depend on order. For each product P the entries name, the selected set is P's installments with every omit removed and every include added. A returned draft satisfies P's entries when it purchases exactly one product whose installments equal the selected set: P itself, or a product the seller offers for that selection in place of P.", + "type": "array", + "items": { "$ref": "/schemas/core/installment-change.json" }, + "minItems": 1, + "maxItems": 500, + "uniqueItems": true, + "x-adcp-validation": { + "unique_by": ["product_id", "installment_ref"], + "ordered_by": "jcs_bytes_of_entry", + "verifier_constraints": { + "duplicate_entries": "reject_at_task_level_with_invalid_request" + } + } +} diff --git a/static/schemas/source/core/installment-change.json b/static/schemas/source/core/installment-change.json new file mode 100644 index 00000000000..7adc6dc1210 --- /dev/null +++ b/static/schemas/source/core/installment-change.json @@ -0,0 +1,28 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/installment-change.json", + "title": "Installment Change", + "x-status": "experimental", + "x-added-in": "3.3.0", + "description": "One installment-level action in a refine_proposals request (media_buy.installment_refinement). It names a product by product_id and an installment by its closed canonical reference. It is a request, not a term: nothing in commercial_terms changes shape.", + "type": "object", + "properties": { + "product_id": { + "type": "string", + "minLength": 1, + "x-entity": "product", + "description": "A product the source proposal purchases, or one that product_changes includes in the same request. The seller checks this membership before it resolves installment_ref, so an unauthorized caller never causes reference resolution." + }, + "installment_ref": { + "$ref": "/schemas/core/canonical-installment-ref.json", + "description": "Installment the action applies to. An omit names one in the product's installments. An include names an installment of a collection the product's collections name. A reference the seller cannot resolve fails the request at task level with REFERENCE_NOT_FOUND, not as an unsatisfied change." + }, + "action": { + "type": "string", + "enum": ["include", "omit"], + "description": "include puts the installment in the selection and omit takes it out. It is required, because a drop that silently defaulted to include is the expensive failure. Swapping a slot is one omit plus one include in the same request." + } + }, + "required": ["product_id", "installment_ref", "action"], + "additionalProperties": true +} diff --git a/static/schemas/source/enums/proposal-refinement-reason.json b/static/schemas/source/enums/proposal-refinement-reason.json index 7c414209a32..81bd4f957a3 100644 --- a/static/schemas/source/enums/proposal-refinement-reason.json +++ b/static/schemas/source/enums/proposal-refinement-reason.json @@ -16,7 +16,7 @@ ], "enumDescriptions": { "commercially_declined": "The seller understood terms requested through the free-text ask but declined to offer them. An unmet typed constraint or product change always uses constraint_unsatisfiable instead, whether the seller cannot or will not meet it.", - "constraint_unsatisfiable": "One or more typed hard constraints or product changes were not satisfied. Takes precedence over every other code when failures co-occur.", + "constraint_unsatisfiable": "One or more typed hard constraints, product changes, or installment changes were not satisfied. Takes precedence over every other code when failures co-occur.", "unsupported_dimension": "The seller cannot parse or validate one or more typed refinement dimensions used by the request.", "uninterpreted": "The seller could not interpret some or all of the free-text ask.", "alternatives_unavailable": "The seller produced fewer distinct draft alternatives than requested.", diff --git a/static/schemas/source/index.json b/static/schemas/source/index.json index 5085ec4182b..ef1688dd2f2 100644 --- a/static/schemas/source/index.json +++ b/static/schemas/source/index.json @@ -369,6 +369,14 @@ "$ref": "/schemas/core/installment-deadlines.json", "description": "Booking, cancellation, and material submission deadlines for an installment" }, + "installment-change": { + "$ref": "/schemas/core/installment-change.json", + "description": "One installment-level include or omit action in a refine_proposals request (experimental)" + }, + "installment-change-set": { + "$ref": "/schemas/core/installment-change-set.json", + "description": "Installment-level include and omit actions for a refine_proposals request (experimental)" + }, "material-deadline": { "$ref": "/schemas/core/material-deadline.json", "description": "A deadline for creative material submission at a specific stage" diff --git a/static/schemas/source/media-buy/proposal-refinement.json b/static/schemas/source/media-buy/proposal-refinement.json index a13d4668b81..7812e1b1992 100644 --- a/static/schemas/source/media-buy/proposal-refinement.json +++ b/static/schemas/source/media-buy/proposal-refinement.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/media-buy/proposal-refinement.json", "title": "Proposal Refinement", - "description": "Fork an immutable proposal, finalize a draft into a held committed snapshot, or extend a committed hold into a new committed snapshot. Revising with structured criteria, typed boundaries, product changes, requested alternatives, and/or a semantic ask creates one or more new drafts; finalizing and extending a hold change no terms. Refining an accepted proposal creates a draft amendment or cancellation proposal against its MediaBuy; the source remains accepted and unchanged. A revised product mix must support the resulting root frequency cap; because omitted criteria inherit, clearing the cap is explicit through remove_media_buy_frequency_cap.", + "description": "Fork an immutable proposal, finalize a draft into a held committed snapshot, or extend a committed hold into a new committed snapshot. Revising with structured criteria, typed boundaries, product changes, installment changes, requested alternatives, and/or a semantic ask creates one or more new drafts; finalizing and extending a hold change no terms. Refining an accepted proposal creates a draft amendment or cancellation proposal against its MediaBuy; the source remains accepted and unchanged. A revised product mix must support the resulting root frequency cap; because omitted criteria inherit, clearing the cap is explicit through remove_media_buy_frequency_cap.", "type": "object", "properties": { "proposal_id": { @@ -69,6 +69,12 @@ "$ref": "/schemas/core/product-change-map.json", "description": "Product IDs mapped to membership actions checked against commercial_terms.purchases. include requires presence; omit requires absence. The verbs match legacy get_products refinement." }, + "installment_changes": { + "$ref": "/schemas/core/installment-change-set.json", + "x-status": "experimental", + "x-added-in": "3.3.0", + "description": "Experimental (media_buy.installment_refinement). Asks for specific installments of a product. Send it only to a seller that lists media_buy.installment_refinement in experimental_features and installment_changes in media_buy.proposal_refinement.supported_dimensions. A satisfying draft purchases, for each named product, one product whose installments equal the selected set; a different set is a different product_id, so the seller may offer a product derived for the selection in its place. The seller MUST NOT change the installments of a product it has offered. Before finalizing, the buyer compares each selected set with the returned product's installments and rejects a mismatch. An entry MUST NOT name a product that product_changes omits in the same request (INVALID_REQUEST); a product_changes include of a named product is satisfied by the purchase that answers its selection. Nothing in commercial_terms changes: terms_digest binds the purchased product_id as it always has." + }, "alternatives": { "type": "object", "description": "Request drafts with distinct commercial terms by count. Every returned alternative MUST carry distinct commercial_terms and therefore a unique terms_digest; diversity preferences remain in ask.", @@ -108,6 +114,7 @@ { "required": ["change_kind"] }, { "required": ["constraints"] }, { "required": ["product_changes"] }, + { "required": ["installment_changes"] }, { "required": ["alternatives"] }, { "required": ["ask"] }, { "required": ["criteria"] }, @@ -125,6 +132,7 @@ { "required": ["change_kind"] }, { "required": ["constraints"] }, { "required": ["product_changes"] }, + { "required": ["installment_changes"] }, { "required": ["alternatives"] }, { "required": ["ask"] }, { "required": ["criteria"] }, @@ -138,6 +146,7 @@ "anyOf": [ { "required": ["constraints"] }, { "required": ["product_changes"] }, + { "required": ["installment_changes"] }, { "required": ["alternatives"] }, { "required": ["ask"] }, { "required": ["criteria"] }, diff --git a/static/schemas/source/media-buy/refine-proposals-response.json b/static/schemas/source/media-buy/refine-proposals-response.json index 8997bcccc5b..3be4f398858 100644 --- a/static/schemas/source/media-buy/refine-proposals-response.json +++ b/static/schemas/source/media-buy/refine-proposals-response.json @@ -66,6 +66,21 @@ "allOf": [{ "$ref": "/schemas/core/product-change-map.json" }], "description": "Requested product actions not satisfied by every returned draft. This is a subset of the request product_changes map and is valid only on partial or unable results." }, + "unsatisfied_installment_changes": { + "type": "array", + "x-status": "experimental", + "x-added-in": "3.3.0", + "description": "Experimental (media_buy.installment_refinement). Requested installment actions not satisfied by every returned draft. Each entry repeats a request installment_changes entry exactly and adds the reason it was not satisfied. It is a subset of the request list and is valid only on partial or unable results, whose reason_code is constraint_unsatisfiable. Every draft on a partial result satisfies every entry absent from this list.", + "items": { + "allOf": [{ "$ref": "/schemas/core/installment-change.json" }], + "properties": { + "reason": { "type": "string", "minLength": 1, "maxLength": 1024, "description": "Why the seller did not satisfy this entry, for example that the installment is sold, held for another buyer, past its booking deadline, or sold only with others." } + }, + "required": ["reason"] + }, + "minItems": 1, + "maxItems": 500 + }, "suggestions": { "type": "array", "items": { "type": "string", "minLength": 1 }, @@ -95,7 +110,8 @@ { "required": ["reason_code"] }, { "required": ["reason"] }, { "required": ["unsatisfied_constraints"] }, - { "required": ["unsatisfied_product_changes"] } + { "required": ["unsatisfied_product_changes"] }, + { "required": ["unsatisfied_installment_changes"] } ] } }, @@ -130,7 +146,8 @@ { "required": ["reason_code"] }, { "required": ["reason"] }, { "required": ["unsatisfied_constraints"] }, - { "required": ["unsatisfied_product_changes"] } + { "required": ["unsatisfied_product_changes"] }, + { "required": ["unsatisfied_installment_changes"] } ] } }, @@ -154,7 +171,8 @@ "then": { "anyOf": [ { "required": ["unsatisfied_constraints"] }, - { "required": ["unsatisfied_product_changes"] } + { "required": ["unsatisfied_product_changes"] }, + { "required": ["unsatisfied_installment_changes"] } ] } } diff --git a/static/schemas/source/protocol/get-adcp-capabilities-response.json b/static/schemas/source/protocol/get-adcp-capabilities-response.json index f2671b2f1cf..e5a2f579007 100644 --- a/static/schemas/source/protocol/get-adcp-capabilities-response.json +++ b/static/schemas/source/protocol/get-adcp-capabilities-response.json @@ -392,6 +392,32 @@ "required": ["experimental_features"] } }, + { + "if": { + "properties": { + "media_buy": { + "properties": { + "proposal_refinement": { + "properties": { + "supported_dimensions": { "contains": { "const": "installment_changes" } } + }, + "required": ["supported_dimensions"] + } + }, + "required": ["proposal_refinement"] + } + }, + "required": ["media_buy"] + }, + "then": { + "properties": { + "experimental_features": { + "contains": { "const": "media_buy.installment_refinement" } + } + }, + "required": ["experimental_features"] + } + }, { "description": "Listing media_buy.collection_catalog (list_collections and collection card webhooks) requires media_buy.collection_cards, because the response carries card fields.", "if": { @@ -1824,10 +1850,10 @@ "properties": { "supported_dimensions": { "type": "array", - "description": "Typed revision dimensions the seller can parse and validate. An empty list authoritatively means ask-only refinement with no typed dimensions. total_budget, cpm, impressions, and flight cover the same-named constraints keys; product_changes covers product_changes; alternatives covers alternatives.count; criteria covers structured discovery criteria. Free-text ask interpretation is competence, not a capability declared here.", + "description": "Typed revision dimensions the seller can parse and validate. An empty list authoritatively means ask-only refinement with no typed dimensions. total_budget, cpm, impressions, and flight cover the same-named constraints keys; product_changes covers product_changes; installment_changes (experimental, media_buy.installment_refinement) covers installment_changes; alternatives covers alternatives.count; criteria covers structured discovery criteria. Free-text ask interpretation is competence, not a capability declared here.", "items": { "type": "string", - "enum": ["total_budget", "cpm", "impressions", "flight", "product_changes", "alternatives", "criteria"] + "enum": ["total_budget", "cpm", "impressions", "flight", "product_changes", "installment_changes", "alternatives", "criteria"] }, "uniqueItems": true }, diff --git a/tests/canonical-installments.test.cjs b/tests/canonical-installments.test.cjs index 693486e8d7a..137a48ed15d 100644 --- a/tests/canonical-installments.test.cjs +++ b/tests/canonical-installments.test.cjs @@ -225,7 +225,8 @@ test('the documentation records the closed gap, the live boundaries, and the wit assert.match(limitations, /canonical and legacy installment shapes will stay forked/); const experimental = readDoc('reference/experimental-status.mdx'); - assert.doesNotMatch(experimental, /media_buy\.installment_selection/, 'the withdrawn feature id has no registry row'); + // The refinement row's prose may name the withdrawn id (adcp#8967); it must not be a row of its own. + assert.doesNotMatch(experimental, /^\| `media_buy\.installment_selection` \|/m, 'the withdrawn feature id has no registry row'); const guide = readDoc('media-buy/product-discovery/collections-and-installments.mdx'); assert.match(guide, /## Installments in the compact lifecycle/); diff --git a/tests/capabilities-structural-bounds.test.cjs b/tests/capabilities-structural-bounds.test.cjs index e0829aea5c2..bfda75a8dbe 100644 --- a/tests/capabilities-structural-bounds.test.cjs +++ b/tests/capabilities-structural-bounds.test.cjs @@ -27,7 +27,7 @@ const read = (...p) => fs.readFileSync(path.join(ROOT, ...p), 'utf8'); const security = read('docs/building/by-layer/L1/security.mdx'); // The documented worst case. Update security.mdx with this number. -const DOCUMENTED_WORST_CASE_BYTES = 99_101_090; +const DOCUMENTED_WORST_CASE_BYTES = 99_101_140; const DOCUMENTED_OPEN_POSITIONS = 190; const rows = bounds.coverage(); diff --git a/tests/installment-refinement.test.cjs b/tests/installment-refinement.test.cjs new file mode 100644 index 00000000000..d885fa43343 --- /dev/null +++ b/tests/installment-refinement.test.cjs @@ -0,0 +1,179 @@ +/** + * Contract test for experimental installment refinement (adcp#8967, + * media_buy.installment_refinement). + * + * A buyer names installments during refine_proposals; the seller answers with a + * product whose installments are the chosen set. Nothing enters + * commercial_terms, so the signed-terms shape stays closed + * (tests/withdrawn-to-4-0.test.cjs pins the withdrawn signed field). + */ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const Ajv = require('ajv'); +const addFormats = require('ajv-formats'); + +const ROOT = path.join(__dirname, '..'); +const SCHEMAS = path.join(ROOT, 'static/schemas/source'); +const { ensureBuiltSchemas, readBuiltSchema } = require('../scripts/built-schemas.cjs'); + +ensureBuiltSchemas(); + +const readSchema = (relative) => JSON.parse(fs.readFileSync(path.join(SCHEMAS, relative), 'utf8')); + +const compiled = new Map(); +function compile(schemaId) { + if (!compiled.has(schemaId)) { + const ajv = new Ajv({ allErrors: true, strict: false, discriminator: true, loadSchema: async (uri) => readBuiltSchema(uri) }); + addFormats(ajv); + compiled.set(schemaId, ajv.compileAsync(readBuiltSchema(schemaId))); + } + return compiled.get(schemaId); +} +const errors = (validate) => JSON.stringify(validate.errors, null, 2); + +const COLLECTION_REF = { publisher_domain: 'creatorloop.example', collection_id: 'creatorloop_channel_juniper_vale' }; +const ref = (id) => ({ collection_ref: { ...COLLECTION_REF }, installment_id: id }); +const PRODUCT_ID = 'creatorloop_creator_juniper_vale'; + +const OMIT = { product_id: PRODUCT_ID, installment_ref: ref('slot_1'), action: 'omit' }; +const INCLUDE = { product_id: PRODUCT_ID, installment_ref: ref('slot_3'), action: 'include' }; + +const revise = (extra) => ({ proposal_id: 'prop_1', action: 'revise', ...extra }); + +const DRAFT = { + proposal_id: 'creatorloop_draft_02', + parent_proposal_id: 'prop_1', + proposal_kind: 'new_media_buy', + proposal_status: 'draft', + expires_at: '2027-01-16T17:00:00Z', + name: 'Juniper Vale integrations', + commercial_terms: { + brand: { domain: 'nova-athletics.example' }, + purchases: [ + { + product_id: 'creatorloop_creator_juniper_vale_slots_2_3', + pricing_option_id: 'flat_usd', + pricing: { pricing_option_id: 'flat_usd', pricing_model: 'flat_rate', currency: 'USD', fixed_price: 12000 }, + budget: 12000, + start_time: '2027-03-01T00:00:00Z', + end_time: '2027-03-31T23:59:59Z', + }, + ], + start_time: '2027-03-01T00:00:00Z', + end_time: '2027-03-31T23:59:59Z', + total_budget: { amount: 12000, currency: 'USD' }, + }, + terms_digest: 'sha256:IO3B2jLn52O7-u_xeztyhTFDUJWbV4mQrCcTfCWpAQg', +}; + +const unsatisfied = { ...INCLUDE, reason: 'Installment is held for another buyer.' }; +const partial = (extra = {}) => ({ + source_proposal_id: 'prop_1', + outcome: 'partial', + proposals: [DRAFT], + reason_code: 'constraint_unsatisfiable', + reason: 'One requested installment is unavailable.', + unsatisfied_installment_changes: [unsatisfied], + ...extra, +}); +const response = (result) => ({ status: 'completed', results: [result], products: [] }); + +test('refinement request: installment_changes alone is a valid revise, including a swap', async () => { + const validate = await compile('/schemas/media-buy/proposal-refinement.json'); + assert.equal(validate(revise({ installment_changes: [OMIT] })), true, errors(validate)); + assert.equal(validate(revise({ installment_changes: [OMIT, INCLUDE] })), true, errors(validate)); +}); + +test('refinement request: malformed installment_changes are rejected', async () => { + const validate = await compile('/schemas/media-buy/proposal-refinement.json'); + const { action: _action, ...noAction } = OMIT; + assert.equal(validate(revise({ installment_changes: [noAction] })), false, 'action is required'); + assert.equal(validate(revise({ installment_changes: [{ ...OMIT, action: 'swap' }] })), false, 'swap is not a verb'); + assert.equal( + validate(revise({ installment_changes: [{ ...OMIT, installment_ref: { ...ref('slot_1'), extra: 1 } }] })), + false, + 'installment_ref is closed' + ); + assert.equal(validate(revise({ installment_changes: [] })), false, 'empty list'); + assert.equal(validate(revise({ installment_changes: [OMIT, { ...OMIT }] })), false, 'exact duplicate entry'); +}); + +test('refinement request: finalize and extend_hold forbid installment_changes', async () => { + const validate = await compile('/schemas/media-buy/proposal-refinement.json'); + assert.equal(validate({ proposal_id: 'prop_1', action: 'finalize' }), true, errors(validate)); + assert.equal(validate({ proposal_id: 'prop_1', action: 'finalize', installment_changes: [OMIT] }), false); + assert.equal(validate({ proposal_id: 'prop_1', action: 'extend_hold' }), true, errors(validate)); + assert.equal(validate({ proposal_id: 'prop_1', action: 'extend_hold', installment_changes: [OMIT] }), false); +}); + +test('change set declares uniqueness by (product_id, installment_ref)', () => { + const set = readSchema('core/installment-change-set.json'); + assert.deepEqual(set['x-adcp-validation'].unique_by, ['product_id', 'installment_ref']); + assert.equal(set.uniqueItems, true); + assert.equal(set.minItems, 1); + assert.equal(set.maxItems, 500); +}); + +test('result row: partial with only unsatisfied_installment_changes satisfies constraint_unsatisfiable', async () => { + const validate = await compile('/schemas/media-buy/refine-proposals-response.json'); + assert.equal(validate(response(partial())), true, errors(validate)); + assert.equal( + validate(response(partial({ unsatisfied_installment_changes: undefined }))), + false, + 'constraint_unsatisfiable needs at least one unsatisfied list' + ); +}); + +test('result row: entries need a reason and the list is forbidden on revised and finalized', async () => { + const validate = await compile('/schemas/media-buy/refine-proposals-response.json'); + assert.equal(validate(response(partial({ unsatisfied_installment_changes: [INCLUDE] }))), false, 'reason is required'); + + const revised = { source_proposal_id: 'prop_1', outcome: 'revised', proposals: [DRAFT] }; + assert.equal(validate(response(revised)), true, errors(validate)); + assert.equal(validate(response({ ...revised, unsatisfied_installment_changes: [unsatisfied] })), false); + + const committed = { ...DRAFT, proposal_status: 'committed' }; + const finalized = { source_proposal_id: 'prop_1', outcome: 'finalized', proposal: committed }; + assert.equal(validate(response(finalized)), true, errors(validate)); + assert.equal(validate(response({ ...finalized, unsatisfied_installment_changes: [unsatisfied] })), false); +}); + +test('capabilities: installment_changes dimension requires media_buy.installment_refinement', async () => { + const validate = await compile('/schemas/protocol/get-adcp-capabilities-response.json'); + const caps = (experimental) => ({ + status: 'completed', + adcp: { major_versions: [3], idempotency: { supported: false } }, + supported_protocols: ['media_buy'], + media_buy: { lifecycle_tools: ['request_proposals', 'refine_proposals'], proposal_refinement: { supported_dimensions: ['product_changes', 'installment_changes'] } }, + ...(experimental ? { experimental_features: experimental } : {}), + }); + assert.equal(validate(caps(['media_buy.installment_refinement'])), true, errors(validate)); + assert.equal(validate(caps(undefined)), false, 'no experimental_features'); + assert.equal(validate(caps(['media_buy.hold_policy'])), false, 'wrong experimental feature'); +}); + +test('metadata: the new surfaces are experimental and added in 3.3.0', () => { + const surfaces = { + 'core/installment-change.json': readSchema('core/installment-change.json'), + 'core/installment-change-set.json': readSchema('core/installment-change-set.json'), + 'proposal-refinement.properties.installment_changes': + readSchema('media-buy/proposal-refinement.json').properties.installment_changes, + 'result row unsatisfied_installment_changes': + readSchema('media-buy/refine-proposals-response.json').properties.results.items.properties.unsatisfied_installment_changes, + }; + for (const [name, schema] of Object.entries(surfaces)) { + assert.equal(schema['x-status'], 'experimental', `${name} x-status`); + assert.equal(schema['x-added-in'], '3.3.0', `${name} x-added-in`); + } +}); + +test('signed terms are untouched: purchases carry no installments, and only the refinement id has a status row', () => { + assert.equal(readSchema('media-buy/product-purchase.json').properties.installments, undefined); + const doc = fs.readFileSync(path.join(ROOT, 'docs/reference/experimental-status.mdx'), 'utf8'); + assert.match(doc, /^\| `media_buy\.installment_refinement` \|/m); + assert.doesNotMatch(doc, /^\| `media_buy\.installment_selection` \|/m); +}); diff --git a/tests/mcp-schema-analysis.test.cjs b/tests/mcp-schema-analysis.test.cjs index 6d6bd301a51..559f185e1a7 100644 --- a/tests/mcp-schema-analysis.test.cjs +++ b/tests/mcp-schema-analysis.test.cjs @@ -58,8 +58,11 @@ test("input-field weight report attributes the largest transitive schema graphs" // Core gender adds one shared predicate reached by five inputs. // core/task-id.json, the one task_id definition, is reached by one input // (+1 instance, +1 unique). - assert.equal(report.definition_instances, 711); - assert.equal(report.unique_definitions, 192); + // Experimental installment refinement (#8967) adds installment-change-set, + // installment-change and the closed canonical-installment-ref to the + // refine_proposals input (+3 instances, +3 unique). + assert.equal(report.definition_instances, 714); + assert.equal(report.unique_definitions, 195); assert.equal(report.repeated_definitions, 136); assert.ok(report.repeated_definition_bytes > 180_000); @@ -187,7 +190,7 @@ test("shared dictionary resolves every experimental tool schema when explicitly assert.equal(view.dictionary.$id, DICTIONARY_ID); // Must match the intentionally pinned unique-definition inventory above. - assert.equal(Object.keys(view.dictionary.$defs).length, 192); + assert.equal(Object.keys(view.dictionary.$defs).length, 195); for (const tool of Object.values(view.tools)) { assert.equal(tool.inputSchema.$defs, undefined); assert.match( diff --git a/tests/withdrawn-to-4-0.test.cjs b/tests/withdrawn-to-4-0.test.cjs index 54dd5b4b7cf..757c08cbbbf 100644 --- a/tests/withdrawn-to-4-0.test.cjs +++ b/tests/withdrawn-to-4-0.test.cjs @@ -38,7 +38,6 @@ test('the withdrawn schemas, enum values and error code are gone', () => { 'enums/definition-pin-ref-kind.json', 'error-details/reference-definition-changed.json', 'brand/rights-scope.json', - 'core/installment-change-set.json', ]) { assert.equal(exists(file), false, `${file} is withdrawn`); } @@ -48,15 +47,12 @@ test('the withdrawn schemas, enum values and error code are gone', () => { const rightUse = readSchema('enums/right-use.json').enum; assert.ok(!rightUse.includes('paid_amplification') && !rightUse.includes('content_reuse')); - const refinement = readSchema('media-buy/proposal-refinement.json'); - assert.equal(refinement.properties.installment_changes, undefined); + // installment_changes and unsatisfied_installment_changes returned as the + // experimental media_buy.installment_refinement surface (adcp#8967); see + // tests/installment-refinement.test.cjs. Only the signed field stays withdrawn. const row = readSchema('media-buy/refine-proposals-response.json').properties.results.items.properties; assert.equal(row.drifted_pins, undefined); - assert.equal(row.unsatisfied_installment_changes, undefined); - const capabilities = readSchema('protocol/get-adcp-capabilities-response.json'); - const dimensions = capabilities.properties.media_buy.properties.proposal_refinement.properties.supported_dimensions; - assert.equal(dimensions.items.enum.includes('installment_changes'), false); }); test('rights-terms keeps its 3.2 shape without the hoisted scope', () => {