Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/installment-refinement.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion docs/building/by-layer/L1/security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
78 changes: 76 additions & 2 deletions docs/media-buy/task-reference/refine_proposals.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand Down Expand Up @@ -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. |
Expand All @@ -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:

Expand Down Expand Up @@ -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`:
Expand Down
1 change: 1 addition & 0 deletions docs/protocol/get_adcp_capabilities.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down
Loading
Loading